faber-tweann Roadmap
View SourceWhat 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_simandpb_simexist.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_velocitysolves through the DXNN path (insight 015).fx_sim(Ch 19),flatland(Ch 18) andsnake_duelremain, 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:
| Module | Role | State |
|---|---|---|
tuning_selection | chooses which neurons to perturb | built — src/tuning_selection.erl (dynamic / dynamic_random / active / current / all), invoked from exoself's perturb_weights/1 |
tuning_duration | computes max_attempts per agent per generation | built — src/tuning_duration.erl (const / wsize_proportional), invoked from exoself's compute_max_attempts/1 |
tot_topological_mutations | how many topological mutations to apply | still 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
badarithrather 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.
- The output activation was ignored.
get_network_data/1never readget_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. activation_to_onnx/1ended in a catch-all returning Tanh. The same silent substitution asnetwork_evaluator's privateapply_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_mutatorcannot 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/1already 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_copiesonly. 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.