Tutorial 0 — A Process in 12 Lines¶

The fastest possible tour of process-bigraph: define a process as a typed function, wire it, and run it — all on one screen.

The @process decorator infers a process's config_schema from the decorated function's keyword-only parameters, so the config values are the function's arguments. No config_schema dict, no initialize recast. The class-based Process API stays available as the escape hatch for stateful processes.

In [1]:
from process_bigraph import allocate_core, Composite, process

1. Define a process¶

A process is a typed function of (state, interval) plus keyword-only config. Here, exponential decay of a species S at some rate. The rate: float = 0.1 keyword-only parameter becomes the process's config — its type and default are read straight off the signature.

In [2]:
@process(inputs={"S": "float"}, outputs={"S": "float"})
def decay(state, interval, *, rate: float = 0.1):
    return {"S": -rate * state["S"] * interval}   # additive delta

The config_schema was inferred for you — rate is a float defaulting to 0.1:

In [3]:
decay.config_schema
Out[3]:
{'rate': {'_type': 'float', '_default': 0.1}}

2. Wire and run¶

Register the process on a core, drop it into a Composite document wired to a shared store S, and run. The document is the same {_type, address, config, inputs, outputs} dict the engine consumes — the decorator changed only how the process is authored, not the wire format. Identity wires ("S": ["S"]) connect the S port to the top-level S store.

In [4]:
core = allocate_core()
core.register_link("decay", decay)

sim = Composite({"state": {
    "S": 10.0,
    "decay": {
        "_type": "process",
        "address": "local:decay",
        "config": {"rate": 0.2},          # override the inferred default
        "inputs":  {"S": ["S"]},
        "outputs": {"S": ["S"]},
    },
}}, core=core)

sim.run(10.0)                              # advance 10 time units
sim.state["S"]
module `pbg_emitters.parquet_emitter` not found during dynamic import
Out[4]:
1.0737418240000003

That is the whole loop: define → wire → run.

3. Ports carry units¶

A port can declare _units, and the engine auto-converts across a wire whenever two compatible-but-different units meet. Here a process emits mass in femtograms (fg) into a store declared in picograms (pg) — the engine scales 1000 fg → 1 pg at the wire, with no code on your part.

In [5]:
@process(inputs={}, outputs={"mass": {"_type": "float", "_units": "fg"}})
def emit_fg(state, interval, *, amount: float = 1000.0):
    return {"mass": amount}               # 1000 femtograms

core = allocate_core()
core.register_link("emit_fg", emit_fg)

sim = Composite({"state": {
    "mass": {"_type": "float", "_units": "pg"},   # store reads picograms
    "emitter": {
        "_type": "process",
        "address": "local:emit_fg",
        "config": {"amount": 1000.0},
        "inputs":  {},
        "outputs": {"mass": ["mass"]},
    },
}}, core=core)

sim.run(1.0)
sim.state["mass"]                          # 1000 fg auto-converted to 1.0 pg
Out[5]:
1.0

Escape hatch: the class-based API¶

@process targets the ~70% of processes that are a pure typed function of (state, interval) + config. Stateful or initialize-heavy processes still use the class-based Process, which stays fully supported — the two forms interoperate freely in the same Composite:

from process_bigraph import Process

class Decay(Process):
    config_schema = {"rate": {"_type": "float", "_default": 0.1}}

    def initialize(self, config):
        self.rate = float(config["rate"])

    def inputs(self):  return {"S": "float"}
    def outputs(self): return {"S": "float"}

    def update(self, state, interval):
        return {"S": -self.rate * state["S"] * interval}

Next steps¶

  • Tutorial 1 — Process-Bigraph Basics: Steps, workflows, emitters.
  • Tutorial 2 — Wrapping an ODE Solver: expose an existing scientific API.
  • Tutorial 4 — Composing a Biological Model: the central dogma from four small processes over shared molecular state.