The core and its seams
pyLEAFS is a base meant to grow. This page describes the architecture of the v1 core and, more importantly, the seams it leaves open so that each later layer attaches as an addition rather than a rewrite. If you are extending pyLEAFS, read this first.
Three design commitments
Dimension-agnostic. There are no separate 2d and 3d codebases. Dimension is
a property of the Grid, set by the length of its shape, and
positions are always (n, D) arrays. Code that avoids hard-coding D works
in both.
Struct-of-arrays. Resources and agents are stored as parallel NumPy arrays,
not as lists of objects. A population’s positions are one (n, D) array, its
fuel one (n,) array, and so on. This keeps the per-step work vectorizable and
dimension-agnostic, and is the natural target for a later Numba pass.
Numba-ready, not yet Numba. Hot loops (per-agent sensing and harvesting) are
written as plain Python/NumPy so the core can be validated for correctness
first. Decorating them with @njit is deferred until the physics is pinned.
The field list
A field is anything spatially distributed that the simulation steps and agents
sample. Field is the structural protocol — step,
sample, deposit — and ResourceField is the only
implementation in v1.
The key seam is that Simulation holds a list of fields and
steps each one:
for f in self.fields:
f.step(self.rng)
A pheromone field, a chemical gradient, or a temperature field is a new class
satisfying the protocol and a new entry in that list. The deposit verb exists
precisely so a pheromone field — which agents write to as well as read — slots
in without changing any call site. Nothing about the step loop assumes a single
hard-coded resource field.
The spatial hash
SpatialHash buckets an arbitrary (n, D) point set into grid
regions and answers neighbourhood queries. It is deliberately agnostic about what
the points are. The resource field keeps its own internal buffer, but predators
sensing prey, agents sensing each other, or any future point-set interaction can
share this one index rather than each growing a bespoke one.
The population list
Like fields, populations are a list that the step loop iterates:
for p in self.populations:
p.step(self.fields, self.rng)
v1 has one greedy-forager population, but the loop is written for many. Predators
are a second population whose step consumes the first; a second forager
species is a third. The ordering of the list is the interaction order, which is
where multi-trophic dynamics will be expressed.
Each agent carries a stable id that survives the swap-compaction used when
agents die, so a caller (the viewer’s inspector, a lineage tracker) can follow a
specific agent across steps.
The reserved controller
The one seam that is named but not yet abstracted is the controller: the map
from what an agent senses to how it steers. In v1 this map is the greedy rule,
and it lives directly in the agent body (Population.step). The later
neuroevolution layer splits it out into a Controller — a GreedyController
and an RNNController (an Elman network with an evolvable genome and sensor
array) — selected per population or per agent. The greedy logic is kept
self-contained so that this extraction is a refactor of one method, not a
redesign.
Adding a layer
Concretely, to add a new model layer you will typically:
Write a new
Field(e.g. a pheromone field) or a newPopulation(e.g. predators), reusing the sharedGridand, where useful,SpatialHash.Append it to the simulation’s
fieldsorpopulationslist.Add a factory or extend
forager()to assemble the new world, and pin its behavior with a test.
Because the loops already iterate lists and the geometry already lives in the grid, the existing layers do not change.