Operon v0.14: Coalgebra, Diffusion, Optics

Formal State Machines, Spatially Varying Gradients, and Conditional Wire Routing

Bogdan Banu · bogdan@banu.be

operon-ai v0.14.0

Summary

Operon v0.14 implements three paper gaps: Coalgebraic State Machines (Paper §4.2) for composable observation and evolution with bisimulation equivalence, Morphogen Diffusion (Paper §6.4) for graph-based spatially varying concentrations that replace the global gradient model, and Optic-Based Wiring (Paper §3.3) for conditional routing (prism) and collection processing (traversal) on wires. The release adds 67 new tests (827 total, zero regressions), 3 examples, and 17 new public exports.

1. The Algebra–Coalgebra Duality

v0.13 built the multicellular hierarchy: cells specialize, form tissues, exchange tools. But the individual cell’s behavior was still described informally. HistoneStore has a retrieve() method and a store() method. ATP_Store has report() and consume(). CellCycleController has get_phase() and advance(). The pattern is the same each time: observe state and evolve state. But without a shared interface, these machines cannot be composed or compared.

The paper (§4.2) identifies this as a coalgebra—the categorical dual of an algebra. Where an algebra builds structure up through constructors, a coalgebra observes structure through destructors. The fundamental signature is:

$$\text{readout}: S \to O \qquad \text{update}: S \times I \to S$$

This is a Mealy machine: a state space $S$, an input alphabet $I$, and an output alphabet $O$. The coalgebraic framing gives us composition for free.

flowchart LR subgraph Parallel["Parallel (S1 × S2)"] direction TB I1[Input] --> C1["Coalgebra A"] I1 --> C2["Coalgebra B"] C1 --> O1["Output A"] C2 --> O2["Output B"] end subgraph Sequential["Sequential (S1 × S2)"] direction TB I2[Input] --> S1["Coalgebra A"] S1 -->|"readout"| S2["Coalgebra B"] S2 --> O3["Output B"] end

1.1 The Coalgebra Protocol

The Coalgebra protocol requires exactly two methods:

@runtime_checkable
class Coalgebra(Protocol[S, I, O]):
    def readout(self, state: S) -> O: ...
    def update(self, state: S, inp: I) -> S: ...

The simplest concrete implementation is FunctionalCoalgebra—two plain functions wrapped into the protocol:

counter = FunctionalCoalgebra(
    readout_fn=lambda s: s,       # observe: current total
    update_fn=lambda s, i: s + i, # evolve: add delta
)

counter.readout(0)    # 0
counter.update(0, 5)  # 5
counter.readout(5)    # 5

1.2 StateMachine: Mutable State with Trace

StateMachine wraps a coalgebra with a current state, providing an imperative interface and recording every transition as a TransitionRecord:

sm = StateMachine(state=0, coalgebra=counter)
outputs = sm.run([1, 2, 3, 4, 5])
# outputs = [0, 1, 3, 6, 10]  (readout before each update)
# sm.state = 15
# sm.trace = [TransitionRecord(...), ...]

The trace is a full audit log: for each step, it records state before, input, output, and state after. Tracing can be disabled per-step with record=False for performance-critical paths.

1.3 Composition

Two composition operators mirror the biological reality:

1.4 Bisimulation

Two state machines are bisimilar if no observer can distinguish them—for every input sequence, they produce the same output sequence. check_bisimulation tests this over a bounded input sequence:

a = StateMachine(state=0, coalgebra=counter_coalgebra())
b = StateMachine(state=0, coalgebra=counter_coalgebra())
result = check_bisimulation(a, b, [1, 2, 3, 4, 5])
# result.equivalent = True
# result.states_explored = 5

c = StateMachine(state=0, coalgebra=counter_coalgebra())
d = StateMachine(state=100, coalgebra=counter_coalgebra())
result = check_bisimulation(c, d, [1, 2, 3])
# result.equivalent = False
# result.witness = (1, 0, 100)  -- first diverging input and outputs

Biological Parallel

Two cells are bisimilar if they respond identically to all stimuli—the definition of “same cell type” in functional terms. You never compare internal state directly; you compare observable behavior. This is precisely the coalgebraic perspective: identity through observation, not through structure.

Reference Implementation: operon_ai/core/coalgebra.py

2. Morphogen Diffusion

v0.13’s MorphogenGradient is global: every agent reads the same concentrations. This is like broadcasting a hormone through the bloodstream—useful, but it misses the key mechanism that patterns embryonic development: spatial diffusion.

In real embryogenesis, morphogens like Bicoid in Drosophila are secreted from localized sources and diffuse through tissue. Cells near the source see high concentration; cells far away see low concentration. The resulting gradient drives spatially patterned gene expression without any central controller.

2.1 Graph-Based Spatial Model

Operon agents don’t have physical positions (Paper §6.5, line 135). Instead, we use graph adjacency from the wiring topology as the spatial model. Each node is an agent or cell; each edge is a connection through which morphogens can diffuse.

flowchart LR S["Source (0.95)"] -->|diffuse| N1["Near (0.42)"] N1 -->|diffuse| N2["Mid (0.18)"] N2 -->|diffuse| F["Far (0.07)"]
field = DiffusionField()
for n in ["Source", "Near", "Mid", "Far"]:
    field.add_node(n)
field.add_edge("Source", "Near")
field.add_edge("Near", "Mid")
field.add_edge("Mid", "Far")

field.add_source(MorphogenSource(
    "Source", MorphogenType.COMPLEXITY, emission_rate=0.5
))
field.run(50)

# Gradient forms: Source > Near > Mid > Far
for n in ["Source", "Near", "Mid", "Far"]:
    c = field.get_concentration(n, MorphogenType.COMPLEXITY)
    print(f"{n}: {c:.3f}")

2.2 The Diffusion Algorithm

Each step() applies four phases:

  1. Emit—Each MorphogenSource adds its emission_rate to its node, capped at max_concentration.
  2. Diffuse—For each node, a diffusion_rate fraction of concentration flows outward, split evenly among neighbors. This is a discrete approximation of Fick’s law on the graph Laplacian.
  3. Decay—All concentrations are multiplied by $(1 - \text{decay\_rate})$, modeling morphogen degradation.
  4. Clamp—Values above 1.0 are capped; values below min_concentration snap to zero, preventing floating-point dust.

Definition: Discrete Graph Diffusion

For node $v$ with neighbors $N(v)$ and concentration $c_v$:

$$c_v' = \left(c_v + \text{emit}(v) - c_v \cdot d + \sum_{u \in N(v)} \frac{c_u \cdot d}{|N(u)|}\right)(1 - \lambda)$$

where $d$ is the diffusion rate and $\lambda$ is the decay rate.

2.3 Tissue Integration

Tissue gains an optional diffusion_field parameter. When present, add_cell() registers nodes and connect_cells() registers edges automatically. Two new methods bridge to the existing gradient API:

When diffusion_field is None (the default), all behavior is identical to v0.13. The integration is fully backward compatible.

Why Graph Adjacency?

Physical coordinates would require inventing a spatial embedding for every agent topology. Graph adjacency is intrinsic—it comes directly from the wiring diagram that already exists. An agent’s “position” is defined by its connectivity, not by arbitrary coordinates. Two agents that are three hops apart in the wiring graph are “far” in the diffusion model.

Reference Implementation: operon_ai/coordination/diffusion.py, Tissue.diffuse() and .get_cell_gradient() in operon_ai/multicell/tissue.py

3. Optic-Based Wiring

v0.13’s wiring system uses PortType matching—a lens-like pattern where data flows through if the types align. This handles the common case but lacks two capabilities the paper describes:

Optics (Paper §3.3) solve both by adding three optic types to the wiring layer:

OpticBehaviorBiological Analogy
LensOptic Pass-through (always transmits) Constitutive expression—always active
PrismOptic Conditional routing by DataType Receptor specificity—only responds to matching ligand
TraversalOptic Map transform over list elements Polymerase processivity—walks a sequence
ComposedOptic Sequential composition of optics Multi-step enzyme cascade

3.1 Prism Routing

The prism is the most interesting optic. It accepts a set of DataType values and rejects everything else. When used with fan-out wiring—one output port connected to multiple prism-filtered wires—data is routed to the correct destination based on its runtime type:

flowchart LR A["Router"] -->|"prism(JSON)"| B["JSON Handler"] A -->|"prism(ERROR)"| C["Error Handler"]
diagram = WiringDiagram()
diagram.add_module(ModuleSpec(name="Router", ...))
diagram.add_module(ModuleSpec(name="JSONHandler", ...))
diagram.add_module(ModuleSpec(name="ErrorHandler", ...))

diagram.connect("Router", "out", "JSONHandler", "in",
    optic=PrismOptic(accept=frozenset({DataType.JSON})))
diagram.connect("Router", "out", "ErrorHandler", "in",
    optic=PrismOptic(accept=frozenset({DataType.ERROR})))

# At runtime: if Router emits JSON, only JSONHandler receives it.
# If Router emits ERROR, only ErrorHandler receives it.
# The rejected module is skipped entirely.

Three changes to DiagramExecutor make this work:

  1. Static type bypass—When a wire carries an optic, require_flow_to is skipped. The optic takes responsibility for type correctness at runtime.
  2. Output coercion relaxation—When all outgoing wires from a port carry optics, the executor accepts TypedValue as-is without enforcing DataType matching against the port spec.
  3. Prism-rejection skip—When a module’s unfilled inputs all come from optic wires whose sources have already executed, the module is skipped with an empty ModuleExecution rather than causing a deadlock.

3.2 Traversal Processing

TraversalOptic maps a transform over list elements on the wire:

doubler = TraversalOptic(transform=lambda x: x * 2)
doubler.transmit([1, 2, 3], DataType.JSON, IntegrityLabel.VALIDATED)
# [2, 4, 6]

# Single values are treated as one-element collections:
doubler.transmit(5, DataType.JSON, IntegrityLabel.VALIDATED)
# 10

3.3 Composition and Coexistence

ComposedOptic chains optics sequentially: all must accept, and transforms are applied left-to-right. Optics also coexist with DenatureFilters on the same wire—denaturation applies first, then the optic:

diagram.connect("A", "out", "B", "in",
    denature=StripMarkupFilter(),
    optic=TraversalOptic(transform=str.upper),
)
# Data flow: raw value -> strip markup -> uppercase each element -> deliver

Why Skip, Not Fallback?

When a prism rejects data, the destination module is skipped entirely rather than receiving a default value. This mirrors biology: a receptor that doesn’t bind its ligand doesn’t activate the downstream pathway with a “nothing bound” signal. It simply doesn’t activate. Skipping also avoids forcing every handler behind a prism to include if input is None boilerplate.

Reference Implementation: operon_ai/core/optics.py, Wire.optic in operon_ai/core/wagent.py, optic handling in DiagramExecutor.execute() in operon_ai/core/wiring_runtime.py

4. Test Coverage

SuiteTestsStatus
Coalgebraic State Machines 19 All pass
Optic-Based Wiring 27 All pass
Morphogen Diffusion 21 All pass
Existing (regression) 760 All pass
Total 827 All pass

5. Examples

ExampleFeature
61_coalgebraic_state_machines.pyCounter coalgebra, StateMachine with trace, parallel & sequential composition, bisimulation
62_morphogen_diffusion.pyLinear chain, star topology, competing sources, local gradients
63_optic_based_wiring.pyLens, prism routing, traversal transforms, composition, denature coexistence

6. What’s Next

With coalgebra, diffusion, and optics in place, the remaining paper gaps center on higher-order composition and formal verification:

Code and examples: github.com/coredipper/operon