v2 language design¶
Status: design, 2026-10-11. Validated by the round-trip prototype described in the plan. The background is in the research notes; the tooling around the language is in registry and authoring.
0. Framing¶
.pblang v2 is a tree-shaped, verifiable projection of a process-bigraph composite. Three artifacts share one abstract
model:
| Artifact | Role | Produced by |
|---|---|---|
*.pblang composite |
reviewable, checkable, canonical text | prompt-guided tools or humans; or decompiled from a document |
*.pblang interface file |
generated .d.ts-like declarations per package |
pblang describe at release time; hand-editable |
{"schema","state"} document, template, *.composite.yaml |
what process-bigraph and the workbench run | pblang compile |
Decisions behind this design:
- The syntax is tree-shaped and mirrors the document: nodes nest like the state tree, edges are declared in place,
wires use
->relative to the edge's parent,..goes up. - The DSL is a verifiable target layer, not the only thing an author writes. Diagnostics must be precise and located, the text must be readable for review, and printing must be canonical so generated text is deterministic.
- Interfaces come from generated interface files imported like TypeScript
.d.tsfiles. - The DSL interoperates both ways with CompositeSpec
*.composite.yaml;param↔ CompositeSpec parameter ↔ value site with a default.
Non-goals: Python builder logic (engine choice, random placement, probe runs) stays in Python. The DSL captures the
result as a ground or template document plus params for the knobs.
1. Semantic model¶
1.1 Abstract document¶
Module := { name?, docs, imports[], params[], types[], lets[], root: Composite, emitters[], options, meta }
Composite := { nodes: {key -> Node}, options?, bridge?, interface? }
Node := Store | Subtree | Edge | Replicate
Store := { type?: TypeExpr, value?: Value | Site, docs }
Subtree := { type?: TypeExpr, nodes: {key -> Node}, docs } // `node x { … }`
Edge := { kind: process|step|composite|shared|shared_ref, iface?: Ref<Interface>,
address?: Address | Site(face), config: Value, inputs: Wires, outputs: Wires,
interval?: Expr, priority?: int, triggers?: [Path], port_overrides?: {inputs, outputs},
inner?: Composite, bridge?: {inputs, outputs, conduits}, process_id?, extra: {}, docs }
Wires := {port -> Path | Wires} Path := [Key | ".." | "*" | Index]
Site := { sort: TypeExpr | Face | Ref<Interface> | NamedSort, default?: Value }
Replicate := { count: Expr, region: Subtree } // _control: replicate
Param := { name, type, default?, required, choices?, docs }
Interface := { kind: process|step|adapter|composite, name, address?, config: {key -> {type, default?, docs}},
inputs: Face, outputs: Face, docs, contract?: {units, min, max, assumptions, refs} }
Face := { ports: {name -> {type, units?, docs}}, conditional: [{when, ports}], open: bool, delegate?: ConfigKey }
Emitter := { address, paths: [Path], config }
Options := { global_time_precision, run_steps_on_init, parallel_*, skip_process_state, contract_strict }
1.2 Denotation¶
Store→schema[path] = type,state[path] = value. A site value emits state only (no schema entry; process-bigraph#213).Subtree→ a dict in both halves.Edge→ the state node{_type, address, config, inputs, outputs, interval?, priority?, _triggers?, _inputs?, _outputs?, …extra}; the schema half gets{"_type": kind}only when there are no port overrides. A composite edge →address: "local:Composite",config: {schema, state, bridge, interface?, options…}.Siteby position: value site (store value, config value, param), address site (addressof an edge, sorted by the interface face), model site (anodewhose sort is a face), named sort, replicate region.Param phas two projections: template mode → a site with_defaultat the referenced position; spec mode →"${p}"at every reference plusparameters: {p: {type, default, choices, description}}.Emitter→ CompositeSpecemitters:or the step node thatprocess_bigraph.emitter.emitter_node_from_declarationbuilds.Options→ top-level document keys, or keys of a nested composite'sconfig.
Deliberately not modelled (stripped by normalization, never printed): instance, _contract, the injected
global_time, emitter results stores, serialized dict-form addresses, the legacy composition key, run-specific
emitter config injected by the workbench, CompositeSpec id.
1.3 Round-trip equivalence¶
def normalize(doc, core):
schema = core.render(core.access(doc.get("schema", {})), defaults=True)
state = strip(doc["state"])
# strip: drop runtime keys; address dict -> "proto:data"; wire str -> [str]; tuples/numpy -> lists;
# int -> float where the schema says float; sort edge keys;
# drop config keys equal to the class default (only when the core resolves the address);
# drop _inputs/_outputs equal to interface() of the resolved class
return {"schema": schema, "state": state, "options": {k: doc[k] for k in OPTION_KEYS if k in doc}}
equivalent(a, b) := normalize(a) == normalize(b)
Three levels, all in the test suite: (1) textual fixpoint, format(parse(text)) == text once a parser exists;
(2) semantic, equivalent(compile(decompile(doc)), doc) for every fixture; (3) spec, the yaml round-trip equals the
original after key sorting, requires recomputed and id dropped. Nested composites compare recursively on
config.state and config.schema using authored documents (saved ones are lossy upstream).
2. Syntax¶
2.1 Lexical¶
v1 terminals are kept: ID, INT with a leading -, FLOAT needs a ., strings in either quote style, // and
/* */ comments. New: /// doc comments attach to the next declaration; ${name} in a string is parameter
interpolation.
Keywords: module import as param type struct interface process step adapter composite shared node store let emit
options meta every priority at with bridge conduits config inputs outputs ports extra triggers replicate when else of
file load true false.
2.2 Grammar sketch¶
The executable form of the subset the prototype prints is the Lark grammar in process_bigraph_lang/v2/parser.py,
verified against every snapshot in tests/fixtures/data/v2/snapshots/. The sketch below is the full target.
Module: ('module' QualifiedName ';')? TopLevel*;
TopLevel: Import | Param | TypeDecl | Let | InterfaceDecl | RootComposite | Emit | Options | Meta;
Import: 'import' (QualifiedName ('.' '{' ID (',' ID)* '}')? | STRING ('as' ID)?) ';';
Param: DOC* 'param' ID ':' TypeExpr ('=' (Value | '?'))? ('choices' '[' Value (',' Value)* ']')? ';';
TypeDecl: DOC* ('type' ID '=' TypeExpr ';' | 'struct' ID '{' Field* '}');
Field: DOC* ID ':' TypeExpr ('=' Value)? ';';
TypeExpr: PrimaryType ('|' PrimaryType)*; // union (bigraph `~`)
PrimaryType: NamedType | GenericType | ArrayType | StructType | LinkType | RawType;
GenericType: ID '<' TypeArg (',' TypeArg)* '>'; // map<K,V> list<T> maybe<T> overwrite<T> const<T>
// set<T> tree<T> tuple<A,B> range<lo,hi>
// enum<a,b,c> object<"pkg.Cls">
ArrayType: 'array' '<' TypeExpr '>' ('[' ShapeDim (',' ShapeDim)* ']')? ('@' STRING)?; // ShapeDim: INT | '_' | param
StructType: '{' Field* '}'; // anonymous struct = bigraph merge a:T|b:U
LinkType: 'link' '<' '{' Field* '}' ',' '{' Field* '}' '>';
RawType: STRING; // any bigraph-schema expression verbatim
Let: DOC* 'let' ID (':' TypeExpr)? '=' Value ';';
InterfaceDecl: DOC* ('process'|'step'|'adapter'|'composite') ID ('at' STRING)? '{'
('config' '{' Field* '}')? ('inputs' FaceBody)? ('outputs' FaceBody)? ('contract' '{' … '}')? '}';
FaceBody: '{' (PortDecl | CondPorts | '...')* '}' | 'of' 'config' '.' ID ';'; // '...' open face; 'of' delegated
PortDecl: DOC* ID ':' TypeExpr ('@' STRING)? ';';
CondPorts: 'when' Predicate '{' PortDecl* '}' ('else' '{' PortDecl* '}')?; // Predicate: config path ('==' Value)?
RootComposite: DOC* 'composite' ID? '{' NodeDecl* '}';
NodeDecl: StoreDecl | SubtreeDecl | EdgeDecl | ReplicateDecl;
StoreDecl: DOC* 'store' Key (':' TypeExpr)? ('=' Value)? ';';
SubtreeDecl: DOC* 'node' Key (':' TypeExpr)? ('=' SiteValue ';' | '{' NodeDecl* '}');
ReplicateDecl: 'replicate' Expr SubtreeDecl;
EdgeDecl: DOC* ('process'|'step') Key ':' EdgeHead ('every' Expr)? ('priority' INT)? '{' EdgeItem* '}'
| DOC* 'composite' Key ('every' Expr)? ('at' STRING)? '{' CompositeItem* '}'
| DOC* 'shared' 'process' Key ':' EdgeHead '{' ('config' ConfigBlock)? '}';
EdgeHead: [InterfaceDecl] ('at' (STRING | '?' ('(' STRING ')')?))? | 'at' STRING | 'shared' [EdgeDecl];
EdgeItem: 'config' ConfigBlock | 'inputs' WireBlock | 'outputs' WireBlock
| 'ports' '{' ('inputs' '{' PortDecl* '}')? ('outputs' '{' PortDecl* '}')? '}'
| 'triggers' '[' Path (',' Path)* ']' | 'extra' '{' KeyValue* '}';
CompositeItem: EdgeItem | 'bridge' '{' ('inputs' WireBlock)? ('outputs' WireBlock)? ('conduits' WireBlock)? '}'
| 'interface' '{' ('outputs' '{' PortDecl* '}')? '}' | Options | NodeDecl;
ConfigBlock: '{' (KeyValue (',' KeyValue)* ','?)? '}' | '=' [Let];
WireBlock: '{' (Wire (',' Wire)* ','?)? '}'; Wire: Key ('->' Path | WireBlock); // nested = sub-ports
Path: Seg ('.' Seg)*; Seg: ID | STRING | '..' | '*' | '[' INT ']'; // fields.A[0][1]
Value: Literal | Ref | SiteValue | StructLit | MapLit | ListLit | TupleLit | EdgeLit | FileRef | Interp;
SiteValue: '?' ('(' Value ')')? ('as' (TypeExpr | NamedSort))?;
EdgeLit: [InterfaceDecl] '{' KeyValue* '}'; // {address, config} child for higher-order nodes
FileRef: 'file' '(' STRING ')' | 'load' '(' STRING ')'; // text / json|npy|npz|csv; inlined at compile
Expr: Value | Expr ('*'|'/'|'+'|'-') Expr; // only in every / replicate / shapes / config leaves
Emit: 'emit' (ID | STRING) '{' Path (',' Path)* '}' ('with' ConfigBlock)? ';';
Options: 'options' '{' KeyValue* '}'; Meta: 'meta' '{' KeyValue* '}'; Key: ID | STRING;
2.3 Rules¶
- Wire paths are relative to the edge's parent, exactly like the document. The printer uses
.and quotes non-identifier keys (node "[0,1]"). - One
Sitetype; position decides the kind.= ?,= ?(v),= ? as positive(value);at ?,at ?("local:FV")(address, sorted by the interface face);node model : Engine = ?;(model site). An edge whose interface has noatmust be writtenat ?, so openness is visible in review. - Literals keep the v1 rules: bare keys make a struct, literal keys make a map,
{}is either;-is allowed in numbers; floats need a.. - Type spelling:
int,bool,float,string,map<string, V>,list<T>,maybe<T>,overwrite<T>,enum<a, b>,array<float>[3,3] @ "uM",{ a: T; b: U },A | B, raw"…". The compiler mapsint→integer,bool→boolean,map<string, V>→map[V]. An unknown type name is an error, never passed through. file()/load(): compile inlines the content so the document stays self-contained. The decompiler externalizes strings longer than 2000 characters and arrays with more than 64 elements into sidecar files named<composite>.<path>.{txt,json,npy}.let: module constants inlined at compile. The decompiler hoists subtrees that appear verbatim at two or more places and exceed about ten leaves (a sharedgridconfig, for example), named after the config key.everyandreplicateaccept expressions (every k * dt). The spec projection cannot represent arithmetic.- Doc comments:
///oncomposite→ specdescription; onparam→ parameterdescription; on interface ports → contract description; on stores and edges → text andpblang doconly (never emitted as document keys). emitaliases:parquet,ram,sqlite,xarray,console,json→local:<X>Emitter; an unknown alias is an error listing the known ones.emit "local:ParquetEmitter" { … }gives an address directly.- Canonical formatter: two-space indent; the decompiler orders stores, then nodes, then edges, each alphabetically; config entries in interface-declared order, then unknown keys alphabetically; one wire per line when more than two; strings double-quoted.
2.4 Interface files¶
Generated once per package (<pkg>/pblang/interfaces.pblang), hand-editable for ports the generator cannot see.
describe emits what config_schema and interface() report, adds ... and a // TODO: dynamic comment on any face
whose inputs()/outputs() body references self.config, and describe --check verifies that a hand-edited file is a
superset of the generated one.
module viva_smoldyn;
struct Species { difc: float; count: int = 0; color: string = "black"; display_size: float = 1.0; }
/// Smoldyn particle simulation in a box.
process SmoldynProcess at "local:!viva_smoldyn.processes.SmoldynProcess" {
config {
dimensions: int = 3;
bounds: list<list<float>>;
boundary_type: string = "r";
dt: float = 0.01;
seed: maybe<int>;
species: map<string, Species>;
reactions: list<map<string, "any">>; // raw type kept verbatim: describe found "map"
}
inputs { }
outputs {
molecule_counts: map<string, int>;
molecule_positions: list<"any">;
time: float @ "s";
}
}
Dynamic ports, written honestly:
module viva_pde_particle;
process SmoldynHybrid at "local:!viva_pde_particle.processes.smoldyn_hybrid.SmoldynHybrid" {
config { grid: map<string, "any">; config_text: string; particle_species: list<string>;
field_species: list<string> = []; dt: float = 0.01; step_multiplier: int = 1;
quiet: bool = true; emit_positions: bool = false; }
inputs { fields: map<string, array<float>> @ "uM"; }
outputs {
particle_counts: map<string, overwrite<array<float>>>;
particle_totals: map<string, overwrite<float>>;
when emit_positions { particle_positions: map<string, overwrite<array<float>>>; }
}
}
process SpatialRecorder at "local:!viva_pde_particle.processes.recorder.SpatialRecorder" {
config { out_dir: string; base_dt: float; every: int; layout: map<string, "any">; species: list<string>;
particle_species: list<string> = []; membranes: map<string, "any"> = {}; region: string = "cell"; }
inputs {
when layout.kind == "mesh" { field_dofs: map<string, array<float>>; } else { fields: map<string, array<float>>; }
when particle_species { particle_positions: map<string, array<float>>; }
... // open face: undeclared ports warn, not error
}
outputs { }
}
/// Runs a child process on a sub-multiple of the base tick.
process Stepper at "local:!viva_pde_particle.processes.stepper.Stepper" {
config { process: edge; dt: float; every: int = 1; phase: int = 0; } // `edge` = an {address, config} child
inputs of config.process;
outputs of config.process;
}
adapter GridCountsToConcentration at "local:!viva_pde_particle.steps.transfer.GridCountsToConcentration" {
config { grid: map<string, "any">; species: list<string>; staircase: map<string, "any"> = {}; }
inputs { particle_counts: map<string, array<float>> @ "count"; }
outputs { particle_conc: map<string, overwrite<array<float>>> @ "uM"; }
}
adapter is step plus role metadata; the checker uses it for unit-mismatch fix hints. The DSL-only edge type is an
{address, config} child spec; in config position it is written as an edge literal (SmoldynHybrid { … }).
3. Mapping tables¶
3.1 DSL → document¶
| DSL | schema half |
state half |
|---|---|---|
store t: float = 0.0; |
"t": "float" |
"t": 0.0 |
store counts: map<string, int>; |
"counts": "map[integer]" |
absent (process-bigraph fills from schema) |
store m: float = ?(1.0); |
none (#213) | {"_type":"site","_sort":"float","_default":1.0} |
store x: float = ? as positive; |
none | {"_type":"site","_sort":"positive"} |
node cell { … } |
"cell": {children} |
"cell": {children} |
node model : Engine = ?; |
none | {"_type":"site","_sort":{"_type":"link","_inputs":…,"_outputs":…}} |
replicate n node cell {…} |
children | {"_control":"replicate","_count":n, …} (assembly.build only) |
process p : I every 0.5 { config{…} inputs{…} outputs{…} } |
"p": {"_type":"process"} |
{"_type":"process","address":I.address,"config":…,"inputs":…,"outputs":…,"interval":0.5} |
… ports { inputs { a: T } } |
{"_type":"process","_inputs":{"a":T}} |
also "_inputs": {"a": T} |
step s : I priority 2 { triggers [a.b] } |
{"_type":"step"} |
{"_type":"step", …, "priority":2, "_triggers":[["a","b"]]} |
process p : I at ? { … } |
{"_type":"process"} |
"address": {"_type":"site","_sort":{"_type":"link",…}} |
process p : I at ?("local:FV") |
same | _sort + "_default":"local:FV" |
process p at "local:X" { ports{…} } |
_inputs/_outputs |
address literal; _inputs/_outputs |
composite c every 1.0 { body bridge{…} interface{…} } |
"c": {"_type":"process"} |
{"_type":"process","address":"local:Composite","interval":1.0,"config":{"schema":…,"state":…,"bridge":…,"interface":…},"inputs":…,"outputs":…} |
shared process e : I { config {…} } |
— | {"_type":"shared_process","address":…,"config":…} |
process a : shared e { inputs{…} } |
— | {"_type":"shared_process_ref","process_id":"e","inputs":…} |
inputs { a -> fields.A[0][1], b -> ../x, sub { p -> q } } |
— | {"a":["fields","A",0,1],"b":["..","x"],"sub":{"p":["q"]}} |
extra { note = "…" } |
— | extra keys verbatim |
emit parquet { counts, cell.mass } with { out_dir = "o" } |
— | step from emitter_node_from_declaration({"address":"local:ParquetEmitter","paths":["counts","cell/mass"],"config":{"out_dir":"o"}}) |
options { global_time_precision = 6 } |
top-level document key | — |
param dt: float = 0.01; + every dt (template mode) |
— | "interval": {"_type":"site","_sort":"float","_default":0.01} |
param n: int = ?; (template mode) |
— | site without _default (required) |
file("m.txt") / load("a.npy") |
— | inlined string / list |
Type expressions: int → integer, bool → boolean, list<T> → list[T], map<string,V> → map[V],
map<K,V> → map[K,V], maybe<T> → maybe[T], overwrite<T> → overwrite[T], enum<a,b> → enum[a,b],
range<0.0,1.0> → range[0.0,1.0], array<float>[3,3] @ "uM" → {"_type":"array","_shape":[3,3],"_data":"float","_units":"uM"}
(dict form when shape or units are present, else array[float]), {a: T; b: U} → a:T|b:U (dict form when a member
is a struct with defaults), A | B → A~B, link<{…},{…}> → {"_type":"link","_inputs":…,"_outputs":…}, "raw" →
verbatim. A field default → {"_type":T,"_default":v}.
3.2 DSL → CompositeSpec yaml¶
| DSL | yaml |
|---|---|
module x; / composite Name {…} |
name: Name (fallback: file stem) |
/// text on composite |
description: "text" |
meta { tags = […], default_n_steps = 100, … } |
the same keys |
param dt: float = 0.01 choices [0.01, 0.1]; + /// |
parameters: {dt: {type: float, default: 0.01, choices: […], description: …}} |
param n: int = ?; |
parameters: {n: {type: integer}} (no default; to_document raises if not overridden) |
every dt, config { dt = dt }, "out_${run}" |
interval: "${dt}", dt: "${dt}", "out_${run}" |
import viva_smoldyn.{SmoldynProcess} + uses |
requires: {processes: [SmoldynProcess]} (computed; meta { requires = … } overrides) |
emit … |
emitters: [{address, paths, config?}] |
options { global_time_precision = 6 } |
global_time_precision: 6 |
| composite body | state: (+ schema: only for typed stores process-bigraph could not infer) |
| sites other than params | not representable → E-SPEC-UNREPRESENTABLE |
| param types beyond float/int/string/bool/list/map | enum<…> becomes string + choices; others → E-SPEC-PARAM-TYPE |
3.3 Document → DSL (decompilation)¶
| Document | DSL |
|---|---|
| dict containing an edge (directly or below) | node key { … } |
| dict with no edges | store key: T = {…}; (T from schema if present, else inferred) |
| leaf value, schema present | store key: T = v; |
| leaf value, no schema | store key: <inferred> = v; (type printed for review) |
| schema entry without state | store key: T; |
{"_type":"site",…} |
= ?(default) / = ? as Sort; in address → at ?; face sort → node x : <interface or link<>> = ? |
"local:Name" / "local:!pkg.mod.Class" |
resolved against interface files on the search path → import pkg.{Name} + process key : Name; unresolved → process key at "…" { ports { … from _inputs/_outputs } } + W-IFACE-UNRESOLVED |
{"protocol","data"} |
"protocol:data" |
config |
keys equal to the interface default dropped; unknown keys kept with W-CONFIG-UNKNOWN-KEY |
_inputs/_outputs equal to the interface face |
omitted; otherwise ports { } |
interval 1.0 on a process |
omitted (default) |
address: "local:Composite" |
composite key every I { … bridge … } |
emitter step equal to emitter_node_from_declaration(decl) |
emit alias { paths } with {config}; otherwise a plain step |
global_time, results, instance, _contract |
dropped |
template sites with _default at interval/config leaves |
param <key>: T = default; + a reference (bare key when unique, else path-joined) |
yaml parameters + ${p} |
param p … + references; ${p} inside strings kept as interpolation |
yaml description, tags, … |
/// and meta {} |
| large strings and arrays | file() / load() sidecars |
4. Worked examples¶
4.1 viva-smoldyn reversible dimerization (from reversible-dimerization.composite.yaml)¶
module viva_smoldyn.composites.reversible_dimerization;
import viva_smoldyn.{SmoldynProcess};
meta { tags = ["particles", "reaction-diffusion", "3d", "simularium"] }
/// Smoldyn 3D reversible dimerization: fast red monomers A diffuse and bind pairwise into
/// slower blue dimers B (A + A <-> B) in a reflecting cubic box. Emits per-species counts,
/// per-molecule positions, and time each step; the positions drive a Simularium trajectory.
composite reversible_dimerization {
store molecule_counts: map<string, int> = {};
store molecule_positions: list<"any"> = [];
store time: float = 0.0;
process smoldyn : SmoldynProcess every 0.5 {
config {
dimensions = 3,
bounds = [[0, 50], [0, 50], [0, 50]],
boundary_type = "r",
dt = 0.01,
seed = 12345,
species = {
"A" = { difc = 3.0, count = 250, color = "red", display_size = 1.2 },
"B" = { difc = 1.0, count = 0, color = "blue", display_size = 2.0 }
},
reactions = [ { "name" = "dimerize", "subs" = ["A", "A"], "prds" = ["B"], "rate" = 2.0, "kb" = 0.15 } ]
}
inputs { }
outputs { molecule_counts -> molecule_counts, molecule_positions -> molecule_positions, time -> time }
}
}
emit parquet { molecule_counts, molecule_positions, time };
pblang compile --to yaml reproduces the yaml (requires computed from the import, name from the composite);
pblang compile produces the document with the emitter step materialized. From the yaml alone the stores are untyped
(molecule_counts: {}); the checker reports W-STORE-UNTYPED with the fix "declare : map<string, int> (from
SmoldynProcess.outputs.molecule_counts)". That review signal is exactly what the DSL is for.
4.2 Nested growth-division composite (local:Composite with a bridge)¶
import demo.{Grow, Divide};
param growth_rate: float = 0.1;
composite grow_divide {
store environment: map<string, float> = { "nutrient" = 10.0 };
store mass_out: float = 0.0;
/// One cell as a nested composite; the bridge exposes its mass and a division signal.
composite cell every 1.0 {
store mass: float = 1.0;
store divide_signal: bool = false;
process grow : Grow every 1.0 {
config { rate = growth_rate }
inputs { mass -> mass, env -> ../environment } // `..` escapes the inner root through the bridge
outputs { mass -> mass }
}
step divide : Divide {
config { threshold = 2.0 }
inputs { mass -> mass }
outputs { divide -> divide_signal }
}
bridge {
inputs { environment -> environment } // inner path for the outer `environment` port
outputs { mass -> mass }
conduits { divide -> divide_signal }
}
interface { outputs { mass: float; } }
options { run_steps_on_init = true }
inputs { environment -> environment } // outer wires, relative to the root
outputs { mass -> mass_out }
}
}
Compiles to cell: {_type: process, address: "local:Composite", interval: 1.0, config: {schema, state, bridge: {inputs:
{environment: ["environment"]}, outputs: {mass: ["mass"]}, conduits: {divide: ["divide_signal"]}}, interface: {outputs:
{mass: "float"}}, run_steps_on_init: true}, inputs, outputs}. Decompiling a saved document whose nested
config.state is empty raises W-NESTED-SAVE-LOSSY.
4.3 viva-pde-particle hybrid (FV PDE + Smoldyn + adapters + Stepper + SpatialRecorder)¶
module viva_pde_particle.composites.hybrid;
import viva_pde_particle.{FVReactionDiffusion, SmoldynHybrid, GridCountsToConcentration,
ExtendGridField, Stepper, SpatialRecorder};
param dt: float = 0.01;
param step_multiplier: int = 5;
/// Which PDE engine fills the `pde` slot; the face is shared by FV and FEniCSx.
param pde_engine: string = "fv" choices ["fv", "fenicsx"];
let grid = { "origin" = [0.0, 0.0, 0.0], "size" = [10.0, 10.0, 10.0], "num" = [20, 20, 20] };
let species = ["A", "B"];
composite hybrid {
store fields: map<string, array<float>[20,20,20] @ "uM"> = load("hybrid.fields.npz");
store particle_counts: map<string, array<float>[20,20,20] @ "count"> = load("hybrid.particle_counts.npz");
store particle_conc: map<string, array<float>[20,20,20] @ "uM"> = load("hybrid.particle_conc.npz");
store particle_totals: map<string, float> = { "A" = 1200.0, "B" = 0.0 };
store fields_ext: map<string, array<float>>;
/// Filled by the builder with the engine chosen by `pde_engine`; both engines satisfy this face.
process pde : FVReactionDiffusion at ?("local:FVReactionDiffusion") every dt {
config { grid = grid, pde = load("hybrid.pde.json"), dt = dt,
domain = { "mask" = ?, "volume_fraction" = ? } } // builder-computed arrays left open
inputs { fields -> fields, external_conc -> particle_conc }
outputs { fields -> fields }
}
step particle_to_field : GridCountsToConcentration {
config { grid = grid, species = species }
inputs { particle_counts -> particle_counts }
outputs { particle_conc -> particle_conc }
}
step extend_field : ExtendGridField {
config { grid = grid, species = species, staircase = {} }
inputs { fields -> fields }
outputs { fields_ext -> fields_ext }
}
/// SmoldynHybrid driven on a sub-multiple of dt, phase k-1 (vcell-fvsolver coupling).
process particles : Stepper every dt {
config {
process = SmoldynHybrid { // edge literal: {address, config}
grid = grid, config_text = file("hybrid.particles.smol"),
particle_species = species, field_species = ["A"],
dt = dt, step_multiplier = step_multiplier
},
dt = dt, every = step_multiplier, phase = step_multiplier - 1
}
inputs { fields -> fields } // face delegated from config.process
outputs { particle_counts -> particle_counts, particle_totals -> particle_totals }
}
process recorder : SpatialRecorder every dt {
config { out_dir = "${out_dir}", base_dt = dt, every = 10,
layout = { "kind" = "grid", "grid" = grid }, species = species }
inputs { fields -> fields } // ok: `when layout.kind == "mesh"` is false
}
}
load() and file() keep the text reviewable while the document stays self-contained; the ? inside domain are
config sites the builder fills with fill_sites; the Stepper child is an edge literal, which the checker uses to
resolve the delegated face and to type-check the child's config; pde_engine is a plain spec parameter (the mapping to
an address stays in the builder; the DSL records the open address site with its default). phase = step_multiplier - 1
is an expression: the spec projection reports E-SPEC-EXPR, the template projection evaluates it from the defaults with
W-PARAM-EVALUATED.
4.4 spatio-flux cell "[0,1]" with array-indexed wires¶
import spatio_flux.{DynamicFBA};
import spatio_flux.types; // registers positive_float etc. (register_types hook)
param n_bins: int = 2;
composite spatial_dfba {
store fields: map<string, array<float>[n_bins, n_bins] @ "mM"> = {
"glucose" = [[10.0, 10.0], [10.0, 10.0]], "acetate" = [[0.0, 0.0], [0.0, 0.0]], "biomass" = [[0.1, 0.1], [0.1, 0.1]]
};
node "[0,1]" {
process dfba : DynamicFBA every 1.0 {
config { model_file = "textbook", biomass_identifier = "biomass",
kinetic_params = { "glucose" = [0.5, 1.0], "acetate" = [0.5, 2.0] },
substrate_update_reactions = { "glucose" = "EX_glc__D_e", "acetate" = "EX_ac_e" },
bounds = { "EX_o2_e" = { "lower" = -2.0, "upper" = ? as maybe<float> },
"ATPM" = { "lower" = 1.0, "upper" = 1.0 } } }
inputs { substrates { glucose -> ../fields.glucose[0][1], acetate -> ../fields.acetate[0][1],
biomass -> ../fields.biomass[0][1] } }
outputs { substrates { glucose -> ../fields.glucose[0][1], acetate -> ../fields.acetate[0][1],
biomass -> ../fields.biomass[0][1] } }
}
}
}
../fields.glucose[0][1] → ["..","fields","glucose",0,1]. The checker resolves the path type through map →
array → index → index and checks it against the port type with core.resolve_schemas. n_bins in a shape is a
param, so the shape is [2,2] in both projections (shapes must be ground at compile time).
4.5 Template with a face site for the engine and a required param¶
import viva_pde_particle.{PdeEngine}; // an interface: a process declaration with no `at`
param n_particles: int = ?; // required: fill_sites must supply it
param dt: float = 0.01;
composite pde_study {
store fields: map<string, array<float>> = {};
node model : PdeEngine = ?; // model site: a whole subtree admitted by the PdeEngine face
process engine : PdeEngine at ? every dt { // address site sorted by the face
config { n = n_particles }
inputs { fields -> fields }
outputs { fields -> fields }
}
}
Compiles in template mode to sites at engine/address (sorted by the link face), engine/config/n (required),
interval (default 0.01) and model. pblang check reports groundness with process-bigraph's own wording:
required site(s) left unfilled: 'engine/config/n', 'engine/address', 'model'.
5. Diagnostics¶
5.1 Stages¶
| Stage | Where | Needs |
|---|---|---|
| S1 | parse (Langium; Lark in the prototype) | text |
| S2 | scope and imports | interface files |
| S3 | static types (Langium validators) | DSL types only |
| S4 | schema (core.access, core.check, resolve_schemas) |
a core with imported register_types |
| S5 | face and contract (admits_why, face_conforms, find_candidates, unit scale) |
core + interface files |
| S6 | registry (link_registry, unresolvable_addresses; optional --env) |
the target environment |
| S7 | groundness (required_open_sites) |
— |
S1–S3 run in the editor; S4–S7 in pblang check and CI. Every diagnostic has a stable code (family prefix plus slug, see
the registry and authoring design), a range, a document path
(slash-joined, as fill_sites uses it) and, where possible, an LSP-shaped fix.
5.2 Codes¶
| Code | Severity | Example |
|---|---|---|
E-UNKNOWN-PORT |
error | smoldyn: port 'molecule_count' is not an output of SmoldynProcess (outputs: molecule_counts, …) |
E-WIRE-TYPE |
error | grow.inputs.mass: port type 'float' cannot be resolved with store 'cell.mass' of type 'string' |
E-UNKNOWN-TYPE |
error | type 'any' is not a bigraph-schema type (it would pass through silently); did you mean "any" as a raw type, or node? |
E-CONFIG-TYPE |
error | smoldyn.config.dimensions: expected integer, got 3.0 |
E-CONFIG-MISSING |
error | recorder.config: required key 'out_dir' is missing |
E-FACE-NOT-ADMITTED |
error | pde: address 'local:FenicsxMesh' does not satisfy face PdeEngine: missing input 'external_conc' |
E-ADDRESS-UNRESOLVABLE |
error | particles: 'local:SmoldynHybrid' is not registered in this environment (known: …) |
E-NOT-GROUND |
error | composite document is not ground — required site(s) left unfilled: 'engine/config/n' |
E-PARAM-UNDECLARED |
error | '${out_dir}' references a parameter that is not declared |
E-MIXED-APPLY |
error | store 'fields' receives 'overwrite[array[float]]' from pde and additive 'array[float]' from extend_field |
E-SPEC-UNREPRESENTABLE |
error | site at 'pde/address' cannot be written as a CompositeSpec; use --template or give it a param |
E-PATH-ESCAPES-ROOT |
error | grow.inputs.env: '../../x' goes above the composite root |
W-WIRE-UNIT |
warning | particle_to_field.inputs.particle_counts: 'count' wired to a store in 'uM' (fix hint lists adapters) |
W-ADDITIVE-OUTPUT |
info | particles.outputs.particle_counts: 'map[array[float]]' is additive; wrap in overwrite<> if the port replaces values |
W-STORE-CREATED-FROM-PORT |
warning | wire 'fields_ext' names no store; it will be created from ExtendGridField.outputs.fields_ext |
W-STORE-UNTYPED |
warning | see 4.1 |
W-OPEN-FACE-PORT |
warning | recorder.inputs.extra: not declared by SpatialRecorder, which has an open face ('...') |
W-CONFIG-UNKNOWN-KEY |
warning | smoldyn.config.colour: not in SmoldynProcess.config |
W-IFACE-UNRESOLVED |
warning | 'local:Foo' has no interface file; ports taken from _inputs/_outputs |
W-NESTED-SAVE-LOSSY |
warning | cell: nested composite has empty config.state (saved documents lose inner state) |
W-PARAM-EVALUATED |
warning | phase = step_multiplier - 1 evaluated with defaults (4) |
E-INTERVAL, E-DUPLICATE-KEY, E-UNRESOLVED-IMPORT, E-EMIT-ALIAS, E-BRIDGE-PORT |
error | as named |
Where the checks run. The checker is TypeScript (dsl/src/language/v2/check.ts), inside the Langium
services: the same code produces editor diagnostics, the browser's diagnostics (the language server runs in a web
worker) and the CLI's check --json envelope. Its inputs are data only: the parsed document and header manifests
(JSON). Units use mathjs. Python calls the CLI (process_bigraph_lang/v2/check.py), which cli-native
provides without Node or Deno at run time (Deno compiles it once when the wheel is built). There is one grammar
and one checker; the compliance suite is dsl/test/v2/check.test.ts, mirrored by the Python tests through the CLI.
Editor services. Hover and completion are built on the same resolution the checker uses
(dsl/src/language/v2/resolve.ts: which header an edge is declared by, and how a path walks the composite
tree). Hover shows an edge's header as a header file would print it, a port's declared type and the store it is
wired to, the store or node a path segment names, a header's config field under a config key, and the
declarations of stores, params, models and emitter aliases, with their /// docs. Completion offers the header's
unwired ports in inputs { } / outputs { }, its config fields in config { } (required first, key =
inserted), the stores and nodes of the edge's parent in a wire path and the keys below a dict store, the emitter
aliases after emit, and interfaces at process x : from the registry and from header files open in the
workspace, with the import they need added as an additional edit. The keywords a key may coincide with
(every, process, ...) are not proposed in key positions. In the editor, header .pblang files in the workspace
answer import a.b.{X} directly (a document with module a.b; declaring X is local:!a.b.X), so a composite
next to its generated headers is checked without a manifest; the CLI takes manifests with --interfaces.
Compile and decompile. The reader (document or CompositeSpec -> model), the writer (model -> document or
CompositeSpec), the printer (model -> text) and the binder (Langium AST -> model) are TypeScript too
(dsl/src/language/v2/{reader,writer,printer,bind}.ts over the model in model.ts), so cli-native compile
and cli-native decompile are the only implementations: the same code serves the CLI, the editor and the
browser. Documents cross the CLI boundary as JSON in bigraph_schema.json_codec's encoding (numpy arrays
tagged; tuples tagged as __tuple__ by the Python side), with ints and floats kept apart (1 is not 1.0),
dicts in source order, and Python's float and string spellings reproduced so that printed text and sidecar
assets are byte-identical on both sides. compile runs the checker first and stops on errors (--strict
makes warnings fatal; --force compiles an existing document that relies on defaults the checker flags).
headers renders a manifest as .pblang header files, model-header renders describe-model's JSON, graph
prints the wiring graph for viewers.
The Python API. process_bigraph_lang.v2 exposes compile_text / compile_file and decompile, thin
wrappers over those commands, plus what has to be Python: describe_module (introspection runs where the
classes import; it writes the manifest the CLI reads), describe_sbml (the model header as JSON), and the
normalizer (equivalent, diff_documents; it needs bigraph-schema's core.render). There is no Python
model of a composite. This is deliberate: a Python tool that builds composites targets the DSL text, where
the result is checked, reviewed and documented, rather than a Python object model that would be a second
authoring surface. A builder in viva-pde-particle, for example, emits .pblang text (or a document it then
decompiles) and compiles that.
5.3 pblang check¶
pblang check <file.pblang>... [--env] [--format json|text] [--spec|--template|--ground]
[--set name=value]... [--interface-path DIR]... [--strict]
exit 0 = no errors; 1 = errors; 2 = tool failure
{
"version": 1,
"ok": false,
"diagnostics": [
{
"code": "E-UNKNOWN-PORT",
"severity": "error",
"stage": "type",
"file": "hybrid.pblang",
"range": {
"start": { "line": 41, "col": 15 },
"end": { "line": 41, "col": 30 }
},
"path": "particles/outputs/particle_count",
"message": "...",
"related": [
{
"file": "viva_pde_particle.pblang",
"line": 88,
"message": "outputs declared here"
}
],
"fix": {
"title": "rename to particle_counts",
"edits": [{ "range": {}, "newText": "particle_counts" }]
},
"docs": "https://…/codes#E-UNKNOWN-PORT"
}
],
"summary": {
"errors": 1,
"warnings": 2,
"ground": false,
"open_sites": ["engine/config/n"]
}
}
fmt, compile, decompile, describe and doc share the same envelope.
5.4 Operator splitting as a construct (the Stepper and the SplittingCoordinator as syntax)¶
viva-pde-particle couples its engines with two wrappers whose faces are their children's: Stepper (run a
child on every k-th tick of a dt clock, phase-shifted, so that it reads the field at T + (k−1)·dt and has its
counts applied at T + k·dt, vcell-fvsolver's lagged coupling) and SplittingCoordinator (run both engines and
the adapters in an explicit order over one coupling interval τ = k·dt on a private copy of the stores). The
coordinator's schemes are:
| Scheme | One interval τ = k·dt |
|---|---|
jacobi |
PDE for (k−1)·dt; particles for τ reading the field now, update held back; PDE for dt; commit the particles. Equals the two-process composite with the Stepper. |
gs_particles_first |
particles for τ; PDE for τ seeing the new particles |
gs_pde_first |
PDE for τ; particles for τ seeing the new field |
strang |
PDE for τ/2; particles for τ; PDE for τ/2 with the new particles (k even) |
Only Jacobi is a per-process clock setting. Gauss–Seidel and Strang are sequential compositions inside one interval: the second engine sees the first one's result before the interval ends, which process-bigraph's scheduler cannot express because every process reads the state at the start of its interval. A splitting scheme is therefore a composition operator over a group of processes, not an attribute of one of them, and the DSL gives it a construct that says the physics as an ordered sequence per coupling interval:
split coupling every 4 * dt {
run pde for 3 * dt;
run particles for 4 * dt, hold; // reads the field now; its update is applied at commit
run pde for dt;
commit particles;
}
Gauss–Seidel is run particles for τ; run pde for τ; (or the reverse) and Strang is
run pde for τ/2; run particles for τ; run pde for τ/2;. Adapters run whenever a store they read changed, the rule
process-bigraph applies to steps, so they need no mention unless an order is pinned. Named schemes are sugar
(split coupling every 4 * dt scheme strang { pde, particles }) whose expansion the formatter and hover show.
pde and particles are ordinary processes declared in the composite, with static headers.
What the construct buys statically: the participants' config and wires are checked as usual, and the schedule itself
is checked, which today happens as ValueErrors in initialize(): each engine's for durations sum to τ per
interval, Strang's k is even, every hold has a commit, and the adapters' faces bridge the engines' stores.
What it compiles to: Jacobi can go to the two-process form with the Stepper (the PDE stays a normal process that
emitters see every dt); the other schemes go to the SplittingCoordinator node with its pde, particles,
adapters and scheme config. The decompiler prints both shapes back as split. Either way the wrappers are
desugaring targets and should live in a shared package rather than in viva-pde-particle; if process-bigraph grows
ordered sub-steps within an interval, the same text compiles to that instead.
Decisions (2026-10-11): the sequence is optional, the named schemes stay, and no scheme means the lagged
Jacobi scheme (split coupling every τ { pde, particles }), which is the coordinator's default and the Stepper
form. Scheme names should keep the distinction visible: lagged (viva's jacobi, kept as an alias),
sequential with the order, strang.
5.4.1 Superposition on a shared store is native, not split¶
Several processes adding increments to one store is process-bigraph's default model: the apply semantics for
numbers, arrays and maps of them are additive, and every delta that lands at the same time is summed onto the
store. Numerically that is simultaneous (Jacobi, additive) splitting, for du/dt = A(u) + B(u) the step
u(T+Δ) = u(T) + Δ·A(u(T)) + Δ·B(u(T)), multirate when intervals differ. It needs no construct. What the
language says about it is the store's apply semantics, which the type already carries (float and
array<float> accumulate, overwrite<T> replaces), and what the checker does about it is the writer analysis of
the registry design: two overwriting writers on one store (W221, an ordering race), an additive writer mixed
with an overwriting one (W220/E-MIXED-APPLY), and a step publishing a whole array with accumulate semantics
(W223). pblang doc lists writers and readers per store with those badges.
split is only for the other coupling style: engines that own their variables and exchange whole values
through ports and adapters, where the question is when each engine reads the other's result within an interval.
5.5 Units: declared on stores, polymorphic on components¶
Units are an annotation on a type, on a store (store conc: map<string, float> @ "uM";) and on a port in a
header (fields: map<string, array<float>> @ "uM";). They are data: _units on the bigraph-schema type, checked
with pint, never executed. The store is the single source of truth for the composite's unit system; every port
wired to it must agree.
Most solvers do not own a unit system. An ODE or PDE engine integrates whatever consistent units the model
carries, so its header must not commit to uM. It declares unit variables instead:
process Tellurium at "…" {
inputs { species: map<string, float> @ "?U"; time: float @ "?T"; }
outputs { species: map<string, overwrite<float>> @ "?U"; fluxes: map<string, float> @ "?U/?T"; }
}
?U and ?T are bound per instance from the stores the ports are wired to, the way type variables are inferred:
species -> conc binds ?U = uM, time -> clock binds ?T = s, and fluxes -> rates is then checked against
uM/s. A variable that only appears in a derived expression is solved for (?T from ?U/?T once ?U is
known). This is what "units-of" and "rate-units-of" mean here: @ "?U" is "the units of whatever this is wired
to" and @ "?U/?T" is "those units per time unit", with the checker proving the composite consistent without
the component naming any unit. If nothing pins a variable the obligation is reported as unresolved, not as an
error: a composite with no declared units is unchecked, not wrong.
Findings: W201 unit-mismatch when a bound expression and its store differ, with the conversion factor when the
dimensions agree (uM/s into mM/s, factor 0.001: insert an adapter or align the stores) or as incompatible when
they do not (uM into kg); I230 when pint cannot parse a unit; I231 when no store pins a variable.
Where an SBML model declares its own unit system, the units belong on the stores the model's species and
parameters are mapped to, and they can be generated from the model: the existing SBML stub generator already reads
the model file and writes stores with values, and writing their @ units from the model's unit definitions is
the same step. A data-level lint that compares a model file's unit definitions with the stores it is wired to is
a separate, later check. Pinning a variable explicitly at an instance (units { U = "uM" }) is possible syntax
for the case where no store declares units; it is not needed when the stores do.
5.6 Model headers and model sites (SBML and other model files)¶
The v1 SBML stub generator read a model file and wrote a composite specialized to it: stores named after that model's species and parameters, values baked in. That conflates two things that v2 keeps apart.
A model file has an interface, like a process does. Its species, parameters and compartments with their
units, its time unit, and which species are amounts rather than concentrations are the model's face. The
model-side twin of describe is describe-model: it reads the file once and writes a model header, a
generated, read-only declaration next to the file (Caravagna2010.model.pblang), not a composite:
model Caravagna2010 from "Caravagna2010.xml" {
time @ "s";
compartments { compartment: float @ "mL" = 1.0; }
species { T: float @ "umol/mL" = 1.0; E: float @ "umol/mL" = 1.0; I: float @ "umol/mL" = 1.0; }
parameters { r2: float = 0.18; b: float = 1.0e-09; … }
meta { sbml = "L2V4", reactions = 3, events = 0, rules = 0 }
}
Everything in it is data and regenerable; units come from the model's unit definitions, which is where the
stores' units in section 5.5 come from. process_bigraph_lang/v2/sbml.py prototypes this on the Caravagna
fixture with the standard library's XML parser (no libsbml).
Two kinds of workflow, both first-class.
- A per-document composite imports the model header and uses it:
store species: Caravagna2010.species = Caravagna2010.initial;, a solver wiredspecies -> species, adapters naming real species (substrates { T -> species.T }), emit paths naming real species. The checker verifies all of that statically against the header: a wire to a species the model lacks, a unit mismatch through?U, an emit path for a species that does not exist. - A per-class workflow is a template. The model is a site and the class is the site's sort, what any filler
must provide:
node model : SBMLModel = ?with a sort that says concentration species in some?U, a time unit?T, and the members the workflow touches (parameters.k1to sweep,species.Afor an adapter). The solver's header is polymorphic (?U/?T), the species store is an open map, the model file is aparam. Whether a given document fits is a staticadmitscheck of its model header against the sort, before anything runs.
Instantiation relates the two: a per-class template plus a model header, with the site filled, is a per-document composite. That is process-bigraph's site-and-fill machinery applied to a data input instead of a process.
What the class can say that v1 could not. The things that decide whether a solver handles a document: amounts
versus concentrations (the v1 stub converted by compartment size, silently), events and rules, local
parameters, SBML level, version and packages (spatial, comp, fbc). Those become predicates of the model
site's sort, and solvers declare what they accept in their headers (accepts: sbml-l3v2, no spatial), so
choosing a solver for a model is the same face matching the registry design uses for adapters.
Syntax (implemented in the prototype grammar). A header is model Name from "file" { time @ "…"; compartments
{…} species {…} parameters {…} meta {…} }; a sort is the same without from. In a sort, a member named _
stands for every member of its block (species { _: float @ "?U"; ... } says "all species, in one unit ?U"),
a block ending in ... admits members the sort does not name, and meta entries are requirements (events = 0,
kind = "concentration"). A model site is a store typed by a sort: store model : ODEModel = ?; is open and
store model : ODEModel = Caravagna2010; fills it with a header. The checker admits the header against the sort
(G301 model-not-admitted for a missing member, an extra member of an exact block or a failed meta requirement;
units bind across the whole header, so species in ?U and time in ?T are checked with the same machinery as
ports; I232 when the model declares no units for a required member, because an undeclared unit is unknown, not
dimensionless). In the document, a filled site becomes the model's blocks as typed stores with the model's values,
and an open site becomes a site whose sort is the schema the sort admits.
Units on values that are not stores. Units belong to the type, wherever the value sits: a header's config field
can declare dt: float @ "?T" and a param step: float @ "min" referenced as config { dt = ${step} } is then
checked against the composite's time unit. Parameter units from a model file are captured when the file declares
them and otherwise left undeclared; nothing is ever assumed dimensionless.
Still to do: Name.species-style member types so a composite can type a store by a header's block, and a
data-level lint comparing a model file's unit definitions with the stores it is wired to.
6. Open questions¶
- Params referenced at several positions in template mode. Substitute at compile time by default (
--set); emit same-key multi-sites only iffill_sitesconfirms multi-fill (a quick test againstprocess_bigraph/templates.py). - Expressions. Allow
+ - * /on literals and params inevery,replicate, shapes and config leaves; the spec projection emitsE-SPEC-EXPRunless the expression is a bare param; the template projection evaluates with defaults. - Union syntax.
A | Bin the DSL (bigraph~); anonymous struct{a: T; b: U}for merges; raw strings cover the rest. nodevsstorefor dicts. The edges-inside rule is deterministic;--node-depth Npromotes dicts tonodefor readability without changing meaning.- Untyped stores.
W-STORE-UNTYPEDwith an auto-fix;pblang fmt --infer-types. - Interface resolution.
<pkg>/pblang/interfaces.pblangin the wheel plus an entry point;PBLANG_PATHoverrides; the catalog aggregates by reading wheels, never by importing. - Units
@ "uM". Contract-level metadata (_unitsin the contract and onarray);W-WIRE-UNITfires only when both ends declare units. - Edge literals in config. Allowed wherever the interface declares an
edgeorlist<edge>field. sharedsemantics. Wires onshared_process_refand whereintervallives need confirmation againstcomposite.py.- Doc comments on stores and edges. Text-only until process-bigraph has a convention for state descriptions.
- Prototype parser. A small Lark grammar in Python gives text-level round trip quickly; the Langium v2 grammar remains the long-term single source of truth and binds to the same model.