diff --git a/docs/plans/0024-node-signature-in-blueprint.md b/docs/plans/0024-node-signature-in-blueprint.md new file mode 100644 index 0000000..6eecfd2 --- /dev/null +++ b/docs/plans/0024-node-signature-in-blueprint.md @@ -0,0 +1,1183 @@ +# 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 (`InputSpec`→`PortSpec`, +`lookback` removed); the one param-dependent quantity (input buffer lookback) moves +to `Node::lookbacks()`, consumed only by bootstrap. `LeafFactory`→`PrimitiveBuilder` +carries the schema. `Blueprint` is deleted; the root is a `Composite` whose input +roles are all source-bound (`Role.source: Option`). `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.rs` — `PortSpec`, `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.rs` — `FlatGraph` 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: + +```rust +/// 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): + +```rust +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NodeSchema { + pub inputs: Vec, + pub output: Vec, + pub params: Vec, +} +``` + +- [ ] **Step 3: Replace `LeafFactory` with `PrimitiveBuilder` carrying the schema** + +In `node.rs`, replace the `LeafFactory` struct (64-71) and its `impl` (73-106) with: + +```rust +/// 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 Box>, +} + +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 + '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 { + (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: + +```rust +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; + + /// 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 { 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, , 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, LeafFactory` → `PortSpec, PrimitiveBuilder`. Replace +`factory()` (31-33) and `impl Node`'s `fn schema` (43-52) with: + +```rust + 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()), + ) + } +``` +```rust + fn lookbacks(&self) -> Vec { + vec![1, 1] + } +``` +Delete the `factory_params_match_built_node_schema` test (≈75-79). + +- [ ] **Step 2: `sub.rs`** + +Import line 6: `InputSpec, LeafFactory` → `PortSpec, PrimitiveBuilder`. Replace +`factory()` (22-24) and `impl Node`'s `fn schema` (34-43) with: + +```rust + 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()), + ) + } +``` +```rust + fn lookbacks(&self) -> Vec { + vec![1, 1] + } +``` +Delete the `factory_params_match_built_node_schema` test (≈66-70). + +- [ ] **Step 3: `exposure.rs`** + +Import line 7: `InputSpec, LeafFactory` → `PortSpec, PrimitiveBuilder`. `factory()` +(27-33) → `builder()`; `fn schema` (37-43) → `fn lookbacks`: + +```rust + 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"))), + ) + } +``` +```rust + fn lookbacks(&self) -> Vec { + vec![1] + } +``` +Delete the lockstep test (≈86-90). + +- [ ] **Step 4: `ema.rs`** + +Import line 21-22: `InputSpec, LeafFactory` → `PortSpec, PrimitiveBuilder`. +`factory()` (58-64) → `builder()`; `fn schema` (68-81) → `fn lookbacks`: + +```rust + 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)), + ) + } +``` +```rust + // 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 { + 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, LeafFactory` → `PortSpec, PrimitiveBuilder`. `factory()` +(26-32) → `builder()`; `fn schema` (36-47) → `fn lookbacks` (the lookback IS the +window — this is the proof case): + +```rust + 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)), + ) + } +``` +```rust + fn lookbacks(&self) -> Vec { + 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, LeafFactory` → `PortSpec, 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`: + +```rust + 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(), + )), + ) + } +``` +```rust + fn lookbacks(&self) -> Vec { + vec![1; self.weights.len()] + } +``` +Delete the lockstep test (≈146). + +- [ ] **Step 7: `recorder.rs`** + +Import line 10: `InputSpec, LeafFactory` → `PortSpec, 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: + +```rust + pub fn builder( + kinds: Vec, + firing: Firing, + tx: Sender<(Timestamp, Vec)>, + ) -> 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())), + ) + } +``` +```rust + fn lookbacks(&self) -> Vec { + 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, LeafFactory` → `PortSpec, PrimitiveBuilder`. `pip_size` +stays a builder ARG. `factory(pip_size)` (61-63) → `builder(pip_size)`; `fn schema` +(67-76) → `fn lookbacks`: + +```rust + 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)), + ) + } +``` +```rust + fn lookbacks(&self) -> Vec { + 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: + +```rust +/// The flat, type-erased, index-wired output of `Composite::compile` — the target +/// `Harness::bootstrap` consumes (C23's "compilat", 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>, + pub signatures: Vec, + pub sources: Vec, + pub edges: Vec, +} +``` +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: + +```rust + pub fn bootstrap(flat: FlatGraph) -> Result { + 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 = 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 = sig + .inputs + .iter() + .zip(depths) + .map(|(spec, depth)| AnyColumn::with_capacity(spec.kind, depth)) + .collect(); + let firing: Vec = sig.inputs.iter().map(|spec| spec.firing).collect(); + let slots: Vec = 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: `LeafFactory` → `PrimitiveBuilder`. +- `BlueprintNode` enum (32-35): `Leaf(LeafFactory)` → `Primitive(PrimitiveBuilder)`. +- The `From for BlueprintNode` impl (38-42): `LeafFactory` → + `PrimitiveBuilder`, the arm `BlueprintNode::Leaf` → `BlueprintNode::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: + +```rust +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Role { + pub name: String, + pub targets: Vec, + /// `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, +} +``` + +- [ ] **Step 5: Add `UnboundRootRole` to `CompileError`** + +In the `CompileError` enum (126-147) add: + +```rust + /// 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: + +```rust +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`: +> ```rust +> 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`: + +```rust + /// 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 { + 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 { + // 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> = Vec::new(); + let mut flat_signatures: Vec = Vec::new(); + let mut flat_edges: Vec = 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 = 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 = 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 { + self.compile_with_params(&[]) + } + + /// Compile under an injected vector, then bootstrap the flat graph. + pub fn bootstrap_with_params(self, params: Vec) -> Result { + let flat = self.compile_with_params(¶ms)?; + Harness::bootstrap(flat).map_err(CompileError::Bootstrap) + } + + /// No-param bootstrap. + pub fn bootstrap(self) -> Result { + 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: + +```rust +/// 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 = 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` 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: + +```rust + BlueprintNode::Primitive(builder) => { + let n = builder.params().len(); + let slice = ¶ms[*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]` they +currently take for the schema read. + +`rewrite_edge` (576-600) and `slot_kind` (618-626): change the parameter from +`flat_nodes: &[Box]` 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` 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` 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 `OutField`s). +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: + +```rust + #[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![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: + +```rust + #[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 `Blueprint` → `Composite`. Body: + +```rust +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 `Edge`s 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: Blueprint` → `bp: Composite`; body +`let flat = bp.compile_with_params(point).expect("valid blueprint"); graph::render_compilat(&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: `LeafFactory` → `PrimitiveBuilder`. +- `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::Leaf` → `Primitive` 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_compilat) is unchanged + (`FlatGraph.sources` is still `Vec`). + +- [ ] **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.