From 753ab5f0ee0baf73260f48e8b61339a2bba4d10d Mon Sep 17 00:00:00 2001 From: Brummel Date: Fri, 10 Jul 2026 22:24:24 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20teach=20the=20Rust=20node-authoring=20p?= =?UTF-8?q?attern=20in=20the=20authoring=20guide=20(=C2=A70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 2 + docs/authoring-guide.md | 174 +++++++++++++++++++++++++++++++++++++++- docs/project-layout.md | 5 ++ 3 files changed, 180 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d611177..c7d335d 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,8 @@ naming (`--name`), and selection (`--select`) flags. A blueprint is authored declaratively from a **JSON op-list**: an ordered list 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 | |---|---| diff --git a/docs/authoring-guide.md b/docs/authoring-guide.md index 6e984a1..3a470cd 100644 --- a/docs/authoring-guide.md +++ b/docs/authoring-guide.md @@ -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 `::` 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 { + 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 { + 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 `.` on both sides of a wire. |---|---|---| | `source` | `{"op":"source","role":,"kind":}` | reserve a bound root **source** role of `kind` — a real input the harness feeds (e.g. `"price"`). | | `input` | `{"op":"input","role":}` | 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 is unbound`. | -| `add` | `{"op":"add","type":,"name":?,"bind":{:}?}` | 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":,"name":?,"bind":{:}?}` | 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":,"into":[, …]}` | 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":,"to":}` | 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":,"as":}` | 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). | diff --git a/docs/project-layout.md b/docs/project-layout.md index b1a124f..5a3a880 100644 --- a/docs/project-layout.md +++ b/docs/project-layout.md @@ -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 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) aura has no built-in coding-LLM. You author by talking to Claude Code, which