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 shared grid

Every field and every population references one Grid. It owns the geometry: the region partition, the toroidal wrap, the minimum-image displacement, and the Moore-neighbour lookup (\(3^{D}\) neighbours per region). Defining geometry in exactly one place is what lets fields and populations interoperate without each re-deriving coordinate conventions.

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:

  1. Write a new Field (e.g. a pheromone field) or a new Population (e.g. predators), reusing the shared Grid and, where useful, SpatialHash.

  2. Append it to the simulation’s fields or populations list.

  3. 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.