:>> [[ABIO]] → ABIO Docs → ABIO Expr Python API ABIO docs → ABIO Alienbio User Guide
ABIO Expr Python API¶
The Python side of Expr: how a Python function becomes a head a spec can call, how an expander builds structure, how forms are built and evaluated from Python, how the drafters and the experiment file are reached from Python, and where the trust boundary sits.
Status. Documents the API as shipped in
alienbio.expr(roadmap M47, 2026-08-30). Every``python fence on this page is executed, in order, bytests/expr/test_spec_examples.py`.
Registering a function — @fn¶
from alienbio.expr import fn
@fn(summary="a gated response", range=(0.0, 1.0))
def gate(s: float, k: float, n: float = 1.0) -> float:
return s**n / (k**n + s**n)
- The function's name is the head name (
name=overrides). Its parameters are the call's parameters; a spec passing an argument the function does not accept is an error at the call, naming the node. - Arguments arrive evaluated — a function never sees a form, so any existing callable can be registered unchanged.
kind=tags the flavor:fn(default),dist,math,rate,scoring,action,measurement,guard,constructor,drafter,agent,experiment. The old per-kind decorators (@rate,@scoring, …) are gone; there is one registry and one decorator.summary,rangeand any other keyword are stored ashead.metafor docs and the CLI.guarded=True(the whole head) orguarded_params={"hazard"}marks what the no-peeking guard protects; the experiment loader reads that metadata off the calls a spec makes.
Injection — ctx and env¶
import math
from alienbio.expr import fn
@fn(kind="dist", summary="log-uniform draw")
def loguniform(low: float, high: float, *, ctx) -> float:
lo, hi = math.log(low), math.log(high)
return float(math.exp(ctx.rng.uniform(lo, hi)))
A keyword-only parameter named ctx receives the evaluation context; one named env receives the environment. Neither appears in the spec's call.
ctx field |
Meaning |
|---|---|
seed |
the node's Seed (child(label) derives a sub-stream) |
rng |
a numpy Generator from that seed |
path |
the node's path in the document, for messages |
trusted |
whether the load was trusted |
limits |
entities, depth, attempts caps |
env adds the scope (env.lookup, env.bind), the registry (env.head(name)), the namespace (env.ns), env.pool(name) (a pool name as the current template instance spells it) and env.error(message) (an ExprError carrying the node's path). The common case — a head that wants its arguments evaluated and the environment — is just @fn with a keyword-only env.
Registering an expander — @expander¶
from alienbio.expr import expander, evaluate, X
@expander(summary="a conversion chain in -> x1 -> ... -> out, as data")
def chain(args, kwargs, env):
src, dst = (evaluate(a, env) for a in args)
n = evaluate(kwargs.get("length", 3), env)
rate = kwargs.get("rate", X.parse("lognormal(0.1, 0.3)"))
mids = [f"{env.ns}.x{i}" for i in range(1, n)]
nodes = [src, *mids, dst]
return {
"molecules": {m: {} for m in mids},
"reactions": {
f"{env.ns}.hop{i}": {"reactants": [a], "products": [b], "rate": rate}
for i, (a, b) in enumerate(zip(nodes, nodes[1:]), 1)
},
}
- An expander receives
(args, kwargs, env)— the argument forms, unevaluated — and returns a form (usually data with forms inside). The interpreter then evaluates what it returned, under the call's child seed, one sub-seed per key. evaluate(form, env)evaluates an argument now. Leave a form in the returned structure to have it evaluated later, per element — that is howrateabove (an unevaluatedlognormal(...)call) becomes one draw per hop; a!qrate would stay oneDistobject shared by every hop.env.nsis the call's namespace (the node path); mint ids under it so two calls never alias.- Logic lives here: a rejection loop, a graph search, a check. A YAML template cannot do those, by design.
Registering a guard — @guard¶
from alienbio.expr import guard, GuardViolation
@guard(summary="the produced pathway is short enough")
def max_pathway_length(value, ctx, max_length: int = 5):
if len(value["molecules"]) + 2 > max_length:
raise GuardViolation(f"pathway too long: max_length={max_length}")
return True
route: !chain {args: [A, B], length: 4, guards: [!x max_pathway_length(max_length=8)], on_fail: retry}
A guard receives the evaluated result of the call it is attached to, plus ctx, plus its own parameters from the spec. Return True to pass; return False or raise GuardViolation(message, offenders=[...]) to fail — offenders names the keys (dotted for depth) that on_fail: prune may drop. on_fail on the call decides what a failure does (ABIO Expr Spec#Guards).
Constructors¶
Every registered Entity head is a constructor head under its head name — !Molecule, !Reaction, !Chemistry call the class's hydrate; !Compartment and !World build the world records. Nothing to write; a mapping with _type: X is the untagged spelling.
The registry¶
from alienbio.expr import Env, registry
Env.standard() # registers the suite heads
head = registry.get("hill")
assert head.kind == "rate"
assert registry.get("chain").is_expander
view = registry.view({"math", "rate"}) # a narrowed registry for rate laws
assert "hill" in view and "source" not in view
rows = registry.describe() # name, kind, signature, summary
assert any(r["name"] == "diagnose" and r["kind"] == "drafter" for r in rows)
One table, populated at import time by the decorators above and by !include x.py under a trusted load. A view is a registry restricted to some kinds (special forms always show); the rate compiler evaluates against the rate view so a rate law cannot call a world-building head. Specs cannot list, inspect or modify the registry.
Building and evaluating forms from Python — X, Env, evaluate¶
from alienbio.expr import X, Env, evaluate, ExprError
env = Env.standard(seed=7) # default registry, root seed 7
form = X.chain("A", "B", length=3) # a Call form
frag = evaluate({"route": form}, env)["route"]
assert set(frag["molecules"]) == {"route.x1", "route.x2"}
same = X.parse('chain("A", "B", length=3)') # inline text → the same form
assert same == form
text = X.dump(form, style="structural")
assert text.strip() == "!chain {args: [A, B], length: 3}"
back = X.load(text) # YAML → form
assert back == form
scope = Env.standard(seed=11, trusted=True).load(
"spec.yaml",
text="n: 3\nmols: !x '[f\"M{i}\" for i in range(n)]'\nk: !x lognormal(0, 1)\n",
)
assert evaluate(X.name("mols"), scope) == ["M0", "M1", "M2"] # a file is a scope
values = scope.force_all() # every top-level binding, evaluated
assert values["mols"] == ["M0", "M1", "M2"]
try:
evaluate(X.chian("A", "B"), env)
except ExprError as exc:
assert isinstance(exc, ValueError) and "unknown head 'chian'" in str(exc)
| API | Meaning |
|---|---|
X.<head>(*args, **kwargs) |
a Call form |
X.name("a.b") |
a Name form |
X.quote(form) |
a Quoted form |
X.parse(text) |
inline text → form (sandbox-checked) |
X.dump(form, style="inline" \| "structural") |
form → text / YAML |
X.load(yaml_text) |
YAML → form |
Env.standard(seed, trusted=False, registry=None, limits=None, bindings=None) |
an environment over the default registry |
env.load(path, text=None, base=None) |
load a file into a child scope (includes resolved relative to base, the file's directory by default); env.force_all() evaluates every binding |
env.bind(**values) / env.child(label) / env.scope(data) |
extend the scope / derive a child seed / open a child scope |
env.hydrate(data, base=) |
resolve !include / !py inside already-loaded forms |
evaluate(form, env) |
the interpreter |
ExprError(message, path) |
every failure, with the node path — a ValueError |
Forms are frozen dataclasses (Name, Call, Quoted, plus the load-time Include / PyRef; literals are plain Python scalars; data is list / dict) and compare by value, so a test can assert on them directly.
Dists from quoted forms¶
from alienbio.suite.dist import Dist
rate = evaluate(X.quote(X.lognormal(0.1, 0.3)), env)
assert isinstance(rate, Dist)
one = rate.sample(env.ctx.seed.child("hop1")) # one draw
again = rate.sample(env.ctx.seed.child("hop1"))
assert one == again # same seed, same draw
scaled = evaluate(X.quote(X.parse("k * 2")), env).run({"k": 4})
assert scaled == 8
A Quoted form evaluates to a QuotedForm object that is a Dist (sample(seed)) and also exposes run(bindings=None, *, seed=None). Heads that take a Dist accept it directly; a bare number where a Dist is expected is promoted to constant.
Drafters, worlds and experiments from Python¶
from alienbio.suite.experiment import DRAFTERS, dial_params, load_spec, spec_from_dict
from alienbio.suite.expr_experiment import load_experiment, spec_to_text
from alienbio.suite.dist import Seed
draft = evaluate(X.identify_pathway(pathway_length=3), Env.standard(seed=3))
world, task = draft # a Draft is (world, task)
assert draft.world is world and task.objective is not None
same_world, _ = DRAFTERS["identify_pathway"](Seed(3), {"pathway_length": 3}) # the runner's shape
assert sorted(same_world.chemistry.reactions) == sorted(world.chemistry.reactions)
assert set(dial_params(registry.get("diagnose"))) >= {"n_nodes", "hazard", "perturbation"}
spec = load_experiment("<doc>", text="""
!experiment
name: doc-small
task: !q identify_pathway(pathway_length=pathway_length)
episode: !q episode(max_turns=4, sim_steps=5)
agent: measure_commit
axes: {pathway_length: [3, 4]}
trials_per_condition: 1
base_seed: 1
""")
assert spec.drafter == "identify_pathway" and spec.axes == (("pathway_length", (3, 4)),)
assert spec.fixed_dials == {"max_turns": 4, "sim_steps": 5}
assert load_experiment("<again>", text=spec_to_text(spec)) == spec # the inverse
A drafter head evaluates to a Draft — the world and the task instance — under the node's seed; DRAFTERS[name] is the same head in the runner's (seed, dials) -> (world, task) shape, passing only the dials the head declares. load_spec(path) / load_experiment(path, text=) read an experiment file into an ExperimentSpec (run_experiment(spec) runs it; bio suite run is the CLI); spec_to_text renders one back.
Trust¶
| Load | Registered heads | !template |
!include *.yaml / *.md |
!py, !include *.py |
|---|---|---|---|---|
| untrusted (default) | any head, any arguments | yes | relative, inside the file's directory | UnsafeSpecError |
trusted (trusted=True) |
any | yes | any path | executed |
Two boundaries: the AST allowlist on !x / !q text (no dunders, no getattr, no unpacking, no lambda, names only from scope) and the registry (a spec can only name what trusted Python registered). A registered head is responsible for validating untrusted arguments; the interpreter's limits bound size and depth. A head that opens files or runs subprocesses must not be registered. UnsafeSpecError lives in alienbio.expr.
Testing a head¶
def test_chain_draws_per_hop():
env = Env.standard(seed=1)
form = X.chain("A", "B", length=3, rate=X.lognormal(0, 1))
frag = evaluate({"route": form}, env)["route"]
rates = [r["rate"] for r in frag["reactions"].values()]
assert len(set(rates)) == 3 # three independent draws
again = evaluate({"route": form}, Env.standard(seed=1))["route"]
assert again == frag # same seed, same world
test_chain_draws_per_hop()