This guide walks through how to add a new brain architecture to
quantum-nematode. The codebase uses a decorator-registration plugin
registry so that adding an architecture is a contained edit — no per-arch
branches survive in the simulation entrypoint or YAML loader.
This guide is the canonical reference for what files to touch and in what order. If you're adding a brain that re-uses an existing topology / learning-rule combination, you may not even need to touch all of them.
Adding a vanilla new architecture should touch at most six files:
packages/quantum-nematode/quantumnematode/brain/arch/<name>.py— the new module (Brain class + Config class + decorator).packages/quantum-nematode/quantumnematode/brain/arch/dtypes.py— add aBrainTypeenum member matching the registered name.packages/quantum-nematode/quantumnematode/brain/arch/__init__.py— import the new module so its decorator runs at package-import time, and re-export the public symbols.packages/quantum-nematode/quantumnematode/utils/config_loader.py— add the new config class to theBrainConfigTypeunion so Pydantic's smart-union picks the right class when loading YAML.packages/quantum-nematode/quantumnematode/utils/brain_factory.py— add a_build_infra_kwargsbranch only if the new brain consumes a different subset of infrastructure kwargs than the default ({num_actions: 4, device}).- A test file under
packages/quantum-nematode/tests/quantumnematode_tests/brain/arch/.
Plus optionally a YAML config under configs/scenarios/ if you're
shipping a runnable baseline alongside the brain.
If you find yourself touching more than six files for a vanilla architecture, the dispatcher / loader factoring has regressed — surface that as a design issue rather than working around it.
Worked example: a hypothetical 1-hidden-layer MLP that consumes food-chemotaxis features and emits 4 action logits. Use this as a template; the same shape applies to any architecture you add.
packages/quantum-nematode/quantumnematode/brain/arch/tinymlp.py:
"""Tiny MLP brain — single hidden layer, REINFORCE update rule."""
from __future__ import annotations
import torch
from torch import nn
from quantumnematode.brain.arch._brain import BrainParams, ClassicalBrain
from quantumnematode.brain.arch._registry import register_brain
from quantumnematode.brain.arch.dtypes import BrainConfig, BrainType
class TinyMLPBrainConfig(BrainConfig):
hidden_dim: int = 16
learning_rate: float = 1e-3
@register_brain(
name="tinymlp",
config_cls=TinyMLPBrainConfig,
brain_type=BrainType.TINY_MLP,
families=("classical",),
)
class TinyMLPBrain(ClassicalBrain):
def __init__(
self,
config: TinyMLPBrainConfig,
num_actions: int = 4,
device: str = "cpu",
) -> None:
super().__init__()
self.config = config
self.device = torch.device(device)
self.net = nn.Sequential(
nn.Linear(2, config.hidden_dim),
nn.Tanh(),
nn.Linear(config.hidden_dim, num_actions),
).to(self.device)
self.optimizer = torch.optim.Adam(
self.net.parameters(),
lr=config.learning_rate,
)
# ... rest of the Brain Protocol surface: preprocess, run_brain,
# update_memory, prepare_episode, post_process_episode, copy.Required keyword arguments to @register_brain:
name: the registry key. Must matchbrain_type.valueexactly — the startup-time consistency check inbrain/arch/__init__.pywill fail fast if they drift.config_cls: the PydanticBrainConfigsubclass the brain accepts.brain_type: the enum member; see § 2.families: a tuple of family tags. Common values:("classical",),("quantum",),("spiking",), or any combination (e.g. a spiking quantum architecture uses("quantum", "spiking")). The family sets exposed fromdtypes.py(QUANTUM_BRAIN_TYPES,CLASSICAL_BRAIN_TYPES,SPIKING_BRAIN_TYPES) are derived from these tags at module-import time, so the family set membership tracks registrations automatically.
packages/quantum-nematode/quantumnematode/brain/arch/dtypes.py:
class BrainType(StrEnum):
# ... existing members ...
TINY_MLP = "tinymlp"The string value must match the name= argument passed to
@register_brain in step 1. The startup-time consistency check
guarantees you can't forget either side.
BrainType is a StrEnum, so BrainType.TINY_MLP == "tinymlp" holds
directly. You can use enum members as dict keys, in match-case
patterns, and in tests interchangeably with the string form.
Add the import + the public re-exports:
from .tinymlp import TinyMLPBrain, TinyMLPBrainConfig
__all__ = [
# ... existing entries ...
"TinyMLPBrain",
"TinyMLPBrainConfig",
]The import is what triggers the @register_brain decorator to run.
Without it, the registry will not know about the new arch and the
consistency check will fail at import time because the enum member
exists with no matching registration.
packages/quantum-nematode/quantumnematode/utils/config_loader.py:
from quantumnematode.brain.arch import (
# ...
TinyMLPBrainConfig,
)
BrainConfigType = (
# ...
| TinyMLPBrainConfig
)This is what lets Pydantic's smart-union pick the right config class
when loading a YAML file with brain.name: tinymlp. If you forget
this step, the YAML will silently parse to the closest-shaped existing
config class and drop any fields specific to your architecture.
packages/quantum-nematode/quantumnematode/utils/brain_factory.py:
If your brain's __init__ accepts only config, num_actions, and
device, you don't need to do anything — the default-shape branch
already handles you.
If your brain needs something different (e.g. an explicit input_dim,
a learning_rate scheduler, a parameter_initializer), add a single
branch:
if brain_type is BrainType.TINY_MLP:
return {
"num_actions": 4,
"device": device,
}The branch is the only place where the per-arch __init__ signature
shape is reflected. Keep it minimal — only the kwargs the brain
actually consumes.
packages/quantum-nematode/tests/quantumnematode_tests/brain/arch/test_tinymlp.py:
- Construction from a default config.
- Registry round-trip:
instantiate_brain("tinymlp", TinyMLPBrainConfig())returns an instance. - One forward-pass shape assertion (4 logits).
- One training-step finiteness assertion (no NaNs after a fake update).
The registry-equivalence test pattern in
tests/quantumnematode_tests/brain/arch/test_registration_equivalence.py
is a good template if you want to assert that
instantiate_brain(name, cfg) and the direct constructor produce
identical initial weights (useful when migrating an existing arch
behind the registry).
configs/scenarios/foraging/tinymlp_klinotaxis.yml:
brain:
name: tinymlp
config:
hidden_dim: 16
learning_rate: 1e-3
# ... env + reward + satiety blocks as usualThe tests/quantumnematode_tests/utils/test_config_loader_yaml_compat.py
YAML-compatibility regression walks configs/scenarios/ and asserts
every YAML loads to a valid Brain via the registry. If your YAML is
malformed, that test will fail with a specific error.
If your brain has a non-trivial topology (e.g. a fixed adjacency mask,
a non-fully-connected layer structure), implement the BrainTopology
Protocol declared in
packages/quantum-nematode/quantumnematode/brain/arch/_topology.py:
from quantumnematode.brain.arch._topology import BrainTopology
class MyTopology(nn.Module):
n_inputs: int
n_outputs: int
n_hidden: int
def forward(self, x: torch.Tensor) -> torch.Tensor: ...
def apply_weight_mask(self) -> None:
"""Re-apply any structural constraints after an optimiser step."""The Protocol is runtime_checkable, so
isinstance(my_topo, BrainTopology) works as a runtime conformance
check. The connectome-PPO brain at
packages/quantum-nematode/quantumnematode/brain/arch/connectome_ppo.py
is the canonical example — its ConnectomeTopology enforces a
strict-mask chemical-synapse adjacency and a fixed gap-junction matrix.
If you want to share a learning rule (PPO, REINFORCE, DQN) across
multiple architectures, implement the LearningRule Protocol in
packages/quantum-nematode/quantumnematode/brain/arch/_rule.py. The
Protocol declares step(topology, batch) -> RuleStepReport and
reset_episode().
This factoring is currently consumed by ConnectomePPOBrain only;
the legacy 19 brains keep their fused (topology, rule) __init__
bodies. Use the factored Protocols if you're writing a new brain that
genuinely separates the two concerns.
- Don't reach into
BRAIN_CONFIG_MAPor the dispatcher directly. Both are now derived from the registry. Edit the registration, not the consumer. - Don't add
if isinstance(brain, MyBrain)branches anywhere outside the brain module itself. The Brain Protocol is the public surface; if you need new behaviour exposed, add it to the Protocol. - Don't skip the
BrainTypeenum member. The startup-time consistency check exists precisely so registration-without-enum is a hard failure, not a silent drift. - Don't bypass
instantiate_brain(...)in entrypoints. All entrypoints (scripts/run_simulation.py,scripts/run_evolution.py) go throughsetup_brain_model(...)which goes through the registry. If a new entrypoint needs to construct a brain, route it through the same factory.
packages/quantum-nematode/quantumnematode/brain/arch/_registry.py— the registry implementation.packages/quantum-nematode/quantumnematode/brain/arch/_topology.py— theBrainTopologyProtocol.packages/quantum-nematode/quantumnematode/brain/arch/_rule.py— theLearningRuleProtocol +RuleStepReport.packages/quantum-nematode/quantumnematode/brain/arch/mlpppo.py— the simplest fully-featured registered Brain (PPO + actor-critic).packages/quantum-nematode/quantumnematode/brain/arch/connectome_ppo.py— the canonical example of a brain that uses the factoredBrainTopologyProtocol with a non-trivial topology.