# Authoring guide: op-scripts, process documents, campaign documents `docs/project-layout.md` describes the shape of a project and the arc of a research session; this document is the practical companion for the three JSON artifact kinds you author headlessly along that arc: 1. an **op-script** (role 6a) — builds a node graph (a strategy blueprint) through `aura graph build`; 2. a **process document** (role 5) — a named validation/eval methodology through `aura process validate|introspect|register|show`; 3. a **campaign document** (role 6b) — experiment intent (instruments × windows × strategy × axes × process) through `aura campaign validate|introspect|register|show|run|runs`. Each section below is a worked, verified example — every command shown was run against this repo and the output is transcribed, not invented. The *why* 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 - **The project's own signal doesn't need a crate by default.** `aura new` scaffolds a **data-only** project (`docs/project-layout.md`, "A project repo (two tiers)") — a strategy over the std vocabulary is a blueprint/campaign document, not Rust: the scaffold ships **one** closed `signal.json` starter that serves both verbs — `aura run` uses its bound values as-is, `aura sweep --axis .fast.length=2,4,8` overrides them (bound = default, not fixed); `--list-axes` lists the open knobs and the bound defaults alike, so every override target is discoverable. - **A project-specific *native* node type** — the moment §0's three-part pattern is actually needed — lives in an attached **node crate**: `aura nodes new ` scaffolds a sibling cdylib crate (`Cargo.toml` + `src/lib.rs`, registered under the project's own `::` prefix) and appends its path to the project's `Aura.toml [nodes]` section. - **A block promoted to universal** — reused across projects and folded into the engine itself — lives in `crates/aura-std/` (or its sibling domain crates: `aura-market`, `aura-strategy`), unprefixed, and is rostered in `crates/aura-vocabulary/src/lib.rs` instead of a node crate'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). The input window `ctx.f64_in(i)` hands those past values back **financial-style: index 0 is the newest cycle, index `k` is `k` cycles back** — so a `lookbacks()` of `vec![n]` makes `w[0]..=w[n-1]` the last `n` values, newest first, and a lookback-spanning node (momentum, ATR, RSI) reads its span as `w[0]` vs `w[length]`, never the other way round. 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 the shipped std vocabulary): one line in the `std_vocabulary_roster!` macro invocation in [`crates/aura-vocabulary/src/lib.rs`](../crates/aura-vocabulary/src/lib.rs) — `"TypeId" => Type,` — which expands into both the resolver `match` and the enumerable type-id list, so the two surfaces cannot drift apart. A zero-arg `Type::builder()` rosters directly; an **arg-bearing** type (structural, non-scalar construction — see "Session anchoring" below and `docs/glossary.md`'s `construction arg` entry, #271) rosters the exact same way — its `builder()` is itself zero-arg, returning a *pending* recipe the `add` op's `args` configures before any `bind`. 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 nodes new` writes into every freshly attached node crate's `src/lib.rs` (`__NS__` is the node crate'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)] } } // Replace `doc` below when renaming this sample node — it must keep // describing the node's own behaviour, not this scaffolding step. 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 }], // C29: every vocabulary entry ships a one-line meaning — the // doc field is required (E0063 without it) and gated at load. doc: "scalar gain: emits the input times the factor param", }, |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 node graph. `aura graph build` reads the op-script from **stdin** (there is no file argument) and prints the canonical blueprint envelope to stdout: ``` $ aura graph build < smacross.json > blueprint.json ``` Nodes are referenced by an **identifier** (given by `add`, see below); ports are dotted `.` on both sides of a wire. ### The ten ops | op | JSON shape | does | |---|---|---| | `doc` | `{"op":"doc","text":}` | declare the composite's one-line meaning (C29) — the op-script twin of the Rust builder's `.doc(...)`. Required before `aura graph register` accepts the product (the store refuses a doc-less composite); at most one per script (a second refuses: `a doc op may appear at most once`). The text is gated: empty or merely restating the composite's name refuses at register. | | `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) — the formal parameter of an **open pattern**, a fragment meant to be wired by an *enclosing* graph. An open pattern builds and registers like any blueprint. Running it standalone is governed by the ordinary run gates: the harness binds input roles to archive columns **by name** (C26), so a pattern whose roles match the data runs as-is; what refuses, by name, is a role the harness cannot bind — or the run surface's other gates (a signal without a bias output or tap; free knobs). | | `add` | `{"op":"add","type":,"name":?,"args":{:}?,"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). `args` (#271) configures an **arg-bearing** type's structural, non-scalar construction (`Session`'s timezone/open, `LinComb`/`CostSum`'s arity) — a closed table of string values, applied BEFORE `bind`; `aura graph introspect --node ` lists a type's declared `arg` rows. `bind` sets zero or more of the (now real) params. | | `use` | `{"op":"use","ref":{"content_id":}\|{"name":