# 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<Composite> 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<Composite, BuildError>` 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<Edge>` / `Vec<Role>` / `Vec<OutField>`, 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<BlueprintNode>,
    schemas: Vec<NodeSchema>,                       // cached at add(), for resolution
    edges: Vec<(OutPort, InPort)>,                  // unresolved until build()
    roles: Vec<(String, Option<ScalarKind>, Vec<InPort>)>,
    out: Vec<(String, OutPort)>,
}

impl GraphBuilder {
    pub fn new(name: impl Into<String>) -> Self { /* ... */ }
    pub fn add(&mut self, item: impl Into<BlueprintNode>) -> 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<Item = InPort>) { /* 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<Composite, BuildError> { /* 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<usize, /* port arm */> {
    let m: Vec<usize> = 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<PrimitiveBuilder> 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<Composite> 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<Composite> 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<Edge>/Vec<Role>/Vec<OutField>
          │     (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<Composite, BuildError>`. 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<Composite> 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.
