Files
Aura/docs/plans/0024-node-signature-in-blueprint.md
T
Brummel 21c1621bd0 docs,engine: drop coined "compilat", use FlatGraph / "flat graph"
"Compilat" (German "Kompilat") was a coined noun for the product of the
bootstrap compilation — neither English nor a natural fit. The runtime
artifact already has a code identifier for exactly this thing: the
`FlatGraph` struct (harness.rs). Replace the coinage with that identifier:

- prose mentions          -> "flat graph" (mirrors the type, reads plainly)
- definitional anchors    -> `FlatGraph` (C11, C23, the running-graph line)
- `render_compilat`       -> `render_flat_graph` (historical render symbol;
                             keeps the `render_blueprint` / `render_flat_graph`
                             source-vs-product pairing)
- `intra-compilat`        -> `intra-graph`
- `from_compilat` (test)  -> `from_flat`

"compilation" / "re-compilation" / "bootstrap-as-compilation" (the process,
ordinary English) are deliberately left untouched. Behaviour-preserving:
only comments, design ledger, specs/plans, one test-local variable and its
assert messages change. Full workspace test suite green; clippy clean.
2026-06-14 17:02:15 +02:00

52 KiB

Node Signature Lives in the Blueprint — Implementation Plan

Parent spec: docs/specs/0024-node-signature-in-blueprint.md

For agentic workers: REQUIRED SUB-SKILL: use the implement skill to run this plan. Steps use - [ ] checkboxes for tracking.

Goal: Consolidate the node data structure so every node's signature (NodeSchema) is declared once and exists in the blueprint pre-build; merge #43 and #36; collapse Blueprint into the root Composite. Behaviour-preserving (C1).

Architecture: NodeSchema becomes the static signature (InputSpecPortSpec, lookback removed); the one param-dependent quantity (input buffer lookback) moves to Node::lookbacks(), consumed only by bootstrap. LeafFactoryPrimitiveBuilder carries the schema. Blueprint is deleted; the root is a Composite whose input roles are all source-bound (Role.source: Option<ScalarKind>). compile validates structurally pre-build via signature() and emits a FlatGraph { nodes, signatures, sources, edges }; bootstrap consumes the FlatGraph.

Tech Stack: aura-core (type epicentre), aura-std (8 nodes), aura-engine (blueprint.rs + harness.rs), aura-cli (sample constructors + compile-only render), aura-ingest (one bootstrap caller).


Compile-gate sequencing (read before executing)

This refactor breaks the whole workspace until every site is threaded (removing Node::schema(), renaming LeafFactory/Leaf, deleting struct Blueprint, reshaping compile/bootstrap). The crate dependency order is aura-core → aura-std → aura-engine → aura-cli/aura-ingest. Tasks gate on per-crate partial builds in that order; the workspace-wide cargo build --workspace / cargo test --workspace / clippy gate lands only in the final task (Task 5). No intermediate task may gate on a workspace build — it would be unsatisfiable while downstream crates are still stale. Because the workspace is red between Task 1 and Task 5, the whole plan is one working-tree-consistent unit: the orchestrator commits once, after Task 5 is green. No partial commits.

Files this plan creates or modifies:

  • Modify: crates/aura-core/src/node.rsPortSpec, NodeSchema, PrimitiveBuilder, Node trait (Task 1)
  • Modify: crates/aura-core/src/lib.rs:42,20 — re-exports/doc (Task 1)
  • Modify: crates/aura-std/src/{add,sub,exposure,ema,sma,lincomb,recorder,sim_broker}.rs (Task 2)
  • Modify: crates/aura-engine/src/blueprint.rs — Blueprint→Composite, signature, FlatGraph wiring, pre-build validation (Task 3, tests Task 4)
  • Modify: crates/aura-engine/src/harness.rsFlatGraph def, bootstrap reshape (Task 3, fixtures Task 4)
  • Modify: crates/aura-cli/src/{main.rs,graph.rs} (Task 5)
  • Modify: crates/aura-ingest/tests/real_bars.rs:25 (Task 5)

Task 1: aura-core — the signature/builder types

Files:

  • Modify: crates/aura-core/src/node.rs:29-34,64-106,113-118,130-144,150-207

  • Modify: crates/aura-core/src/lib.rs:20,42

  • Step 1: Replace InputSpec with PortSpec (drop lookback)

In crates/aura-core/src/node.rs, replace the InputSpec definition at 27-34:

/// One declared input **port** of a node: its scalar kind and firing policy (C6).
/// The lookback depth is NOT here — it is a build-time *sizing* concern answered by
/// `Node::lookbacks()`, not part of the static signature (a node's lookback can
/// depend on an injected param, e.g. `Sma`'s window = its `length`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PortSpec {
    pub kind: ScalarKind,
    pub firing: Firing,
}
  • Step 2: Point NodeSchema.inputs at PortSpec

In node.rs, the NodeSchema struct at 113-118 — change the inputs field type (the output/params fields and the doc are unchanged):

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NodeSchema {
    pub inputs: Vec<PortSpec>,
    pub output: Vec<FieldSpec>,
    pub params: Vec<ParamSpec>,
}
  • Step 3: Replace LeafFactory with PrimitiveBuilder carrying the schema

In node.rs, replace the LeafFactory struct (64-71) and its impl (73-106) with:

/// A param-generic blueprint **primitive** recipe (C19): a node's full declared
/// signature (`NodeSchema` — inputs/output/params) plus a closure that builds a
/// sized instance through the node's own constructor (the single sizing/validation
/// gate). A blueprint holds these recipes, never built instances, so it stays
/// value-empty until a param-set is injected (C19/C23). The signature is declared
/// here ONCE — the built node no longer re-declares it (closes the param-declared-
/// twice drift, #36) and a value-empty recipe now exposes its full I/O interface
/// pre-build (#43).
pub struct PrimitiveBuilder {
    name: &'static str,
    schema: NodeSchema,
    // The build closure's type is exactly the recipe contract (a param slice in, a
    // boxed node out); a type alias would not clarify it.
    #[allow(clippy::type_complexity)]
    build: Box<dyn Fn(&[Scalar]) -> Box<dyn Node>>,
}

impl PrimitiveBuilder {
    /// `name` is the param-generic render label (the node type, e.g. `"SMA"`);
    /// `schema` the full declared signature; `build` constructs a sized node from a
    /// kind-checked param slice.
    pub fn new(
        name: &'static str,
        schema: NodeSchema,
        build: impl Fn(&[Scalar]) -> Box<dyn Node> + 'static,
    ) -> Self {
        Self { name, schema, build: Box::new(build) }
    }
    /// The full declared signature (read pre-build by `Composite::param_space`,
    /// `BlueprintNode::signature`, and the renderer).
    pub fn schema(&self) -> &NodeSchema {
        &self.schema
    }
    /// The declared tunable params (a view into the signature; pre-build).
    pub fn params(&self) -> &[ParamSpec] {
        &self.schema.params
    }
    /// Build a sized node from its param slice (the slice is kind-checked by the
    /// caller before this runs).
    pub fn build(&self, params: &[Scalar]) -> Box<dyn Node> {
        (self.build)(params)
    }
    /// The param-generic render label for the blueprint view (C22): just the node
    /// type, e.g. `SMA`.
    pub fn label(&self) -> String {
        self.name.to_string()
    }
}
  • Step 4: Swap Node::schema() for Node::lookbacks()

In node.rs, the Node trait (130-144) — replace the fn schema declaration at 131 with fn lookbacks. The exact trait body:

pub trait Node {
    /// The per-input buffer **lookback** depth (each `>= 1`), in input-slot order —
    /// the only signature-adjacent quantity that may depend on an injected param
    /// (e.g. `Sma`'s window = its `length`). Read once by `Harness::bootstrap` to
    /// size each input column; never on the hot path. Length MUST equal the node's
    /// declared `signature().inputs.len()`.
    fn lookbacks(&self) -> Vec<usize>;

    /// Compute one cycle's output row, or `None` (filter / warm-up / pure sink). A
    /// returned `Some(row)` must satisfy `row.len() == signature().output.len()`.
    fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]>;

    /// A human-readable label for the built (valued) node, e.g. `SMA(2)`.
    fn label(&self) -> String;
}

Note: the existing trait doc comment block above pub trait Node and the eval/label doc text are preserved; only the schema method becomes lookbacks. If the trait has other methods (e.g. a provided label default), keep them unchanged.

  • Step 5: Migrate aura-core's own #[cfg(test)] module

In node.rs, the test module (≈150-207) uses the old API. Apply:

  • The Bare test-fixture node's fn schema (153-155) → remove it, add fn lookbacks(&self) -> Vec<usize> { vec![] } (Bare has no inputs — confirm by reading its current schema; if it declares inputs, return one 1 per input).
  • InputSpec { kind, lookback, firing } constructions (≈163-164) → PortSpec { kind, firing } (drop the lookback field).
  • leaf_factory_label_is_the_bare_type (177-188): the two LeafFactory::new(name, params, build) calls → PrimitiveBuilder::new(name, NodeSchema { inputs: vec![], output: vec![], params }, build) (supply the schema the fixture needs; if the fixture only checks label(), an empty-inputs/empty-output schema with the same params is correct). Rename the test to primitive_builder_label_is_the_bare_type.
  • leaf_factory_build_runs_the_closure (191-194): .schema().params read at 193 → the built node has no schema() now; assert against the builder's params() instead (f.params()), and rename to primitive_builder_build_runs_the_closure.
  • schema_carries_declared_params (197-206): retarget onto PrimitiveBuilder::schema() (a builder-level assertion) — assert_eq!(b.schema().params, vec![...]).

Read the exact current bodies before editing; preserve each test's intent (label, closure-runs, params-declared), only swapping the removed API.

  • Step 6: Update lib.rs re-exports and doc

In crates/aura-core/src/lib.rs: the re-export line (≈42) names LeafFactory and InputSpec — rename to PrimitiveBuilder and PortSpec in the pub use list. The module doc (≈20) mentioning these names — update prose to the new names.

  • Step 7: Gate — aura-core builds and tests green

Run: cargo build -p aura-core && cargo test -p aura-core Expected: PASS — aura-core lib compiles; its unit tests (label, closure, params, PortSpec) pass. (Downstream crates are still red; that is expected until Task 5.)


Task 2: aura-std — every node declares its signature on the builder

Each of the 8 nodes gets the SAME shape of change: factory()builder() returning PrimitiveBuilder::new(name, <the schema that was in impl Node>, build) with InputSpec{...,lookback,...}PortSpec{...} (drop lookback); impl Node drops fn schema, adds fn lookbacks; the per-node factory_params_match_built_node_schema test is deleted. The exact per-node code follows; the eval/label/new bodies and all doc comments are unchanged.

Files: crates/aura-std/src/{add,sub,exposure,ema,sma,lincomb,recorder,sim_broker}.rs

  • Step 1: add.rs

Import line 5: InputSpec, LeafFactoryPortSpec, PrimitiveBuilder. Replace factory() (31-33) and impl Node's fn schema (43-52) with:

    pub fn builder() -> PrimitiveBuilder {
        PrimitiveBuilder::new(
            "Add",
            NodeSchema {
                inputs: vec![
                    PortSpec { kind: ScalarKind::F64, firing: Firing::Any },
                    PortSpec { kind: ScalarKind::F64, firing: Firing::Any },
                ],
                output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }],
                params: vec![],
            },
            |_| Box::new(Add::new()),
        )
    }
    fn lookbacks(&self) -> Vec<usize> {
        vec![1, 1]
    }

Delete the factory_params_match_built_node_schema test (≈75-79).

  • Step 2: sub.rs

Import line 6: InputSpec, LeafFactoryPortSpec, PrimitiveBuilder. Replace factory() (22-24) and impl Node's fn schema (34-43) with:

    pub fn builder() -> PrimitiveBuilder {
        PrimitiveBuilder::new(
            "Sub",
            NodeSchema {
                inputs: vec![
                    PortSpec { kind: ScalarKind::F64, firing: Firing::Any },
                    PortSpec { kind: ScalarKind::F64, firing: Firing::Any },
                ],
                output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }],
                params: vec![],
            },
            |_| Box::new(Sub::new()),
        )
    }
    fn lookbacks(&self) -> Vec<usize> {
        vec![1, 1]
    }

Delete the factory_params_match_built_node_schema test (≈66-70).

  • Step 3: exposure.rs

Import line 7: InputSpec, LeafFactoryPortSpec, PrimitiveBuilder. factory() (27-33) → builder(); fn schema (37-43) → fn lookbacks:

    pub fn builder() -> PrimitiveBuilder {
        PrimitiveBuilder::new(
            "Exposure",
            NodeSchema {
                inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any }],
                output: vec![FieldSpec { name: "exposure", kind: ScalarKind::F64 }],
                params: vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }],
            },
            |p| Box::new(Exposure::new(p[0].as_f64().expect("scale slot is F64"))),
        )
    }
    fn lookbacks(&self) -> Vec<usize> {
        vec![1]
    }

Delete the lockstep test (≈86-90).

  • Step 4: ema.rs

Import line 21-22: InputSpec, LeafFactoryPortSpec, PrimitiveBuilder. factory() (58-64) → builder(); fn schema (68-81) → fn lookbacks:

    pub fn builder() -> PrimitiveBuilder {
        PrimitiveBuilder::new(
            "EMA",
            NodeSchema {
                inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any }],
                output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }],
                params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
            },
            |p| Box::new(Ema::new(p[0].as_i64().expect("length slot is I64") as usize)),
        )
    }
    // recursive: the running average lives in internal state, so only the newest
    // sample is read — `length` sizes alpha, not the window.
    fn lookbacks(&self) -> Vec<usize> {
        vec![1]
    }

Delete the lockstep test (≈160). The other .schema() reads in ema.rs tests (≈163-166) — retarget onto the new API: any built.schema() becomes a check against Ema::builder().schema() (builder-level) or is dropped if it only re-checked the lockstep; preserve any behavioural assertion (warm-up values) unchanged.

  • Step 5: sma.rs

Import line 7: InputSpec, LeafFactoryPortSpec, PrimitiveBuilder. factory() (26-32) → builder(); fn schema (36-47) → fn lookbacks (the lookback IS the window — this is the proof case):

    pub fn builder() -> PrimitiveBuilder {
        PrimitiveBuilder::new(
            "SMA",
            NodeSchema {
                inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any }],
                output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }],
                params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
            },
            |p| Box::new(Sma::new(p[0].as_i64().expect("length slot is I64") as usize)),
        )
    }
    fn lookbacks(&self) -> Vec<usize> {
        vec![self.length]
    }

sma.rs is the std test-aggregation hub. Migrate its tests:

  • Delete factory_params_match_built_node_schema (≈128).

  • sma_warms_up_then_tracks_the_window_mean (≈79-): it sizes the input column from schema.inputs[0].kind/.lookback. Replace with a directly-built Sma and sma.lookbacks()[0] for the depth, ScalarKind::F64 for the kind (the node has no schema()): AnyColumn::with_capacity(ScalarKind::F64, sma.lookbacks()[0]).

  • The *_declares_* / nodes_declare_expected_params / paramless_nodes_have_no_params reads at ≈74,131,140,144,148,154-158 use X::new(..).schema().params — retarget each onto X::builder().schema().params (the declared params now live on the builder, not the built node). Keep every asserted value identical (e.g. LinComb's weights[0]/weights[1], Exposure's scale, SMA's length, the empty-params nodes). Update imports in that test to use builder() where it called factory() or new().schema().

  • labels_carry_identifying_params (≈) asserts X::new(..).label() — unchanged (label() stays on Node).

  • Step 6: lincomb.rs

Import line 10: InputSpec, LeafFactoryPortSpec, PrimitiveBuilder. factory(arity) (45-58) → builder(arity) — arity stays a builder ARG (the schema is built from it, static per blueprint); fn schema (60-72) → fn lookbacks:

    pub fn builder(arity: usize) -> PrimitiveBuilder {
        let inputs = (0..arity)
            .map(|_| PortSpec { kind: ScalarKind::F64, firing: Firing::Any })
            .collect();
        let params = (0..arity)
            .map(|i| ParamSpec { name: format!("weights[{i}]"), kind: ScalarKind::F64 })
            .collect();
        PrimitiveBuilder::new(
            "LinComb",
            NodeSchema { inputs, output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }], params },
            |p| Box::new(LinComb::new(
                p.iter().map(|s| s.as_f64().expect("weight slot is F64")).collect(),
            )),
        )
    }
    fn lookbacks(&self) -> Vec<usize> {
        vec![1; self.weights.len()]
    }

Delete the lockstep test (≈146).

  • Step 7: recorder.rs

Import line 10: InputSpec, LeafFactoryPortSpec, PrimitiveBuilder. The kinds/firing are builder ARGS threaded into the schema (static per blueprint, like LinComb arity). factory(kinds, firing, tx) (33-42) → builder(...); fn schema (45-53) → fn lookbacks. The schema must be built BEFORE the move closure captures kinds/tx, so clone for the closure:

    pub fn builder(
        kinds: Vec<ScalarKind>,
        firing: Firing,
        tx: Sender<(Timestamp, Vec<Scalar>)>,
    ) -> PrimitiveBuilder {
        let inputs = kinds.iter().map(|&kind| PortSpec { kind, firing }).collect();
        let build_kinds = kinds.clone();
        PrimitiveBuilder::new(
            "Recorder",
            NodeSchema { inputs, output: vec![], params: vec![] }, // sink: empty output (C8)
            move |_| Box::new(Recorder::new(&build_kinds, firing, tx.clone())),
        )
    }
    fn lookbacks(&self) -> Vec<usize> {
        vec![1; self.kinds.len()]
    }

Delete the lockstep test (≈85-90). The rec.schema() read at ≈98 → drop or retarget onto Recorder::builder(...).schema() preserving its intent (likely asserting empty output / the input kinds); keep the behavioural recording assertions unchanged.

  • Step 8: sim_broker.rs

Import line 7: InputSpec, LeafFactoryPortSpec, PrimitiveBuilder. pip_size stays a builder ARG. factory(pip_size) (61-63) → builder(pip_size); fn schema (67-76) → fn lookbacks:

    pub fn builder(pip_size: f64) -> PrimitiveBuilder {
        PrimitiveBuilder::new(
            "SimBroker",
            NodeSchema {
                inputs: vec![
                    PortSpec { kind: ScalarKind::F64, firing: Firing::Any }, // 0 exposure
                    PortSpec { kind: ScalarKind::F64, firing: Firing::Any }, // 1 price
                ],
                output: vec![FieldSpec { name: "equity", kind: ScalarKind::F64 }],
                params: vec![],
            },
            move |_| Box::new(SimBroker::new(pip_size)),
        )
    }
    fn lookbacks(&self) -> Vec<usize> {
        vec![1, 1]
    }

Delete the lockstep test (≈126).

  • Step 9: Gate — aura-std builds and tests green

Run: cargo build -p aura-std && cargo test -p aura-std Expected: PASS. The suite is smaller by 8 deleted lockstep tests; all behavioural node tests (warm-up, clamp, sum, difference, EMA seeding, recording) pass. Verify the 8 deletions landed: ! grep -rn "factory_params_match_built_node_schema" crates/aura-std/ Expected: no matches.


Task 3: aura-engine lib — Composite absorbs Blueprint; signature; FlatGraph; bootstrap

Files:

  • Modify: crates/aura-engine/src/harness.rs:49,114-200 (FlatGraph def + bootstrap)

  • Modify: crates/aura-engine/src/blueprint.rs:15,32-42,48-51,126-257,266-310,415-450,466-571

  • Step 1: Define FlatGraph in harness.rs

In crates/aura-engine/src/harness.rs, next to SourceSpec (≈49), add:

/// The flat, type-erased, index-wired output of `Composite::compile` — the target
/// `Harness::bootstrap` consumes (C23's "flat graph", now a named type). `signatures[i]`
/// is the static signature of `nodes[i]` (gathered from each primitive's builder at
/// lowering); `bootstrap` reads kinds/output from it and `nodes[i].lookbacks()` for
/// buffer depth. `sources` are the lowered bound roles, in role-declaration order.
pub struct FlatGraph {
    pub nodes: Vec<Box<dyn Node>>,
    pub signatures: Vec<NodeSchema>,
    pub sources: Vec<SourceSpec>,
    pub edges: Vec<Edge>,
}

Ensure NodeSchema is imported in harness.rs (add to the aura_core use list if not already present).

  • Step 2: Reshape Harness::bootstrap to consume a FlatGraph

Replace the bootstrap signature (114-118) and its schema-collection/sizing (121-137). The new head:

    pub fn bootstrap(flat: FlatGraph) -> Result<Harness, BootstrapError> {
        let FlatGraph { nodes, signatures, sources, edges } = flat;
        let n = nodes.len();

        // size each node's input columns: KIND/firing from the carried signature,
        // DEPTH from the built node's lookbacks() (the one param-dependent quantity)
        let mut boxes: Vec<NodeBox> = Vec::with_capacity(n);
        for (nd, sig) in nodes.into_iter().zip(signatures.iter()) {
            let depths = nd.lookbacks();
            debug_assert_eq!(depths.len(), sig.inputs.len(), "lookbacks() arity == signature inputs");
            let inputs: Vec<AnyColumn> = sig
                .inputs
                .iter()
                .zip(depths)
                .map(|(spec, depth)| AnyColumn::with_capacity(spec.kind, depth))
                .collect();
            let firing: Vec<Firing> = sig.inputs.iter().map(|spec| spec.firing).collect();
            let slots: Vec<SlotState> = sig
                .inputs
                .iter()
                .map(|_| SlotState { fresh_at: 0, last_ts: Timestamp(i64::MIN) })
                .collect();
            let out_len = sig.output.len();
            boxes.push(NodeBox { node: nd, inputs, firing, slots, out_len });
        }

Then the source-target and edge kind-checks (the loops after 137) change their oracle from schemas to signatures — replace every schemas.get(...) with signatures.get(...); the .inputs/.output field reads are identical (PortSpec still has .kind). Keep the topological sort and the rest of the function unchanged.

  • Step 3: Rename the LeafFactory import and the BlueprintNode::Leaf arm

In crates/aura-engine/src/blueprint.rs:

  • Import line 15: LeafFactoryPrimitiveBuilder.

  • BlueprintNode enum (32-35): Leaf(LeafFactory)Primitive(PrimitiveBuilder).

  • The From<LeafFactory> for BlueprintNode impl (38-42): LeafFactoryPrimitiveBuilder, the arm BlueprintNode::LeafBlueprintNode::Primitive.

  • Every production BlueprintNode::Leaf match arm → BlueprintNode::Primitive, at: 275, 303, 328, 399, 423, 476, 494 (the ::Composite arm beside each is unchanged). The bodies (reading f.label()/f.params()) are unchanged — f is now &PrimitiveBuilder, whose params() returns &self.schema.params (same accessor, so all param/alias/fan-in code at 279/328/400/424 works verbatim).

  • Step 4: Add source to Role

Role struct (48-51) gains the field:

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Role {
    pub name: String,
    pub targets: Vec<Target>,
    /// `None` = an open interior port (wired by the enclosing graph's edges);
    /// `Some(kind)` = a bound ingestion feed of `kind` (only meaningful at the root,
    /// where it lowers to a `FlatGraph` source). C3: sources bind at ingestion only.
    pub source: Option<ScalarKind>,
}
  • Step 5: Add UnboundRootRole to CompileError

In the CompileError enum (126-147) add:

    /// A root input role `role` has no bound source (`source: None`) — an open port
    /// at the root, which has no enclosing graph to wire it. Only a fully source-
    /// bound composite is runnable.
    UnboundRootRole { role: usize },
  • Step 6: Add BlueprintNode::signature and derive_signature

After the BlueprintNode enum / its From impl (≈42), add:

impl BlueprintNode {
    /// The node's declared signature, pre-build, uniform across both arms — a
    /// primitive returns its builder's declared schema; a composite derives it from
    /// its interior. This is "every node has a signature in the blueprint".
    pub fn signature(&self) -> NodeSchema {
        match self {
            BlueprintNode::Primitive(b) => b.schema().clone(),
            BlueprintNode::Composite(c) => derive_signature(c),
        }
    }
}

/// Derive a composite's signature from its interior (no build): one input port per
/// input role (kind = the role's interior target slot kind; firing is a non-load-
/// bearing `Any` placeholder — a composite's ports dissolve at inline, only the kind
/// is consulted by an enclosing graph's wiring check), one output field per
/// re-exported `OutField` (kind = the interior producer's field kind), and the
/// aggregated param-space.
fn derive_signature(c: &Composite) -> NodeSchema {
    let inputs = c
        .input_roles()
        .iter()
        .map(|role| {
            let kind = role
                .targets
                .first()
                .map(|t| interior_slot_kind(c.nodes(), c.edges(), t))
                .unwrap_or(ScalarKind::F64);
            PortSpec { kind, firing: Firing::Any }
        })
        .collect();
    let output = c
        .output()
        .iter()
        .map(|of| {
            let kind = c.nodes()[of.node].signature().output[of.field].kind;
            FieldSpec { name: leak_name(&of.name), kind }
        })
        .collect();
    let mut params = Vec::new();
    collect_params(c.nodes(), "", c.params(), &mut params);
    NodeSchema { inputs, output, params }
}

/// The scalar kind of the interior input slot a composite target addresses,
/// resolving one level (a target into a nested composite reads that composite's
/// derived input-port kind).
fn interior_slot_kind(nodes: &[BlueprintNode], _edges: &[Edge], t: &Target) -> ScalarKind {
    nodes[t.node].signature().inputs[t.slot].kind
}

FieldSpec.name is &'static str but OutField.name is a String. Deriving an owned signature needs a &'static str for the field name. Resolve with a small helper leak_name(&str) -> &'static str using Box::leak(s.to_string().into_boxed_str()) — acceptable because derive_signature is a cold, pre-build, render/validation path (never the hot loop), and the leaked names are bounded by the static blueprint. Add it near derive_signature:

fn leak_name(s: &str) -> &'static str {
    Box::leak(s.to_string().into_boxed_str())
}

Import PortSpec, FieldSpec, Firing, ScalarKind from aura_core in blueprint.rs as needed.

  • Step 7: Delete struct Blueprint; move its methods onto Composite

Delete struct Blueprint (151-155) and the entire impl Blueprint block (157-257). Add the following methods to impl Composite (the block at 78-122), so the root graph IS a Composite:

    /// The aggregated, flat, path-qualified param-space (C12): every node's declared
    /// params, concatenated in lowering order. The ROOT uses an empty path prefix
    /// (its own name does not prefix — preserving the pre-refactor param names);
    /// interior composite names prefix via the recursion in `collect_params`.
    pub fn param_space(&self) -> Vec<ParamSpec> {
        let mut out = Vec::new();
        collect_params(&self.nodes, "", &self.params, &mut out);
        out
    }

    /// Compile this composite as the ROOT graph under an injected param vector:
    /// validate structurally pre-build (via `signature()`, no node built), require
    /// every root role bound, then lower (build each primitive, gather its signature,
    /// inline composites, rewrite edges, lower bound roles to flat sources).
    pub fn compile_with_params(self, params: &[Scalar]) -> Result<FlatGraph, CompileError> {
        // structural validation, all pre-build (no node constructed):
        check_fan_in_distinguishability(&self.nodes)?;
        validate_wiring(&self.nodes, &self.edges, &self.input_roles, &self.output)?;
        for (r, role) in self.input_roles.iter().enumerate() {
            if role.source.is_none() {
                return Err(CompileError::UnboundRootRole { role: r });
            }
        }

        let expected = self.param_space().len();
        if params.len() != expected {
            return Err(CompileError::ParamArity { expected, got: params.len() });
        }

        let mut flat_nodes: Vec<Box<dyn Node>> = Vec::new();
        let mut flat_signatures: Vec<NodeSchema> = Vec::new();
        let mut flat_edges: Vec<Edge> = Vec::new();
        let mut cursor = 0usize;

        let lowerings = lower_items(
            self.nodes,
            params,
            &mut cursor,
            &mut flat_nodes,
            &mut flat_signatures,
            &mut flat_edges,
        )?;

        for e in &self.edges {
            for fe in rewrite_edge(e, &lowerings, &flat_signatures)? {
                flat_edges.push(fe);
            }
        }

        // each bound root role lowers to a flat source, in role-declaration order
        let mut flat_sources: Vec<SourceSpec> = Vec::with_capacity(self.input_roles.len());
        for role in &self.input_roles {
            let kind = role.source.expect("root role bound (checked above)");
            let mut targets: Vec<Target> = Vec::new();
            for t in &role.targets {
                targets.extend(resolve_target(t, &lowerings)?);
            }
            flat_sources.push(SourceSpec { kind, targets });
        }

        Ok(FlatGraph { nodes: flat_nodes, signatures: flat_signatures, sources: flat_sources, edges: flat_edges })
    }

    /// No-param compile (errors `ParamArity` if any param is declared).
    pub fn compile(self) -> Result<FlatGraph, CompileError> {
        self.compile_with_params(&[])
    }

    /// Compile under an injected vector, then bootstrap the flat graph.
    pub fn bootstrap_with_params(self, params: Vec<Scalar>) -> Result<Harness, CompileError> {
        let flat = self.compile_with_params(&params)?;
        Harness::bootstrap(flat).map_err(CompileError::Bootstrap)
    }

    /// No-param bootstrap.
    pub fn bootstrap(self) -> Result<Harness, CompileError> {
        self.bootstrap_with_params(vec![])
    }
  • Step 8: Add the pre-build validate_wiring pass

Add a free function (near check_fan_in_distinguishability, ≈340) that performs the output-range and edge/role kind checks the lowering used to do off built schema() (539/586/619) — now off signature(), pre-build, recursing into composites:

/// Pre-build structural validation via `signature()` (no node constructed): every
/// edge's producer field and consumer slot are in range and kind-matched; every
/// output re-export and role target is in range and kind-consistent. Recurses into
/// nested composites so the checks hold at every level. This is what lets `compile`
/// reject a wiring fault before any build closure fires.
fn validate_wiring(
    nodes: &[BlueprintNode],
    edges: &[Edge],
    roles: &[Role],
    output: &[OutField],
) -> Result<(), CompileError> {
    // edges: index-range + producer/consumer kind match. The kind-mismatch variant
    // is the SAME one bootstrap returns today (Bootstrap(KindMismatch)), just raised
    // pre-build — so existing tests asserting that variant for a compiled graph stay
    // green, while the fault is now caught before any build closure fires.
    for e in edges {
        let from = nodes.get(e.from).ok_or(CompileError::BadInteriorIndex)?.signature();
        let to = nodes.get(e.to).ok_or(CompileError::BadInteriorIndex)?.signature();
        let f = from.output.get(e.from_field).ok_or(CompileError::BadInteriorIndex)?;
        let s = to.inputs.get(e.slot).ok_or(CompileError::BadInteriorIndex)?;
        if f.kind != s.kind {
            return Err(CompileError::Bootstrap(BootstrapError::KindMismatch {
                producer: f.kind,
                consumer: s.kind,
            }));
        }
    }
    // roles: every target in range, and all targets of one role share a kind
    // (RoleKindMismatch — the existing variant, today read off built schema()).
    for (r, role) in roles.iter().enumerate() {
        let mut role_kind: Option<ScalarKind> = None;
        for t in &role.targets {
            let sig = nodes.get(t.node).ok_or(CompileError::BadInteriorIndex)?.signature();
            let k = sig.inputs.get(t.slot).ok_or(CompileError::BadInteriorIndex)?.kind;
            match role_kind {
                None => role_kind = Some(k),
                Some(k0) if k0 != k => return Err(CompileError::RoleKindMismatch { role: r }),
                Some(_) => {}
            }
        }
    }
    // outputs: each re-export's field index in range
    for of in output {
        let sig = nodes.get(of.node).ok_or(CompileError::OutputPortOutOfRange)?.signature();
        if of.field >= sig.output.len() {
            return Err(CompileError::OutputPortOutOfRange);
        }
    }
    // recurse into nested composites
    for item in nodes {
        if let BlueprintNode::Composite(c) = item {
            validate_wiring(c.nodes(), c.edges(), c.input_roles(), c.output())?;
        }
    }
    Ok(())
}

BootstrapError is already in scope (the CompileError::Bootstrap(BootstrapError) variant at blueprint.rs:136). This preserves every existing error variant — the composite-level output/role/edge checks that lowering performed (lower_composite at 532-568) stay as the index resolution they already are; their kind/range faults are now pre-empted here. Keep lowering's ok_or(...) guards as defensive duplicates (they will not fire after validation, but cost nothing and keep lowering total). Bootstrap keeps its own edge/source kind-checks unchanged (Task 3 Step 2) — a hand-wired FlatGraph passed straight to bootstrap (not through compile) is still validated there, so the bootstrap-rejects test suite is unaffected.

  • Step 9: Thread flat_signatures through lower_items/inline_composite/rewrite_edge

The lowering helpers gain a flat_signatures: &mut Vec<NodeSchema> parameter so each built primitive's signature is gathered parallel to the built node, and the index-range reads switch from built schema() to the gathered signatures.

lower_items (466-500): add the param; in the Primitive arm, gather the signature at build:

            BlueprintNode::Primitive(builder) => {
                let n = builder.params().len();
                let slice = &params[*cursor..*cursor + n]; // in range: arity checked up front
                for (i, spec) in builder.params().iter().enumerate() {
                    let got = slice[i].kind();
                    if got != spec.kind {
                        return Err(CompileError::ParamKindMismatch { slot: *cursor + i, expected: spec.kind, got });
                    }
                }
                let index = flat_nodes.len();
                flat_signatures.push(builder.schema().clone());
                flat_nodes.push(builder.build(slice));
                *cursor += n;
                lowerings.push(ItemLowering::Leaf { index });
            }

The Composite arm forwards flat_signatures into inline_composite.

inline_composite (504-) and lower_composite (the inner): add the flat_signatures param and forward it into the recursive lower_items. Their existing reads of flat_nodes[*index].schema().output.len() (539, 586) → flat_signatures[*index].output.len(), and slot_kind's flat_nodes[t.node].schema().inputs... (619-625) → flat_signatures[t.node].inputs.... Pass flat_signatures (a &[NodeSchema]) to rewrite_edge/slot_kind in place of the flat_nodes: &[Box<dyn Node>] they currently take for the schema read.

rewrite_edge (576-600) and slot_kind (618-626): change the parameter from flat_nodes: &[Box<dyn Node>] to flat_signatures: &[NodeSchema] and read flat_signatures[*index].output.len() / flat_signatures[t.node].inputs.get(t.slot).

All four call sites of these helpers (compile's edge loop, lower_composite's edge loop, the output/role resolution) pass &flat_signatures now. Thread consistently so the whole module compiles.

  • Step 10: Update signature_of's match arm

signature_of (266-310): the BlueprintNode::Leaf(f) arm at 275 → Primitive(f); body unchanged (f.label(), aliases_on, recursion). The Composite arm unchanged.

  • Step 11: check_alias_indices / leaf_has_unaliased_param arms

check_alias_indices (325-334) matches!(&nodes[a.node], BlueprintNode::Leaf(f) ...)BlueprintNode::Primitive(f). leaf_has_unaliased_param (397-406) BlueprintNode::Leaf(f)Primitive(f). Bodies unchanged (read f.params()).

  • Step 12: Gate — aura-engine lib builds

Run: cargo build -p aura-engine Expected: PASS — the lib compiles. (aura-engine's own #[cfg(test)] is still stale; it migrates in Task 4. cargo test -p aura-engine is NOT run here.)


Task 4: aura-engine tests — migrate fixtures + behaviour-preservation + new tests

Files:

  • Modify: crates/aura-engine/src/harness.rs (test fixtures ≈350-510, tests 522-1700)

  • Modify: crates/aura-engine/src/blueprint.rs (test fixtures 641-705, tests 760-1640)

  • Step 1: Migrate the test-fixture Node impls

Both crates' test modules define fixture nodes (impl Node for ... with fn schema): harness.rs at 357,384,407,438,477,502; blueprint.rs at 641,667,687,703. For each fixture: remove fn schema, add fn lookbacks(&self) -> Vec<usize> returning one 1 per declared input (read the fixture's current schema().inputs.len() to get the count); and wherever that fixture is wired into a blueprint, its signature must be declared on the PrimitiveBuilder that constructs it. If a fixture is constructed via a raw Box::new(Fixture) rather than a builder, its signature is provided where it is lowered — convert those fixtures to expose a builder() returning PrimitiveBuilder::new(name, NodeSchema { inputs: vec![PortSpec{...}; k], output, params }, |_| Box::new(...)) mirroring the schema they used to return. Replace every InputSpec { kind, lookback, firing } in fixtures (harness.rs 360-445/479/504; blueprint.rs 644-705) with PortSpec { kind, firing }.

  • Step 2: Migrate Blueprint::new call sites to Composite::new (root)

Every Blueprint::new(nodes, sources, edges) in the engine tests (blueprint.rs at 432-style sites: 767,977,1146,... — the recon's Blueprint::new callers) becomes a root Composite::new(name, nodes, edges, bound_roles, params, output):

  • the sources: Vec<SourceSpec> argument converts to bound roles: each SourceSpec { kind, targets }Role { name: "src".into(), targets, source: Some(kind) } pushed into the input_roles vec;

  • name = a test-local string (e.g. "root"); params = vec![]; output = vec![] (these test roots end in sinks/taps, no re-export — confirm per test; if a test re-exported an output, carry its OutFields). Then .compile_with_params(...) returns a FlatGraph; Harness::bootstrap(nodes, sources, edges)Harness::bootstrap(flat). Update each affected test body.

  • Step 3: Migrate Harness::bootstrap test-fixture callers

The ≈35 Harness::bootstrap(...) calls in harness.rs tests (525-1695) currently pass (nodes, sources, edges). Each constructs a FlatGraph now. Where a test hand-wires nodes directly (not via compile), it must also supply signatures — build a FlatGraph { nodes, signatures, sources, edges } where signatures is each node's declared NodeSchema (the fixtures expose it via their builder().schema() or an inline NodeSchema { ... } matching the fixture). For tests that bootstrap a compiled graph, pass the FlatGraph from compile_with_params straight through.

This is the largest test-migration surface. Work fixture-by-fixture; the gate (Step 6) is the arbiter. Keep every asserted run output (recorded rows, equity, determinism) byte-identical — only the construction API changes, never the values.

  • Step 4: Pin the behaviour-preservation tests (must stay green, values unchanged)

These existing tests assert run output / determinism and MUST pass unchanged after migration (confirmed present by recon):

  • composite_sma_cross_runs_bit_identical_to_hand_wired (blueprint.rs:1198)
  • same_vector_bootstraps_identically (blueprint.rs:1329)
  • injecting_a_different_vector_changes_the_run (blueprint.rs:1285)
  • param_space_is_deterministic (blueprint.rs:1615)
  • multi_output_composite_taps_distinct_fields_through_a_run (blueprint.rs:1233)
  • chain_source_sma_runs (harness.rs:522), fan_out_join_dag_runs_deterministically (harness.rs:549), recording_is_deterministic (harness.rs:1136), milestone_end_to_end_mixed_dag_records_every_stream_deterministically (harness.rs:1554), signal_quality_loop_is_deterministic (harness.rs:1692)

Run (after migration): cargo test -p aura-engine composite_sma_cross_runs_bit_identical_to_hand_wired same_vector_bootstraps_identically param_space_is_deterministic recording_is_deterministic Expected: PASS (4+ named tests run, 0 failed) — names verified against the tree, so the filter resolves.

  • Step 5: Add the new tests for this cycle's behaviour

Add to blueprint.rs's test module:

    #[test]
    fn primitive_signature_equals_builder_schema() {
        // a primitive's pre-build signature IS its builder's declared schema
        let b = Sma::builder();
        let node = BlueprintNode::Primitive(Sma::builder());
        assert_eq!(node.signature(), b.schema().clone());
    }

    #[test]
    fn composite_signature_is_derived_from_interior() {
        // macd composite: 1 f64 input role; output macd/signal/histogram (all f64)
        let node = BlueprintNode::Composite(macd_fixture()); // a test-local macd builder
        let sig = node.signature();
        assert_eq!(sig.inputs.len(), 1);
        assert_eq!(sig.inputs[0].kind, ScalarKind::F64);
        assert_eq!(sig.output.iter().map(|f| f.kind).collect::<Vec<_>>(), vec![ScalarKind::F64; 3]);
        assert_eq!(sig.params.len(), 3); // fast, slow, signal
    }

    #[test]
    fn compile_rejects_kind_mismatch_without_building() {
        // a builder whose build closure PANICS if called — proves validation is pre-build
        let exploding = PrimitiveBuilder::new(
            "Boom",
            NodeSchema {
                inputs: vec![PortSpec { kind: ScalarKind::I64, firing: Firing::Any }],
                output: vec![FieldSpec { name: "v", kind: ScalarKind::I64 }],
                params: vec![],
            },
            |_| panic!("build must not run when validation fails pre-build"),
        );
        // an f64 producer wired into Boom's i64 slot
        let root = Composite::new(
            "root",
            vec![Sma::builder().into(), exploding.into()],
            vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }], // f64 -> i64 slot
            vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) }],
            vec![],
            vec![],
        );
        let err = root.compile_with_params(&[Scalar::I64(3)]);
        // kind fault caught pre-build (no panic) — same variant bootstrap would give
        assert!(matches!(
            err,
            Err(CompileError::Bootstrap(BootstrapError::KindMismatch { .. }))
        ));
    }

    #[test]
    fn unbound_root_role_is_rejected() {
        let root = Composite::new(
            "root",
            vec![Sma::builder().into()],
            vec![],
            vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
            vec![],
            vec![],
        );
        assert_eq!(root.compile_with_params(&[Scalar::I64(3)]), Err(CompileError::UnboundRootRole { role: 0 }));
    }

Add macd_fixture() as a test helper mirroring the CLI macd(name) composite (or reuse an existing engine test composite that has a typed multi-output boundary). compile_rejects_kind_mismatch_without_building asserts Bootstrap(KindMismatch) (the variant validate_wiring returns for an edge kind fault, Task 3 Step 8 — the same one bootstrap gives today, so the test and the code agree). Ensure BootstrapError is imported in the engine test module.

Add to aura-std (or engine) a sizing-invariant test:

    #[test]
    fn lookbacks_arity_matches_signature_inputs() {
        // every std node: one lookback per declared input
        assert_eq!(Sma::new(3).lookbacks(), vec![3]);
        assert_eq!(Sma::new(3).lookbacks().len(), Sma::builder().schema().inputs.len());
        assert_eq!(Add::new().lookbacks().len(), Add::builder().schema().inputs.len());
    }
  • Step 6: Gate — aura-engine tests green

Run: cargo test -p aura-engine Expected: PASS — all migrated behaviour-preservation tests + the new signature/ validation tests pass; 0 failed.


Task 5: aura-cli + aura-ingest — migrate call sites; compile-only render; workspace gate

Files:

  • Modify: crates/aura-cli/src/main.rs:122-176,194-360, tests 381-720

  • Modify: crates/aura-cli/src/graph.rs:18,21,61,169,212-213,250,325,337,399-400,482

  • Modify: crates/aura-ingest/tests/real_bars.rs:25

  • Step 1: Migrate the sma_cross / macd composites (interior roles open)

In main.rs, sma_cross (122-140) and macd (194-): Sma::factory()/Ema::factory()/ Sub::factory()::builder(); each interior Role { name, targets }Role { name, targets, source: None } (interior roles are open ports). The ParamAlias/OutField/Edge/Target args are unchanged. Return type stays Composite.

  • Step 2: Migrate the root blueprints to root composites

build_sample (146-171): return type BlueprintComposite. Body:

fn build_sample() -> Composite {
    let (tx_eq, _rx_eq) = mpsc::channel();
    let (tx_ex, _rx_ex) = mpsc::channel();
    Composite::new(
        "sample",
        vec![
            BlueprintNode::Composite(sma_cross("sma_cross")),
            Exposure::builder().into(),
            SimBroker::builder(0.0001).into(),
            Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq).into(),
            Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex).into(),
        ],
        vec![
            Edge { from: 0, to: 1, slot: 0, from_field: 0 },
            Edge { from: 1, to: 2, slot: 0, from_field: 0 },
            Edge { from: 2, to: 3, slot: 0, from_field: 0 },
            Edge { from: 1, to: 4, slot: 0, from_field: 0 },
        ],
        vec![Role {
            name: "price".into(),
            targets: vec![Target { node: 0, slot: 0 }, Target { node: 2, slot: 1 }],
            source: Some(ScalarKind::F64),
        }],
        vec![], // params: the interior sma_cross carries the aliases
        vec![], // output: the root ends in sinks, no re-export
    )
}

sample_blueprint (174-176) return type → Composite.

macd_strategy_blueprint (234-): same transform — return Composite, the source becomes one bound Role { name: "price", targets: [node0/slot0, node2/slot1], source: Some(ScalarKind::F64) }, name: "macd_strategy", params: vec![], output: vec![], the four Edges unchanged (note the from_field: 2 histogram edge is preserved). macd_blueprint (264-) return type → Composite.

  • Step 3: Migrate the compile/bootstrap call sites in main.rs

run_macd (≈300-302): let (nodes, sources, edges) = ...compile_with_params(&macd_point())...let flat = macd_strategy_blueprint(tx_eq, tx_ex).compile_with_params(&macd_point()).expect("valid macd blueprint"); and Harness::bootstrap(nodes, sources, edges)Harness::bootstrap(flat).

render_compiled (336-337): signature bp: Blueprintbp: Composite; body let flat = bp.compile_with_params(point).expect("valid blueprint"); graph::render_flat_graph(&flat.nodes, &flat.sources, &flat.edges, color).

run_sample/sample_harness (the bootstrap at main.rs:53): same FlatGraph threading as run_macd.

  • Step 4: Bring graph.rs to compile (rename only, no render tuning)

In crates/aura-cli/src/graph.rs:

  • Import 18: LeafFactoryPrimitiveBuilder.

  • render_blueprint(bp: &Blueprint, ...) (61) and collect_distinct_composites(bp: &Blueprint) (169): parameter type &Blueprint&Composite (the root is a composite now). Their bodies read bp.nodes()/bp.edges() (unchanged on Composite); they also read bp.sources() — the root's sources now come from bound roles. For the compile-only mandate, replace any bp.sources() read with an iteration over bp.input_roles() filtered to role.source.is_some() (a bound role is the entry the old source was). If the renderer mapped a SourceSpec to an entry label, map a bound Role to the same entry shape (name + targets). Render-output regression is accepted — the goal is "compiles + runs", not fidelity.

  • Match arms BlueprintNode::LeafPrimitive at 118, 212-213, 325, 337, 399-400.

  • factory: &LeafFactory param at 250&PrimitiveBuilder (its .params()/ .label() calls are unchanged).

  • The sources: &[SourceSpec] param at 482 (render_flat_graph) is unchanged (FlatGraph.sources is still Vec<SourceSpec>).

  • Step 5: Migrate aura-cli's #[cfg(test)] module

The cli tests (≈381-720) build Blueprint::new (432,448,624,670), use .factory() (418,426,452,618,637-643), .compile_with_params (472,487,490,538), and Role {...} (130,211,420,428,653). Apply the same transforms as Tasks 2/5: Blueprint::new → root Composite::new (source→bound role), .factory().builder(), Role gains source (None for interior, Some(kind) for a root entry), .compile_with_params returns a FlatGraph (destructure flat.nodes/flat.sources/ flat.edges or pass flat whole to bootstrap). Keep every behavioural assertion (determinism, recorded values) identical.

  • Step 6: Re-capture the two render goldens (value-asserted to the new output)

blueprint_view_golden (main.rs:496) and compiled_view_golden (main.rs:535) assert exact rendered strings that may change under the root-composite migration. After the suite compiles, run each and update its expected literal to the renderer's actual post-migration output (a deliberate, inspected re-capture — render tuning is the next cycle; this only pins current behaviour so the gate is green). Leave a comment on each golden: // re-captured in cycle 0024 (compile-only render migration); fidelity tuned next cycle. The behaviour tests run_macd_compiles_from_nested_composite_and_is_deterministic (563) and run_sample_is_deterministic_and_non_trivial (713) must pass with values unchanged (they assert run output, not render strings).

  • Step 7: Migrate the aura-ingest bootstrap caller

crates/aura-ingest/tests/real_bars.rs:25 calls Harness::bootstrap(...). Thread the FlatGraph: if it compiles a blueprint, take the FlatGraph from compile_with_params and pass it; if it hand-wires, build a FlatGraph { nodes, signatures, sources, edges } with the declared signatures. Keep the integration assertion unchanged.

  • Step 8: Workspace gate — build, test, clippy all green

Run: cargo build --workspace Expected: PASS (0 errors) — every crate compiles; no Blueprint/LeafFactory/ InputSpec/.schema()/Leaf symbol remains.

Run: cargo test --workspace Expected: PASS — all behaviour-preservation tests green; the 8 lockstep tests are gone; the new signature/validation/lookbacks tests pass; the two goldens match their re-captured literals.

Run: cargo clippy --workspace --all-targets -- -D warnings Expected: PASS (0 warnings).

Run (symbol sweep — the rename is complete): ! git grep -nE '\b(LeafFactory|InputSpec)\b|BlueprintNode::Leaf|struct Blueprint\b|fn schema\(' -- 'crates/*' Expected: no matches (every removed/renamed symbol is gone from the source tree).


Notes for the orchestrator (not tasks)

  • Design ledger drift (deferred to cycle-close audit): docs/design/INDEX.md documents Node::schema(), LeafFactory, BlueprintNode::Leaf as live contracts (≈195,232,249,265-266,594-595,610). This plan does NOT edit the ledger (out of the spec's component scope); the architect drift review at cycle-close reconciles the renamed-contract prose.
  • Interior bound roles (out of scope): compile_with_params reads role.source only at the root; inline_composite ignores it (interior roles are open). A bound role inside a nested composite (a source mid-graph) violates C3 and is not modelled or validated this cycle — no test exercises it.
  • Box::leak in derive_signature: bounded by the static blueprint, on a cold pre-build path. If a future cycle makes FieldSpec.name a String, this leak disappears; tracked implicitly by the render cycle that follows.