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:
@@ -77,6 +77,8 @@ naming (`--name`), and selection (`--select`) flags.
|
|||||||
|
|
||||||
A blueprint is authored declaratively from a **JSON op-list**: an ordered list
|
A blueprint is authored declaratively from a **JSON op-list**: an ordered list
|
||||||
of by-identifier construction ops replayed against the standard node vocabulary.
|
of by-identifier construction ops replayed against the standard node vocabulary.
|
||||||
|
Adding a new node *type* to that vocabulary is Rust, not JSON — see
|
||||||
|
`docs/authoring-guide.md`, §0, "Authoring a new node in Rust".
|
||||||
|
|
||||||
| Command | Purpose |
|
| Command | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|||||||
+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
|
lives in the design ledger (`docs/design/INDEX.md`, C20/C25) and the
|
||||||
glossary; this document only teaches the *shape*.
|
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
|
## 1. Op-scripts — building a strategy blueprint by hand
|
||||||
|
|
||||||
An op-script is a JSON **array of ops**, replayed in order to construct a
|
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"`). |
|
| `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`. |
|
| `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). |
|
| `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). |
|
| `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). |
|
| `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). |
|
||||||
|
|||||||
@@ -81,6 +81,11 @@ Shared nodes are `rlib` dependencies; the hot-reload unit stays the project-side
|
|||||||
`cdylib` that composes them, so editing a shared node still rebuilds and reloads
|
`cdylib` that composes them, so editing a shared node still rebuilds and reloads
|
||||||
the dependent.
|
the dependent.
|
||||||
|
|
||||||
|
Every tier follows the same three-part pattern — a `Node` impl, a
|
||||||
|
`PrimitiveBuilder` recipe, and rostering the type id into a vocabulary
|
||||||
|
lookup — worked through end to end, with the scaffold's own starter node as
|
||||||
|
the example, in [`docs/authoring-guide.md`, §0](authoring-guide.md#0-authoring-a-new-node-in-rust).
|
||||||
|
|
||||||
## Authoring happens in Claude Code (contract C17)
|
## Authoring happens in Claude Code (contract C17)
|
||||||
|
|
||||||
aura has no built-in coding-LLM. You author by talking to Claude Code, which
|
aura has no built-in coding-LLM. You author by talking to Claude Code, which
|
||||||
|
|||||||
Reference in New Issue
Block a user