# Node Signature Lives in the Blueprint — Design Spec

**Date:** 2026-06-09
**Status:** Draft — awaiting user spec review
**Authors:** orchestrator + Claude

## Goal

Make every node's **signature** — its declared inputs, output, and params —
exist in the blueprint, **pre-build**, as a single source of truth. Today a
primitive node's I/O interface exists only on the *built* node's `schema()`
(post-build), and its params are declared **twice** (on the value-empty factory
*and* on the built node's schema), kept in agreement only by a per-node lockstep
test. This cycle consolidates the node data structure so that:

- `NodeSchema` becomes the static **signature** and is declared **once**, on the
  pre-build recipe — closing #43 (a value-empty recipe declared no I/O interface)
  and #36 (params declared twice).
- The root graph stops being a special type. `Blueprint` collapses into
  `Composite`: the root is just the `Composite` whose input roles are all bound
  to data feeds. There is no "main graph" — only the **Main-Composite-Node**, the
  entry node, governed by the same conditions as every other node.

This is a **behaviour-preserving** structural refactor (C1): every existing
backtest, composite, and harness test stays green. Graph rendering
(`aura-cli/src/graph.rs`) is **out of scope** beyond *making it compile* — render
tuning is the next cycle, and is explicitly deferred here.

## Architecture

One structural axis (a node is `Primitive` or `Composite`), one signature
question for both arms, one runnable form (a fully-bound composite).

The signature is **fully static** per blueprint — verified across the current
node roster: input kinds + firing, output fields, and params are all
param-independent. `LinComb`'s variable arity is a **factory argument**
(`LinComb::factory(arity)`, fixed per blueprint, C19), not an injected param. The
**only** param-dependent quantity in today's `NodeSchema` is an input's buffer
**lookback** depth (`Sma`'s lookback *is* its injected `length`). That one value
is not a signature concern but a **sizing** concern; it moves out of the
signature to a build-time query consumed only by `bootstrap`.

The construction pipeline becomes a clean three-noun chain:

```
Blueprint  ──compile──▶  FlatGraph  ──bootstrap──▶  Harness
(nested, named,          (flat, index-wired,        (runnable: buffers
 param-generic)           carries node+signature)    sized, topo-sorted)
```

`compile` does all structural validation **pre-build** through `signature()`
(kind-checks, output-range, fan-in distinguishability — no node built to check
it), then builds each primitive and emits the `FlatGraph`. `bootstrap` reads
kinds/output from the carried signatures and calls `node.lookbacks()` for buffer
depth.

## Concrete code shapes

### User-facing: how a node author declares a node (before → after)

The authoring surface is the node author writing a `Node` impl + its recipe. The
redundancy this cycle removes is visible right here — the params declared twice
collapse to once, and the I/O interface that was trapped in the build closure is
now declared on the recipe.

```rust
// BEFORE — crates/aura-std/src/sma.rs: params declared twice; I/O only post-build
impl Sma {
    pub fn factory() -> LeafFactory {
        LeafFactory::new(
            "SMA",
            vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }], // (1) here
            |p| Box::new(Sma::new(p[0].as_i64().expect("length slot is I64") as usize)),
        )
    }
}
impl Node for Sma {
    fn schema(&self) -> NodeSchema {
        NodeSchema {
            inputs: vec![InputSpec { kind: ScalarKind::F64, lookback: self.length, firing: Firing::Any }],
            output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }],
            params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }], // (1) AGAIN — #36 drift
        }
    }
    fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> { /* unchanged */ }
    fn label(&self) -> String { format!("SMA({})", self.length) }
}
```

```rust
// AFTER — the signature is declared ONCE on the builder; the built node answers
// only the value-dependent sizing question.
impl Sma {
    pub fn builder() -> PrimitiveBuilder {
        PrimitiveBuilder::new(
            "SMA",
            NodeSchema {
                inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any }], // no lookback
                output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }],
                params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }], // the ONE declaration
            },
            |p| Box::new(Sma::new(p[0].as_i64().expect("length slot is I64") as usize)),
        )
    }
}
impl Node for Sma {
    fn lookbacks(&self) -> Vec<usize> { vec![self.length] } // the only param-dependent quantity
    fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> { /* unchanged */ }
    fn label(&self) -> String { format!("SMA({})", self.length) }
    // no schema() — the signature lives on the builder
}
```

For a paramless node (`Sub`, `Add`) `lookbacks()` is `vec![1; n_inputs]`; for a
sink (`Recorder`, `output: vec![]`) the signature's `output` stays empty (C8
unchanged) and `lookbacks()` is one entry per recorded input.

### Engine type shapes (before → after) — secondary

```rust
// crates/aura-core/src/node.rs — the signature loses lookback
// BEFORE
struct InputSpec  { kind: ScalarKind, lookback: usize, firing: Firing }
struct NodeSchema { inputs: Vec<InputSpec>, output: Vec<FieldSpec>, params: Vec<ParamSpec> }
trait  Node       { fn schema(&self) -> NodeSchema; fn eval(..) -> ..; fn label(&self) -> String; }

// AFTER
struct PortSpec   { kind: ScalarKind, firing: Firing }                 // lookback removed
struct NodeSchema { inputs: Vec<PortSpec>, output: Vec<FieldSpec>, params: Vec<ParamSpec> }
trait  Node       { fn lookbacks(&self) -> Vec<usize>; fn eval(..) -> ..; fn label(&self) -> String; }
```

```rust
// crates/aura-core/src/node.rs — the recipe carries the signature
// BEFORE
struct LeafFactory   { name: &'static str, params: Vec<ParamSpec>, build: Box<dyn Fn(&[Scalar]) -> Box<dyn Node>> }
// AFTER
struct PrimitiveBuilder { name: &'static str, schema: NodeSchema, build: Box<dyn Fn(&[Scalar]) -> Box<dyn Node>> }
// accessors: name(), schema() -> &NodeSchema, build(slice) -> Box<dyn Node>
// params() is now schema().params (no second list)
```

```rust
// crates/aura-engine/src/blueprint.rs — Blueprint collapses into Composite; renames; FlatGraph
// BEFORE
enum   BlueprintNode { Leaf(LeafFactory), Composite(Composite) }
struct Blueprint     { nodes: Vec<BlueprintNode>, sources: Vec<SourceSpec>, edges: Vec<Edge> }
struct Composite     { name, nodes, edges, input_roles: Vec<Role>, params, output }
struct Role          { name: String, targets: Vec<Target> }

// AFTER — `Blueprint` deleted; root is a Composite whose roles are all bound
enum   BlueprintNode { Primitive(PrimitiveBuilder), Composite(Composite) }
struct Composite     { name, nodes, edges, input_roles: Vec<Role>, params, output }
struct Role          { name: String, targets: Vec<Target>, source: Option<ScalarKind> }
//   source == None        → open interior port
//   source == Some(kind)  → bound ingestion feed of `kind`; runnable iff every root role is Some

impl BlueprintNode {
    // ONE signature question for both arms (the heart of "every node has a signature in the blueprint")
    fn signature(&self) -> NodeSchema {
        match self {
            BlueprintNode::Primitive(b) => b.schema().clone(),     // declared
            BlueprintNode::Composite(c) => derive_signature(c),    // inputs = role kinds, output = OutField kinds,
                                                                    // params = aggregated — pre-build, no build
        }
    }
}
```

```rust
// crates/aura-engine/src/{blueprint.rs,harness.rs} — compile emits FlatGraph; bootstrap consumes it
// BEFORE
fn compile_with_params(self, params: &[Scalar]) -> Result<(Vec<Box<dyn Node>>, Vec<SourceSpec>, Vec<Edge>), CompileError>;
fn bootstrap(nodes: Vec<Box<dyn Node>>, sources: Vec<SourceSpec>, edges: Vec<Edge>) -> Result<Harness, BootstrapError>;

// AFTER
struct FlatGraph {
    nodes:      Vec<Box<dyn Node>>,
    signatures: Vec<NodeSchema>,   // parallel to nodes — every flat node is a primitive with a known signature
    sources:    Vec<SourceSpec>,   // lowered bound roles, in role-declaration order (deterministic, C1)
    edges:      Vec<Edge>,
}
fn compile_with_params(self, params: &[Scalar]) -> Result<FlatGraph, CompileError>;
fn bootstrap(flat: FlatGraph) -> Result<Harness, BootstrapError>;
// SourceSpec the TYPE survives as the flat ingestion descriptor { kind, targets };
// what dissolves is the standalone Blueprint.sources field — sources now come from bound roles.
```

## Components

- **`crates/aura-core/src/node.rs`** — `InputSpec` → `PortSpec` (drop `lookback`);
  `NodeSchema.inputs: Vec<PortSpec>`; `Node::schema()` removed, `Node::lookbacks()
  -> Vec<usize>` added; `LeafFactory` → `PrimitiveBuilder` carrying `schema:
  NodeSchema` (its `params()` is now `schema().params`).
- **`crates/aura-std/src/*.rs`** — every node in the current roster (`sma`, `ema`,
  `lincomb`, `exposure`, `add`, `sub`, `sim_broker`, `recorder`): `factory()` →
  `builder()` declaring the
  full `schema`; `impl Node` drops `schema()`, adds `lookbacks()`. Per-node
  `factory_params_match_built_node_schema` lockstep tests are **deleted** (their
  subject is now structurally impossible).
- **`crates/aura-engine/src/blueprint.rs`** — delete `struct Blueprint`; promote
  its `compile*`/`bootstrap*`/`param_space` methods onto `Composite`; rename
  `BlueprintNode::Leaf` → `Primitive`; add `Role.source`; `derive_signature` for
  the composite arm; structural validation reads `signature()` **pre-build**;
  `compile` emits `FlatGraph` (gathering each primitive's signature parallel to
  the built node); add `CompileError::UnboundRootRole { role }`.
- **`crates/aura-engine/src/harness.rs`** — `Harness::bootstrap` takes a
  `FlatGraph`; sizing reads `node.lookbacks()`, kind/output checks read the carried
  signatures (no `schema()` call on built nodes).
- **`crates/aura-cli/src/graph.rs`** + sample constructors + tests — migrate all
  `Blueprint`/`LeafFactory`/`.factory()` call sites to the new types/names. The
  renderer is brought to **compile only**; its output may regress this cycle and
  is re-tuned next cycle.

## Data flow

1. **Authoring.** A blueprint is a root `Composite`: interior `nodes` (each a
   `Primitive(PrimitiveBuilder)` or nested `Composite`), `edges`, and `input_roles`
   where the root's roles each carry `source: Some(kind)`.
2. **`param_space()`** (pre-build, on `Composite`) walks the tree and concatenates
   each node's declared params (from `signature().params`) in lowering order — the
   same deterministic order `compile` uses, unchanged.
3. **`compile_with_params`** runs structural validation through `signature()`
   **without building** (fan-in distinguishability, role kind-checks, output-range);
   then lowers: builds each primitive from its kind-checked param slice, pushing the
   built node **and** its `PrimitiveBuilder::schema` into the parallel
   `FlatGraph.{nodes,signatures}`; inlines composites (boundary dissolves, C23);
   lowers each bound root role into a `FlatGraph.sources` entry `{ kind, flat
   targets }` in role order. Returns the `FlatGraph`.
4. **`bootstrap`** sizes each node's input columns from `signatures[i].inputs[k].kind`
   and `node.lookbacks()[k]`, lifts firing from the signature, kind-checks sources
   and edges against the carried signatures, topo-sorts (Kahn), and returns the
   `Harness`. Run-loop and `eval` are untouched.

## Error handling

`CompileError` keeps every existing variant (`BadInteriorIndex`,
`RoleKindMismatch`, `OutputPortOutOfRange`, `ParamKindMismatch`, `ParamArity`,
`IndistinguishableFanIn`, `Bootstrap`). The kind/output/fan-in checks now fire
**pre-build** (off `signature()`) — same errors, no node built to reach them.

One variant is added:

- **`UnboundRootRole { role }`** — compiling/bootstrapping a root `Composite` that
  has an `input_role` with `source: None` (an open port at the root, which has no
  parent to bind it). This is the structural form of "only a fully-bound composite
  is runnable."

`BootstrapError` is unchanged in spirit; its kind-mismatch checks now read the
`FlatGraph.signatures` rather than a built node's `schema()`.

## Testing strategy

- **Behaviour preservation (C1) — the clamp.** Every existing engine/std/cli test
  for backtest output, the MACD composite, fan-in, output bindings, and harness
  semantics must stay green unchanged. Same input → same run.
- **Delete the #36 lockstep tests.** `factory_params_match_built_node_schema` (per
  node) is removed: with the signature declared once, the drift it guarded is
  structurally impossible. Its removal *is* the proof #36 is closed.
- **New: signature is pre-build and uniform.**
  - `BlueprintNode::Primitive(b).signature() == b.schema()` (declared), and a
    `Composite`'s `signature()` equals the interface derived from its interior
    (role kinds in, OutField kinds out, aggregated params) — asserted against the
    MACD composite (`fast:i64, slow:i64, signal:i64` in; `macd, signal, histogram`
    f64 out).
  - `compile` rejects a kind mismatch **without building** — a blueprint wiring an
    f64 producer into an i64 slot errors `RoleKindMismatch`/`KindMismatch` with no
    node constructed (assert via a builder whose `build` closure panics if called).
- **New: the root is just a bound composite.** A root `Composite` with every role
  `source: Some(kind)` bootstraps to a `Harness` that produces byte-identical
  output to the equivalent pre-refactor `Blueprint` (pin one existing sample).
- **New: `UnboundRootRole`.** A root `Composite` with one `source: None` role
  errors `UnboundRootRole { role }` at compile.
- **New: sizing decoupled.** `node.lookbacks().len() == signature().inputs.len()`
  for every std node (a cheap invariant test); `Sma::new(k).lookbacks() == vec![k]`.

## Acceptance criteria

aura's criterion: the change measurably **removes redundancy** and reintroduces no
failure class the core constraints exist to eliminate. This cycle:

- [ ] `NodeSchema` is declared once per node (on `PrimitiveBuilder`); `Node::schema()`
      no longer exists; #36's lockstep tests are deleted and the suite is still green.
- [ ] A value-empty node recipe exposes its full I/O signature pre-build
      (`PrimitiveBuilder::schema()`), closing #43; `BlueprintNode::signature()`
      answers uniformly for both arms without building.
- [ ] `struct Blueprint` is gone; the root is a `Composite` whose bound roles carry
      `source: Some(kind)`; a fully-bound root bootstraps with byte-identical output
      to the pre-refactor sample (C1); an open root role errors `UnboundRootRole`.
- [ ] `compile` emits a `FlatGraph` and validates structure pre-build; `bootstrap`
      consumes the `FlatGraph`, sizing from `lookbacks()` and kind-checking from the
      carried signatures.
- [ ] Renames landed (`Leaf`→`Primitive`, `LeafFactory`→`PrimitiveBuilder`); the
      workspace builds and `cargo clippy --workspace --all-targets -- -D warnings`
      is clean. `aura-cli/src/graph.rs` compiles (render tuning deferred to the next
      cycle; visible render regression is acceptable and tracked).
- [ ] Closes #43 and #36.
