Skip to content

Contracts

Three rules survive contact with every task: effectors don’t mean the same thing across tasks, raw scores aren’t comparable across tasks, and evolvable parameters stay separate from runtime state. This page is the contract you lean on when reading a run or writing a node — not a how-to. To add a part, see Extending it; for preset/task/ablation tables and the sweep schema, see the Reference.

Effectors are non-uniform

Every reservoir emits an effector vector EE, but each task/body decodes it differently. Channel 1 in Pong is not the same physical quantity as channel 1 in cartpole or torus. See Environments & Tasks for the per-task RR/EE widths; here is how each body reads the vector:

task / bodyEdecode
:wall2differential wheel speeds. eL, eR clamped to [0,1][0,1]; speed (eL+eR)/2(e_L+e_R)/2, heading change eReLe_R-e_L; a wall hit forces a random ±45° turn with no translation.
:tracking2eye-rotation command. Eye heading changes by 10(e1e2)10(e_1-e_2)° per tick as the stimulus advances separately.
:pong, :pong_hitrate2paddle vote. Paddle moves 100(e1e2)100(e_1-e_2), clamped to range.
:cartpole (+ _hard/_swingup/_long)2binary force vote. e1e2e_1 \ge e_2 \Rightarrow negative force, else positive. Variants change force/bounds/initial state, not the interface.
:torus / VENBody3VEN kinematics. e3e_3 drives forward acceleration; e2e1e_2-e_1 drives heading acceleration; speed and heading rate are damped/capped by VENParams.

Source: src/tasks/Tasks.jl, src/world/Body.jl.

normalized_score is bounded, not comparable

normalized_score(task, raw_score) is the per-task transform rollout, evolve, and the benchmark use to make fitness comparable enough for optimizer bookkeeping:

clamp ⁣(rawfloorceilingfloor,0,1)\operatorname{clamp}\!\left(\frac{\text{raw} - \text{floor}}{\text{ceiling} - \text{floor}},\, 0,\, 1\right)

It is not a universal physical unit. Saturation at 0 or 1 means the raw score fell outside the chosen floor/ceiling — the value is pinned, not meaningfully equal across tasks.

taskraw keyfloorceilingraw score means
:wall:nav_score0.7631.0collision_free_rate * movement_gate (collision-free navigation while moving)
:tracking:track_score0.01.0mean cosine alignment to the stimulus
:pong / :pong_hitrate:hit_rate0.3560.701fraction of return opportunities hit (identical TaskSpec)
:cartpole:score0.01.0survival, step_count / default_ticks
:cartpole_hard:score0.01.0fraction balanced, hard variant
:cartpole_swingup:mean_uprightness0.1571.0mean (cosθ+1)/2(\cos\theta + 1)/2
:cartpole_long:score0.01.0fraction balanced, long pole

Base :cartpole reports survival; the variants report a window score (balanced_fraction or mean_uprightness). Both normalize onto [0,1][0,1], but the raw meanings differ. :torus is a registered swarm symbol, not a TaskSpec with a floor/ceiling — read torus runs through collective metrics instead.

Source: src/tasks/Tasks.jl (normalized_score), src/tasks/Scoring.jl (floor/ceiling).

Ensemble metrics

rollout!(collective, ticks; window) returns the task/env metrics plus liveness diagnostics:

  • score — task-specific raw scalar from single-agent envs; meaning depends on the table above. Normalize before any cross-task comparison.
  • polarization — heading alignment: length of the mean unit heading vector. 0 disordered, 1 fully aligned.
  • milling — absolute mean rotational order about the centroid; high means agents circle the group center.
  • liveness — reservoir-activity sanity check over the window: rate_mean, rate_var, total_spikes_window, alive. alive requires 0.01<rate_mean<0.990.01 < \text{rate\_mean} < 0.99, nonzero variance, and enough spikes.

Swarm runs also report mean nearest-neighbour distance, mean pairwise distance, cohesion (nearest + pairwise), and input stability.

Source: src/world/Ensemble.jl, src/world/Metrics.jl.

Node & extension contract

A high-level node constructor must accept (n_nodes, n_receptors, n_effectors; seed=0, kwargs...) and return a Reservoir implementing:

step!(r, receptors) # advance one tick, return spikes/rates
effectors(r, spikes) # map spikes/rates to an E-vector of length n_effectors(r)
reset!(r) # reset runtime state
n_receptors(r)
n_effectors(r)

Because you’re adding methods to BrainlessLab’s generics, import them — using alone won’t attach your methods and simulate won’t dispatch to your node:

import BrainlessLab: step!, effectors, reset!, n_receptors, n_effectors

Keep genotype and runtime state separate — do not hide state inside pack_params, and do not expect snapshot_state to define the search space:

  • pack_params / unpack_params / paramdim — evolvable parameters an optimizer stores, mutates, and reloads as a genome.
  • snapshot_state / load_state! — transient state (membrane activations, learned weights, spike buffers, noise index, compartment voltages) for replay/reset.

The full node contract and a copy-ready skeleton live in Extending it and Nodes overview.

Source: src/core/Interfaces.jl.