Files
Aura/docs/specs/0024-node-signature-in-blueprint.md
T
Brummel 78d68fa8ad spec: 0024 node signature in blueprint
Consolidate the node data structure so every node's signature (NodeSchema:
inputs/output/params) exists in the blueprint pre-build, declared once.

- Signature vs sizing split: NodeSchema becomes the static signature
  (InputSpec -> PortSpec, lookback removed); the one param-dependent quantity
  (input buffer lookback) moves to a build-time Node::lookbacks() query.
  Node::schema() is removed.
- PrimitiveBuilder (ex-LeafFactory) carries the schema; the built node no
  longer re-declares it -> closes the params-declared-twice drift.
- Blueprint collapses into Composite: Role gains source: Option<ScalarKind>;
  the root is the fully-bound composite (no "main graph"); new error
  UnboundRootRole.
- compile -> FlatGraph -> bootstrap: FlatGraph carries node + signature in
  parallel; bootstrap sizes from lookbacks(), kind-checks from the signatures.
- Renames: BlueprintNode::Leaf -> Primitive, LeafFactory -> PrimitiveBuilder.

Behaviour-preserving (C1). Render is out of scope beyond compiling (next cycle).

refs #43 #36
2026-06-09 11:09:06 +02:00

15 KiB

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.

// 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) }
}
// 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

// 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; }
// 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)
// 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
        }
    }
}
// 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.rsInputSpecPortSpec (drop lookback); NodeSchema.inputs: Vec<PortSpec>; Node::schema() removed, Node::lookbacks() -> Vec<usize> added; LeafFactoryPrimitiveBuilder 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::LeafPrimitive; 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.rsHarness::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 (LeafPrimitive, LeafFactoryPrimitiveBuilder); 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.