Encoder

September 10, 2026 · View on GitHub

Status: implemented in Encoder/.

Definitions

Symbol / termMeaning
EncoderThis component: a frozen hypercube reservoir that encodes one field per episode.
dimDimension of the cube. Valid range [5, 24].
NNumber of vertices, N = 2ᵈⁱᵐ. Also the length of one field.
cubeAll N vertices. Each vertex is a neuron, labelled by a dim-bit address.
fieldOne sample: N values, vertex i holding sample i. What you encode.
neighborThe vertex reached by flipping one address bit. Every vertex has dim neighbors.
episodeOne RunEpisode call: load s₀, drive T passes, return the newest slice.
s₀Start state: N × M floats of delay-line history, drawn once at construction. State, not weights.
ic_seedSeed for drawing s₀. Separate from the weight seed.
MHistory depth: how many past outputs the delay line keeps. Valid range [1, 64].
sliceOne age of the delay line, N floats. Age 0 is the newest; that is what RunEpisode returns.
TPasses per episode (config field passes). 0 means T = N, a full tour.
cPass counter within an episode, 0 .. T−1.
driveThe length-N field injected on one pass: the input re-addressed by XOR with c.
leak rateMix between the previous output and the new tanh. 1 means full replacement.
input scalingHow hard the field drives the cube. Input weights are drawn U(−1, 1), then multiplied by input_scaling / √dim.
spectral radiusTarget size of the recurrent operator, applied to the recurrent weights only.
seedMaster seed for the weight draws. Named substreams keep input, recurrent, and probe draws from colliding.
kDimension of the face kept for the Decoder. Strictly less than dim. Compression is this cut.

A still field has no next sample. The encoder invents a short stretch of time: it shows the same field to the cube T times, each time under a different addressing, and reads the reservoir once at the end. The weights never move. The start state is the same every episode. The same field therefore always produces the same cube. That is the encoding.

The cube it returns has N values. Compression is keeping a k-dimensional face of that cube, k < dim, and handing only that prefix to the Decoder. The encoder itself always produces the full cube; the cut is the caller's. How the Decoder puts a field of length N back from that face is decoder.md.

The Encoder lives in Encoder/ and depends on nothing else in this tree. Nothing in Encoder includes Decoder.

The cube

Label each vertex with a dim-bit integer. Two vertices are neighbors exactly when their labels differ in one bit. To walk to a neighbor, flip that bit:

v_nn = v XOR (1 << axis)

A field is one float per vertex, laid out in address order. The input is a field. The output is a field. In between, each vertex looks only at its neighbors — dim of them, never the whole cube, and never itself.

Weights and a step

At construction the encoder draws two blocks of weights and then freezes them. Nothing in an episode touches them.

The input block is N × dim: each vertex has one weight per neighbor for the staged drive. Those weights start as U(−1, 1) and are then scaled by input_scaling / √dim. That knob is how hard the field pushes the cube.

The recurrent block is N × M × dim: each vertex has one weight per neighbor per delay-line age. Those weights are drawn the same way, scaled by 1 / √(dim · M), then rescaled as a block so the delay-line operator has spectral radius near the configured target. The target is a construction setting; RealizedSpectralRadius() is the estimate after that rescale.

One step at a vertex: sum the neighbor drives through the input weights, sum the neighbor history through the recurrent weights, take tanh, and mix with the previous output by the leak rate. Leak rate 1 replaces the output entirely. The delay line then ages: the new output becomes age 0, and the staged drive is cleared.

An episode

Call RunEpisode once per field. Inside, in order: reload s₀, then for each of T passes build the re-addressed drive, inject it, and step; then return a pointer to the newest slice.

Start state s₀. N × M floats, one full delay line. Drawn once, at construction, from ic_seed, i.i.d. uniform on [−0.5, 0.5]. This is state, not weights. Each episode copies it into the live delay line and sets c to 0, so every field starts the orbit from the same place and nothing carries over from the field before.

The drive. The caller's field x is never modified. On pass c, vertex v is driven by the value at address v XOR c, masked to N−1. XOR with c is a fixed, invertible re-addressing: the geometry and the weights stay put, only the registration of the field moves. Incrementing c and driving again is the synthetic time series — the same picture, T successive addressings. T = 0 in the config means T = N, a full tour. Larger T wraps.

Read once. After the last pass, RunEpisode returns the newest slice, N floats, valid until the next RunEpisode.

const float* out = enc.RunEpisode(x);   // x: N floats. out: N floats.

That pointer is the full encoder state. The Decoder takes the first 2ᵏ of it, k < dim, and nothing else.

Files

Encoder/
    Encoder.h        EncoderConfig, class Encoder
    Encoder.cpp

CMake target Encoder, a static library with no further links.

Interface

struct EncoderConfig
{
    size_t   dim;               // cube dimension; N = 2ᵈⁱᵐ; [5, 24]
    uint64_t seed;              // weight draws
    float    spectral_radius;  // recurrent target, > 0
    float    leak_rate;         // (0, 1]
    float    input_scaling;     // drive strength
    size_t   history_depth;     // M; [1, 64]
    size_t   passes;            // T; 0 means T = N
    uint64_t ic_seed;           // s₀ draw; separate from seed
};

class Encoder
{
public:
    static std::unique_ptr<Encoder> Create(const EncoderConfig& cfg);

    // One episode. x is not modified. Newest slice, N floats,
    // valid until the next RunEpisode.
    const float* RunEpisode(std::span<const float> x);

    size_t Size() const;                   // N
    EncoderConfig Config() const;          // passes already resolved
    float RealizedSpectralRadius() const;
};

Validation at Create: dim in [5, 24], spectral radius finite and positive, leak rate finite and in (0, 1], input scaling finite, history depth in [1, 64]. The finiteness test is on the bits, since std::isfinite is unreliable under fast-math. passes = 0 is stored as N. RunEpisode throws if x is not length N.

Contract summary

  • Weights and s₀ are fixed at Create. An episode does not train anything.
  • RunEpisode always reloads s₀ first. Episodes do not chain.
  • The caller's field is not modified. The drive is built in scratch.
  • The pointer from RunEpisode is the newest slice, N floats, and is invalid after the next RunEpisode.
  • Config reports the resolved T, not a stored 0.
  • Compression is the k-face the caller keeps. The encoder always returns N.