Skip to content

Pitfalls

phyelds programs are ordinary Python, so Python will not stop you from writing code that breaks the aggregate semantics. These are the mistakes that are easy to make, with what goes wrong and what to do instead. Most of them are about alignment: read that page first.

A data-dependent number of aggregate calls

Calls are aligned by their position. In a loop, the i-th iteration of a device is aligned with the i-th iteration of its neighbors, whatever they iterate over.

# ❌ device 0 iterates over ["a", "b"], the others over ["b"]:
#    device 0's "a" exchange receives the others' "b" values
@aggregate
def program():
    seen = {}
    for key in my_keys():
        seen[key] = neighbors(value_of(key))
    return seen

Wrapping the body in with align(key): does not fix it: the position is still part of the path. Use one of these instead:

# ✅ one exchange: share a dictionary, then pick from it
@aggregate
def program():
    mine = {key: value_of(key) for key in my_keys()}
    field = neighbors(mine)
    return {
        key: [values[key] for values in field.data.values() if key in values]
        for key in mine
    }

# ✅ the same sequence on every device
ALL_KEYS = ["a", "b"]

@aggregate
def program():
    seen = {}
    for key in ALL_KEYS:
        values = neighbors(value_of(key) if key in my_keys() else None)
        if key in my_keys():
            seen[key] = [v for v in values if v is not None]
    return seen

The same holds for comprehensions, map, and any other repetition.

:= in a conditional expression does not split the network

@aggregate aligns the branches of a conditional expression by moving them into lambdas. A branch with := (or yield, await) would change meaning there, so it is left as it is, and its two branches are aligned with each other: devices on different branches exchange values.

# ❌ the := keeps both branches together: robots and sensors hear each other
values = (heard := neighbors("robot")) if sense("is_robot") else neighbors("sensor")

# ✅ assign outside the expression
heard = neighbors("robot") if sense("is_robot") else neighbors("sensor")

Helpers without @aggregate

A plain function called from an aggregate one shares its caller's alignment, and its if statements are not rewritten.

# ❌ the branches of `pick` are not separated
def pick(flag):
    if flag:
        return neighbors("left")
    return neighbors("right")

# ✅ decorate every function that (directly or not) calls remember or neighbors
@aggregate
def pick(flag):
    ...

Aggregate functions without source code

@aggregate reads the source of the function to rewrite it. In code built with exec, python -c, or the plain interactive interpreter, there is no source: phyelds prints WARNING: AST transformation failed and runs the function without the if rewrite (and prints it again at every call). Define aggregate functions in a file or in a notebook cell.

Mutating shared values

The bundled simulator delivers values by reference: the list a neighbor shared is that neighbor's state. Mutating it changes another device.

# ❌ appends to the neighbors' remembered lists
for other in neighbors(items.value).exclude_self().values():
    other.append(item)

# ✅ build new values
received = neighbors(items.value).exclude_self().values()
set_items(tuple({*items.value, *(x for other in received for x in other)}))

Prefer immutable values (tuples, frozensets, frozen dataclasses) in remember and neighbors. Hosts written for real networks serialise messages and do not have this problem, so code that relies on sharing works in the simulator only.

Module-level state

Every simulated device runs in the same process, so a global variable is shared by all of them: a hidden communication channel that does not exist on real devices.

# ❌ all devices append to the same list
seen = []

@aggregate
def program():
    seen.append(local_id())

# ✅ per-device state goes in remember
@aggregate
def program():
    set_seen, seen = remember(())
    set_seen((*seen, local_id()))

Constants are fine; mutable globals are not.

Random values and other per-round surprises

The program runs from the top at every round, so random.random() gives a new value each round. If a device needs one random value (an id, a priority), keep it:

_, priority = remember(random.random())   # drawn once, then remembered

The same applies to anything expensive or with side effects: printing, logging and I/O run on every device at every round. To expose a value, store it as an output.

Storing None

None means "no state": after set_value(None), the next round remember returns its initial value again. Use another sentinel (a dedicated object, float("inf"), an empty tuple) for "no value yet".

State inside branches resets

State that is not used in a round is dropped. A remember inside an if branch starts again from its initial value every time the device re-enters the branch. This is the intended semantics (a branch is a separate computation); if the value must survive, remember it outside the if.

Sharing a field

neighbors(field) raises TypeError: a NeighborhoodField only makes sense on the device that built it. Share a plain value computed from it (min(field), field.local(), field.exclude_self()).

Expecting instant answers

A field is not computed in one round: it converges, and the time it takes depends on the diameter of the network. Read results after enough rounds, and design for the transient.

Some patterns also converge more slowly in one direction. The classic gradient lowers its estimates quickly (good news travels fast) but raises them slowly when a source disappears, because devices keep leaning on each other's stale values (the rising value problem: see the tutorial). Account for it when a gradient drives decisions, or use a gradient that bounds it.

Editing node data in the simulator

node.update(new_data=...) replaces the whole data dictionary, including the state of the program. To change a sensor, assign the key: node.data["source"] = True.