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:
+173
-1
@@ -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). |
|
||||
|
||||
Reference in New Issue
Block a user