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
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:
NodeSchemabecomes 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.
Blueprintcollapses intoComposite: the root is just theCompositewhose 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.rs—InputSpec→PortSpec(droplookback);NodeSchema.inputs: Vec<PortSpec>;Node::schema()removed,Node::lookbacks() -> Vec<usize>added;LeafFactory→PrimitiveBuildercarryingschema: NodeSchema(itsparams()is nowschema().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 fullschema;impl Nodedropsschema(), addslookbacks(). Per-nodefactory_params_match_built_node_schemalockstep tests are deleted (their subject is now structurally impossible).crates/aura-engine/src/blueprint.rs— deletestruct Blueprint; promote itscompile*/bootstrap*/param_spacemethods ontoComposite; renameBlueprintNode::Leaf→Primitive; addRole.source;derive_signaturefor the composite arm; structural validation readssignature()pre-build;compileemitsFlatGraph(gathering each primitive's signature parallel to the built node); addCompileError::UnboundRootRole { role }.crates/aura-engine/src/harness.rs—Harness::bootstraptakes aFlatGraph; sizing readsnode.lookbacks(), kind/output checks read the carried signatures (noschema()call on built nodes).crates/aura-cli/src/graph.rs+ sample constructors + tests — migrate allBlueprint/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
- Authoring. A blueprint is a root
Composite: interiornodes(each aPrimitive(PrimitiveBuilder)or nestedComposite),edges, andinput_roleswhere the root's roles each carrysource: Some(kind). param_space()(pre-build, onComposite) walks the tree and concatenates each node's declared params (fromsignature().params) in lowering order — the same deterministic ordercompileuses, unchanged.compile_with_paramsruns structural validation throughsignature()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 itsPrimitiveBuilder::schemainto the parallelFlatGraph.{nodes,signatures}; inlines composites (boundary dissolves, C23); lowers each bound root role into aFlatGraph.sourcesentry{ kind, flat targets }in role order. Returns theFlatGraph.bootstrapsizes each node's input columns fromsignatures[i].inputs[k].kindandnode.lookbacks()[k], lifts firing from the signature, kind-checks sources and edges against the carried signatures, topo-sorts (Kahn), and returns theHarness. Run-loop andevalare 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 rootCompositethat has aninput_rolewithsource: 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 aComposite'ssignature()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:i64in;macd, signal, histogramf64 out).compilerejects a kind mismatch without building — a blueprint wiring an f64 producer into an i64 slot errorsRoleKindMismatch/KindMismatchwith no node constructed (assert via a builder whosebuildclosure panics if called).
- New: the root is just a bound composite. A root
Compositewith every rolesource: Some(kind)bootstraps to aHarnessthat produces byte-identical output to the equivalent pre-refactorBlueprint(pin one existing sample). - New:
UnboundRootRole. A rootCompositewith onesource: Nonerole errorsUnboundRootRole { 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:
NodeSchemais declared once per node (onPrimitiveBuilder);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 Blueprintis gone; the root is aCompositewhose bound roles carrysource: Some(kind); a fully-bound root bootstraps with byte-identical output to the pre-refactor sample (C1); an open root role errorsUnboundRootRole.compileemits aFlatGraphand validates structure pre-build;bootstrapconsumes theFlatGraph, sizing fromlookbacks()and kind-checking from the carried signatures.- Renames landed (
Leaf→Primitive,LeafFactory→PrimitiveBuilder); the workspace builds andcargo clippy --workspace --all-targets -- -D warningsis clean.aura-cli/src/graph.rscompiles (render tuning deferred to the next cycle; visible render regression is acceptable and tracked). - Closes #43 and #36.