faber-tweann Roadmap

View Source

What this package intends to implement but does not yet.

README.md states what is. This file states what will be. A capability moves from here into the README when it lands, accompanied by a test that exercises it, and for performance claims by committed benchmark output.

Nothing here may be described as a feature in the README, the guides, EDoc comments, or the hex package description until it moves.

Ordering is not arbitrary. It follows the chapter dependency chain in Gene Sher's Handbook of Neuroevolution Through Erlang, because that book is the reference implementation for the DXNN2 architecture this package ports. See faber-ecosystem/docs/CONFORMANCE.md for the module-by-module gap analysis and faber-ecosystem/plans/PLAN_FABER_FOUNDATION.md for the full plan.


1. The scape and the fitness channel (Handbook Ch 7) — DONE

Status: implemented (insights 008, 009). xor_sim only; other scapes and recurrent networks remain.

scape.erl and xor_sim.erl exist. The fitness channel runs end to end: scape → actuator → cortex → exoself, with goal_reached propagation. The exoself spawns scapes, sends {exoself_terminated, Fitness}, and terminates. A single agent runs to completion through the process-per-neuron path and produces real fitness (insight 009), and the population_monitor drives a full generation of such evaluations (insight 010).

Still open under this heading:

  • xor_sim and pb_sim exist. pb_sim (Handbook Ch 14) is a faithful port of the cart-pole physics with four variants (single/double pole x with/without velocity); pb_1_with_velocity solves through the DXNN path (insight 015). fx_sim (Ch 19), flatland (Ch 18) and snake_duel remain, referenced by their morphologies.
  • Recurrent networks are supported. A recurrent (feedback) edge's source seeds its target with a default [0.0] at link time (faithful to DXNN2 neuron:prep/1), so the target does not deadlock on the first cycle; from cycle 1 the real feedback flows. Recurrence is derived at phenotype-build time by partitioning each neuron's outputs by layer (constructor and exoself), so it is robust to mutations that do not maintain the stale ro_ids cache. Proven in recurrent_neuron_tests (a self-recurrent neuron carries state across cycles) and recurrent_evolution_tests. This unblocks the WITHOUT-velocity pole variants (non-Markov, need memory) and the LTC/CfC comparison. Deferred: a flush/reseed handshake between memetic tuning attempts (attempts 2+ currently start from the previous attempt's final recurrent state rather than a fresh [0.0]; harmless, non-deadlocking, but not yet a clean reset).
  • Multi-generation evolution works and solves XOR and pole; see 2b, 2c and insight 015.

The message protocol is specified in faber-ecosystem/docs/PROTOCOL.md.

2. The memetic tuning layer (Handbook Ch 10)

Status: tuning_selection + tuning_duration DONE (insight 014). tot_topological_mutations still fixed.

DXNN2's distinguishing property over NEAT is that it interleaves a stochastic hill-climber over synaptic weights with topological evolution. Three modules drive it:

ModuleRoleState
tuning_selectionchooses which neurons to perturbbuiltsrc/tuning_selection.erl (dynamic / dynamic_random / active / current / all), invoked from exoself's perturb_weights/1
tuning_durationcomputes max_attempts per agent per generationbuiltsrc/tuning_duration.erl (const / wsize_proportional), invoked from exoself's compute_max_attempts/1
tot_topological_mutationshow many topological mutations to applystill a fixed count

Both modules are faithful ports of DXNN2 and are now wired into exoself. The genotype default is DXNN2-faithful (dynamic_random + wsize_proportional).

Surprise (insight 014): the DXNN2-faithful shallow tuner does NOT solve XOR at a 30-agent / 50-generation budget — it plateaus at fitness ~1.2 while the crude deep tuner (all + const 60) solves 3/3. XOR is a small precise-weight problem where deep per-agent hill-climbing dominates and shallow-subset tuning is starved. The tuner's real payoff is expected on larger problems (pole balancing), which is the next measurement. xor_evolves_tests therefore pins the deep config explicitly; the default stays DXNN2-faithful.

2b. Genotype lifecycle integrity across generations — DONE

Status: fixed (insight 011). Evolution runs 150+ generations, fitness climbs monotonically. What remains is search quality, item 2c.

Five coupled bugs, all found by bisecting from a running population: spliced neurons all landing on layer 0; link mutations ignoring feedforward direction; add_bias crashing link_neurons (bias resolved as a pid); a crashing agent cascading through spawn_link to kill the run; and the memetic weight tuner being entirely dead (neurons ignored perturb/backup/restore). All fixed.

2c. Solve XOR through the DXNN path — DONE

Status: solved (insight 012). 4/4 runs solve at generations 12-22.

The 011 plateau (RMSE 0.36) was a tuning-depth limit, not topology: raising the memetic hill-climber's attempts from 15 to 60 lets selection's good topologies tune to completion, and XOR falls. test/integration/xor_evolves_tests.erl is the permanent proof.

Remaining under this heading, deferred:

  • max_attempts is a fixed default (60), not computed per agent. DXNN2 derives it via tuning_duration (item 2).
  • Recurrent networks still lack first-cycle seeding; feedforward only.
  • The insight-004 control comparison (evaluations-to-solve, DXNN path vs domain_sdk weight-only) is now unblocked and is the next real measurement.

3. Mnesia genotype storage (Handbook Ch 8.4.1)

Status: not implemented. Storage is ETS, in-memory, lost on VM exit.

genotype:init_db/0 creates ETS tables. There is no mnesia: call anywhere in src/. genotype.erl:166 notes that part of the API "exists for API compatibility with the old Mnesia interface", and rebar.config records "mnesia removed - now using ETS for genotype storage".

The README and CLAUDE.md claimed Mnesia persistence; those claims are being corrected to describe ETS, and the capability is recorded here instead.

Intended: Mnesia with disc_copies, single node only. Not Mnesia clustering across nodes, which would compete with the genome-over-mesh model used downstream. This also resolves a live split-brain: faber-neuroevolution's lc_chain.erl:398-450 reads genotypes expecting Mnesia while this package stores them in ETS.

Cleanup that comes with it: remove the unused mnesia entry from faber_tweann.app.src's applications list and the vestigial {error, {mnesia_error, term()}} type in network_compiler.erl:43.

4. Oja's rule (Handbook Ch 15.2)

Status: native half exists, Erlang half does not.

native/faber_nn_nifs/src/lib.rs exports oja_update_batch/4, but there is no plasticity_oja module implementing the plasticity behaviour, so it is unreachable. Present implementations are plasticity_hebbian, plasticity_modulated and plasticity_none.

5. Substrate encoding and HyperNEAT (Handbook Ch 16, Ch 17)

Status: not implemented. genotype.erl:271 raises substrate_not_implemented.

This is the largest single item. Chapter 16 is 73 printed pages, roughly 42 of listings, of which substrate.erl alone is a 19-page listing. Chapter 17 adds 18 pages.

New modules: substrate, substrate_cpp (coordinate pre-processor), substrate_cep (connectivity expression producer). Modified: genotype, exoself, genome_mutator, morphology, records.hrl.

A #substrate{} record was scaffolded but nothing implements it. It needs id, agent_id, densities, linkform, plasticity, cpp_ids, cep_ids, plus a substrate_id field on #agent{}.

Design note from the book, worth honouring: do not create separate #substrate_cpp{} / #substrate_cep{} records. Sher explicitly rejects that and instead adds a type field (neural | substrate_cpp | substrate_cep) to the existing #sensor{} and #actuator{} records, so that every mutation operator and linking function does not have to be duplicated.

Ch 16 can be built with plasticity = none; it does not hard-depend on Ch 15. Ch 17 (abcn and iterative substrate plasticity) requires Ch 16.

When this lands, the "Substrate Networks" and "HyperNEAT via CPPNs" claims may return to the faber-ecosystem README. Not before.

6. Measured SIMD

Status: claimed in the past, never implemented, claims withdrawn in v2.0.0.

native/faber_nn_nifs/src/lib.rs contains zero SIMD: no std::arch, no target_feature, no rayon, no par_iter. The former "SIMD Batch Activations" heading described plain sequential map calls. The only concurrency mechanism is rustler's DirtyCpu scheduler flag.

Intended: real vectorisation on the dense/layered path. The sparse topological-order graph walk in CompiledNetwork::evaluate will not vectorise usefully and should be left scalar.

Prerequisite: a benchmark harness whose output is committed. test/benchmark/bench_nif_vs_erlang.erl exists but no recorded numbers do. No speedup figure may be published without committed output naming its execution path via tweann_nif:impl/0.

7. Instrumentation (Handbook Ch 12, Ch 13)

Status: not implemented.

#trace{} and #stat{} records exist in records.hrl but no code emits them. benchmarker, polis and trainer do not exist.

Without benchmarker and the goal_reached signal from item 1, average evaluations-to-solve cannot be measured, and therefore no comparison against the published pole-balancing literature is possible.


8. A genotype that can leave the machine — 8a and 8b DONE, 8c open

Status: not implemented, and it is the gap that decides whether topology evolution is usable by anything outside a single VM.

⚠ This section contains one defect alongside two capabilities. Recording a defect here does not make it a feature; it is here because it is the thing that must be fixed before either capability is worth building.

Topology evolution itself works. genome_mutator dispatches nine topological operators, four LTC and three parametric, over a real genotype graph, with crossover, innovation numbering, three selection algorithms, Pareto ranking and parsimony pressure. test/integration/xor_evolves_tests.erl solves XOR through the full process-per-neuron path in 12 to 22 generations, and pole balancing, LTC and recurrent evolution have equivalent tests.

What an evolved genotype cannot do is leave the VM it was bred in.

8a. ⚠ DEFECT: network_evaluator:from_genotype/1 discards the weights and reports success.

Its @doc says it reads the agent's structure and weights "from Mnesia". Both halves are false: there is no Mnesia (item 3), and no weight is transferred. build_network_from_structure/1 counts the neurons, invents a layer shape (N < 10 -> [N], otherwise two layers of N div 2) and fills it with random weights, under its own comment "Create network with random weights (topology approximation only)". It then returns {ok, Network}.

An evolved champion handed to this function comes back the right size and brain-dead, and nothing errors. The truthful note lives on a private function three hundred lines below the public promise that contradicts it.

Intended: carry the weights when the evolved topology is representable as dense layers, and return {error, not_layerable} when it is not. Correct the @doc. An arbitrary or recurrent genotype must refuse rather than approximate, because a silent approximation behind an {ok, _} is worse than no bridge at all.

8b. Canonical genotype serialisation — DONE.

Landed as genotype_codec, with genotype:to_binary/1, from_binary/1 and genome_id/1 delegating to it. Moved to README.md per this file's rule.

Hand-rolled canonical encoding over the closed term subset a genotype actually contains (atoms, integers, floats, binaries, proper lists, tuples), refusing everything else rather than guessing. Verified against include/records.hrl: these records contain no maps, and a map is the shape that made the sibling's I.12 possible. Lossless, because choosing which fields "matter" would be the same silent lossy conversion that 8a is a defect for. Atoms decode through binary_to_existing_atom/2, so an untrusted genome cannot mint atoms and a genome from an incompatible build is refused by name.

Two things learned by building it, both recorded because they are the kind of thing that gets re-derived wrongly:

  • The VM cannot hold a non-finite float. Arithmetic raises badarith rather than overflowing, and the bit syntax refuses to match infinity and NaN patterns. A finiteness check on the encode side is therefore defensive code for an impossible state. The check belongs only on decode, where bytes arrive from elsewhere.
  • A round-trip assertion cannot see a canonicality regression. The first version of the zero-normalisation test passed with and without the normalisation it was named after, because both forms are deterministic and both round-trip. Found by injecting the regression rather than by reading. It asserts the bytes now. Three regressions were injected in total; two were caught by the tests as written and the third was not, which is why the count is worth stating.

Still true and worth keeping in view: this is orthogonal to item 3. Item 3 is surviving a VM restart on one machine; this is a genome being a value that can travel.

8c. ONNX from an arbitrary DAG — STILL OPEN. The LAYERABLE path is now verified, which it was not before, and two defects in it were fixed on the way.

⚠ Read the status precisely. Arbitrary-DAG export is not implemented. What changed is that the dense-layer path it would extend is no longer unverified.

What was wrong, and how it was found. Every eunit test for network_onnx asserted only that bytes came out and that there were more than zero of them. Nothing ever loaded a model, so nothing could see whether the exported graph computed the right function. scripts/check_onnx_export.escript and scripts/check_onnx_export.py now run the exported model in onnxruntime and compare against network_evaluator:evaluate/2. That is a guard comparing two sides of a boundary, and the first run failed 1 of 4.

  1. The output activation was ignored. get_network_data/1 never read get_output_activation/1, and the hidden activation was applied to every layer including the last. A relu-hidden, linear-output network exported with relu on its output: onnxruntime returned 0.0 where the evaluator returned -0.676. Invisible whenever the two activations are equal, which is what every pre-existing test used.
  2. activation_to_onnx/1 ended in a catch-all returning Tanh. The same silent substitution as network_evaluator's private apply_activation/2, so an activation with no ONNX mapping exported a model computing something else and said nothing. Now a refusal.

Both fixed, both with eunit regressions that do not need Python. The script covers seven cases and all seven agree, including one that goes the whole way: a genotype that only ever existed in ETS, through from_genotype/1 (item 8a), to ONNX, to onnxruntime, at delta 0.0. That is the first evidence that an evolved controller can actually leave the BEAM, as opposed to the claim that it can.

What remains for 8c proper. to_onnx/1 is still -spec to_onnx(network_evaluator:network()), so it takes only the dense-layer representation. Exporting an arbitrary topology needs a topological sort, per-neuron Gather and Concat to assemble arbitrary predecessor sets, and Loop or Scan for recurrence.

One of those is no longer owed. genotype_to_dag topologically sorts a genotype and emits it as a flat indexed node list, so 8c can start from that rather than from an ETS graph. The sort, the index assignment and the cycle refusal are done; what is left is the protobuf emission for arbitrary predecessor sets.

Note the evaluation half already exists: tweann_nif:compile_network/3 and tweann_nif:evaluate/2 handle arbitrary DAG and recurrent topologies exactly. What is missing is serialisation on either side of a runtime that is there. And the work now has a harness that can tell whether it is right, which is the part it did not have before.

9. Memory in the DAG evaluator — the DELAY ORGANELLE is DONE, the leaky integrator is not

Status: a unit delay landed in 2.2.0 and moved to the README. What remains is a richer organelle, and CfC itself.

Done. tweann_nif:evaluate_with_state/3 threads a state vector, and a neuron typed delay is a memory organelle: it emits what it captured last tick and applies no activation, so it adds no ordering constraint and a feedback path through one is not a cycle. Arbitrary topology and temporal memory ARE now available together, which they were not.

The shape was chosen on evidence rather than convenience. Insight 018 ranked the three places memory can live as none, then wiring, then neuron, with CfC last. Insight 023 found that a solver which actually solved a memory task was a pure linear chain. A delay organelle is that chain, offered by the substrate.

Not done, and the reason each is separate:

  • A leaky integrator with an evolvable time constant. Strictly more expressive than a unit delay and closer to what CfC does, at the cost of one evolvable parameter per organelle and a numeric contract the two implementations must match exactly. The state plumbing it needs now exists and is held in agreement by a differential test, which is why delay went first.
  • A mutation operator that adds an organelle. Today a delay can be authored but evolution has no operator that introduces one, so genome_mutator cannot discover memory structurally. That is the step which would make this a TWEANN capability rather than a hand-authoring one.
  • CfC and LTC on the DAG path. Still refused. A per-neuron continuous-time update is a different thing from a unit delay and mapping one onto the other would be an approximation reported as success.
  • A stateful ONNX export, separately owed for the layered path too, where to_onnx/1 already refuses a CfC network. An organelle is the easier case: a state tensor in and out, no Loop or Scan required.

Not planned

  • Mnesia clustering across nodes. Single-node disc_copies only. Genome distribution belongs to the mesh layer, not to the storage engine.
  • Prebuilt NIF artifacts. NIFs are built from source. Shipping prebuilt shared objects couples the package to a specific glibc.
  • A silent NIF fallback. Removed in v2.0.0. Implementation is selected explicitly and a missing library raises. Two implementations silently disagreed for months because the native path was never exercised.