Files
Aura/docs/plans/0016-param-set-injection.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

27 KiB
Raw Blame History

Param-set injection — Implementation Plan

Parent spec: docs/specs/0016-param-set-injection.md

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

Goal: Make a blueprint value-empty — a leaf is a LeafFactory recipe (params → sized node) — and bind a positional Scalar vector at bootstrap via a new build-then-wire compile path, with kind + arity checks.

Architecture: LeafFactory { name, params, build } lands in aura-core; the 7 aura-std nodes expose factory(); aura-engine's BlueprintNode::Leaf becomes a factory, compile_with_params/bootstrap_with_params build each leaf from its kind-checked param slice while lowering (the existing structural inline/edge/source rewrite is unchanged), and the vestigial pre-build schema methods are removed; aura-cli's blueprint render reads the param-generic LeafFactory::label() and the sample/goldens are re-expressed against the vector.

Tech Stack: Rust workspace — aura-core (Node/Scalar contract), aura-std (nodes), aura-engine (blueprint/compile/harness), aura-cli (run/graph faces).

Sequencing (compile gates): The Leaf(LeafFactory) change breaks every blueprint-leaf author site until repaired, so tasks are gated crate-by-crate: Task 1 cargo build -p aura-core, Task 2 -p aura-std, Task 3 cargo build -p aura-engine --all-targets, Task 4 cargo build -p aura-cli --all-targets then cargo test --workspace. Tasks 12 are purely additive (no breakage); Task 3 is the breaking change and repairs every aura-engine site (incl. its own tests) in one task so its compile gate is satisfiable; Task 4 repairs aura-cli + runs the workspace gate.


Files this plan creates or modifies

  • Modify: crates/aura-core/src/node.rs — add LeafFactory.
  • Modify: crates/aura-core/src/scalar.rs — add as_i64/as_f64.
  • Modify: crates/aura-core/src/lib.rs — re-export LeafFactory.
  • Modify: crates/aura-std/src/{sma,exposure,lincomb,sub,add,sim_broker,recorder}.rs — each gains fn factory(...) + a factory↔schema params test.
  • Modify: crates/aura-engine/src/blueprint.rsLeaf(LeafFactory), From<LeafFactory>, collect_params, compile_with_params, bootstrap_with_params, CompileError variants, remove vestigial schema methods + their test, re-express fixtures + tests.
  • Modify: crates/aura-cli/src/graph.rsrender_blueprint uses LeafFactory::label().
  • Modify: crates/aura-cli/src/main.rs — sample blueprint → factories + vector; param-form call sites; re-capture blueprint-view goldens; move the swap to the compiled view.

Task 1: aura-core — LeafFactory + Scalar accessors

Files:

  • Modify: crates/aura-core/src/node.rs

  • Modify: crates/aura-core/src/scalar.rs

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

  • Step 1: Add LeafFactory to node.rs

After the ParamSpec struct (ends node.rs:58) and before the NodeSchema doc, add (the use at node.rs:11 already imports Scalar):

/// A param-generic blueprint leaf (C19): a node's declared tunable 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).
pub struct LeafFactory {
    name: &'static str,
    params: Vec<ParamSpec>,
    build: Box<dyn Fn(&[Scalar]) -> Box<dyn Node>>,
}

impl LeafFactory {
    /// `name` is the param-generic render label (the node type, e.g. `"SMA"`);
    /// `params` the declared knobs; `build` constructs a sized node from a
    /// kind-checked param slice.
    pub fn new(
        name: &'static str,
        params: Vec<ParamSpec>,
        build: impl Fn(&[Scalar]) -> Box<dyn Node> + 'static,
    ) -> Self {
        Self { name, params, build: Box::new(build) }
    }
    /// The declared tunable params (read by `Blueprint::param_space`, pre-build).
    pub fn params(&self) -> &[ParamSpec] {
        &self.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 "structure
    /// before"): the node type plus its tunable param *names* — no values, a
    /// value-empty recipe has none — e.g. `SMA(length)`, `LinComb(weights[0],
    /// weights[1])`, or bare `SimBroker` when paramless.
    pub fn label(&self) -> String {
        if self.params.is_empty() {
            self.name.to_string()
        } else {
            let knobs: Vec<&str> = self.params.iter().map(|p| p.name.as_str()).collect();
            format!("{}({})", self.name, knobs.join(", "))
        }
    }
}
  • Step 2: Add value accessors to scalar.rs

Inside the existing impl Scalar block (after kind, scalar.rs:30-37), add:

    /// The `i64` payload, or `None` if this scalar is not an `I64`.
    pub fn as_i64(self) -> Option<i64> {
        if let Scalar::I64(v) = self { Some(v) } else { None }
    }
    /// The `f64` payload, or `None` if this scalar is not an `F64`.
    pub fn as_f64(self) -> Option<f64> {
        if let Scalar::F64(v) = self { Some(v) } else { None }
    }
  • Step 3: Re-export LeafFactory

In crates/aura-core/src/lib.rs:42, add LeafFactory to the pub use node::{...} list (keep alphabetical): pub use node::{FieldSpec, Firing, InputSpec, LeafFactory, Node, NodeSchema, ParamSpec};

  • Step 4: Tests in node.rs and scalar.rs

In node.rs tests (reuse the Bare node already defined in that module, node.rs test mod), add:

    #[test]
    fn leaf_factory_label_is_param_generic() {
        let with = LeafFactory::new(
            "SMA",
            vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
            |_| Box::new(Bare),
        );
        assert_eq!(with.label(), "SMA(length)");
        let none = LeafFactory::new("Sub", vec![], |_| Box::new(Bare));
        assert_eq!(none.label(), "Sub");
    }

    #[test]
    fn leaf_factory_build_runs_the_closure() {
        let f = LeafFactory::new("Bare", vec![], |_| Box::new(Bare));
        assert_eq!(f.build(&[]).schema().params, Vec::<ParamSpec>::new());
    }

In scalar.rs tests, add:

    #[test]
    fn scalar_value_accessors_are_kind_exact() {
        assert_eq!(Scalar::I64(3).as_i64(), Some(3));
        assert_eq!(Scalar::I64(3).as_f64(), None);
        assert_eq!(Scalar::F64(0.5).as_f64(), Some(0.5));
        assert_eq!(Scalar::F64(0.5).as_i64(), None);
    }

(If scalar.rs has no #[cfg(test)] mod tests, add one with use super::*;.)

  • Step 5: Gate

Run: cargo test -p aura-core Expected: PASS, including leaf_factory_label_is_param_generic, leaf_factory_build_runs_the_closure, scalar_value_accessors_are_kind_exact.


Task 2: aura-std — factory() on the 7 nodes

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

Each factory() is an inherent method in the node's existing impl <Node> block (beside new). Add LeafFactory to each file's use aura_core::{...} line.

  • Step 1: Sma::factory (sma.rs)
    /// The param-generic recipe for a blueprint leaf: declares `length` and builds
    /// through `Sma::new` (the single sizing/validation gate; the slice is
    /// kind-checked before `build` runs, so the typed read is total).
    pub fn factory() -> LeafFactory {
        LeafFactory::new(
            "SMA",
            vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
            |p| Box::new(Sma::new(p[0].as_i64().expect("length slot is I64") as usize)),
        )
    }
  • Step 2: Exposure::factory (exposure.rs)
    pub fn factory() -> LeafFactory {
        LeafFactory::new(
            "Exposure",
            vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }],
            |p| Box::new(Exposure::new(p[0].as_f64().expect("scale slot is F64"))),
        )
    }
  • Step 3: LinComb::factory(arity) (lincomb.rs)

The arity is topology (fixed per blueprint, C19), taken as a factory arg; only the weight values are injected.

    pub fn factory(arity: usize) -> LeafFactory {
        let params = (0..arity)
            .map(|i| ParamSpec { name: format!("weights[{i}]"), kind: ScalarKind::F64 })
            .collect();
        LeafFactory::new(
            "LinComb",
            params,
            |p| Box::new(LinComb::new(
                p.iter().map(|s| s.as_f64().expect("weight slot is F64")).collect(),
            )),
        )
    }
  • Step 4: paramless Sub/Add::factory (sub.rs, add.rs)
    // sub.rs
    pub fn factory() -> LeafFactory {
        LeafFactory::new("Sub", vec![], |_| Box::new(Sub::new()))
    }
    // add.rs
    pub fn factory() -> LeafFactory {
        LeafFactory::new("Add", vec![], |_| Box::new(Add::new()))
    }
  • Step 5: SimBroker::factory(pip_size) (sim_broker.rs)

pip_size is metadata (C10/C15), not a tunable param — captured by the closure.

    pub fn factory(pip_size: f64) -> LeafFactory {
        LeafFactory::new("SimBroker", vec![], move |_| Box::new(SimBroker::new(pip_size)))
    }
  • Step 6: Recorder::factory(kinds, firing, tx) (recorder.rs)

The channel + kinds + firing are non-param construction args — captured; tx is cloned per build (mpsc::Sender: Clone).

    pub fn factory(
        kinds: Vec<ScalarKind>,
        firing: Firing,
        tx: Sender<(Timestamp, Vec<Scalar>)>,
    ) -> LeafFactory {
        LeafFactory::new("Recorder", vec![], move |_| {
            Box::new(Recorder::new(&kinds, firing, tx.clone()))
        })
    }
  • Step 7: factory↔schema params agreement test (one per node)

Add to each node's #[cfg(test)] mod tests a test asserting factory().params() equals the built node's schema().params. Example for sma.rs:

    #[test]
    fn factory_params_match_built_node_schema() {
        let f = Sma::factory();
        let built = f.build(&[Scalar::I64(3)]);
        assert_eq!(f.params(), built.schema().params.as_slice());
    }

Mirror it per node with a valid sample slice: Exposure &[Scalar::F64(0.5)]; LinComb::factory(2) &[Scalar::F64(1.0), Scalar::F64(-1.0)]; Sub/Add &[]; SimBroker::factory(0.0001) &[]; Recorder::factory(vec![ScalarKind::F64], Firing::Any, tx) &[] (make a throwaway mpsc::channel() for tx).

  • Step 8: Gate

Run: cargo test -p aura-std Expected: PASS, including the 7 factory_params_match_built_node_schema tests.


Task 3: aura-engine — value-empty leaf, build-then-wire compile, errors

Files: Modify crates/aura-engine/src/blueprint.rs.

  • Step 1: BlueprintNode::Leaf + the lift

Change the enum (blueprint.rs:27-30) and replace the generic From<N> (blueprint.rs:33-37):

pub enum BlueprintNode {
    Leaf(LeafFactory),
    Composite(Composite),
}

impl From<LeafFactory> for BlueprintNode {
    fn from(factory: LeafFactory) -> Self {
        BlueprintNode::Leaf(factory)
    }
}

Add LeafFactory and Scalar to the use aura_core::{...} at blueprint.rs:14.

  • Step 2: Remove the vestigial pre-build schema methods

Delete the impl BlueprintNode { fn schema(&self) -> NodeSchema {...} } block (blueprint.rs:39-48) and Composite::schema (blueprint.rs:103-114). Both have no live caller — compile resolves every interface on the built flat nodes. Delete the unit test composite_schema_derives_role_and_output_kinds (blueprint.rs:435).

  • Step 3: collect_params reads factory.params()

In collect_params (blueprint.rs:217-240), the Leaf arm (220-229):

            BlueprintNode::Leaf(factory) => {
                for p in factory.params() {
                    let name = if prefix.is_empty() {
                        p.name.clone()
                    } else {
                        format!("{prefix}.{}", p.name)
                    };
                    out.push(ParamSpec { name, kind: p.kind });
                }
            }

(param_space at blueprint.rs:166-170 is otherwise unchanged.)

  • Step 4: Two new CompileError variants

In enum CompileError (blueprint.rs:120-130) add:

    /// An injected param value's scalar kind does not match the slot's declared
    /// kind. `slot` is the flat param-space index.
    ParamKindMismatch { slot: usize, expected: ScalarKind, got: ScalarKind },
    /// The injected vector's length does not equal the sum of declared params.
    ParamArity { expected: usize, got: usize },

ScalarKind is already imported (blueprint.rs:14).

  • Step 5: Thread params+cursor through lower_items / inline_composite

lower_items (blueprint.rs:255-274) gains params: &[Scalar] and cursor: &mut usize, and its Leaf arm builds (kind-checking) instead of moving a node:

fn lower_items(
    items: Vec<BlueprintNode>,
    params: &[Scalar],
    cursor: &mut usize,
    flat_nodes: &mut Vec<Box<dyn Node>>,
    flat_edges: &mut Vec<Edge>,
) -> Result<Vec<ItemLowering>, CompileError> {
    let mut lowerings = Vec::with_capacity(items.len());
    for item in items {
        match item {
            BlueprintNode::Leaf(factory) => {
                let n = factory.params().len();
                let slice = &params[*cursor..*cursor + n]; // in range: arity checked up front
                for (i, spec) in factory.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_nodes.push(factory.build(slice));
                *cursor += n;
                lowerings.push(ItemLowering::Leaf { index });
            }
            BlueprintNode::Composite(c) => {
                lowerings.push(inline_composite(c, params, cursor, flat_nodes, flat_edges)?);
            }
        }
    }
    Ok(lowerings)
}

inline_composite (blueprint.rs:278-339) gains the same params: &[Scalar] + cursor: &mut usize params and forwards them on its recursive lower_items call (blueprint.rs:295): let interior = lower_items(nodes, params, cursor, flat_nodes, flat_edges)?;. Its signature line becomes:

fn inline_composite(
    c: Composite,
    params: &[Scalar],
    cursor: &mut usize,
    flat_nodes: &mut Vec<Box<dyn Node>>,
    flat_edges: &mut Vec<Edge>,
) -> Result<ItemLowering, CompileError> {
  • Step 6: compile_with_params + bootstrap_with_params + thin no-param wrappers

Replace compile (blueprint.rs:179-204) and bootstrap (207-210) with the param-driven path plus no-param wrappers. The arity is checked up front via param_space().len() so the per-leaf slices never overrun (only kind can fail):

    /// Compile the value-empty recipe under an injected param vector: build each
    /// leaf from its kind-checked slice while lowering, then rewrite edges/sources
    /// exactly as before (structure is param-invariant, C19/C23). The vector is
    /// total and positional — one value per `param_space()` slot, in slot order.
    #[allow(clippy::type_complexity)]
    pub fn compile_with_params(
        self,
        params: &[Scalar],
    ) -> Result<(Vec<Box<dyn Node>>, Vec<SourceSpec>, Vec<Edge>), CompileError> {
        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_edges: Vec<Edge> = Vec::new();
        let mut cursor = 0usize;
        let lowerings = lower_items(self.nodes, params, &mut cursor, &mut flat_nodes, &mut flat_edges)?;

        for e in &self.edges {
            for fe in rewrite_edge(e, &lowerings, &flat_nodes)? {
                flat_edges.push(fe);
            }
        }
        let mut flat_sources: Vec<SourceSpec> = Vec::with_capacity(self.sources.len());
        for src in &self.sources {
            let mut targets: Vec<Target> = Vec::new();
            for t in &src.targets {
                targets.extend(resolve_target(t, &lowerings)?);
            }
            flat_sources.push(SourceSpec { kind: src.kind, targets });
        }
        Ok((flat_nodes, flat_sources, flat_edges))
    }

    /// No-param compile (a blueprint that declares no params); errors `ParamArity`
    /// if any param is declared.
    #[allow(clippy::type_complexity)]
    pub fn compile(self) -> Result<(Vec<Box<dyn Node>>, Vec<SourceSpec>, Vec<Edge>), CompileError> {
        self.compile_with_params(&[])
    }

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

    /// No-param bootstrap (paramless blueprint).
    pub fn bootstrap(self) -> Result<Harness, CompileError> {
        self.bootstrap_with_params(vec![])
    }

(Keep the existing #[allow(clippy::type_complexity)] + comment that sat above compile.)

  • Step 7: Re-express the fixtures (mechanical)

Every blueprint-leaf author site in the test module changes by the rules below; the hand-wired hand_wired_sma_cross_harness (which builds nodes directly into Harness::bootstrap, not via BlueprintNode) is unchanged.

  • Sma::new(k).into()Sma::factory().into(); the k moves into the caller's bootstrap/compile vector (Scalar::I64(k)).
  • Exposure::new(s).into()Exposure::factory().into(); sScalar::F64(s).
  • SimBroker::new(p).into()SimBroker::factory(p).into() (pip captured, no vector slot).
  • Recorder::new(&[K..], f, tx).into()Recorder::factory(vec![K..], f, tx).into().
  • LinComb::new(w).into()LinComb::factory(w.len()).into(); the weights → w.iter().map(|x| Scalar::F64(*x)) in the caller's vector.
  • sma_cross(fast, slow) builder (blueprint.rs:721-732) → sma_cross() taking no args, building two Sma::factory() leaves; callers move fast/slow into their vector.
  • A test-local node Leaf(Box::new(X)) (sites 440, 535, 576, 618, 648) → an inline factory: BlueprintNode::Leaf(LeafFactory::new("X", vec![], |_| Box::new(X::new()))) (or X::new().into() if that test node is given a factory(); inline is simpler for one-off test nodes).
  • bp.bootstrap()bp.bootstrap_with_params(vec![..]) with the vector matching the leaves' declared params in param_space() order; bp.compile()bp.compile_with_params(&[..]). For a paramless fixture, bootstrap_with_params( vec![]) / compile_with_params(&[]) (or the thin bootstrap()/compile()).

composite_sma_cross_harness (blueprint.rs:736-766) becomes value-empty: Composite(sma_cross()), Exposure::factory().into(), SimBroker::factory(0.0001) .into(), Recorder::factory(vec![ScalarKind::F64], Firing::Any, tx_eq).into(), etc. Its declared param_space() is [length:I64, length:I64, scale:F64], so its point vector is vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)].

  • Step 8: Re-express the load-bearing tests

  • composite_sma_cross_runs_bit_identical_to_hand_wired (blueprint.rs:768-792): build the composite via bp.bootstrap_with_params(vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)]); the hand-wired side stays Sma::new(2), Sma::new(4), Exposure::new(0.5). Assertions unchanged (traces bit-identical).

  • param_space_mirrors_compiled_flat_node_param_order (802-834) and ..._under_nesting (886-936): call bp.compile_with_params(&[..]) with the matching vector instead of bp.compile(); the flat_nodes.iter().flat_map(|n| n.schema().params) projection and the kind-by-slot assertions are unchanged (built nodes still carry schema().params). For the single-level case the vector is [Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)]; for the nested strategy → { fast_slow → [Sma, Sma, Sub], LinComb } case it is [Scalar::I64(2), Scalar::I64(4), Scalar::F64(1.0), Scalar::F64(-1.0)].

  • param_space_is_flat_path_qualified_and_slot_disambiguated (837), top_level_leaf_params_are_unqualified (876), param_space_is_deterministic (885), param_space_empty_for_paramless_and_empty_blueprints (896): rebuild their blueprints with factory leaves; the param_space() assertions are unchanged.

  • Step 9: New injection tests

Add to the test module:

    #[test]
    fn injecting_a_different_vector_changes_the_run() {
        let prices = synthetic_prices();
        let (bp, eq, _ex) = composite_sma_cross_harness();
        let mut a = bp.bootstrap_with_params(vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)])
            .expect("compiles");
        a.run(vec![prices.clone()]);
        let a_eq = eq.try_iter().collect::<Vec<_>>();

        let (bp2, eq2, _ex2) = composite_sma_cross_harness();
        let mut b = bp2.bootstrap_with_params(vec![Scalar::I64(5), Scalar::I64(20), Scalar::F64(1.0)])
            .expect("compiles");
        b.run(vec![prices]);
        let b_eq = eq2.try_iter().collect::<Vec<_>>();

        assert!(!a_eq.is_empty() && !b_eq.is_empty(), "both traces populated");
        assert_ne!(a_eq, b_eq, "a different vector must yield a different run");
    }

    #[test]
    fn wrong_kind_is_a_param_kind_mismatch() {
        let (bp, _eq, _ex) = composite_sma_cross_harness();
        // slot 0 is I64 (an SMA length); inject F64 there
        let err = bp.bootstrap_with_params(vec![Scalar::F64(2.0), Scalar::I64(4), Scalar::F64(0.5)])
            .unwrap_err();
        assert!(matches!(err, CompileError::ParamKindMismatch { slot: 0, .. }));
    }

    #[test]
    fn wrong_arity_is_a_param_arity_error() {
        let (short, _e1, _x1) = composite_sma_cross_harness();
        assert!(matches!(
            short.bootstrap_with_params(vec![Scalar::I64(2)]).unwrap_err(),
            CompileError::ParamArity { expected: 3, got: 1 }
        ));
        let (long, _e2, _x2) = composite_sma_cross_harness();
        assert!(matches!(
            long.bootstrap_with_params(
                vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5), Scalar::F64(0.0)]
            ).unwrap_err(),
            CompileError::ParamArity { expected: 3, got: 4 }
        ));
    }

    #[test]
    fn same_vector_bootstraps_identically() {
        let prices = synthetic_prices();
        let (bp, eq, _ex) = composite_sma_cross_harness();
        let mut a = bp.bootstrap_with_params(vec![Scalar::I64(3), Scalar::I64(9), Scalar::F64(0.7)])
            .expect("compiles");
        a.run(vec![prices.clone()]);
        let (bp2, eq2, _ex2) = composite_sma_cross_harness();
        let mut b = bp2.bootstrap_with_params(vec![Scalar::I64(3), Scalar::I64(9), Scalar::F64(0.7)])
            .expect("compiles");
        b.run(vec![prices]);
        assert_eq!(eq.try_iter().collect::<Vec<_>>(), eq2.try_iter().collect::<Vec<_>>());
    }

(If composite_sma_cross_harness returns a fresh blueprint+receivers per call, each test calls it anew as shown; keep its existing return signature.)

  • Step 10: Gate

Run: cargo build -p aura-engine --all-targets Expected: 0 errors (every fixture site repaired).

Run: cargo test -p aura-engine Expected: PASS — bit-identity, both mirror tests, the four new injection tests, and all param_space* tests green.


Task 4: aura-cli — param-generic render + sample + goldens

Files: Modify crates/aura-cli/src/graph.rs, crates/aura-cli/src/main.rs.

  • Step 1: render_blueprint reads LeafFactory::label() (graph.rs)

The two leaf arms (graph.rs:60-63 top-level, :69-72 composite-interior) push node.label(). The leaf is now a LeafFactory; push factory.label():

            BlueprintNode::Leaf(factory) => {
                let id = labels.len();
                labels.push(factory.label());
                item_display.push(ItemDisplay::Leaf(id));
            }

and inside the composite loop:

                        BlueprintNode::Leaf(factory) => {
                            let id = labels.len();
                            labels.push(factory.label());
                            interior_ids.push(id);
                        }

(render_flat_graph / the compiled-view renderer operates on built flat nodes via Node::label() and is unchanged.)

  • Step 2: Sample blueprint → factories (main.rs)

  • sma_cross(name, fast, slow) (main.rs:120-131): build two Sma::factory() leaves; drop fast/slow from the builder (they move to the injected vector). Keep name for the composite.

  • build_sample(fast, slow) (main.rs:136-161): the four .into() lifts become Exposure::factory().into(), SimBroker::factory(0.0001).into(), Recorder::factory(...).into() per their constructors; the composite is BlueprintNode::Composite(sma_cross(name)). build_sample no longer bakes fast/slow.

  • sample_blueprint (main.rs:164-166) and the bp.compile() sites (main.rs:180,221,272): supply the point vector. The sample's param_space() is [length:I64, length:I64, scale:F64], so its vector is vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)]. Use compile_with_params(&[..]) / bootstrap_with_params(vec![..]) at those sites.

  • Step 3: Move the swap to the compiled view (main.rs)

sample_blueprint_swapped (main.rs:201-203) + swapped_sma_inputs_render_differently (main.rs:230): the blueprint view is now param-generic and identical for both orderings, so the swap is not observable there. Re-express the swap as a different injected vector (vec![Scalar::I64(4), Scalar::I64(2), Scalar::F64(0.5)]) and assert the compiled view differs (render_flat_graph of the compiled flat nodes shows SMA(4)/SMA(2) swapped), not the blueprint view. Rename the test to its new premise (e.g. swapped_param_vector_changes_the_compiled_render).

  • Step 4: Re-capture the blueprint-view goldens (main.rs)

blueprint_view_shows_cluster_and_param_labels (main.rs:206), blueprint_view_golden (:238): the blueprint-view labels become param-generic — SMA(length) (both SMAs identical), Exposure(scale), SimBroker. Update the pinned ASCII strings to the param-generic form. The compiled-view goldens compiled_view_dissolves_the_composite_boundary (:219) and compiled_view_golden (:270) stay valued (SMA(2), SMA(4), Exposure(0.5), SimBroker(0.0001)) — do not change them.

To get the exact new blueprint-view bytes, run the rendering in a scratch assertion or cargo run -- graph (blueprint view) after Steps 1-2 compile, and paste the produced ASCII verbatim into the golden. Do not hand-guess box-drawing columns.

  • Step 5: Gate

Run: cargo build -p aura-cli --all-targets Expected: 0 errors.

Run: cargo test --workspace Expected: PASS — all crates green, including the re-captured goldens and the re-premised swap test.

Run: cargo clippy --workspace --all-targets -- -D warnings Expected: clean.