docs: teach the Rust node-authoring pattern in the authoring guide (§0)

New top-level section covering the three-part shape every node type
follows — Node impl (lookbacks/eval/label with the C8 warm-up filter),
PrimitiveBuilder recipe (NodeSchema + positional build closure), and
rostering (project-side aura_project! vs std-side roster macro) — with
the aura-new Scale starter as the verbatim worked example and the five
fresh #236 nodes as arity-variant references. Cross-linked from the
op-script vocabulary note, project-layout, and the README.

closes #228
This commit is contained in:
2026-07-10 22:24:24 +02:00
parent 00e15b9371
commit 753ab5f0ee
3 changed files with 180 additions and 1 deletions
+173 -1
View File
@@ -18,6 +18,178 @@ of this three-artifact split (closed-vocabulary data, never a logic DSL)
lives in the design ledger (`docs/design/INDEX.md`, C20/C25) and the
glossary; this document only teaches the *shape*.
All three artifact kinds above assume the node **types** they reference
already exist. §0 covers the one piece of this arc that is Rust, not data:
adding a new node type in the first place.
## 0. Authoring a new node in Rust
An op-script's `add` op (§1) instantiates a node **type** from the closed
vocabulary — but that vocabulary itself is not data, it is compiled Rust.
Adding a new node type (a new indicator, combinator, or signal primitive) is
role-2 work: you write a `Node` implementation, describe it to the bootstrap
with a `PrimitiveBuilder` recipe, and roster the type id so the loader can
find it by name. This is the one seam in the whole arc where the answer is
"write Rust", not "write JSON" (C17/C20) — and it is a small, fixed shape,
the same shape every node in `aura-std` already follows.
### Where the code goes
- **A project's own signal** — the common case — lives in the project crate
`aura new` scaffolds (`docs/project-layout.md`, "Where reusable nodes
live"): one `src/lib.rs` (or a module under `src/`), registered under the
project's own `<namespace>::` prefix.
- **A block promoted to universal** — reused across projects and folded into
the engine itself — lives in `crates/aura-std/src/`, unprefixed, and is
rostered in `crates/aura-std/src/vocabulary.rs` instead of a project's
`vocabulary()` function. The pattern below is identical either way; only
the rostering call site differs (see "Rostering the type" below).
### The three-part pattern
Every node type — std or project-local — is three things:
1. **A `Node` implementation.** A plain struct holding whatever state the
node needs between cycles (often just its output cell), plus three
methods: `lookbacks()` (how many past values, per input, the node reads —
`vec![1]` for a node that only ever looks at the current cycle),
`eval(ctx) -> Option<&[Cell]>` (the per-cycle computation — return `None`
until every input the node needs has fired at least once; this warm-up
filter is what keeps a downstream consumer from ever observing a
fabricated value, C8), and `label()` (a short, human-readable debug
string — not the type id used for serialization, see below).
2. **A `PrimitiveBuilder` recipe.** A `builder()` constructor that pairs a
`NodeSchema` (its input ports as `PortSpec`, its output fields as
`FieldSpec`, and its bindable params as `ParamSpec`) with a **build
closure** `|p| Box::new(Type::new(...))` that reads bound param values out
of `p` positionally (`p[0].f64()`, `p[1].i64()`, …, matching the `params`
order in the schema) and constructs the node. This one recipe is what lets
the bootstrap turn a blueprint's *serialized* param values into a live,
concrete node — a project or op-script never constructs a node directly.
3. **Rostering the type id.** The recipe is useless until something maps the
serialized type-id string (e.g. `"Scale"`, `"my_lab::ThirdCandle"`) back to
its `builder()`. This is a closed, compiled-in `match` — never a dynamic
registry (domain invariant 9) — so a node exists in the vocabulary only if
its type id is added to exactly one of these two match tables:
- **Project-side:** the `vocabulary()` / `type_ids()` pair the
`aura_core::aura_project!` macro wires up (every `aura new` scaffold
emits a starter pair — see the worked example below). Add one match arm
to `vocabulary()` and one entry to `type_ids()`'s slice.
- **Std-side** (only when promoting a node into `aura-std` itself): one
line in the `std_vocabulary_roster!` macro invocation in
[`crates/aura-std/src/vocabulary.rs`](../crates/aura-std/src/vocabulary.rs)
`"TypeId" => Type,` — which expands into both the resolver `match` and
the enumerable type-id list, so the two surfaces cannot drift apart.
An unrostered type fails safe either way: the loader refuses with a clean
`LoadError::UnknownNodeType` naming the missing id, and the type is simply
absent from `aura graph introspect --vocabulary` — never a silent partial
load.
### Worked example: `Scale`, a one-input, one-param node
This is the starter node `aura new` writes into every fresh project's
`src/lib.rs` (`__NS__` is the project's namespace) — copy-pasteable, and
already exercised end to end by the scaffold's own tests:
```rust
use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
ScalarKind,
};
/// One-input scalar gain: emits `input * factor`. Emits `None` until its
/// input has a value (warm-up filter, C8).
pub struct Scale {
factor: f64,
out: [Cell; 1],
}
impl Scale {
pub fn new(factor: f64) -> Self {
Self { factor, out: [Cell::from_f64(0.0)] }
}
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"my_lab::Scale",
NodeSchema {
inputs: vec![PortSpec {
kind: ScalarKind::F64,
firing: Firing::Any,
name: "value".into(),
}],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![ParamSpec { name: "factor".into(), kind: ScalarKind::F64 }],
},
|p| Box::new(Scale::new(p[0].f64())),
)
}
}
impl Node for Scale {
fn lookbacks(&self) -> Vec<usize> {
vec![1]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
let w = ctx.f64_in(0);
if w.is_empty() {
return None;
}
self.out[0] = Cell::from_f64(w[0] * self.factor);
Some(&self.out)
}
fn label(&self) -> String {
format!("my_lab::Scale({})", self.factor)
}
}
fn vocabulary(type_id: &str) -> Option<PrimitiveBuilder> {
match type_id {
"my_lab::Scale" => Some(Scale::builder()),
_ => None,
}
}
fn type_ids() -> &'static [&'static str] {
&["my_lab::Scale"]
}
aura_core::aura_project! {
namespace: "my_lab",
vocabulary: vocabulary,
type_ids: type_ids,
}
```
Reading this top to bottom against the three-part pattern: `Scale` is the
`Node` impl (one input, one param, a one-cycle lookback); `Scale::builder()`
is the `PrimitiveBuilder` recipe (`params: vec![ParamSpec { name: "factor",
... }]` declares the one bindable knob, and the build closure `|p|
Box::new(Scale::new(p[0].f64()))` reads it back positionally at bootstrap
time); and `vocabulary()` / `type_ids()` — wired up by `aura_project!` — are
the rostering. Once this compiles into the project's `cdylib`, `my_lab::Scale`
is a normal citizen of the vocabulary: `aura graph introspect --vocabulary`
lists it, `aura graph introspect --node my_lab::Scale` shows its port/param
shape exactly like a std node, and an op-script's `add` op can instantiate it
by that type id.
### Other worked examples, if you need a different arity
`aura-std` itself has several small, deliberately minimal nodes worth reading
alongside `Scale` for the shapes that recur most:
- [`crates/aura-std/src/const_node.rs`](../crates/aura-std/src/const_node.rs)
(`Const`) — another single-param node, but a *source*-shaped one: it needs
a driving input purely to be evaluated at all (a zero-input node never
fires in the total-push engine, C8), and ignores that input's actual value.
- [`crates/aura-std/src/div.rs`](../crates/aura-std/src/div.rs) (`Div`),
[`crates/aura-std/src/max.rs`](../crates/aura-std/src/max.rs) (`Max`),
[`crates/aura-std/src/min.rs`](../crates/aura-std/src/min.rs) (`Min`) —
two-input, paramless combinators (`inputs: vec![lhs, rhs]`, `params:
vec![]`, an eval that reads `ctx.f64_in(0)` and `ctx.f64_in(1)`).
- [`crates/aura-std/src/abs.rs`](../crates/aura-std/src/abs.rs) (`Abs`) — the
minimal case: one input, no params, `lookbacks() == vec![1]`.
## 1. Op-scripts — building a strategy blueprint by hand
An op-script is a JSON **array of ops**, replayed in order to construct a
@@ -37,7 +209,7 @@ are dotted `<identifier>.<port>` on both sides of a wire.
|---|---|---|
| `source` | `{"op":"source","role":<str>,"kind":<ScalarKind>}` | reserve a bound root **source** role of `kind` — a real input the harness feeds (e.g. `"price"`). |
| `input` | `{"op":"input","role":<str>}` | reserve an open root **input** role (kind inferred from the slots it feeds) — for a fragment meant to be wired by an *enclosing* graph. A standalone document built with `aura graph build` finalizes as a closed root, so an `input` role that is never bound refuses at the end: `finalize: root input role <name> is unbound`. |
| `add` | `{"op":"add","type":<TypeId>,"name":<str>?,"bind":{<param>:<Scalar>}?}` | instantiate a node of a type in the closed vocabulary (`aura graph introspect --vocabulary`). `name` becomes the node's identifier for later ops (default: the type's own lowercase label — two unnamed nodes of the same type then collide). `bind` sets zero or more of its params. |
| `add` | `{"op":"add","type":<TypeId>,"name":<str>?,"bind":{<param>:<Scalar>}?}` | instantiate a node of a type in the closed vocabulary (`aura graph introspect --vocabulary`) — see §0 below for how a type gets into that vocabulary in the first place. `name` becomes the node's identifier for later ops (default: the type's own lowercase label — two unnamed nodes of the same type then collide). `bind` sets zero or more of its params. |
| `feed` | `{"op":"feed","role":<str>,"into":[<port>, …]}` | fan a previously-declared role into one or more interior input slots, all-or-nothing (a failing target leaves none of the batch wired). |
| `connect` | `{"op":"connect","from":<port>,"to":<port>}` | wire one interior output field to one interior input slot. A `connect` that would close a dataflow cycle is rejected immediately — the only legal feedback path is an explicit delay/state node (domain invariant 5). |
| `expose` | `{"op":"expose","from":<port>,"as":<str>}` | promote an interior output field to a boundary output under the alias `as` — the only op whose name key is a real *alias* (contrast `add`'s `name`, which is an identifier, not a rename). |