# GraphBuilder — name-based blueprint wiring — Design Spec **Date:** 2026-06-14 **Status:** Draft — awaiting user spec review **Authors:** orchestrator + Claude ## Goal Add an additive, fluent `GraphBuilder` that authors a blueprint's topology by **typed node handles** and **port names** instead of raw positional indices, and resolves them to the existing index-wired `Composite` at a single fallible terminal `build()`. The flat graph stays wired by raw index (C23): name resolution happens at the authoring boundary, the same posture param-name resolution already has (`Binder`/`with` → `bootstrap`, `crates/aura-engine/src/blueprint.rs:259,413`). Today an edge is four bare `usize` fields — `Edge { from, to, slot, from_field }` (`crates/aura-engine/src/harness.rs:30`) — where `from`/`to` are positions in the `Composite`'s `nodes` Vec, `slot` indexes the consumer's `NodeSchema.inputs`, and `from_field` indexes the producer's output record. Inserting a node renumbers every later index; the integers carry no meaning at the call site, so hand-comments stand in for what the code cannot say. This cycle replaces that authoring surface with names, while leaving the raw-index `Composite::new` and the entire compile path untouched. **In scope:** the `GraphBuilder` module, the `From for BlueprintNode` lift the nested-`add` path needs, the `BuildError` surface, and a parity test proving a builder-authored `Composite` is byte-identical to the hand-wired index form. **Out of scope (tracked as issue #65):** structurally closing the SimBroker exposure/price ordering swap (#21). This builder makes that swap *legible* — a named slot replaces a bare index — but not *impossible*: correct port names with transposed sources still resolve. The structural fix promotes `PortSpec.name` / `FieldSpec.name` to load-bearing addressing keys and is a separate, ledger-level cycle. ## Architecture A new module `crates/aura-engine/src/builder.rs`, re-exported from `crates/aura-engine/src/lib.rs`. The builder is a pure authoring accumulator over the existing structs; it adds no engine type and changes no existing one. **Accumulate, then resolve at the terminal** — the exact shape of the existing `Binder` (accumulate by-name with `with`, resolve to a positional vector at the fallible `bootstrap` terminal). Node references are typed `NodeHandle` values (carrying the assigned `nodes`-Vec index), so a node reference cannot be mistyped; only port/field names are strings, and they are resolved at `build()`. - `add(item) -> NodeHandle` pushes a `BlueprintNode` into an internal `Vec`, caches its `NodeSchema` via `BlueprintNode::signature()` (uniform across the primitive and nested-composite arms, `crates/aura-engine/src/blueprint.rs:54`), and returns a `NodeHandle(idx)` where `idx` is the position — i.e. the future `nodes`-Vec index, identity unchanged from today. - `input_role(name) -> RoleHandle` / `source_role(name, kind) -> RoleHandle` reserve a `Role` slot and return its handle. - `connect(OutPort, InPort)`, `feed(RoleHandle, [InPort])`, `expose(OutPort, name)` accumulate *unresolved* `(handle, port-name)` pairs — infallible, no resolution yet. `NodeHandle::in_(name)` / `out(name)` are thin value constructors returning `InPort { node, name }` / `OutPort { node, name }`. - `build() -> Result` is the single resolution point: it resolves every accumulated port/field name against the cached schemas by exactly-one-match (the `PrimitiveBuilder::bind` posture, `crates/aura-core/src/node.rs:170`, but returning `Err` instead of panicking), assembles `Vec` / `Vec` / `Vec`, and hands them to the **unchanged** `Composite::new` (`crates/aura-engine/src/blueprint.rs:139`). From `build()` onward the pipeline is byte-for-byte today's: `validate_wiring` → `lower_items`/`inline_composite` → `rewrite_edge`/`resolve_target` → `FlatGraph`. The flat graph never sees a name; the run loop (C1/C2) is untouched. Kind checks are **not** duplicated in the builder — a resolved edge that connects mismatched kinds still surfaces through the existing `validate_wiring` / bootstrap kind-check (name resolution is necessary, not sufficient; kinds remain the structural gate). ## Concrete code shapes ### Worked author example (the acceptance evidence) The shared `sma_cross` fixture (`crates/aura-engine/src/test_fixtures.rs:30`), re-authored through `GraphBuilder`. The names (`series`, `value`, `lhs`, `rhs`) are the real declared port/field names of `Sma`/`Sub`. ```rust use aura_engine::GraphBuilder; use aura_std::{Sma, Sub}; fn sma_cross() -> Composite { let mut g = GraphBuilder::new("sma_cross"); let fast = g.add(Sma::builder().named("fast")); // NodeHandle(0) let slow = g.add(Sma::builder().named("slow")); // NodeHandle(1) let sub = g.add(Sub::builder()); // NodeHandle(2) let price = g.input_role("price"); // open role, source: None g.feed(price, [fast.in_("series"), slow.in_("series")]); g.connect(fast.out("value"), sub.in_("lhs")); // from_field & slot by name g.connect(slow.out("value"), sub.in_("rhs")); g.expose(sub.out("value"), "out"); g.build().expect("sma_cross handles resolve") } ``` The SimBroker `#21` leg, the sharpest legibility payoff (slot 0 = `exposure`, slot 1 = `price`, both `f64` — today distinguished only by a bare integer): ```rust g.connect(exposure.out("exposure"), broker.in_("exposure")); // names, not slot 0/1 g.feed(price_src, [broker.in_("price")]); // a typo'd port -> UnknownInPort ``` ### Before → after implementation shapes (secondary) New types in `crates/aura-engine/src/builder.rs` (shapes, not final bytes): ```rust #[derive(Clone, Copy)] pub struct NodeHandle(usize); #[derive(Clone, Copy)] pub struct RoleHandle(usize); #[derive(Clone, Copy)] pub struct InPort { node: usize, name: &'static str } #[derive(Clone, Copy)] pub struct OutPort { node: usize, name: &'static str } impl NodeHandle { pub fn in_(self, name: &'static str) -> InPort { InPort { node: self.0, name } } pub fn out(self, name: &'static str) -> OutPort { OutPort { node: self.0, name } } } pub struct GraphBuilder { name: String, nodes: Vec, schemas: Vec, // cached at add(), for resolution edges: Vec<(OutPort, InPort)>, // unresolved until build() roles: Vec<(String, Option, Vec)>, out: Vec<(String, OutPort)>, } impl GraphBuilder { pub fn new(name: impl Into) -> Self { /* ... */ } pub fn add(&mut self, item: impl Into) -> NodeHandle { /* push + cache signature */ } pub fn input_role(&mut self, name: &str) -> RoleHandle { /* source: None */ } pub fn source_role(&mut self, name: &str, kind: ScalarKind) -> RoleHandle { /* source: Some */ } pub fn connect(&mut self, from: OutPort, to: InPort) { self.edges.push((from, to)); } pub fn feed(&mut self, role: RoleHandle, into: impl IntoIterator) { /* extend roles[role.0].2 */ } pub fn expose(&mut self, from: OutPort, name: &str) { self.out.push((name.into(), from)); } pub fn build(self) -> Result { /* resolve all -> Composite::new(...) */ } } pub enum BuildError { BadHandle { node: usize }, // a handle from another builder (out of range) UnknownInPort { node: usize, name: String }, AmbiguousInPort{ node: usize, name: String }, UnknownOutPort { node: usize, name: String }, AmbiguousOutPort{ node: usize, name: String }, } ``` The resolver, mirroring `bind`'s collect-then-reject (`node.rs:170`) but fallible: ```rust fn resolve_slot(schema: &NodeSchema, name: &str) -> Result { let m: Vec = schema.inputs.iter().enumerate() .filter(|(_, p)| p.name == name).map(|(i, _)| i).collect(); match m.as_slice() { [i] => Ok(*i), [] => Err(unknown), _ => Err(ambiguous) } } // resolve_field is the symmetric scan over schema.output (FieldSpec.name). ``` The one new lift the nested-`add` path requires (none exists today — only `impl From for BlueprintNode` at `blueprint.rs:44`): ```rust // before: nested composites wrapped by hand — BlueprintNode::Composite(sma_cross()) // after: add() accepts a Composite directly impl From for BlueprintNode { fn from(c: Composite) -> Self { BlueprintNode::Composite(c) } } ``` One re-export line in `crates/aura-engine/src/lib.rs` beside the existing blueprint re-exports: `pub use builder::GraphBuilder;` (plus the handle/error types). ## Components | Component | Location | Role | |---|---|---| | `GraphBuilder` | `crates/aura-engine/src/builder.rs` (new) | The authoring accumulator; `build()` is the sole resolution point | | `NodeHandle` / `RoleHandle` | same | Typed, `Copy` references minted by `add` / `input_role` / `source_role` | | `InPort` / `OutPort` | same | Unresolved `(node-index, port-name)` endpoints | | `BuildError` | same | Authoring-layer faults surfaced at `build()` | | `From for BlueprintNode` | `crates/aura-engine/src/blueprint.rs` | The nested-`add` lift (new impl) | | `Composite::new` (unchanged) | `crates/aura-engine/src/blueprint.rs:139` | The lowering target `build()` funnels into | | `BlueprintNode::signature()` (unchanged) | `crates/aura-engine/src/blueprint.rs:54` | Supplies the cached `NodeSchema` resolution reads | ## Data flow ``` author: g.add(...) -> NodeHandle ; g.connect(h.out("v"), k.in_("lhs")) ; ... │ (accumulate handles + names; schemas cached at add) ▼ g.build() ── resolve every (node-index, port-name) against schemas[node] ──▶ Vec/Vec/Vec │ (exactly-one-match; Err on unknown/ambiguous) ▼ Composite::new(name, nodes, edges, roles, output) [UNCHANGED] ▼ compile_with_params -> lower_items/inline_composite -> rewrite_edge -> FlatGraph [UNCHANGED, raw-index] ▼ Harness::bootstrap (kind-check, topo-sort) -> run loop [UNCHANGED, no names] ``` Names exist only between `add`/`connect` and `build`. Past `build()` the representation is byte-identical to a hand-written `Composite::new`. ## Error handling - `build()` returns `Result`. The wiring methods (`add`/`connect`/`feed`/`expose`/`input_role`/`source_role`) are infallible accumulators — all faults are deferred to the single `build()` resolution point, mirroring `Binder` (accumulate with `with`, fail at `bootstrap`). - `BuildError` variants: `UnknownInPort` / `UnknownOutPort` (no `PortSpec.name` / `FieldSpec.name` matches), `AmbiguousInPort` / `AmbiguousOutPort` (more than one matches — only reachable if a node declares duplicate port/field names), `BadHandle` (a handle whose index is out of range, i.e. minted by a different builder). - Kind mismatches are **not** a `BuildError`: a resolved edge that connects mismatched scalar kinds still surfaces through the existing `validate_wiring` / `Harness::bootstrap` kind-check (`crates/aura-engine/src/harness.rs:170`), exactly as a hand-wired `Composite` does today. Name resolution is necessary, not sufficient. - Resolving by port name relies on input-port names (and output-field names) being unique within a node. Every shipped node satisfies this today; it becomes a soft expectation for builder-wired nodes, surfaced as `AmbiguousInPort` / `AmbiguousOutPort` rather than a silent wrong pick. (Enforcing within-node name-uniqueness as an invariant is part of the #65 follow-up, not this cycle.) ## Testing strategy 1. **Parity (the headline acceptance).** Author `sma_cross` and `composite_sma_cross_harness` through `GraphBuilder`; assert the produced `Composite` compiles to a `FlatGraph` with edges and sources equal to the hand-wired index form in `crates/aura-engine/src/test_fixtures.rs`. This extends the existing `composite_sma_cross_runs_bit_identical_to_hand_wired` pattern one level up (builder-authored vs hand-authored). 2. **Resolution errors.** A `connect` / `feed` to a non-existent port name yields `UnknownInPort` / `UnknownOutPort` at `build()`; a node with two same-named ports yields `AmbiguousInPort`. 3. **Nested composite addressing.** Build `sma_cross` via the builder, `add` it into a root builder, and wire its boundary by role name (`in_("price")`) and output name (`out("out")`) — resolved against the composite's derived signature (`derive_signature`, `blueprint.rs:68`). 4. **Coexistence.** Existing raw-index `Composite::new` call sites and their tests keep compiling and passing unchanged; the two authoring forms interoperate (a builder can `add` a `Composite::new`-built composite, and vice versa via `build()?`). 5. **#21 legibility.** A SimBroker leg wired by `in_("exposure")` / `in_("price")` resolves to the correct slots; a transposed *name* (`in_("pirce")`) is an `UnknownInPort`, where the bare-index form would have been silently accepted. ## Acceptance criteria - [ ] A `GraphBuilder`-authored `Composite` is byte-identical (equal `FlatGraph` edges and sources) to the hand-wired index form, pinned by a parity test on the shared `sma_cross` fixtures. - [ ] Port/field-name resolution is exactly-one-match with a recoverable `BuildError` surface (`UnknownInPort` / `UnknownOutPort` / `AmbiguousInPort` / `AmbiguousOutPort` / `BadHandle`); no panic on a bad name. - [ ] Nested composites wire through the same API via `From for BlueprintNode`, addressed by derived role/output names. - [ ] The raw-index `Composite::new` stays public and unchanged; all existing call sites compile and pass; both authoring forms interoperate. - [ ] The flat graph is unchanged: no name reaches `FlatGraph`; `compile_with_params` / `inline_composite` / `rewrite_edge` / `Harness::bootstrap` / the run loop are untouched. Determinism (C1) and no-look-ahead (C2) hold by construction. - [ ] `cargo build --workspace`, `cargo test --workspace`, and `cargo clippy --workspace --all-targets -- -D warnings` are green.