robo_net (faber_tweann v2.4.0)
View SourceRobo Rumble: the deterministic integer forward pass (pure, table-driven).
The companion to robo_sim. The engine was always going to be integer; the FORWARD PASS is the real determinism hazard named by PLAN_ROBO_RUMBLE.md section 3, because math:tanh/1 comes out of libm and libm is not bit-identical across libc versions. A controller that called it would replay differently on two honest boxes, which is worse than cheating because nobody could tell. So there is no libm here and no float here.
UNITS. The network works in Q12: 4096 is 1.0. This is deliberately NOT the arena's scale of 256. Arena quantities widen into Q12 by an exact shift of 4, losing nothing. Q12 puts 8193 distinct levels on an activation, against the 1025 distinct values of the widest intent field, so activation quantisation is never what limits a controller. Weights are Q12 as well, so a weight times an activation is Q24, the dot product accumulates in Q24 with NO intermediate rounding, and exactly ONE rounding happens per neuron, in activate/1.
THAT SINGLE ROUNDING IS A RULE, not a convenience. Integer addition is associative, so an exact Q24 accumulator makes the summation order irrelevant: visiting the same weight-input pairs in any order gives the same neuron, to the bit. Narrowing per term instead would make a neuron depend on the order its inputs were visited, which is a real cross-machine divergence the moment anything reorders a list. The pairing is of course not free to change, only the order in which the pairs are summed.
SMOOTHNESS IS NOT REQUIRED, and the choice of tanh should not be read as claiming otherwise. Evolution uses no gradients. The engine hard-clamps every intent field, so the terminal nonlinearity of any controller is a hard clamp whatever the hidden activation is. What the task actually needs from a nonlinearity is that it be bounded, odd, monotone, and flat late rather than early, so that neurons do not park on an exactly-zero-slope plateau where weight mutations produce no behavioural change and selection has nothing to grade. tanh is chosen from that family for two reasons only: its saturation onset in Q12 is late (4.875), and every table entry below has a one-line closed form a reviewer can recompute, which a fitted polynomial does not.
Every operation below is integer comparison, negation, addition, subtraction, multiplication, arithmetic shift, mask and tuple indexing. All of those are exactly specified by the Erlang language with no implementation freedom, and Erlang integers are arbitrary precision so nothing can overflow or wrap. That is what makes a match a pure function of its inputs.
THE NETWORK. A fixed-topology feedforward multilayer perceptron. The topology is a list of layer widths, so [8, 10, 5] is eight sensors, one hidden layer of ten, five outputs. Every layer including the OUTPUT layer is passed through activate/1, which is why the output range is guaranteed rather than hoped for and why to_range/2 is total. The parameters are ONE FLAT LIST OF INTEGERS at the arena's scale of 256, because the optimisers this repo already owns (mu_lambda_es, sep_cma_es) evolve flat vectors and nothing else. The layout is fixed and is part of the genome contract: layers in order, neurons within a layer in order, and for each neuron its BIAS FIRST followed by one weight per input in input order. weight_count/1 is the length that layout requires.
THE FLOAT BOUNDARY IS quantize/1 AND NOTHING ELSE. Optimisers search in floating point, so somewhere a float has to become an integer. That happens once, at PHENOTYPE BUILD TIME, before the match starts, and its result is what gets hashed into the genome. No function reachable from eval/3 touches a float. If a float ever appears downstream of quantize/1 the determinism guarantee is void, so that boundary is the thing to defend in review.
ROUNDING: every mapping here is exactly ODD, applied by splitting on sign, but they are NOT all the same rounding rule and the difference matters to a porter. quantize/1, narrow/1 and to_arena/1 round HALF AWAY FROM ZERO: quantize/1 via the round/1 BIF, the other two by adding their own constant before shifting. to_range/2 TRUNCATES TOWARD ZERO; it adds no constant at all. That was stated wrongly here until an audit caught it, and it is the one place where following this header rather than the code produces cross-language divergence, because adding the missing constant would change every intent field the network emits on every turn. The consequence worth stating is negative: no arithmetic right shift in this module is ever applied to a negative operand, so the classic fixed-point trap of mixing floor semantics with truncate-toward-zero semantics is not merely specified here, it is never reached. A port to another language must copy that rule rather than substitute a division, which truncates toward zero in C and Rust and floors in Python.
Summary
Functions
-spec activate(integer()) -> -4096..4096.
-spec eval([non_neg_integer()], [integer()], [integer()]) -> [integer()].
-spec eval_q12([non_neg_integer()], [integer()], [integer()]) -> [integer()].
-spec scale() -> pos_integer().
-spec tanh(integer()) -> -4096..4096.
-spec to_range(integer(), non_neg_integer()) -> integer().
-spec weight_count([non_neg_integer()]) -> non_neg_integer().
-spec weight_limit() -> pos_integer().