Skip to content

日本語

Extern Functions (Plugins)

Experimental

The plugin system is experimental and relatively immature: the WASM runtime is new, has seen little real-world use, and its surface — the ABI, the manifest format, the diagnostics, and the lockfile pinning rules — may change in any release. Treat results from plugins with the same skepticism you would apply to any unreviewed external code, and please report issues.

Extern functions let a graphcal project call quantity functions implemented outside the language — WebAssembly plugin modules vendored in the project, or native functions provided by the embedder (the CLI, the language server, or a program embedding the evaluation engine). They are the escape hatch for computations that cannot be expressed as a dag block: iterative solvers, special functions, property libraries, coordinate transforms.

Purity and Invocation Order

Plugin functions, including native implementations supplied by an embedder, must be pure: their results must depend on their explicit inputs, not on invocation history, shared mutable state, or the order in which other functions run. Internal caching is acceptable only when it does not change results.

Graphcal respects data dependencies but does not guarantee an order for independent plugin invocations. An observed order is not a contract; it may change between roots and inline calls, evaluations, versions, or execution strategies. Do not use declaration order or plugin side effects to coordinate calculations.

An impure, order-dependent implementation is a plugin bug and makes the Graphcal evaluation results undefined: Graphcal provides no guarantee about the resulting calculation values. This is not permission for memory unsafety or for bypassing applicable ABI validation, sandboxing, or resource limits. Native implementations remain trusted embedder code; Graphcal cannot prove their purity.

Declaring a Plugin

An import plugin block declares which functions a plugin provides and, crucially, their full dimensional signatures — in graphcal vocabulary, at the import site:

import plugin "plugins/fluids.wasm" as fluids {
    fn density(p: Pressure, t: Temperature) -> Mass * Length^-3;
    fn lerp<D: Dim>(a: D, b: D, t: Dimensionless) -> D;
    fn geometric_mean<D1: Dim, D2: Dim>(x: D1, y: D2) -> D1^(1/2) * D2^(1/2);
}
  • The path string identifies the plugin. A path ending in .wasm names a WebAssembly module file, resolved relative to the project root (never the importing file) and required to stay inside it. Any other spelling — such as the built-in "graphcal:demo" — is an identity provided natively by the embedder's host function registry.
  • The alias (as fluids) is mandatory. Extern functions are only callable qualified through it — fluids::density(...) — never bare. This mirrors the explicitness of module imports and keeps the built-in function namespace closed.
  • Each fn declares named parameters and a result type. Parameter and result types may be Bool, Int, quantity types written as dimension expressions, or arrays of any of those scalar kinds over one or more declared index variables (flags: Bool[I], counts: Int[I], xs: D[I], matrix: D[I, J]); the result may additionally be a record type in scope (see Record-Shaped Results).

Signatures are declared explicitly rather than inferred from the plugin: the declaration in your source is the contract your project type-checks against, reviewable in plain-text diffs and usable by editor tooling without the binary. At load time, each declaration is verified structurally against the manifest embedded in the .wasm module — renaming dimension variables or parameters is fine, but any difference in dimensional shape is a compile error (P005), so drift between source and binary can never be silently reinterpreted. If a dimensional signature names an unknown qualified path, the diagnostic preserves that complete path instead of flattening it into a dimension leaf.

Dimension Variables

A signature may declare dimension variables in explicit angle-bracket binders, making a function polymorphic over dimensions:

fn lerp<D: Dim>(a: D, b: D, t: Dimensionless) -> D;

At each call site, D binds to the actual argument dimension, every other D parameter must match it, and the result dimension is computed from the binding. A mismatch diagnostic names the earlier parameter that established the binding; Graphcal does not substitute placeholder parameter names if validated signature metadata is inconsistent. Result types may combine several variables with rational powers — full cross-variable dimension algebra:

fn geometric_mean<D1: Dim, D2: Dim>(x: D1, y: D2) -> D1^(1/2) * D2^(1/2);
node scale: Length = demo::geometric_mean(4.0 m, 9.0 m);   // = 6 m

One rule keeps checking decidable: every dimension variable must first appear as a bare parameter (x: D) before it is used in a compound form (D^2, D1 * D2) or in the result. A signature like fn sq<D: Dim>(x: D^2) -> D is rejected — it would require solving for D rather than binding it.

Dimension polymorphism is deliberately parametric: the plugin never learns which dimension D was bound to, so it cannot branch on units — the implicit behavior graphcal bans stays banned across the plugin boundary.

Arrays over Index Variables

A signature may also declare index variables (I: Index) and take or return arrays of quantities, booleans, or integers over them:

import plugin "plugins/dsp.wasm" as dsp {
    fn smooth<D: Dim, I: Index>(xs: D[I], window: Dimensionless) -> D[I];
    fn total<D: Dim, I: Index>(xs: D[I]) -> D;
    fn transpose<D: Dim, I: Index, J: Index>(matrix: D[I, J]) -> D[J, I];
    fn invert<I: Index>(flags: Bool[I]) -> Bool[I];
    fn increment<I: Index>(counts: Int[I]) -> Int[I];
}

index Maneuver = { Departure, Correction, Insertion };
node dv: Velocity[Maneuver] = { Maneuver#Departure: 2.0 km/s, Maneuver#Correction: 0.5 km/s, Maneuver#Insertion: 1.5 km/s };
node dv_smooth: Velocity[Maneuver] = dsp::smooth(@dv, 3.0);

Index variables follow the same explicit discipline as dimension variables:

  • Every array axis position must name one of the declared Index binders. Concrete declared indexes (Velocity[Maneuver]) and structural indexes (D[Fin(3)]) cannot be written directly in an extern signature, but either can bind a variable at a call site. Index variables are parametric: the plugin sees ordered extents and dense row-major typed values, never an index's identity or labels.
  • Two parameters sharing an index variable must be passed arrays over the same index.
  • Every result axis must reuse an index variable that indexes some parameter. Its extent is determined by an input, so a plugin cannot invent output extents. Result axes may be reordered (D[I, J] -> D[J, I]) and are rebuilt over exactly the binding arguments' typed indexes and keys, ready for indexing and for comprehensions like any other array.
  • A bare quantity-array element (xs: D[I]) is a binding occurrence for D, just like a bare quantity parameter. Bool and Int arrays do not participate in dimension-variable binding.
  • The leaf kind is checked exactly: Bool[I], Int[I], and Dimensionless[I] are distinct types with no implicit conversion. Arrays have one or more axes; each axis is non-empty.

Record-Shaped Results

A function may return several named values at once by declaring a record type in scope as its result:

import plugin "plugins/stats.wasm" as stats {
    fn span<D: Dim, I: Index>(xs: D[I]) -> DvSpan;
}

type DvSpan { DvSpan(lo: Velocity, hi: Velocity) }

node dv_span: DvSpan = stats::span(@dv);
node spread: Velocity = @dv_span.lo - @dv_span.hi;

The plugin's manifest never learns the type's name — it declares the flattened field shape (names, order, and kinds), and the declaration binds that shape to the nominal record type. Field names and order are part of the contract: a plugin declaring {min, max} does not match a declaration whose record has {lo, hi} (P005). The result evaluates to an ordinary record-shaped algebraic value with working field access and matching. The plugin ABI calls its flattened representation a struct shape; this is not a separate Graphcal type category.

Restrictions in this phase, each with a dedicated compile error:

  • The named type must be record-shaped — a single constructor named after the type. Types with multiple constructors have no single flattened layout to cross the boundary.
  • Fields must be Bool, Int, or concrete quantity types — generic records and dimension-variable fields cannot cross yet.
  • Record-shaped algebraic values are result-only. Such a parameter should be passed as separate quantity parameters instead.

Calling Extern Functions

Extern calls look like qualified function calls and participate in the graph like any other expression:

param v0: Velocity = 100.0 m/s;
param v1: Velocity = 300.0 m/s;

node v_mid: Velocity = demo::lerp(@v0, @v1, 0.25);

Restrictions, all enforced at compile time:

  • Extern functions are runtime-provided, so they cannot appear in const expressions, domain bounds, or unit scale expressions (P004).
  • Calls must be alias-qualified; a bare lerp(...) is an unknown function.
  • There is no auto-lifting over indexed values: an extern quantity function applies element-wise only through an explicit for comprehension, keeping the iteration visible in the source.
index Sample = { A, B };
node xs: Length[Sample] = { Sample#A: 1.0 m, Sample#B: 2.0 m };
node mids: Length[Sample] = for s: Sample {
    demo::lerp(@xs[s], 10.0 m, 0.5)
};

WASM Plugin Modules

A graphcal plugin is a core WebAssembly module, vendored in the project (committed next to the sources) and executed by an embedded, deterministic interpreter. The module must satisfy the ABI, all checked at load time before any plugin code runs:

  • Manifest. The module embeds a JSON manifest in a custom section named graphcal-manifest, declaring abi_version: 5 and each provided function's dimensional signature (dimension and index variables, named parameters, the result — including array kinds and struct field layouts). Fixed dimensions are spelled structurally over the eight prelude base dimensions (Length, Time, Mass, Temperature, ElectricCurrent, Amount, LuminousIntensity, Angle) with rational exponents — Velocity is Length^1 * Time^-1. Quantity kinds use the "quantity" JSON tag. Array entries carry an explicit quantity, "bool", or "int" element kind. User-defined base dimensions cannot cross the binary boundary. The JSON payload is limited to 256 KiB and 256 functions. Names are at most 256 UTF-8 bytes; each function may declare at most 32 dimension variables, 32 index variables, and 32 parameters; arrays have at most 31 axes; struct results have at most 256 flattened fields; and each monomial has at most 64 combined variable and fixed-dimension factors.
  • Value ABI. Each function's wasm export type follows its signature: quantity/Bool/Int parameters use one f64 ABI slot each (raw SI base units for quantities; Int as exactly-representable integers, Bool as 1.0/0.0). A rank-R array parameter is an i32 pointer followed by R i32 extents, pointing at the shape product of dense little-endian row-major f64 elements. Quantity elements must be finite, Bool elements must be numeric 0.0 or 1.0 (-0.0 is false), and Int elements use the same exact binary64 policy as scalar Int. Every element is validated before entering typed runtime/plugin code and again on results. A quantity/Bool/Int result is the single f64 return value; an array or struct result replaces the return with one trailing i32 out-pointer the plugin fills — the product of the signature-bound result extents for an array, or one slot per field for a struct. The complete lowered function signature may use at most 32 raw WebAssembly parameters; array pointers, every axis extent, and an out-pointer each count separately. This is checked in the manifest and by the Rust SDK before code generation. A non-finite quantity flows into graphcal's ordinary non-finite containment.
  • Allocator exports. A module that takes or returns arrays or structs must export its memory as "memory" plus graphcal_alloc(size: i32) -> i32 (8-byte-aligned) and graphcal_free(ptr: i32, size: i32). The host allocates every buffer before a call, writes the inputs, and frees everything after reading the result — a plugin never retains a buffer across calls.
  • No imports. The module may import nothing — with one exception: graphcal::fail(ptr: i32, len: i32), the host-provided failure reporter. The import ban makes plugins I/O-free, and the host creates a fresh instance for each logical call so mutable globals, tables, linear memory, allocator state, and start side effects cannot carry history from one graph node or re-evaluation into another. Together these rules make the boundary pure by construction. A module importing WASI or other host APIs is rejected with a dedicated diagnostic (P007). A module importing graphcal::fail must export its linear memory as "memory" so the failure message can be read.
  • Resource bounds. Plugin modules may be at most 16 MiB by default. Calls run under a fuel budget (roughly an instruction count) and memory limits. Exceeding a limit produces an error. See Project Fuel Policies to configure fuel budgets.
  • Determinism. Plugin arithmetic is IEEE-754 deterministic and the math is compiled into the module, so results are bit-identical across platforms.

Project Fuel Policies

Every plugin call receives 100,000,000 fuel units by default. A reviewed multi-file project may raise or lower that budget globally, or only for named heavy functions, in graphcal.toml:

[package]
name = "simulation"

[plugins]
fuel_per_call = 250_000_000

[[plugins.function_limits]]
plugin = "plugins/solver.wasm"
function = "solve"
fuel_per_call = 1_500_000_000

A function-specific entry takes precedence over [plugins].fuel_per_call; when neither exists, the embedder's default applies. Each configured value must be between 1 and 2,000,000,000 inclusive. This hard maximum preserves a finite availability bound when a project is opened by the language server.

Each package's manifest controls only its own plugin defaults and function limits. The application package does not silently override a dependency's budgets, and dependencies cannot change the interpreter's hard limits. Two versions of a package have separate plugin identities and policies.

The plugin path must be a portable root-relative .wasm path. Whenever that plugin is loaded by an entry point, the selector must match one of its declared extern functions; a stale or misspelled function for an active plugin is a manifest error. The selected budget covers one complete logical call — fresh instantiation and start, allocator round-trips, the kernel body, and deallocation. Memory, table, encoded-module, strict compilation, and cache limits are unchanged.

Keep overrides narrow and benchmark them. Fuel bounds work as deterministic circuit breakers, not runtime deadlines; a large value can make editor re-evaluation less responsive even though the call remains sandboxed.

To report a domain failure (say, an out-of-range property lookup), a plugin calls graphcal::fail with a UTF-8 message; the call is aborted and the message surfaces in the node's diagnostic. Traps and exhausted fuel are reported the same way, without a custom message.

Authoring

The graphcal-plugin Rust SDK declares each function once — signature in graphcal's extern-declaration syntax, body in Rust — and generates both the wasm export and the embedded manifest from that single source of truth:

graphcal_plugin::plugin! {
    /// Linear interpolation between `a` and `b`.
    fn lerp<D: Dim>(a: D, b: D, t: Dimensionless) -> D {
        (b - a).mul_add(t, a)
    }
}

Under the ABI v5 SDK, array parameters arrive as borrowed typed views: ArrayView<'_, f64> for quantities, ArrayView<'_, bool> for Bool, and ArrayView<'_, i64> for Int. They expose an ordered shape and flattened row-major typed data; array bodies return the corresponding validated graphcal_plugin::Array<T>. A Rust declaration may spell a record result structurally, for example -> { lo: Pressure, hi: Pressure }; the macro generates the corresponding named output type (SpanOutput for span) used by the body. Bool, Int, and quantity bodies use bool, i64, and SI f64, respectively.

graphcal plugin new scaffolds a ready-to-build crate and graphcal plugin test validates and calls the built module. See the Plugin Authoring guide for the full workflow — including failure reporting, native testing, and authoring without the SDK (a plugin is any toolchain output satisfying the module contract above; the graphcal-plugin-abi crate provides the manifest model and an embed_manifest helper for build tooling).

Trust: Lockfile Pins

For projects with a graphcal.toml, the lockfile is the trust boundary for plugin code. graphcal deps lock scans the package's sources for wasm plugin imports and records each file's SHA-256 in graphcal.lock:

[[plugin]]
path = "plugins/fluids.wasm"
sha256 = "3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855c"

At load time the pin is enforced, hard errors and never prompts: a plugin without a pin fails with P009 ("run graphcal deps lock"), and a plugin whose bytes hash differently from the pin fails with P010. New or changed plugin code can therefore only enter the project through a reviewable graphcal.lock diff. Plugin artifacts must be regular files within the package root, not symbolic links, and are subject to project loading limits.

Package handling:

  • Ad-hoc files (no graphcal.toml anywhere above) load plugins unpinned — there is no lock regime to audit against, and the sandbox plus resource bounds still apply.
  • Dependency packages may declare Wasm plugins and need no separate [[plugin]] entries. Their locked tree hash includes the manifest, source directory, and every Wasm artifact imported by those sources, including imports inside nested DAGs and binaries outside the source directory. Paths resolve within the declaring package root; traversal and symbolic links are rejected. Native evaluation and LSP analysis execute only the verified captured bytes. Changing a binary requires a new reviewed dependency lock.
  • Package instances scope Wasm identity: two versions can both import plugins/solver.wasm without collisions. Embedder-provided host functions remain global identities.

Failure Semantics

Static checking uses function metadata without invoking host functions. Extern calls are runtime-only: constant expressions and domain bounds cannot invoke them.

Extern functions can fail at runtime (a plugin reports a failure, traps, runs out of fuel, or a host function returns an error). Failures follow graphcal's per-node containment model:

  • The failing node reports an evaluation error naming the alias, function, and plugin (e.g. extern function `inv.inverse` (plugin "plugins/inv.wasm") failed: division by zero).
  • Nodes that depend on it report dependency failed.
  • Unrelated nodes keep evaluating. A failed call also discards the plugin instance, so a damaged plugin cannot corrupt later calls.

If a declared extern function is missing entirely — the plugin file is absent, fails validation, or its manifest does not provide the function — that is a load-time error reported on the declaration before evaluation starts (P003, P005–P010 depending on the cause).

The Host Function Registry

Embedders provide native implementations by injecting a HostFunctionRegistry — a map from (plugin path, function name) to a function of shape fn(&[HostFnValue]) -> Result<HostFnValue, HostFnError>, where a HostFnValue is a single f64, a shaped row-major HostArray, or fixed-layout record slots. WASM plugins register through the same interface (the graphcal-plugin-host crate loads a project's vendored modules into the registry), so the evaluator itself stays WASM-free:

use graphcal_eval::eval::compile_and_eval_from_project_with_host_fns;
use graphcal_eval::host_fns::demo_registry;
use graphcal_plugin_host::{PluginHost, register_project_plugins};

let mut registry = demo_registry();
register_project_plugins(&PluginHost::new(), &project, &mut registry);
let result = compile_and_eval_from_project_with_host_fns(&project, &overrides, &registry)?;

Native registry entries carry no manifest, so their declarations are trusted as-is — appropriate for embedder-controlled functions. The CLI and language server inject the built-in demo plugin ("graphcal:demo": lerp, inverse, geometric_mean, normalize, matrix_transpose, dv_range) so extern declarations — including array and struct-returning ones — can be exercised without any plugin file.