Skip to content

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

graphcal plugin new fluid-props
cd fluid-props

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 element xs: 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);
graphcal deps lock    # records the module's SHA-256 in graphcal.lock

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 explicit to_jd/from_jd-style conversions), Bool/Int array 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.