13 — A system is a function over tables
Concept node: see the DAG and glossary entry 13.

A system is a function that reads from one or more tables and writes to one or more tables. It declares its inputs (the read-set) and its outputs (the write-set). It has no hidden state, no global side effects, no interaction with the outside world during a tick. The signature is the contract.
def motion(pos_x: np.ndarray, pos_y: np.ndarray,
vel_x: np.ndarray, vel_y: np.ndarray,
dt: float) -> None:
pos_x += vel_x * dt
pos_y += vel_y * dt
Read-set: vel_x, vel_y, dt. Write-set: pos_x, pos_y. That is the entire contract. This system can run any time those four columns and dt are available and nothing else is writing pos_x or pos_y. It runs once per tick over the whole population — there is no per-creature loop in the body. The for-loop disappeared into numpy.
Three shapes
Every system takes one of three shapes.
An operation is 1→1: every input row produces exactly one output row. motion is an operation — each creature’s position is updated to its new position. Most update functions are operations.
A filter is 1→{0, 1}: every input row produces zero or one output rows. apply_starve (from code/sim/SPEC.md) is a filter — each creature with energy ≤ 0 produces an entry in to_remove; creatures with energy > 0 produce nothing. The numpy form is one line:
def starving(energy: np.ndarray) -> np.ndarray:
return np.where(energy <= 0)[0] # returns the indices to remove
An emission is 1→N: every input row produces zero or more output rows. apply_reproduce is an emission — a parent above the energy threshold produces two offspring (a 1→2 emission).
These three shapes are the same shapes a database query takes. SELECT * FROM t WHERE p is a filter, SELECT a + b FROM t is an operation, SELECT explode(arr) FROM t is an emission. A system is a database operation written in Python against numpy columns instead of SQL against tables. If you have ever written SQL, you already know the vocabulary; the work is recognising your simulation in those terms.
Return type is half the contract
The three shapes also fix the return type. An operation mutates its write-set in place and returns None — the work has already happened by the time the call returns. A filter returns a new array of indices. An emission returns one or more new arrays. The pattern is: mutators return None; producers return the thing they produced.
The reason for the asymmetry is that the alternative — having a mutator return its own write-set — is a silent aliasing bug. world = step(world) reads like it produces a new world, but if step mutates and returns the same object, both names point at the same state and the caller cannot tell from the call site which is true. Python’s standard library encodes the rule exactly: list.sort() returns None so that xs = xs.sort() fails loudly; sorted() returns a new list. The system convention is the same rule applied to columns.
There is one named exception: a function that builds a world from nothing — from a seed, a file, or a log — returns the new world. Its signature gives it away: it does not take an existing world to mutate. build_world(seed), load(path), replay(initial, log) are constructors, not systems. The return value is the only place the new state can go.
The OOP method is the anti-shape
This is the moment to name what most Python tutorials teach instead. The method-on-object shape — class Creature: def tick(self, dt): self.pos += self.vel * dt — is the same lesson rotated through self, and the rotation costs you everything important. The signature def tick(self, dt) does not tell you what the method reads or writes. The body does, but only after you read it. The contract is no longer expressible at the call site; it is implicit in the body of the method, which means you cannot reason about composition without inlining every method.
It also costs you the loop. The natural caller for Creature.tick is for c in creatures: c.tick(dt) — a Python-level loop, one method dispatch per element, interpreter-bound at the floor of ~5 ns per element from §1, plus another ~50-100 ns of getattr and method-call overhead per attribute. From code/measurement/tick_budget.py the cost is 27.9 ms per tick at 1,000,000 creatures for one motion system, against 0.6 ms for the function-over-columns form. The system shape is not just clearer — it is the only one that fits inside a 30 Hz budget at scale.
The wider rule: a function that takes self does not have a declared read-set or write-set. A function that takes columns does. This is one of the two or three places where “OOP versus data-oriented” is not a stylistic choice — it is whether your system has a contract you can read.
Logging is a separate system
The other reflex Python encourages is to write to stdout from inside the loop. print(f"creature {i} starved"), logger.info(...), traceback.print_exc() — all of these are side effects that violate the system’s no-hidden-output contract. The fix is the same shape as everything else in this book: there is a log_events table, a logging system writes to it, and a separate flush system writes the table to disk or stdout.
The book builds this discipline at §37 — The log is the world. For now, the rule is: if a system needs to communicate with the outside, it does so through a column declared in its write-set. There are no surprise prints.
Observability and tests are systems too
A debug inspector is a system whose read-set is “all relevant columns” and whose write-set is “nothing observable” — it gathers data for inspection and produces no side effects on the world. In production it is absent, not gated by a flag — the program simply does not contain it.
A test is also a system. assert pos.shape == vel.shape and not np.any(np.isnan(pos)) is a system whose read-set is pos and vel, write-set is nothing, and whose effect is to fail loudly if the contract of the previous system was violated. Tests-as-systems is the §43 topic, but you have been writing them since §5 exercise 1.
A system declares its inputs, declares its outputs, and does no more. That is the shape that lets every other discipline in the book work.
A few patterns to watch for
A function that reads a column, writes to it, and reads it again in the same call is not a system — it has implicit ordering inside the body. Either split it into two systems with explicit ordering, or buffer the writes until the function exits. A function that takes a world object and mutates whatever it likes is not a system — it has no declared write-set, and you cannot reason about it from its signature.
The contract that the system has no hidden state is what makes systems compose. Two systems with disjoint write-sets can run in parallel without coordination (§31). Two systems whose read-set and write-set form a chain must run in order (§14). The contract is the basis for all of this.
Exercises
Use the deck from §5, your tick_lab from §11, or the §0 simulator skeleton; any of them provides enough tables.
- Identify the shape. Classify each as operation, filter, or emission:
- Squaring every entry in a
np.ndarrayoffloat32. - Filtering even integers from a
np.ndarrayofint32. - Splitting each string in a
list[str]into words, returning all words. - Computing the sum of a
np.ndarrayofint32.
- Squaring every entry in a
- Write motion as a system. With
pos_x, pos_y, vel_x, vel_yas numpyfloat32columns of length 100, writemotion(pos_x, pos_y, vel_x, vel_y, dt)as defined in the prose. Apply it to 100 creatures with random initial positions and velocities. Print the position of one creature across 10 ticks. The body is two lines. - Declare the contract. Add a docstring to
motionlisting its read-set and write-set explicitly. The signature plus the docstring is the system’s contract. - Write a filter. With
energy: np.ndarray, writestarving(energy)returning a numpy array of indices whereenergy[i] <= 0. This is the read-only first half ofapply_starve. - Write an emission. With
parent_energy: np.ndarray, thresholdthreshold: float, writereproduce(parent_energy, threshold)returning two parallel arrays —parent_indicesandoffspring_energies— for each parent above threshold, with two entries each. This is a 1→2 emission. (Hint:mask = parent_energy > threshold; idx = np.where(mask)[0]; np.repeat(idx, 2).) - Observe non-systems. Find a function in your previous work (or any Python tutorial) that takes
selfand mutates whatever it likes, or writes to a global, or callsprintfrom inside the body. Note what makes it not a system. Try to express its read-set and write-set from the signature alone — confirm you cannot. - The OOP cost in your fingers. Run
uv run code/measurement/tick_budget.py. Read the table. Note that you have just seen, at 1,000,000 creatures, what happens when the loop is in the body of a method instead of in numpy. The 30 Hz row is over for the Python dataclass version. The system-shaped version uses 1.8% of the budget. - (stretch) A test as a system. Write
def no_creature_moved_too_far(prev_pos_x, prev_pos_y, cur_pos_x, cur_pos_y, max_step)returning indices where any creature moved further thanmax_stepbetween two ticks. The “test” is just an inspection system reading the world. Hint:dx = cur_pos_x - prev_pos_x; dy = cur_pos_y - prev_pos_y; np.where(dx*dx + dy*dy > max_step*max_step)[0].
Reference notes in 13_system_as_function_solutions.md.
What’s next
§14 — Systems compose into a DAG takes the next step: when many systems run together, how do they fit?