HypercubeWorldModel Python SDK

September 10, 2026 · View on GitHub

Definitions

Symbol / termMeaning
WorldModelThe product class: two frozen Encoders plus a trained Predictor.
DecoderReconstruction: a code of 2ᵏ values in, a field of N out. Separate class.
EncoderFrozen hypercube reservoir. View encoder on a dim-cube; action encoder on a k-cube.
PredictorLocally connected net that maps E(x) and E(a) to the next E(x). The only part that trains.
dimView cube dimension. N = 2ᵈⁱᵐ.
NView field length, 2ᵈⁱᵐ. WorldModel.N.
kCode face dimension and action cube dimension. code_size = 2ᵏ.
code, E(x), E(a)Length-2ᵏ arrays. E(x) is the view code; E(a) is the action code.
fieldN values, one per view-cube vertex. What encode reads.
paint_stripesLay a short vector onto a field as contiguous stripes.
code_size, latent_dim2ᵏ. Adapter.latent_dim is this.
passesEpisode length T. 0 means a full tour of that encoder's cube.
z_maxPredictor depth. 0 means k+1.
action_scaleMultiplier on E(a) as the Predictor sees it.
rolloutWorldModel.rollout takes action codes. The DMC adapter's rollout takes raw actions and encodes them once.
action_spaceAdapter bounds: .low and .high. Not a gymnasium Box. The WorldModel never sees bounds.

HypercubeWorldModel is a world model with frozen encoders on a Boolean hypercube. Its neurons sit on the vertices of the cube: a cube of dimension dim has N = 2ᵈⁱᵐ of them. A view, meaning one observation, comes in as a field of N values, one per vertex. A frozen hypercube reservoir, the Encoder, runs an episode on that field and returns a cube of N values, and the first 2ᵏ of them are kept, for some k smaller than dim. Those 2ᵏ values are the view's code, written E(x). It is what other systems call the latent. An action, meaning what the agent did, comes in as a picture of 2ᵏ values and goes through a second frozen Encoder on a cube of dimension k; its whole output is the action code, E(a). A locally connected net, the Predictor, learns to map E(x) and E(a) to the next E(x). It is the only part of the world model that trains.

Two classes own the product. WorldModel is the world model: the two encoders and the Predictor. Decoder is reconstruction: a second locally connected net that takes a code of 2ᵏ values and gives back a field of N, trained on its own. One function, paint_stripes, turns a short vector into a field. A planner needs only the first class. A task that has to see what a code stands for needs the second.

This is a map API, not a stream API: one field in, one episode, one code out. The model does not remember the last view. State that has to persist across steps lives with the caller.

C++ core and contracts: CPP_SDK.md.
The components, one by one: encoder.md / predictor.md / world_model.md / decoder.md.
PyPI-facing package story: python/README.md.
Package version: single source python/hypercube_worldmodel/_version.py. The wheel metadata, hypercube_worldmodel.version, and the compiled core all read it, and the core refuses to build if it disagrees with WorldModel.h.

Contents

Installation

From PyPI (preferred)

Pre-built wheels, no compiler required:

pip install hypercube-worldmodel

Import as hypercube_worldmodel (PyPI name hypercube-worldmodel). Wheels cover Python 3.10 through 3.14 on common Windows (x64), Linux (x86_64, aarch64), and macOS (x86_64, arm64) builds. NumPy is the only runtime dependency.

From source (full repository)

Compile only from a full clone of HypercubeWorldModel. The extension compiles the C++ core, which sits outside the python package directory; a python-only tree is not enough.

Requirements: Python 3.10 or later, a C++23 compiler (GCC 13+, Clang 17+, MSVC 2022+), CMake 3.21 or later, scikit-build-core, pybind11, NumPy.

git clone https://github.com/dliptak001/HypercubeWorldModel.git
cd HypercubeWorldModel/python
pip install .

On Windows with MinGW (for example the CLion toolchain):

pip install scikit-build-core pybind11 numpy
$env:PATH = "C:\path\to\mingw\bin;" + $env:PATH
$env:CMAKE_GENERATOR = "Ninja"
$env:CMAKE_MAKE_PROGRAM = "C:\path\to\ninja.exe"
$env:CC = "C:\path\to\mingw\bin\gcc.exe"
$env:CXX = "C:\path\to\mingw\bin\g++.exe"
pip install . --no-build-isolation

Running tests

From the repository root after install:

pip install "./python[test]"
pytest python/tests -v --import-mode=importlib

Importlib mode avoids the source tree shadowing the installed extension. Run from the repository root, not from inside python: from there the source package, which has no compiled core, shadows the installed one.

Examples

The Quick start below is enough after pip install. Longer demos live in the git tree under python/examples/; they are not part of the wheel. From a clone, repository root:

pip install hypercube-worldmodel   # or: pip install ./python
python python/examples/plane_point.py
python python/examples/plan_toy.py

Quick start

A point on a plane moves by a bounded velocity. The view is the position, the action is the velocity.

import numpy as np
import hypercube_worldmodel as hw

rng = np.random.default_rng(0)
count = 512
obs = rng.uniform(-1, 1, (count, 2)).astype(np.float32)
act = rng.uniform(-1, 1, (count, 2)).astype(np.float32)
obs_next = np.clip(obs + 0.1 * act, -1, 1)

wm = hw.WorldModel(dim=6, k=5, passes=12, leak_rate=0.25, input_scaling=0.8,
                   action_scale=0.33, z_max=15, gather_span=5, tanh_last=True,
                   lr=0.03, lr_min_frac=0.05, restore_best=True)

z = wm.encode(hw.paint_stripes(obs, wm.N))                # (count, code_size)
za = wm.encode_action(hw.paint_stripes(act, wm.code_size))
z_next = wm.encode(hw.paint_stripes(obs_next, wm.N))

wm.fit(z, za, z_next, epochs=200, batch_size=16, verbose=True)

hat = wm.predict(z[0], za[0])          # (code_size,) predicted next view code
path = wm.rollout(z[0], za[:3])        # (4, code_size): z[0] then three steps
wm.save("model.wm")

Explicit (full control)

fit is nothing but this loop. Drive it yourself to interleave your own metrics, schedules, or early stopping:

for epoch in range(epochs):
    wm.set_epoch(epoch, epochs)               # cosine learning rate
    for start in range(0, count, batch_size):
        wm.begin_batch()
        idx = slice(start, start + batch_size)
        wm.accumulate(z[idx], za[idx], z_next[idx])   # forward, loss, backward per row
        wm.end_batch()                        # one Adam step per batch
    wm.observe(wm.evaluate(z_val, za_val, z_val_next), epoch)   # lower wins
wm.restore_best()

The order matters and nothing enforces it for you: accumulate reads the codes you pass and sums into the gradient, end_batch steps the weights, and the gradient is a sum over the batch, so the effective step scales with batch size.

The cycle

construct WorldModel
encode every view and encode_action every action once, into your arrays
for each epoch:
    set_epoch                    (cosine learning rate)
    for each batch:
        begin_batch
        accumulate(z, za, z_next)
        end_batch
    evaluate the validation triples
    observe(metric, epoch)
restore_best
predict / rollout / save

Encoding is outside the epoch loop on purpose. The encoders are frozen, so a code never changes; a stream is encoded once and trained on many times.

What a step is

view field (N values)                 action picture (2ᵏ values)
    |                                     |
    v  view encoder, one episode          v  action encoder, one episode
cube of N values                      cube of 2ᵏ values
    |  keep the first 2ᵏ                  |  keep all of it
    v                                     v
E(x), the view code ------+   +------ E(a), the action code
                          v   v
        (k+1)-cube: E(x) on one half, action_scale × E(a) on the other
                          |
                          v  Predictor, one forward pass
        2ᵏ⁺¹ outputs; the first 2ᵏ are the predicted next E(x)
  • The view encoder runs on the dim-cube; the action encoder is the same kind of machine on a k-cube, built from the same knobs with dim replaced by k.
  • Both encoders are frozen. Nothing in them is ever trained, and a code depends only on its field.
  • The Predictor's cube is k+1, twice the code. Only the first 2ᵏ outputs are read; the loss is taken there and nowhere else.
  • Nothing in the loss mentions the action. Whether the net reads it is a question for a test: feed one view code with two different action codes and see whether the prediction changes.
  • The Decoder stands outside this diagram. It takes any view code, the encoder's or the Predictor's, and gives back a field of N values.

API reference

Constructor WorldModel(dim, k, **kwargs)

All knobs are fixed at construction, the same contract as the C++ WorldModelConfig with its EncoderConfig and Predictor knobs laid flat. Every keyword has a default; the two positional arguments do not.

import hypercube_worldmodel as hw

wm = hw.WorldModel(
    dim,                 # view cube; N = 2**dim; 5 to 24, and at least 6 here
    k,                   # code face and action cube; at least 5, less than dim
    action_scale=...,    # multiplier on E(a) as the Predictor sees it; finite, > 0
    encoder_seed=...,    # encoder weight draw (both encoders)
    ic_seed=...,         # encoder episode start state (both encoders)
    spectral_radius=..., # target for the recurrent block; finite, > 0
    leak_rate=...,       # leaky integrator mix; finite, in (0, 1]
    input_scaling=...,   # input drive strength; finite
    history_depth=...,   # delay line length M; 1 to 64
    passes=...,          # passes per episode T; 0 = a full tour of each cube
    z_max=...,           # Predictor depth; 0 = k+1, else >= 2
    gather_span=...,     # Predictor lookback window width; 2 to 6
    tanh_last=...,       # True: tanh on the Predictor's last depth too
    seed=...,            # Predictor weight draw
    lr=..., lr_min_frac=..., lr_decay_epochs=...,   # Adam and cosine schedule
    restore_best=..., beta1=..., beta2=..., eps=...,
)
ParameterTypeDescription
dimintView cube dimension. N = 2ᵈⁱᵐ. The Encoder accepts 5 to 24; a WorldModel needs at least 6 so that k has room.
kintCode face dimension and action cube dimension. At least 5, strictly less than dim. code_size = 2ᵏ.
action_scalefloatMultiplier on E(a) as it is laid next to E(x) for the Predictor. Finite, > 0. Brings E(a) to the level of E(x).
encoder_seed, ic_seedintWeight draw and episode start state. Both encoders use both; the action encoder differs only in its cube.
spectral_radiusfloatTarget for the recurrent block. Finite, > 0.
leak_ratefloatLeaky integrator mix. Finite, in (0, 1]; 1 is full replacement each step.
input_scalingfloatInput drive strength. Finite.
history_depthintDelay line length M, 1 to 64.
passesintPasses per episode T. 0 means a full tour of whichever cube the encoder sits on: N for the view, 2ᵏ for the action.
z_maxintPredictor depth. 0 means k+1, antipodal reach on the Predictor cube; else at least 2.
gather_spanintPredictor lookback window width, 2 to 6.
tanh_lastboolTrue applies tanh on the Predictor's last depth too.
seedintPredictor weight draw.
lrfloatAdam step size and cosine peak. Finite, > 0.
lr_min_fracfloatCosine floor as a fraction of lr, in [0, 1]; 1 is a constant rate.
lr_decay_epochsintCosine horizon; 0 uses the epochs given to fit or set_epoch.
restore_bestboolSnapshot the Predictor weights on a new low observed metric; fit restores at the end.
beta1, beta2floatAdam moment decays, in [0, 1).
epsfloatAdam denominator floor. Finite, > 0.

What the encoder knobs do to an episode is in encoder.md. What the training knobs do to a run is in predictor.md.

Methods

Every method that takes codes or fields accepts one row (a 1-D array) or many (a 2-D array with one row per sample) and returns the same shape. The loop over rows runs in C++ with the GIL released.

MethodRole
encode(fields)View codes. (N,) or (count, N) in; (code_size,) or (count, code_size) out.
last_cube()The full N-value episode behind the most recent encode.
encode_action(pictures)Action codes. (code_size,) or (count, code_size) in and out. A full episode on the action cube per row, not a lookup.
predict(z, za)Predicted next view codes. Matching rows, or one za broadcast against many z.
rollout(z0, actions)Chain predict over a plan of action codes: (H, code_size) or (count, H, code_size) in; (H + 1, code_size) or (count, H + 1, code_size) out, row 0 being z0.
pack(z, za)What the Predictor sees, 2 × code_size per row. Rarely needed.
fit(z, za, z_next, *, epochs, batch_size, val, shuffle_seed, verbose)Shuffle, batch, cosine schedule, restore-best. With val as a (z, za, z_next) tuple the observed metric is evaluate on it, else the mean training loss. Returns self. Calling again continues from the current weights.
evaluate(z, za, z_next)Mean squared error per code value of predict against z_next. Lower is better.
begin_batch()Clear the accumulated gradient.
accumulate(z, za, z_next)Forward, loss, backward for every row; returns the summed loss, 0.5 × SSE over the code per pair.
end_batch()One Adam step on the accumulated gradient.
set_epoch(epoch, num_epochs)Apply the cosine schedule for this epoch.
observe(metric, epoch)Restore-best bookkeeping; lower wins, strictly.
restore_best()Write the best-metric snapshot back into the Predictor.
reset_training()Forget the optimizer run: Adam moments, step count, lr, best snapshot. Weights untouched.
add_grad(g)Sum a gradient of the same layout onto the accumulated one, for hosts that train replicas.
save(path) / WorldModel.load(path)The binary file the C++ WorldModel::Save and Load use.

Properties

PropertyMeaning
dim, kGeometry as given
N, code_size2ᵈⁱᵐ and 2ᵏ
passes, action_passesResolved passes per view episode and per action episode
z_maxResolved Predictor depth (0 already replaced by k+1)
action_scaleAs given
num_weightsPredictor weights: 2ᵏ⁺¹ × (k+1) × gather_span × z_max
weightsThe Predictor weights as a float32 array, layout depth, axis, tap, vertex. Settable, exact length required
gradThe accumulated gradient, same layout as weights
realized_spectral_radius, action_realized_spectral_radiusThe estimate each encoder settled on

paint_stripes(x, size)

Lays a short vector onto a field as contiguous stripes: cell j of the result takes value j × d // size of x, where d is the vector's length, so each value fills a block of about size / d cells and every cell is written. x is (d,) or (count, d) with 1 ≤ d ≤ size; the result is (size,) or (count, size), float32. Use size N for a view and code_size for an action. It is the same arithmetic as the C++ PaintStripes, and the test suite checks that.

The Decoder

The Decoder is a locally connected net on the dim-cube that takes a code of 2ᵏ values, places it on the first 2ᵏ vertices with every other vertex fed zero, and writes a field of N values: the reconstruction. Training pairs are a code and the field it came from. Once trained, it decodes any code of 2ᵏ values, whether encode produced it or predict did. It is a separate class with its own weights, its own training cycle, and its own file, and it never touches the WorldModel.

Pairing it with a WorldModel is two numbers: its dim is the WorldModel's dim and its k is the WorldModel's k.

dec = hw.Decoder(
    dim, k,              # the WorldModel's dim and k; k strictly less than dim
    z_max=...,           # depth; 0 = dim, else >= 2
    gather_span=...,     # lookback window width; 2 to 6
    tanh_last=...,       # True: tanh on the last depth too
    seed=...,            # weight draw
    lr=..., lr_min_frac=..., lr_decay_epochs=...,   # Adam and cosine schedule
    restore_best=..., beta1=..., beta2=..., eps=...,
)

dec.fit(z, fields, epochs=..., batch_size=...)   # fits the input scale first
field = dec.decode(z[0])                          # (N,)
fields = dec.decode(z)                            # (count, N)
dec.save("model.dec")
again = hw.Decoder.load("model.dec")
Method or propertyRole
decode(codes)Reconstructed fields. (code_size,) or (count, code_size) in; (N,) or (count, N) out.
fit(codes, fields, *, epochs, batch_size, val, shuffle_seed, verbose, fit_input_scale)The standard cycle. Sets the input scale from codes first unless fit_input_scale is False. val is a (codes, fields) tuple.
evaluate(codes, fields)Mean squared error per field value of decode against fields.
fit_input_scale(codes)Set the input scale to 1 / max abs over every value, so scaled codes lie in [−1, 1]. Frozen after that and saved with the weights.
input_scaleThe scale in effect. Settable.
begin_batch, accumulate(codes, fields), end_batch, set_epoch, observe, restore_best, reset_training, add_gradThe same cycle as WorldModel. accumulate returns the summed loss, 0.5 × SSE over the field per pair.
weights, grad, num_weightsAs for WorldModel; num_weights is N × dim × gather_span × z_max
dim, k, N, code_size, z_maxGeometry; z_max resolved
save(path) / Decoder.load(path)The binary file the C++ Decoder::Save and Load use: config, input scale, weights.

What the knobs do, and how the reconstruction error behaves as k moves, is in decoder.md and compression_test.md.

Shapes

ArrayShapeNotes
a view field(N,) or (count, N)one value per vertex of the view cube
an action picture(code_size,) or (count, code_size)one value per vertex of the action cube
a code(code_size,) or (count, code_size)what encode, encode_action, and predict return; what predict, accumulate, and decode take
a plan(H, code_size) or (count, H, code_size)action codes for rollout
a rollout(H + 1, code_size) or (count, H + 1, code_size)row 0 is the start code
a short vector(d,) or (count, d)what paint_stripes takes, d ≤ the target size

Every array is converted to contiguous float32 on the way in; prefer handing over float32 to avoid the copy. Returned arrays are float32 copies that you own.

Driving the model from a planner

A sampling planner needs a small surface from a model: encode a view, encode an action, step a code, roll a code out over a plan, and score the result. The package gives the first four and leaves the fifth to the caller, because scoring depends on the task and not on the model.

The DeepMind Control Suite protocol for a latent world model maps onto the package through a thin adapter. WorldModel.rollout takes action codes. The adapter's rollout takes raw actions (B, H, act_dim), paints and encode_action's that block once, then calls WorldModel.rollout. CEM from the evaluation guide is then copy-paste: it samples in action space and passes those arrays to rollout.

This path is state-based. Concatenate the DMC observation dict into a vector, paint_stripes onto N, encode. A 64×64 RGB frame is thousands of pixels; N is 2ᵈⁱᵐ (64 at dim 6). paint_stripes cannot put a camera frame on the cube. Pixels need a painter the host writes. The evaluation guide says the same for a hypercube encoder: feed the state vector, skip rendering.

Planner needsWhereNote
latent_dimcode_size2ᵏ. Adapter.latent_dim
encode(obs)paint_stripes then encodeobs is (count, obs_dim) state. Not a pixel frame
encode_action(a)paint_stripes then encode_actionraw bounded actions. A full action episode per row
predict(z, za)predictcodes in, codes out; batches in C++
rollout(z0, actions)adapter: raw (B, H, act_dim)encodes the block once, then WorldModel.rollout on codes
action_space.low / .highadapter.action_spaceplanner clips here; the WorldModel never sees bounds
cost(zs, goal)callerdistance to a goal code, or a reward probe on frozen codes

A goal is a view like any other: paint it, encode it, and compare codes. python/examples/plan_toy.py is that adapter, with CEM in the guide's shape. For a state-based suite task, swap the environment and keep the adapter.

Error handling

Python-side checks raise ValueError with a short message: a bad array shape, a code or field of the wrong length, mismatched row counts, a weight array of the wrong length, epochs or batch_size below 1. The C++ constructors validate every config range and every batched call validates lengths; their std::invalid_argument maps to ValueError. File trouble in save and load maps to RuntimeError.

Typical mistakes:

SymptomFix
ValueError on k at constructionk must be at least 5 and strictly less than dim, so dim must be at least 6
ValueError on a float knobspectral_radius, leak_rate, input_scaling, action_scale, lr, and eps must be finite; a NaN is rejected even under fast-math
ValueError on encodeThe field must be N long; a state vector goes through paint_stripes first
ValueError on encode_actionThe picture must be code_size long, not N; the action cube is k, not dim
ValueError on rolloutactions must be (H, code_size) for one start code, (count, H, code_size) for many, with count matching z0
Loss looks hugeaccumulate returns 0.5 × the sum of squared error over the code, summed over the rows
Loss never fallsCheck the cycle order, and that begin_batch runs per batch, not per epoch
Two actions give the same predictionThe Predictor may be ignoring E(a); raise action_scale, and check that one view code with different action codes gives different predictions
A code changed between runsIt cannot; the encoders are frozen. Check the field, or the seeds
Second fit behaves oddlyreset_training first; Adam moments and step count persist
restore_best did nothingrestore_best must be True, and observe must have seen a finite, lower metric
decode output is nonsensefit_input_scale was skipped, or was fitted on other codes; it is one constant and it is saved with the weights
Weights load rejectedThe length must equal num_weights: same k, z_max, and gather_span

Model persistence

MechanismWhat is storedOptimizer state?
WorldModel save / loadConfig, Predictor weights, in the C++ WorldModel file formatNo
Decoder save / loadConfig, input scale, weights, in the C++ Decoder file formatNo
pickle, either classConstructor keywords plus weights (plus the input scale for a Decoder)No

save writes the same binary file the C++ class writes, so a model trained in Python loads in C++ and the other way round. load rebuilds both encoders from the seeds in the file and puts the weights back; encode, encode_action, and predict on the loaded instance reproduce the original exactly, and the test suite checks all three with passes left at 0, the case where the two encoders resolve different T.

pickle captures the constructor keywords as given, passes at 0 included, and the weight array, so an unpickled model rebuilds the same way. The pickle version is bumped when the layout changes; newer libraries reject unknown future versions with an upgrade message.

The raw weight array is also yours through the weights property, so any external format works without either.

Security: loading a pickle uses pickle.load. Never unpickle untrusted files. The save and load files are plain binary with a magic and a version and carry no code.

Limitations

  • One WorldModel or Decoder is not thread-safe for concurrent calls from multiple host threads. Separate instances on separate threads are fine; the extension releases the GIL during encode, encode_action, predict, rollout, decode, accumulate, and end_batch, so multi-instance threading gets real parallelism.
  • encode_action is a full episode on the action cube, T passes, and it costs more than a predict. A sampler that paints and encodes every raw action it draws pays that for every sample at every horizon step, and that, not predict, is the hot path. Encode each distinct action once and reuse the code; a planner with a finite action set encodes the set up front.
  • predict and rollout loop over rows in C++ but run one Predictor pass per row on one thread. A planner that wants a batch across cores creates one WorldModel per thread with the same keywords, shares weights through the weights property, and drives them from Python threads.
  • fit drops a short tail each epoch so every Adam step sees exactly batch_size samples, unless the whole set is smaller than one batch.
  • Native contracts and the C++ surface: CPP_SDK.md.

Dependencies

LayerWhat
RuntimeNumPy
Wheel installNo compiler
From-source buildFull repo clone, C++23, CMake 3.21 or later, scikit-build-core, pybind11

The C++ core is compiled into the extension itself.