Skip to main content

rlevo_evolution/neuroevolution/
phenotype.rs

1//! Phenotype construction — turning a [`TopologyGenome`] into a callable network.
2//!
3//! [`PhenotypeBuilder`] abstracts *how* a genome becomes a network so a future
4//! CPPN/`HyperNEAT` builder is a new impl, not a trait change. The reference
5//! builder, [`InterpretedBuilder`], produces an [`InterpretedPhenotype`]: a
6//! host-side **feedforward** evaluator built from bare `Tensor<B, 2>` arithmetic
7//! over the genome's enabled connections in topological order — **no Burn
8//! `Module`**, no autodiff, no `Recorder`. NEAT phenotypes need only a forward
9//! pass, so skipping Burn's `Module` (whose `#[derive]` needs a static field
10//! structure a data-defined topology cannot supply) costs nothing.
11//!
12//! The interpreted seam ([`PhenotypeBuilder`]/[`Phenotype`]) evaluates one genome
13//! at a time. Its population-batched companion, [`BatchPhenotypeEvaluator`], runs
14//! the *whole* population in one device-resident forward pass; the dense-padded
15//! [`DensePaddedEvaluator`] is the stock-Burn implementation.
16
17use std::collections::{BTreeMap, HashMap, VecDeque};
18use std::marker::PhantomData;
19
20use burn::tensor::backend::Backend;
21use burn::tensor::{Bool, Tensor, TensorData, activation};
22
23use super::topology::{ActivationFn, NodeGene, NodeId, NodeKind, SIGMOID_GAIN, TopologyGenome};
24
25/// Builds a callable [`Phenotype`] from a [`TopologyGenome`].
26///
27/// Implementors decide the network representation; the v1 reference is
28/// [`InterpretedBuilder`]. A substrate-based builder (`HyperNEAT`) would take its
29/// substrate layout in its constructor and implement this same trait.
30pub trait PhenotypeBuilder<B: Backend> {
31    /// Compile `genome` into a callable [`Phenotype`], allocating any
32    /// device-side resources on `device`.
33    fn build(
34        &self,
35        genome: &TopologyGenome,
36        device: &<B as burn::tensor::backend::BackendTypes>::Device,
37    ) -> Box<dyn Phenotype<B>>;
38}
39
40/// A callable network: a forward pass from a batch of inputs to a batch of
41/// outputs.
42pub trait Phenotype<B: Backend>: Send + Sync {
43    /// Run a forward pass, mapping `[batch, num_inputs]` to
44    /// `[batch, num_outputs]`. The compute device is taken from `input`.
45    fn forward(&self, input: Tensor<B, 2>) -> Tensor<B, 2>;
46}
47
48/// The v1 reference builder — produces an [`InterpretedPhenotype`].
49#[derive(Debug, Clone, Copy, Default)]
50pub struct InterpretedBuilder;
51
52impl<B: Backend> PhenotypeBuilder<B> for InterpretedBuilder {
53    fn build(
54        &self,
55        genome: &TopologyGenome,
56        _device: &<B as burn::tensor::backend::BackendTypes>::Device,
57    ) -> Box<dyn Phenotype<B>> {
58        Box::new(InterpretedPhenotype::<B>::new(genome))
59    }
60}
61
62/// Pre-computed evaluation plan for one non-input node.
63#[derive(Debug, Clone)]
64struct NodeEval {
65    id: NodeId,
66    /// Enabled incoming edges as `(source_node, weight)` pairs.
67    incoming: Vec<(NodeId, f32)>,
68    bias: f32,
69    activation: ActivationFn,
70}
71
72/// Host-side feedforward reference phenotype.
73///
74/// Stores only host-side evaluation metadata (topological order, per-node
75/// incoming edges, biases, activations) — **no** Burn tensors — so it is
76/// trivially `Send + Sync` and cheap to construct. The forward device is taken
77/// from the input tensor.
78#[derive(Debug, Clone)]
79pub struct InterpretedPhenotype<B: Backend> {
80    /// Input node ids in input-column order.
81    input_ids: Vec<NodeId>,
82    /// Output node ids in output-column order.
83    output_ids: Vec<NodeId>,
84    /// Non-input nodes in topological order, with their evaluation plan.
85    eval_order: Vec<NodeEval>,
86    _backend: PhantomData<fn() -> B>,
87}
88
89impl<B: Backend> InterpretedPhenotype<B> {
90    /// Compile a genome into an evaluation plan.
91    ///
92    /// Nodes are ordered topologically over **all** structural edges; because
93    /// every genome maintains the feedforward DAG invariant, this order is valid
94    /// for the enabled subgraph the forward pass actually follows.
95    #[must_use]
96    pub fn new(genome: &TopologyGenome) -> Self {
97        let mut input_ids: Vec<NodeId> = filter_ids(genome, |k| matches!(k, NodeKind::Input));
98        input_ids.sort_unstable();
99
100        let mut output_ids: Vec<NodeId> = filter_ids(genome, |k| matches!(k, NodeKind::Output));
101        output_ids.sort_unstable();
102
103        // One O(n) pass to index nodes by id, and one O(e) pass to group enabled
104        // incoming edges by target — replacing the former per-node O(n) lookup +
105        // O(e) rescan (O(n²)+O(n·e)) with an O(n+e) build.
106        let nodes_by_id: HashMap<NodeId, &NodeGene> =
107            genome.nodes.iter().map(|n| (n.id, n)).collect();
108        let mut incoming_by_target: HashMap<NodeId, Vec<(NodeId, f32)>> = HashMap::new();
109        for c in genome.connections.iter().filter(|c| c.enabled) {
110            incoming_by_target
111                .entry(c.target)
112                .or_default()
113                .push((c.source, c.weight));
114        }
115
116        let order = topological_order(genome);
117        let mut eval_order: Vec<NodeEval> = Vec::with_capacity(order.len());
118        for nid in order {
119            let Some(&node) = nodes_by_id.get(&nid) else {
120                continue;
121            };
122            if matches!(node.kind, NodeKind::Input) {
123                continue;
124            }
125            let incoming: Vec<(NodeId, f32)> = incoming_by_target.remove(&nid).unwrap_or_default();
126            eval_order.push(NodeEval {
127                id: nid,
128                incoming,
129                bias: node.bias,
130                activation: node.activation,
131            });
132        }
133
134        Self {
135            input_ids,
136            output_ids,
137            eval_order,
138            _backend: PhantomData,
139        }
140    }
141}
142
143impl<B: Backend> Phenotype<B> for InterpretedPhenotype<B> {
144    fn forward(&self, input: Tensor<B, 2>) -> Tensor<B, 2> {
145        let [batch, _num_inputs] = input.dims();
146        let device = input.device();
147
148        let mut values: HashMap<NodeId, Tensor<B, 2>> =
149            HashMap::with_capacity(self.input_ids.len());
150        if !self.input_ids.is_empty() {
151            // One split of the whole input into `[batch, 1]` columns, versus the
152            // former per-column full-tensor clone (O(I²·B) → O(I·B)).
153            let columns: Vec<Tensor<B, 2>> = input.chunk(self.input_ids.len(), 1);
154            for (iid, column) in self.input_ids.iter().copied().zip(columns) {
155                values.insert(iid, column);
156            }
157        }
158
159        for node in &self.eval_order {
160            let mut acc = Tensor::<B, 2>::zeros([batch, 1], &device);
161            for (src, weight) in &node.incoming {
162                if let Some(src_value) = values.get(src) {
163                    acc = acc + src_value.clone().mul_scalar(*weight);
164                }
165            }
166            acc = acc.add_scalar(node.bias);
167            values.insert(node.id, apply_activation::<B>(node.activation, acc));
168        }
169
170        let columns: Vec<Tensor<B, 2>> = self
171            .output_ids
172            .iter()
173            .map(|oid| {
174                values
175                    .get(oid)
176                    .cloned()
177                    .unwrap_or_else(|| Tensor::<B, 2>::zeros([batch, 1], &device))
178            })
179            .collect();
180        Tensor::cat(columns, 1)
181    }
182}
183
184/// Tensor-side activation, mirroring [`ActivationFn::apply`] exactly (including
185/// the [`SIGMOID_GAIN`] steepening) so a hand-computed truth table matches.
186fn apply_activation<B: Backend>(act: ActivationFn, x: Tensor<B, 2>) -> Tensor<B, 2> {
187    match act {
188        ActivationFn::Sigmoid => activation::sigmoid(x.mul_scalar(SIGMOID_GAIN)),
189        ActivationFn::Tanh => activation::tanh(x),
190        ActivationFn::Relu => activation::relu(x),
191        ActivationFn::Linear => x,
192    }
193}
194
195// ===========================================================================
196// Population-batched evaluation
197// ===========================================================================
198
199/// Population-level batched forward pass — the device-resident companion to the
200/// per-genome [`PhenotypeBuilder`]/[`Phenotype`] interpreted seam.
201///
202/// Where [`Phenotype::forward`] evaluates one genome, an implementor of this
203/// trait evaluates an entire population on a shared observation batch in a single
204/// pass. The [`DensePaddedEvaluator`] v1 impl does so with stock Burn tensor ops
205/// (no custom kernel); the interpreted path remains the correctness oracle (a
206/// numerical-parity test pins the two within float epsilon).
207pub trait BatchPhenotypeEvaluator<B: Backend>: Send + Sync {
208    /// Evaluate every genome on the shared observation batch `obs`.
209    ///
210    /// `obs` has shape `[batch, obs_dim]`, where `obs_dim` must equal the
211    /// population's (constant) input-node count. The result has shape
212    /// `[pop, batch, action_dim]` — the population dimension is kept explicit so
213    /// a caller can reduce fitness per genome without index arithmetic. Column
214    /// order along `obs_dim`/`action_dim` matches the interpreted phenotype's
215    /// (input/output nodes in ascending-id order).
216    fn evaluate_population(
217        &self,
218        genomes: &[TopologyGenome],
219        obs: Tensor<B, 2>,
220        device: &<B as burn::tensor::backend::BackendTypes>::Device,
221    ) -> Tensor<B, 3>;
222}
223
224/// Dense-padded [`BatchPhenotypeEvaluator`].
225///
226/// Each call compiles the `P` genomes into padded `(P, N, N)` weight, `(P, N)`
227/// bias, and per-activation mask tensors over a node budget `N = max_nodes`, then
228/// runs a synchronous-update forward pass. Because v1 NEAT is feedforward, the
229/// pass is **exact**: after `d` synchronous updates every node at topological
230/// depth `d` has settled, so iterating the population's **deepest enabled path**
231/// (≤ `N − 1`) resolves every genome. The dense path uses that tight depth bound
232/// rather than the static `N − 1` worst case — NEAT topologies are typically
233/// sparse and shallow, and the per-step cost is a dense `N×N` matmul, so
234/// over-iterating dominates the runtime at scale. The whole pass is stock Burn
235/// (batched `matmul`, broadcast add, `mask_where`, the four elementwise
236/// activations); the absent-edge mask folds into the zero weight.
237///
238/// Memory is dominated by the `weights` tensor: `(256, 50) → 2.56 MB`,
239/// `(256, 200) → 41 MB` (f32). The [`max_nodes_cap`](Self::max_nodes_cap) guards
240/// it when topologies grow.
241#[derive(Debug, Clone, Copy)]
242pub struct DensePaddedEvaluator {
243    /// Hard ceiling on `N = max_nodes`. A population whose largest genome exceeds
244    /// it panics rather than silently allocating an oversized weight tensor.
245    pub max_nodes_cap: usize,
246}
247
248impl DensePaddedEvaluator {
249    /// Build an evaluator with the given node-budget ceiling.
250    #[must_use]
251    pub fn new(max_nodes_cap: usize) -> Self {
252        Self { max_nodes_cap }
253    }
254}
255
256impl Default for DensePaddedEvaluator {
257    /// A `512`-node ceiling — far past where the dense path stays affordable
258    /// (41 MB at `N = 200`), so it never binds in practice.
259    fn default() -> Self {
260        Self { max_nodes_cap: 512 }
261    }
262}
263
264impl<B: Backend> BatchPhenotypeEvaluator<B> for DensePaddedEvaluator {
265    fn evaluate_population(
266        &self,
267        genomes: &[TopologyGenome],
268        obs: Tensor<B, 2>,
269        device: &<B as burn::tensor::backend::BackendTypes>::Device,
270    ) -> Tensor<B, 3> {
271        let pop = genomes.len();
272        let [batch, obs_dim] = obs.dims();
273        if pop == 0 {
274            return Tensor::<B, 3>::zeros([0, batch, 0], device);
275        }
276        let max_nodes = genomes.iter().map(|g| g.nodes.len()).max().unwrap_or(0);
277        assert!(
278            max_nodes <= self.max_nodes_cap,
279            "largest genome has {max_nodes} nodes, exceeding max_nodes_cap {}",
280            self.max_nodes_cap
281        );
282
283        let PaddedPopulation {
284            weights,
285            bias,
286            act_masks,
287            input_slots,
288            num_inputs,
289            num_outputs,
290            n,
291            iterations,
292        } = PaddedPopulation::<B>::compile(genomes, device);
293        assert_eq!(
294            obs_dim, num_inputs,
295            "obs feature dim {obs_dim} must equal the population's input-node count {num_inputs}"
296        );
297
298        // Seed input rows with the observation (others 0): obsᵀ stacked over the
299        // padding rows, broadcast across the population. Held fixed every step.
300        let obs_t = obs.swap_dims(0, 1); // (num_inputs, batch)
301        let seed_2d = if n > num_inputs {
302            let pad = Tensor::<B, 2>::zeros([n - num_inputs, batch], device);
303            Tensor::cat(vec![obs_t, pad], 0) // (N, batch)
304        } else {
305            obs_t
306        };
307        let seeded = seed_2d.unsqueeze_dim::<3>(0).repeat_dim(0, pop); // (P, N, batch)
308
309        // Broadcast the per-node bias/masks across the batch dimension.
310        let bias = bias.unsqueeze_dim::<3>(2); // (P, N, 1) — broadcasts on add
311        let input_slots = input_slots.unsqueeze_dim::<3>(2).repeat_dim(2, batch);
312        let [mask_sigmoid, mask_tanh, mask_relu, mask_linear] = act_masks;
313        let mask_sigmoid = mask_sigmoid.unsqueeze_dim::<3>(2).repeat_dim(2, batch);
314        let mask_tanh = mask_tanh.unsqueeze_dim::<3>(2).repeat_dim(2, batch);
315        let mask_relu = mask_relu.unsqueeze_dim::<3>(2).repeat_dim(2, batch);
316        let mask_linear = mask_linear.unsqueeze_dim::<3>(2).repeat_dim(2, batch);
317
318        let mut values = seeded.clone(); // (P, N, batch)
319        for _ in 0..iterations {
320            let acc = weights.clone().matmul(values.clone()) + bias.clone(); // (P, N, batch)
321            // Heterogeneous per-node activation: one masked pass per variant,
322            // reusing the interpreted formulas (incl. SIGMOID_GAIN) verbatim.
323            let mut out = Tensor::<B, 3>::zeros([pop, n, batch], device);
324            out = out.mask_where(
325                mask_sigmoid.clone(),
326                activation::sigmoid(acc.clone().mul_scalar(SIGMOID_GAIN)),
327            );
328            out = out.mask_where(mask_tanh.clone(), activation::tanh(acc.clone()));
329            out = out.mask_where(mask_relu.clone(), activation::relu(acc.clone()));
330            out = out.mask_where(mask_linear.clone(), acc);
331            // Re-seat the inputs so they keep carrying the observation.
332            values = out.mask_where(input_slots.clone(), seeded.clone());
333        }
334
335        // Output rows are contiguous at `num_inputs..num_inputs + num_outputs`.
336        let result = values.slice([0..pop, num_inputs..num_inputs + num_outputs, 0..batch]);
337        result.swap_dims(1, 2) // (P, num_outputs, batch) -> (P, batch, action_dim)
338    }
339}
340
341/// Per-generation host compile of `P` genomes into padded dense tensors.
342///
343/// Within each genome, node ids are assigned local rows in the order
344/// `[inputs by id][outputs by id][others by id]`. Because NEAT fixes the
345/// input/output node set across the whole population, input rows are always
346/// `0..num_inputs` and output rows always `num_inputs..num_inputs + num_outputs`
347/// — uniform across `P`, so seeding and output-slicing need no per-genome index
348/// arithmetic. Padding rows (id-free, beyond a genome's node count) carry no
349/// activation mask and no edges, so they stay zero and never feed a real node.
350struct PaddedPopulation<B: Backend> {
351    /// `(P, N, N)` — `weights[p, i, j]` is the weight of enabled edge `j → i`.
352    weights: Tensor<B, 3>,
353    /// `(P, N)` per-node bias (0 for input and padding rows).
354    bias: Tensor<B, 2>,
355    /// One `(P, N)` bool mask per [`ActivationFn`] variant, in the order
356    /// `[Sigmoid, Tanh, Relu, Linear]`.
357    act_masks: [Tensor<B, 2, Bool>; 4],
358    /// `(P, N)` bool mask, true at input rows (held fixed each iteration).
359    input_slots: Tensor<B, 2, Bool>,
360    num_inputs: usize,
361    num_outputs: usize,
362    n: usize,
363    /// Synchronous-update iterations needed to settle every genome: the maximum
364    /// enabled-subgraph longest path (in edges) across the population, floored at
365    /// `1` so bias-only nodes get their activation applied. This is the **tight,
366    /// exact** bound — `N − 1` is the safe static worst case, but NEAT topologies
367    /// are typically far shallower, and over-iterating is the dominant cost at
368    /// scale (a dense `N×N` matmul per step), so the depth bound is what makes
369    /// the dense path competitive on wide-but-shallow populations.
370    iterations: usize,
371}
372
373impl<B: Backend> PaddedPopulation<B> {
374    fn compile(
375        genomes: &[TopologyGenome],
376        device: &<B as burn::tensor::backend::BackendTypes>::Device,
377    ) -> Self {
378        let pop = genomes.len();
379        let num_inputs = count_kind(&genomes[0], NodeKind::Input);
380        let num_outputs = count_kind(&genomes[0], NodeKind::Output);
381        let n = genomes.iter().map(|g| g.nodes.len()).max().unwrap_or(0);
382        // Tight exact iteration bound: the deepest enabled path in the whole
383        // population (≤ N − 1), floored at 1 so a bias-only node still activates.
384        let iterations = genomes
385            .iter()
386            .map(longest_path_edges)
387            .max()
388            .unwrap_or(0)
389            .max(1);
390
391        let mut weights = vec![0.0f32; pop * n * n];
392        let mut bias = vec![0.0f32; pop * n];
393        let mut masks: [Vec<f32>; 4] = [
394            vec![0.0f32; pop * n],
395            vec![0.0f32; pop * n],
396            vec![0.0f32; pop * n],
397            vec![0.0f32; pop * n],
398        ];
399        let mut input_slots = vec![0.0f32; pop * n];
400
401        for (p, genome) in genomes.iter().enumerate() {
402            debug_assert_eq!(
403                count_kind(genome, NodeKind::Input),
404                num_inputs,
405                "every genome must share the population input-node count"
406            );
407            debug_assert_eq!(
408                count_kind(genome, NodeKind::Output),
409                num_outputs,
410                "every genome must share the population output-node count"
411            );
412            let local = local_rows(genome);
413            for node in &genome.nodes {
414                let base = p * n + local[&node.id];
415                if matches!(node.kind, NodeKind::Input) {
416                    input_slots[base] = 1.0;
417                } else {
418                    bias[base] = node.bias;
419                    masks[act_index(node.activation)][base] = 1.0;
420                }
421            }
422            let wbase = p * n * n;
423            for conn in &genome.connections {
424                if !conn.enabled {
425                    continue;
426                }
427                let i = local[&conn.target];
428                let j = local[&conn.source];
429                weights[wbase + i * n + j] = conn.weight;
430            }
431        }
432
433        let weights = Tensor::<B, 3>::from_data(TensorData::new(weights, [pop, n, n]), device);
434        let bias = Tensor::<B, 2>::from_data(TensorData::new(bias, [pop, n]), device);
435        let act_masks = masks.map(|m| {
436            Tensor::<B, 2>::from_data(TensorData::new(m, [pop, n]), device).greater_elem(0.5)
437        });
438        let input_slots = Tensor::<B, 2>::from_data(TensorData::new(input_slots, [pop, n]), device)
439            .greater_elem(0.5);
440
441        Self {
442            weights,
443            bias,
444            act_masks,
445            input_slots,
446            num_inputs,
447            num_outputs,
448            n,
449            iterations,
450        }
451    }
452}
453
454/// Count nodes of a given kind in a genome.
455fn count_kind(genome: &TopologyGenome, kind: NodeKind) -> usize {
456    genome.nodes.iter().filter(|n| n.kind == kind).count()
457}
458
459/// Longest path, in **enabled** edges, through the genome's feedforward DAG.
460///
461/// This is the number of synchronous updates the dense forward pass needs to
462/// settle every node (signal advances one edge per step). Disabled edges carry a
463/// zero weight in the dense matrix, so they never propagate and are excluded
464/// here, giving a tighter bound than counting all structural edges. Relaxing in
465/// the all-edges topological order is valid because the enabled subgraph is a
466/// subset of that DAG.
467fn longest_path_edges(genome: &TopologyGenome) -> usize {
468    let mut out_edges: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
469    for conn in &genome.connections {
470        if conn.enabled {
471            out_edges.entry(conn.source).or_default().push(conn.target);
472        }
473    }
474    let mut depth: HashMap<NodeId, usize> = genome.nodes.iter().map(|n| (n.id, 0usize)).collect();
475    for node in topological_order(genome) {
476        let here = depth.get(&node).copied().unwrap_or(0);
477        if let Some(targets) = out_edges.get(&node) {
478            for &t in targets {
479                let slot = depth.entry(t).or_insert(0);
480                *slot = (*slot).max(here + 1);
481            }
482        }
483    }
484    depth.into_values().max().unwrap_or(0)
485}
486
487/// Dense-table index for an activation variant (the `act_masks` array order).
488fn act_index(act: ActivationFn) -> usize {
489    match act {
490        ActivationFn::Sigmoid => 0,
491        ActivationFn::Tanh => 1,
492        ActivationFn::Relu => 2,
493        ActivationFn::Linear => 3,
494    }
495}
496
497/// Map every node id to its local row, ordered `[inputs][outputs][others]`, each
498/// group ascending by id (so input/output rows align with the interpreted
499/// phenotype's column order).
500fn local_rows(genome: &TopologyGenome) -> HashMap<NodeId, usize> {
501    let mut inputs: Vec<NodeId> = filter_ids(genome, |k| matches!(k, NodeKind::Input));
502    let mut outputs: Vec<NodeId> = filter_ids(genome, |k| matches!(k, NodeKind::Output));
503    let mut others: Vec<NodeId> =
504        filter_ids(genome, |k| !matches!(k, NodeKind::Input | NodeKind::Output));
505    inputs.sort_unstable();
506    outputs.sort_unstable();
507    others.sort_unstable();
508
509    let mut map: HashMap<NodeId, usize> = HashMap::with_capacity(genome.nodes.len());
510    for (row, id) in inputs.into_iter().chain(outputs).chain(others).enumerate() {
511        map.insert(id, row);
512    }
513    map
514}
515
516/// Collect the ids of nodes whose kind satisfies `pred`.
517fn filter_ids(genome: &TopologyGenome, pred: impl Fn(NodeKind) -> bool) -> Vec<NodeId> {
518    genome
519        .nodes
520        .iter()
521        .filter(|n| pred(n.kind))
522        .map(|n| n.id)
523        .collect()
524}
525
526/// Kahn topological sort over **all** structural edges, breaking ties by
527/// ascending node id for reproducibility. Returns every node id; any node left
528/// unordered by a (non-invariant) cycle is appended at the end so the forward
529/// pass never indexes a missing node.
530fn topological_order(genome: &TopologyGenome) -> Vec<NodeId> {
531    let mut in_degree: BTreeMap<NodeId, usize> =
532        genome.nodes.iter().map(|n| (n.id, 0usize)).collect();
533    let mut adj: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
534    for c in &genome.connections {
535        if let Some(d) = in_degree.get_mut(&c.target) {
536            *d += 1;
537        }
538        adj.entry(c.source).or_default().push(c.target);
539    }
540
541    // BTreeMap iteration is sorted, so the seed queue is in ascending id order.
542    let mut queue: VecDeque<NodeId> = in_degree
543        .iter()
544        .filter(|&(_, &d)| d == 0)
545        .map(|(&id, _)| id)
546        .collect();
547
548    let mut order: Vec<NodeId> = Vec::with_capacity(genome.nodes.len());
549    while let Some(n) = queue.pop_front() {
550        order.push(n);
551        if let Some(succ) = adj.get(&n) {
552            let mut targets = succ.clone();
553            targets.sort_unstable();
554            for t in targets {
555                if let Some(d) = in_degree.get_mut(&t) {
556                    *d -= 1;
557                    if *d == 0 {
558                        queue.push_back(t);
559                    }
560                }
561            }
562        }
563    }
564
565    if order.len() < genome.nodes.len() {
566        for n in &genome.nodes {
567            if !order.contains(&n.id) {
568                order.push(n.id);
569            }
570        }
571    }
572    order
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578    use crate::neuroevolution::topology::{ConnectionGene, InnovationId};
579    use burn::backend::Flex;
580
581    type TestBackend = Flex;
582
583    /// A hand-built feedforward genome reproduces a known truth table.
584    ///
585    /// Network: inputs 0, 1 → hidden 2 (Relu) → output 3 (Linear). With
586    /// `h = relu(1·in0 + 1·in1)` and `out = 2·h + 0.5`, the four binary input
587    /// rows give outputs `0.5, 2.5, 2.5, 4.5`.
588    #[test]
589    fn test_interpreted_phenotype_reproduces_truth_table() {
590        let device = Default::default();
591        let nodes = vec![
592            NodeGene {
593                id: NodeId::new(0),
594                kind: NodeKind::Input,
595                activation: ActivationFn::Linear,
596                bias: 0.0,
597            },
598            NodeGene {
599                id: NodeId::new(1),
600                kind: NodeKind::Input,
601                activation: ActivationFn::Linear,
602                bias: 0.0,
603            },
604            NodeGene {
605                id: NodeId::new(2),
606                kind: NodeKind::Hidden,
607                activation: ActivationFn::Relu,
608                bias: 0.0,
609            },
610            NodeGene {
611                id: NodeId::new(3),
612                kind: NodeKind::Output,
613                activation: ActivationFn::Linear,
614                bias: 0.5,
615            },
616        ];
617        let conns = vec![
618            ConnectionGene {
619                innovation: InnovationId::new(0),
620                source: NodeId::new(0),
621                target: NodeId::new(2),
622                weight: 1.0,
623                enabled: true,
624            },
625            ConnectionGene {
626                innovation: InnovationId::new(1),
627                source: NodeId::new(1),
628                target: NodeId::new(2),
629                weight: 1.0,
630                enabled: true,
631            },
632            ConnectionGene {
633                innovation: InnovationId::new(2),
634                source: NodeId::new(2),
635                target: NodeId::new(3),
636                weight: 2.0,
637                enabled: true,
638            },
639        ];
640        let genome = TopologyGenome::new(nodes, conns);
641
642        let builder = InterpretedBuilder;
643        let pheno = PhenotypeBuilder::<TestBackend>::build(&builder, &genome, &device);
644
645        let input = Tensor::<TestBackend, 2>::from_data(
646            burn::tensor::TensorData::new(vec![0.0f32, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0], [4, 2]),
647            &device,
648        );
649        let out = pheno
650            .forward(input)
651            .into_data()
652            .into_vec::<f32>()
653            .expect("output host-read of a tensor this test just built");
654        let expected = [0.5f32, 2.5, 2.5, 4.5];
655        for (got, want) in out.iter().zip(expected.iter()) {
656            approx::assert_relative_eq!(*got, *want, epsilon = 1e-5);
657        }
658    }
659
660    /// A disabled connection is skipped in the forward pass.
661    #[test]
662    fn test_interpreted_phenotype_skips_disabled_edges() {
663        let device = Default::default();
664        let nodes = vec![
665            NodeGene {
666                id: NodeId::new(0),
667                kind: NodeKind::Input,
668                activation: ActivationFn::Linear,
669                bias: 0.0,
670            },
671            NodeGene {
672                id: NodeId::new(1),
673                kind: NodeKind::Output,
674                activation: ActivationFn::Linear,
675                bias: 1.0,
676            },
677        ];
678        // Single edge 0 -> 1 is DISABLED, so output = bias = 1.0 regardless.
679        let conns = vec![ConnectionGene {
680            innovation: InnovationId::new(0),
681            source: NodeId::new(0),
682            target: NodeId::new(1),
683            weight: 99.0,
684            enabled: false,
685        }];
686        let genome = TopologyGenome::new(nodes, conns);
687        let pheno = InterpretedPhenotype::<TestBackend>::new(&genome);
688        let input = Tensor::<TestBackend, 2>::from_data(
689            burn::tensor::TensorData::new(vec![5.0f32, 7.0], [2, 1]),
690            &device,
691        );
692        let out = pheno
693            .forward(input)
694            .into_data()
695            .into_vec::<f32>()
696            .expect("output host-read of a tensor this test just built");
697        approx::assert_relative_eq!(out[0], 1.0, epsilon = 1e-6);
698        approx::assert_relative_eq!(out[1], 1.0, epsilon = 1e-6);
699    }
700
701    /// A zero-input genome does not panic and emits `activation(bias)`.
702    ///
703    /// Guards the `if !self.input_ids.is_empty()` branch in
704    /// [`InterpretedPhenotype::forward`]: with no input nodes the input tensor
705    /// has 0 columns, so the eager `input.chunk(0, 1)` would be an invalid
706    /// zero-chunk split. The single Output node has no incoming edges, so its
707    /// value is `activation(bias)` — here `Linear`, so exactly `bias`.
708    #[test]
709    fn test_interpreted_phenotype_forward_handles_zero_inputs() {
710        let device = Default::default();
711        // A lone Output node: no inputs, no edges. output = linear(bias) = bias.
712        let nodes = vec![NodeGene {
713            id: NodeId::new(0),
714            kind: NodeKind::Output,
715            activation: ActivationFn::Linear,
716            bias: 0.75,
717        }];
718        let conns: Vec<ConnectionGene> = vec![];
719        let genome = TopologyGenome::new(nodes, conns);
720        let pheno = InterpretedPhenotype::<TestBackend>::new(&genome);
721
722        // Shape [batch, 0]: an empty input tensor drives the zero-input guard.
723        let batch: usize = 3;
724        let input = Tensor::<TestBackend, 2>::from_data(
725            burn::tensor::TensorData::new(Vec::<f32>::new(), [batch, 0]),
726            &device,
727        );
728        let out = pheno.forward(input);
729        assert_eq!(
730            out.dims(),
731            [batch, 1],
732            "zero-input forward must return [batch, num_outputs]"
733        );
734        let out_vec = out
735            .into_data()
736            .into_vec::<f32>()
737            .expect("output host-read of a tensor this test just built");
738        for got in &out_vec {
739            approx::assert_relative_eq!(*got, 0.75f32, epsilon = 1e-6);
740        }
741    }
742
743    /// The dense-padded batch evaluator agrees with the interpreted phenotype
744    /// oracle within float epsilon, pinning the parity the module docs claim.
745    ///
746    /// Uses a population of feedforward genomes that share the NEAT-invariant
747    /// input/output node set (2 inputs, 1 output) but vary in hidden structure,
748    /// depth, and activation — so both the padding path and the multi-step
749    /// synchronous update are exercised. Column/row order lines up because both
750    /// paths key inputs and outputs by ascending node id.
751    #[test]
752    #[allow(clippy::too_many_lines)] // hand-built genome literals, not real logic
753    fn test_dense_matches_interpreted_within_epsilon() {
754        let device = Default::default();
755
756        // Genome A: in0, in1 -> hidden2 (Relu) -> out3 (Linear, bias 0.5).
757        let genome_a: TopologyGenome = TopologyGenome::new(
758            vec![
759                NodeGene {
760                    id: NodeId::new(0),
761                    kind: NodeKind::Input,
762                    activation: ActivationFn::Linear,
763                    bias: 0.0,
764                },
765                NodeGene {
766                    id: NodeId::new(1),
767                    kind: NodeKind::Input,
768                    activation: ActivationFn::Linear,
769                    bias: 0.0,
770                },
771                NodeGene {
772                    id: NodeId::new(2),
773                    kind: NodeKind::Hidden,
774                    activation: ActivationFn::Relu,
775                    bias: 0.0,
776                },
777                NodeGene {
778                    id: NodeId::new(3),
779                    kind: NodeKind::Output,
780                    activation: ActivationFn::Linear,
781                    bias: 0.5,
782                },
783            ],
784            vec![
785                ConnectionGene {
786                    innovation: InnovationId::new(0),
787                    source: NodeId::new(0),
788                    target: NodeId::new(2),
789                    weight: 1.0,
790                    enabled: true,
791                },
792                ConnectionGene {
793                    innovation: InnovationId::new(1),
794                    source: NodeId::new(1),
795                    target: NodeId::new(2),
796                    weight: 1.0,
797                    enabled: true,
798                },
799                ConnectionGene {
800                    innovation: InnovationId::new(2),
801                    source: NodeId::new(2),
802                    target: NodeId::new(3),
803                    weight: 2.0,
804                    enabled: true,
805                },
806            ],
807        );
808
809        // Genome B: in0, in1 -> out2 (Sigmoid, bias 0.1), no hidden layer.
810        let genome_b: TopologyGenome = TopologyGenome::new(
811            vec![
812                NodeGene {
813                    id: NodeId::new(0),
814                    kind: NodeKind::Input,
815                    activation: ActivationFn::Linear,
816                    bias: 0.0,
817                },
818                NodeGene {
819                    id: NodeId::new(1),
820                    kind: NodeKind::Input,
821                    activation: ActivationFn::Linear,
822                    bias: 0.0,
823                },
824                NodeGene {
825                    id: NodeId::new(2),
826                    kind: NodeKind::Output,
827                    activation: ActivationFn::Sigmoid,
828                    bias: 0.1,
829                },
830            ],
831            vec![
832                ConnectionGene {
833                    innovation: InnovationId::new(0),
834                    source: NodeId::new(0),
835                    target: NodeId::new(2),
836                    weight: 0.5,
837                    enabled: true,
838                },
839                ConnectionGene {
840                    innovation: InnovationId::new(1),
841                    source: NodeId::new(1),
842                    target: NodeId::new(2),
843                    weight: -0.3,
844                    enabled: true,
845                },
846            ],
847        );
848
849        // Genome C: two hidden nodes feeding a Tanh output (deeper, mixed acts).
850        // in0, in1 -> hidden2 (Tanh), hidden3 (Relu) -> out4 (Tanh).
851        let genome_c: TopologyGenome = TopologyGenome::new(
852            vec![
853                NodeGene {
854                    id: NodeId::new(0),
855                    kind: NodeKind::Input,
856                    activation: ActivationFn::Linear,
857                    bias: 0.0,
858                },
859                NodeGene {
860                    id: NodeId::new(1),
861                    kind: NodeKind::Input,
862                    activation: ActivationFn::Linear,
863                    bias: 0.0,
864                },
865                NodeGene {
866                    id: NodeId::new(2),
867                    kind: NodeKind::Hidden,
868                    activation: ActivationFn::Tanh,
869                    bias: 0.2,
870                },
871                NodeGene {
872                    id: NodeId::new(3),
873                    kind: NodeKind::Hidden,
874                    activation: ActivationFn::Relu,
875                    bias: -0.1,
876                },
877                NodeGene {
878                    id: NodeId::new(4),
879                    kind: NodeKind::Output,
880                    activation: ActivationFn::Tanh,
881                    bias: 0.0,
882                },
883            ],
884            vec![
885                ConnectionGene {
886                    innovation: InnovationId::new(0),
887                    source: NodeId::new(0),
888                    target: NodeId::new(2),
889                    weight: 0.7,
890                    enabled: true,
891                },
892                ConnectionGene {
893                    innovation: InnovationId::new(1),
894                    source: NodeId::new(1),
895                    target: NodeId::new(2),
896                    weight: -0.5,
897                    enabled: true,
898                },
899                ConnectionGene {
900                    innovation: InnovationId::new(2),
901                    source: NodeId::new(0),
902                    target: NodeId::new(3),
903                    weight: 0.4,
904                    enabled: true,
905                },
906                ConnectionGene {
907                    innovation: InnovationId::new(3),
908                    source: NodeId::new(1),
909                    target: NodeId::new(3),
910                    weight: 0.9,
911                    enabled: true,
912                },
913                ConnectionGene {
914                    innovation: InnovationId::new(4),
915                    source: NodeId::new(2),
916                    target: NodeId::new(4),
917                    weight: 1.2,
918                    enabled: true,
919                },
920                ConnectionGene {
921                    innovation: InnovationId::new(5),
922                    source: NodeId::new(3),
923                    target: NodeId::new(4),
924                    weight: -0.8,
925                    enabled: true,
926                },
927            ],
928        );
929
930        let genomes: Vec<TopologyGenome> = vec![genome_a, genome_b, genome_c];
931        let pop: usize = genomes.len();
932        let out_dim: usize = 1; // one output node per genome by construction
933
934        // Non-binary observations exercise the activations off their fixed points.
935        let batch: usize = 4;
936        let obs_data: Vec<f32> = vec![0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.5, -0.5];
937        let obs: Tensor<TestBackend, 2> = Tensor::<TestBackend, 2>::from_data(
938            burn::tensor::TensorData::new(obs_data, [batch, 2]),
939            &device,
940        );
941
942        let dense: Tensor<TestBackend, 3> =
943            DensePaddedEvaluator::default().evaluate_population(&genomes, obs.clone(), &device);
944        assert_eq!(
945            dense.dims(),
946            [pop, batch, out_dim],
947            "dense evaluator must return a [pop, batch, action_dim] tensor"
948        );
949        let dense_vec: Vec<f32> = dense
950            .into_data()
951            .into_vec::<f32>()
952            .expect("host-read of a tensor this test just built");
953
954        for (p, genome) in genomes.iter().enumerate() {
955            let interp: Vec<f32> = InterpretedPhenotype::<TestBackend>::new(genome)
956                .forward(obs.clone())
957                .into_data()
958                .into_vec::<f32>()
959                .expect("host-read of a tensor this test just built");
960            for b in 0..batch {
961                for o in 0..out_dim {
962                    let dense_val: f32 = dense_vec[p * batch * out_dim + b * out_dim + o];
963                    let interp_val: f32 = interp[b * out_dim + o];
964                    approx::assert_relative_eq!(
965                        dense_val,
966                        interp_val,
967                        epsilon = 1e-4,
968                        max_relative = 1e-3
969                    );
970                }
971            }
972        }
973    }
974}