Plugin Authoring¶
Experimental
The plugin system is experimental: the ABI, the SDK macro surface, and the CLI commands on this page may change in any release. Please report issues.
This guide walks through writing a WASM plugin in Rust with the
graphcal-plugin SDK — from scaffold to a pinned, evaluating module. For
the language-side view (declaring and calling extern functions, the module
contract, trust rules), see
Extern Functions.
A graphcal plugin is a pure, sandboxed kernel library: functions over
SI quantities, dense arrays, and record-shaped results, with dimensional
signatures checked by the graphcal compiler at every call site. Plugins
fit computations that cannot be a dag block — iterative solvers, special
functions, property libraries, coordinate transforms. They cannot touch
the filesystem or network by construction.
1. Scaffold¶
This creates a ready-to-build Rust crate:
fluid-props/
├── Cargo.toml # cdylib + rlib, graphcal-plugin dependency
├── rust-toolchain.toml # stable + the wasm32-unknown-unknown target
├── justfile # `just build`, `just test`
├── src/lib.rs # a plugin! block with sample kernels
└── README.md
2. Declare and implement¶
Everything lives in one plugin! block — signatures in graphcal's
extern-declaration syntax, bodies in Rust:
graphcal_plugin::plugin! {
/// Ideal-gas density of dry air.
fn air_density(p: Pressure, t: Temperature) -> Mass / Volume {
const R_SPECIFIC: f64 = 287.052874; // J/(kg*K)
if t <= 0.0 {
graphcal_plugin::fail!("temperature must be positive, got {t} K");
}
p / (R_SPECIFIC * t)
}
/// Linear interpolation, polymorphic over the dimension of `a`/`b`.
fn lerp<D: Dim>(a: D, b: D, t: Dimensionless) -> D {
(b - a).mul_add(t, a)
}
/// Arrays carry an ordered shape and flattened row-major values.
fn share<D: Dim, I: Index>(xs: D[I]) -> Dimensionless[I] {
let total: f64 = xs.iter().sum();
let values = xs.iter().map(|x| x / total).collect();
graphcal_plugin::Array::new(xs.shape().to_vec(), values)
.unwrap_or_else(|error| graphcal_plugin::fail!("{error}"))
}
/// Result axes may reorder axes that parameters bind.
fn transpose<D: Dim, I: Index, J: Index>(xs: D[I, J]) -> D[J, I] {
let [rows, columns] = xs.shape() else {
graphcal_plugin::fail!("transpose expects rank two");
};
let values = (0..*columns)
.flat_map(|column| {
(0..*rows).map(move |row| xs.values()[row * columns + column])
})
.collect();
graphcal_plugin::Array::new(vec![*columns, *rows], values)
.unwrap_or_else(|error| graphcal_plugin::fail!("{error}"))
}
/// Struct results are declared structurally; the macro generates a
/// named `SpanOutput` type so same-kind fields cannot swap silently.
fn span<I: Index>(xs: Pressure[I]) -> { lo: Pressure, hi: Pressure } {
let lo = xs.iter().copied().fold(f64::INFINITY, f64::min);
let hi = xs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
SpanOutput { lo, hi }
}
}
From this single declaration the macro generates the wasm exports and
the manifest embedded in the module — arity, parameter order, and
dimensional signatures cannot drift apart, and the signature lines can be
pasted verbatim into the .gcl import site.
Signature syntax¶
Parameter and result types are Bool, Int, dimension expressions, or
arrays of quantities over one or more declared index variables (xs: D[I],
matrix: D[I, J]); a result may also be a braced struct shape
(-> { lo: Pressure, hi: Pressure }).
Dimension expressions are built from:
| Vocabulary | Names |
|---|---|
| Dimension variables | declared per function as D: Dim binders in <...> |
| Index variables | declared per function as I: Index binders in <...> |
| Prelude base dimensions | Length, Time, Mass, Temperature, ElectricCurrent, Amount, LuminousIntensity, Angle |
| Prelude derived dimensions | Velocity, Acceleration, Force, Energy, Power, Frequency, Pressure, Area, Volume |
| The empty product | Dimensionless |
combined with *, /, parentheses, and ^ exponents — integers (^2,
^-3) or parenthesized rationals (^(1/2), ^(-1/2)). Derived names are
expanded to base-dimension exponents in the manifest, so Pressure and
Mass * Length^-1 * Time^-2 declare the same contract.
Two rules mirror the compiler's checks (violations are compile errors in the plugin crate, with the same meaning as P005/P016 on the graphcal side):
- every dimension variable must first appear as a bare parameter type
(
x: D, or a bare array elementxs: D[I]) before any compound use (D^2,D1 * D2, or the result); - every result-array axis must reuse an index variable that indexes some array parameter, and every declared index variable must index one; axes may be reordered, but a plugin cannot invent an extent;
- struct-shape fields are concrete (
Bool,Int, fixed dimensions — no dimension variables) with unique names; - exponents are non-zero.
The struct shape is the one spot where the Rust and .gcl spellings
differ: the plugin declares the shape (it cannot see graphcal type
names), while the .gcl import site names a record type in scope whose
fields must match the shape — names, order, and kinds.
In the body¶
Parameters arrive with their declared names and natural Rust types —
f64 for quantities, bool for Bool, i64 for Int, and
graphcal_plugin::ArrayView for arrays. ArrayView::shape() returns extents
in signature order and values() returns dense row-major SI values. An array
body returns a validated graphcal_plugin::Array; its shape must exactly match
the extents bound by the result's axis list. Other results are f64, bool,
i64, or the generated ...Output struct. Array-moving functions still
compile natively, so cargo test needs no wasm toolchain.
Quantity values are SI base units, always. A Pressure parameter is
pascals; a Velocity result is metres per second. Graphcal checks
dimensions at every call site, but it cannot see whether your math treats
a pascal as a bar — that residual risk lives inside the plugin, so keep
kernel math in SI throughout.
Dimension variables are parametric: the body never learns which dimension
D was bound to, so dimension-polymorphic kernels must be
dimension-uniform (interpolation yes, sin of a D no).
Failures and panics¶
Call graphcal_plugin::fail!("...") (or fail(&str)) for domain
failures — the message aborts the call and surfaces in the failing node's
diagnostic, while unrelated nodes keep evaluating. Rust panics (from
assert!, unwrap, arithmetic checks) are forwarded through the same
channel with the panic message, so they are diagnosable rather than
anonymous traps. On non-wasm targets both are ordinary panics.
3. Test natively¶
The plugin! expansion is plain Rust off-wasm, so kernels are unit-tested
with cargo test exactly like any crate — failures appear as panics with
the fail! message:
#[test]
fn density_of_air_at_stp() {
let rho = super::air_density(101_325.0, 288.15);
assert!((rho - 1.225).abs() < 1e-3);
}
4. Build and validate¶
cargo build --release --target wasm32-unknown-unknown
graphcal plugin test target/wasm32-unknown-unknown/release/fluid_props.wasm \
--call air_density 101325 288.15
graphcal plugin test runs every load-time ABI check (manifest, import
ban, export types), prints the module's SHA-256 and a paste-ready
import plugin block, and --call executes one function under the same
fuel and memory limits evaluation uses — arguments in SI base units,
true/false for Bool, integers for Int, and rectangular JSON arrays
with the declared rank ([1.0,2.5,3], [[1,2],[3,4]]). For
struct-returning functions the import block
is preceded by a suggested record declaration (rename it freely — the
loader compares shapes, not names).
5. Vendor, declare, pin¶
Copy the module into the graphcal project (say plugins/), paste the
declarations, and pin:
import plugin "plugins/fluid_props.wasm" as fluids {
fn air_density(p: Pressure, t: Temperature) -> Mass / Volume;
fn lerp<D: Dim>(a: D, b: D, t: Dimensionless) -> D;
}
node rho: Mass / Volume = fluids.air_density(@chamber_p, @chamber_t);
The lockfile is the trust boundary: plugin bytes can only change together
with a reviewable graphcal.lock diff. See
Trust: Lockfile Pins.
Scope and limits (ABI v4)¶
- Values are SI quantities,
Bool,Int, non-empty multi-axis arrays of quantities, and record-shaped results with concrete fields. Not crossing the boundary yet:Datetime(use explicitto_jd/from_jd-style conversions),Bool/Intarray elements, struct parameters, generic records, and dimension-variable struct fields. - One
plugin!block per plugin (a second block fails the wasm link with a duplicate-symbol error); helper functions can live anywhere in the crate. - Plugins may import nothing (the SDK's failure channel is the one exception, wired automatically), so crates that pull in I/O, threads, or randomness will be rejected at load time.
- Keep vocabulary in
.gcl: plugins cannot define units, dimensions, or types.
Authoring without the SDK¶
The SDK is a convenience, not part of the trust model — a plugin is any
core wasm module satisfying the module
contract: exports
whose wasm types follow their signatures (one f64 per quantity/Bool/Int
slot, an i32 pointer followed by one i32 extent per array axis, and a
trailing out-pointer for array/struct results), the
graphcal_alloc/graphcal_free pair when buffers are involved, a
graphcal-manifest custom section, and no imports beyond the optional
graphcal::fail. For non-Rust toolchains,
emit the manifest JSON (the graphcal-plugin-abi crate documents the
model and provides embed_manifest for build tooling) and verify the
result with graphcal plugin test.