genotype_to_dag (faber_tweann v2.4.0)

View Source

Convert an evolved genotype into the flat node list the DAG evaluator takes, so an arbitrary evolved topology can be flown at inference speed.

This is the counterpart to genotype_to_network. That one targets network_evaluator, which is a stack of dense layers, so it refuses any genotype whose connections skip or cross a layer. This one targets tweann_nif:compile_network/3, which imposes no layer structure at all: any acyclic connection pattern converts.

========================================================================== WHAT THIS BUYS, AND WHAT IT DOES NOT ==========================================================================

Buys: arbitrary feedforward topology, evaluated synchronously, in Rust when the native path is loaded. That is the only route by which a topology produced by genome_mutator can be flown at the rate a simulation needs. The process-per-neuron phenotype is the alternative and it is orders of magnitude slower.

Memory comes from a DELAY ORGANELLE rather than from per-neuron state. A neuron whose neuron_type is delay emits what it captured last tick and applies no activation, so its output does not depend on this tick's inputs. Two consequences, and the second is the point:

The state vector holds one float per organelle rather than one per neuron, so it stays small and its layout is explicit.

⚠ And A FEEDBACK PATH THROUGH A DELAY IS NOT A CYCLE. A delay contributes no ordering constraint, so a genotype where neuron A feeds a delay that feeds back into A converts, sorts and evaluates. A cycle that does NOT pass through a delay is still refused, because that one really has no order.

The other organelle is LEAKY: its state moves toward its input by one part in time_constant each tick and the state is the output. It reads this tick's inputs, so unlike a delay it is ordered normally and does NOT break a cycle. A chain of delays gives discrete memory; a leaky integrator gives a decaying trace, and the two are different tools.

CFC is supported too, and getting here took a detour worth recording. There were THREE implementations of the CfC update in this package, disagreeing by up to 0.36 on the same inputs: ltc_dynamics (the process phenotype), the Rust NIF, and tweann_nif_fallback, which discarded tau entirely. Putting CfC on this path before that was settled would have meant choosing one of the three by accident. The native implementation is now the reference and the fallback mirrors it exactly; this path uses that one.

⚠ LTC proper is still refused. Its update is an Euler step and needs a dt that nothing in the genotype carries, so a value would have to be invented.

========================================================================== THE CONTRACT, WHICH IS TIGHTER THAN THE SPEC SUGGESTS ==========================================================================

The node tuple carries an index, and the two implementations do not treat it the same way. The native compile_network discards it and pushes nodes into a vector, so a node's LIST POSITION is its index. The Erlang fallback builds a map keyed on the index it was given. The two agree only when index equals position, and nothing checks.

They diverge again on a source index that does not exist: the native evaluator indexes a vector and would panic, the fallback reads a map with a default of 0.0 and carries on.

And neither sorts. The native loop iterates the vector once in order, so a connection whose source appears later reads whatever that slot held, which is 0.0. A caller passing an unsorted list gets a silently wrong answer from both.

So this module emits, and asserts, all four:

1. index equals list position, over one contiguous run from zero 2. the first InputCount nodes are the inputs 3. topological order, every source strictly earlier than its consumer, EXCEPT for a delay's own sources, which are read a tick later and may therefore name anything 4. every source index in range

A genotype that cannot satisfy 3 is cyclic, which is recurrence, and is refused rather than evaluated into nonsense.

Summary

Functions

Convert and hand straight to the evaluator.

The flat node list, the input count and the output indices.

Types

dag/0

-type dag() :: {[node_tuple()], non_neg_integer(), [non_neg_integer()]}.

node_tuple/0

-type node_tuple() :: {non_neg_integer(), atom(), atom(), float(), [{non_neg_integer(), float()}]}.

reason/0

-type reason() :: {not_convertible, why()} | {missing, atom(), term()}.

why/0

-type why() ::
          {cyclic, [term()]} |
          {unsupported_neuron_type, atom()} |
          {non_positive_time_constant, term()} |
          {unknown_source, term(), term()} |
          {weight_count_mismatch, term(), non_neg_integer(), non_neg_integer()} |
          no_neurons.

Functions

compile(AgentId)

-spec compile(term()) -> {ok, reference() | map()} | {error, reason()}.

Convert and hand straight to the evaluator.

nodes(AgentId)

-spec nodes(term()) -> {ok, dag()} | {error, reason()}.

The flat node list, the input count and the output indices.

Pure, so it can be inspected and tested without loading a NIF.