diff --git a/docs/specs/0039-graphbuilder-name-based-wiring.md b/docs/specs/0039-graphbuilder-name-based-wiring.md new file mode 100644 index 0000000..0f48b68 --- /dev/null +++ b/docs/specs/0039-graphbuilder-name-based-wiring.md @@ -0,0 +1,277 @@ +# 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 compilat 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 compilat 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 compilat 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.