plan: 0039 graphbuilder-name-based-wiring

Task-by-task plan for the typed-handle GraphBuilder: the From<Composite> lift,
the builder module (types + accumulators + the fallible build() resolver), the
lib.rs wiring, and the test suite (structural parity, harness FlatGraph parity,
the five BuildError variants, #21 legibility, coexistence + clippy gate).

refs #64
This commit is contained in:
2026-06-14 02:16:54 +02:00
parent 292c95756f
commit 031081bf62
@@ -0,0 +1,603 @@
# GraphBuilder — name-based blueprint wiring — Implementation Plan
> **Parent spec:** `docs/specs/0039-graphbuilder-name-based-wiring.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Add an additive, fluent `GraphBuilder` that authors blueprint topology
by typed node handles + port/field names, resolving them to the existing
index-wired `Composite` at a single fallible `build()`.
**Architecture:** A new `crates/aura-engine/src/builder.rs` module accumulates
typed handles (`NodeHandle`/`RoleHandle`) and unresolved `(handle, name)`
endpoints (`InPort`/`OutPort`); `build() -> Result<Composite, BuildError>`
resolves every name against each node's cached `NodeSchema` by exactly-one-match
(the `PrimitiveBuilder::bind` posture, but fallible) and hands the resolved
`Vec<Edge>`/`Vec<Role>`/`Vec<OutField>` to the unchanged `Composite::new`. A new
`impl From<Composite> for BlueprintNode` lets `add` accept nested composites.
Names never reach the compilat (C23 holds by construction).
**Tech Stack:** Rust, `aura-engine` (blueprint/harness), `aura-core` (NodeSchema/
PortSpec/FieldSpec/ScalarKind), `aura-std` nodes for tests.
---
**Files this plan creates or modifies:**
- Create: `crates/aura-engine/src/builder.rs` — the `GraphBuilder` accumulator,
`NodeHandle`/`RoleHandle`/`InPort`/`OutPort`, `BuildError`, and the `build()`
resolver, plus a `#[cfg(test)] mod tests`.
- Modify: `crates/aura-engine/src/blueprint.rs:44` — add `impl From<Composite> for
BlueprintNode` beside the existing `From<PrimitiveBuilder>`.
- Modify: `crates/aura-engine/src/lib.rs:34-43` — `mod builder;` plus the
`pub use builder::{...}` re-export.
- Test: `crates/aura-engine/src/builder.rs` (`mod tests`) — structural parity,
harness FlatGraph parity, resolution errors, nested addressing, #21 legibility.
---
### Task 1: `From<Composite> for BlueprintNode` lift
**Files:**
- Modify: `crates/aura-engine/src/blueprint.rs:44-48`
- [ ] **Step 1: Write the failing test**
Append to the existing `#[cfg(test)] mod tests` in `crates/aura-engine/src/blueprint.rs`
(the module that starts at `blueprint.rs:753`, which already has `use super::*;`
and `use aura_std::{... Sub ...};`):
```rust
#[test]
fn composite_lifts_into_blueprint_node_via_from() {
let c = Composite::new(
"leaf",
vec![Sub::builder().into()],
vec![],
vec![],
vec![OutField { node: 0, field: 0, name: "o".into() }],
);
let bn: BlueprintNode = c.into();
assert!(matches!(bn, BlueprintNode::Composite(_)));
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cargo test -p aura-engine composite_lifts_into_blueprint_node_via_from`
Expected: FAIL — compile error `the trait `From<Composite>` is not implemented for `BlueprintNode`` (the `.into()` does not resolve).
- [ ] **Step 3: Write minimal implementation**
Immediately after the existing `impl From<PrimitiveBuilder> for BlueprintNode { ... }`
block at `crates/aura-engine/src/blueprint.rs:44-48`, add:
```rust
/// Ergonomic lift: a nested composite becomes a `Composite` blueprint item, so a
/// builder's `add` can accept a sub-graph the same way it accepts a primitive.
impl From<Composite> for BlueprintNode {
fn from(c: Composite) -> Self {
BlueprintNode::Composite(c)
}
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cargo test -p aura-engine composite_lifts_into_blueprint_node_via_from`
Expected: PASS
---
### Task 2: The `GraphBuilder` module + structural parity
**Files:**
- Create: `crates/aura-engine/src/builder.rs`
- Modify: `crates/aura-engine/src/lib.rs:34-43`
- [ ] **Step 1: Create the module skeleton (types + accumulators + `build()` stub)**
Create `crates/aura-engine/src/builder.rs` with:
```rust
//! `GraphBuilder` — name-based blueprint authoring (spec 0039).
//!
//! A fluent, additive authoring surface that wires a blueprint by typed node
//! handles and port/field *names*, resolving them to the raw-index `Composite`
//! at a single fallible `build()`. The compilat stays index-wired (C23): names
//! are resolved at the authoring boundary and never reach `FlatGraph` — the same
//! posture param-name resolution already has (`Binder`, blueprint.rs). Resolution
//! mirrors `PrimitiveBuilder::bind`'s collect-then-reject (node.rs), but returns
//! `Err(BuildError)` instead of panicking.
use aura_core::{NodeSchema, ScalarKind};
use crate::blueprint::{BlueprintNode, Composite, OutField, Role};
use crate::harness::{Edge, Target};
/// A typed, `Copy` reference to a node added to a [`GraphBuilder`], carrying the
/// node's index in the builder's `nodes` Vec.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct NodeHandle(usize);
/// A typed, `Copy` reference to a role reserved on a [`GraphBuilder`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RoleHandle(usize);
/// An unresolved consumer endpoint: a node handle's index plus an input-port name.
#[derive(Clone, Copy, Debug)]
pub struct InPort {
node: usize,
name: &'static str,
}
/// An unresolved producer endpoint: a node handle's index plus an output-field name.
#[derive(Clone, Copy, Debug)]
pub struct OutPort {
node: usize,
name: &'static str,
}
impl NodeHandle {
/// Address an input port of this node by name (resolved at [`GraphBuilder::build`]).
pub fn in_(self, name: &'static str) -> InPort {
InPort { node: self.0, name }
}
/// Address this node's output field by name (resolved at [`GraphBuilder::build`]).
pub fn out(self, name: &'static str) -> OutPort {
OutPort { node: self.0, name }
}
}
/// An authoring-layer fault surfaced at [`GraphBuilder::build`]. Resolution by
/// name is necessary, not sufficient: a name-resolved edge that connects
/// mismatched scalar kinds still surfaces downstream as a bootstrap kind-check.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BuildError {
/// A handle whose index is out of range (i.e. minted by a different builder).
BadHandle { node: usize },
/// No input port of `node` is named `name`.
UnknownInPort { node: usize, name: String },
/// More than one input port of `node` is named `name`.
AmbiguousInPort { node: usize, name: String },
/// No output field of `node` is named `name`.
UnknownOutPort { node: usize, name: String },
/// More than one output field of `node` is named `name`.
AmbiguousOutPort { node: usize, name: String },
}
/// A fluent, additive accumulator that authors a [`Composite`] by typed handles
/// and port/field names. See the module docs and spec 0039.
pub struct GraphBuilder {
name: String,
nodes: Vec<BlueprintNode>,
schemas: Vec<NodeSchema>,
edges: Vec<(OutPort, InPort)>,
roles: Vec<(String, Option<ScalarKind>, Vec<InPort>)>,
out: Vec<(String, OutPort)>,
}
impl GraphBuilder {
/// Start a builder for a composite named `name` (a non-load-bearing render symbol).
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
nodes: Vec::new(),
schemas: Vec::new(),
edges: Vec::new(),
roles: Vec::new(),
out: Vec::new(),
}
}
/// Add a node (primitive builder or nested composite); returns its handle.
pub fn add(&mut self, item: impl Into<BlueprintNode>) -> NodeHandle {
let node = item.into();
let schema = node.signature();
let idx = self.nodes.len();
self.nodes.push(node);
self.schemas.push(schema);
NodeHandle(idx)
}
/// Reserve an open input role (wired by an enclosing graph); returns its handle.
pub fn input_role(&mut self, name: &str) -> RoleHandle {
let idx = self.roles.len();
self.roles.push((name.to_string(), None, Vec::new()));
RoleHandle(idx)
}
/// Reserve a bound ingestion role of `kind` (a root source); returns its handle.
pub fn source_role(&mut self, name: &str, kind: ScalarKind) -> RoleHandle {
let idx = self.roles.len();
self.roles.push((name.to_string(), Some(kind), Vec::new()));
RoleHandle(idx)
}
/// Wire a producer's output field into a consumer's input port (resolved at build).
pub fn connect(&mut self, from: OutPort, to: InPort) {
self.edges.push((from, to));
}
/// Fan a role's value into one or more consumer input ports.
pub fn feed(&mut self, role: RoleHandle, into: impl IntoIterator<Item = InPort>) {
self.roles[role.0].2.extend(into);
}
/// Re-export a producer's output field at the composite boundary under `name`.
pub fn expose(&mut self, from: OutPort, name: &str) {
self.out.push((name.to_string(), from));
}
/// Resolve every accumulated name to an index and assemble the [`Composite`].
pub fn build(self) -> Result<Composite, BuildError> {
unimplemented!("GraphBuilder::build is filled in the next step")
}
}
```
- [ ] **Step 2: Wire the module into the crate**
In `crates/aura-engine/src/lib.rs`, add `mod builder;` to the module block (after
`mod blueprint;` at line 34, keeping alphabetical order):
```rust
mod blueprint;
mod builder;
mod graph_model;
mod harness;
mod report;
mod sweep;
```
Then add the re-export immediately after the `pub use blueprint::{...};` block
(which ends at line 43):
```rust
pub use builder::{BuildError, GraphBuilder, InPort, NodeHandle, OutPort, RoleHandle};
```
- [ ] **Step 3: Write the failing structural-parity test**
Append a test module at the end of `crates/aura-engine/src/builder.rs`:
```rust
#[cfg(test)]
mod tests {
use super::{BuildError, GraphBuilder};
use crate::test_fixtures::sma_cross as hand_sma_cross;
use crate::{Composite, OutField, Role, Target};
use aura_core::{Firing, ScalarKind};
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
use std::sync::mpsc;
/// The `sma_cross` sub-composite authored through the builder.
fn built_sma_cross() -> Composite {
let mut g = GraphBuilder::new("sma_cross");
let fast = g.add(Sma::builder().named("fast"));
let slow = g.add(Sma::builder().named("slow"));
let sub = g.add(Sub::builder());
let price = g.input_role("price");
g.feed(price, [fast.in_("series"), slow.in_("series")]);
g.connect(fast.out("value"), sub.in_("lhs"));
g.connect(slow.out("value"), sub.in_("rhs"));
g.expose(sub.out("value"), "out");
g.build().expect("sma_cross resolves")
}
#[test]
fn builder_sma_cross_structurally_equals_hand_wired() {
let built = built_sma_cross();
let hand = hand_sma_cross();
assert_eq!(built.edges(), hand.edges());
assert_eq!(built.input_roles(), hand.input_roles());
assert_eq!(built.output(), hand.output());
}
}
```
- [ ] **Step 4: Run test to verify it fails**
Run: `cargo test -p aura-engine builder_sma_cross_structurally_equals_hand_wired`
Expected: FAIL — panic `not implemented: GraphBuilder::build is filled in the next step` (the `unimplemented!()` body of `build()`).
- [ ] **Step 5: Implement `build()` and the resolvers**
Replace the `build()` stub body in `crates/aura-engine/src/builder.rs` with the
real resolution, and add the two private resolver methods inside the same
`impl GraphBuilder` block (after `build`):
```rust
/// Resolve every accumulated name to an index and assemble the [`Composite`].
pub fn build(self) -> Result<Composite, BuildError> {
let mut edges = Vec::with_capacity(self.edges.len());
for (from, to) in &self.edges {
let from_field = self.resolve_field(from)?;
let (to_node, slot) = self.resolve_slot(to)?;
edges.push(Edge { from: from.node, to: to_node, slot, from_field });
}
let mut roles = Vec::with_capacity(self.roles.len());
for (name, source, ports) in &self.roles {
let mut targets = Vec::with_capacity(ports.len());
for p in ports {
let (node, slot) = self.resolve_slot(p)?;
targets.push(Target { node, slot });
}
roles.push(Role { name: name.clone(), targets, source: *source });
}
let mut output = Vec::with_capacity(self.out.len());
for (name, from) in &self.out {
let field = self.resolve_field(from)?;
output.push(OutField { node: from.node, field, name: name.clone() });
}
Ok(Composite::new(self.name, self.nodes, edges, roles, output))
}
/// Resolve an `InPort` to `(node-index, slot)` by exactly-one-match on the
/// node's input-port names (the `PrimitiveBuilder::bind` posture, fallible).
fn resolve_slot(&self, p: &InPort) -> Result<(usize, usize), BuildError> {
let schema = self
.schemas
.get(p.node)
.ok_or(BuildError::BadHandle { node: p.node })?;
let m: Vec<usize> = schema
.inputs
.iter()
.enumerate()
.filter(|(_, port)| port.name == p.name)
.map(|(i, _)| i)
.collect();
match m.as_slice() {
[i] => Ok((p.node, *i)),
[] => Err(BuildError::UnknownInPort { node: p.node, name: p.name.to_string() }),
_ => Err(BuildError::AmbiguousInPort { node: p.node, name: p.name.to_string() }),
}
}
/// Resolve an `OutPort` to a `from_field` by exactly-one-match on the node's
/// output-field names.
fn resolve_field(&self, p: &OutPort) -> Result<usize, BuildError> {
let schema = self
.schemas
.get(p.node)
.ok_or(BuildError::BadHandle { node: p.node })?;
let m: Vec<usize> = schema
.output
.iter()
.enumerate()
.filter(|(_, f)| f.name == p.name)
.map(|(i, _)| i)
.collect();
match m.as_slice() {
[i] => Ok(*i),
[] => Err(BuildError::UnknownOutPort { node: p.node, name: p.name.to_string() }),
_ => Err(BuildError::AmbiguousOutPort { node: p.node, name: p.name.to_string() }),
}
}
```
- [ ] **Step 6: Run test to verify it passes**
Run: `cargo test -p aura-engine builder_sma_cross_structurally_equals_hand_wired`
Expected: PASS
---
### Task 3: Harness FlatGraph parity (headline acceptance)
**Files:**
- Test: `crates/aura-engine/src/builder.rs` (`mod tests`)
- [ ] **Step 1: Write the parity test**
Append inside the `#[cfg(test)] mod tests` of `crates/aura-engine/src/builder.rs`:
```rust
#[test]
fn builder_harness_compiles_identically_to_hand_wired() {
use crate::test_fixtures::composite_sma_cross_harness;
// hand-wired reference harness -> FlatGraph
let (hand_bp, _rx_eq, _rx_ex) = composite_sma_cross_harness();
let hand_flat = hand_bp.compile().expect("hand harness compiles");
// builder-authored harness (nests a builder-authored sma_cross) -> FlatGraph
let (tx_eq, _r1) = mpsc::channel();
let (tx_ex, _r2) = mpsc::channel();
let mut g = GraphBuilder::new("root");
let xross = g.add(built_sma_cross());
let expo = g.add(Exposure::builder());
let broker = g.add(SimBroker::builder(0.0001));
let eq = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq));
let ex = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex));
let src = g.source_role("src", ScalarKind::F64);
g.feed(src, [xross.in_("price"), broker.in_("price")]);
g.connect(xross.out("out"), expo.in_("signal"));
g.connect(expo.out("exposure"), broker.in_("exposure"));
g.connect(broker.out("equity"), eq.in_("col[0]"));
g.connect(expo.out("exposure"), ex.in_("col[0]"));
let built_flat = g.build().expect("root resolves").compile().expect("built compiles");
assert_eq!(built_flat.edges, hand_flat.edges);
assert_eq!(built_flat.sources, hand_flat.sources);
}
```
- [ ] **Step 2: Run test to verify it passes**
Run: `cargo test -p aura-engine builder_harness_compiles_identically_to_hand_wired`
Expected: PASS (the builder-authored harness lowers to the same raw-index
`FlatGraph` edges and sources as the hand-wired fixture).
---
### Task 4: Resolution-error surface
**Files:**
- Test: `crates/aura-engine/src/builder.rs` (`mod tests`)
- [ ] **Step 1: Write the error tests**
Append inside the `#[cfg(test)] mod tests` of `crates/aura-engine/src/builder.rs`:
```rust
#[test]
fn unknown_in_port_is_reported_at_build() {
let mut g = GraphBuilder::new("x");
let a = g.add(Sma::builder());
let b = g.add(Sub::builder());
g.connect(a.out("value"), b.in_("nope"));
assert_eq!(
g.build().unwrap_err(),
BuildError::UnknownInPort { node: 1, name: "nope".into() }
);
}
#[test]
fn unknown_out_port_is_reported_at_build() {
let mut g = GraphBuilder::new("x");
let a = g.add(Sma::builder());
let b = g.add(Sub::builder());
g.connect(a.out("nope"), b.in_("lhs"));
assert_eq!(
g.build().unwrap_err(),
BuildError::UnknownOutPort { node: 0, name: "nope".into() }
);
}
#[test]
fn out_of_range_handle_is_bad_handle() {
// g1 mints index 2; g2 has only index 0 — using g1's handle in g2 is out of range.
let mut g1 = GraphBuilder::new("g1");
g1.add(Sma::builder());
g1.add(Sma::builder());
let third = g1.add(Sub::builder()); // NodeHandle(2)
let mut g2 = GraphBuilder::new("g2");
let only = g2.add(Sub::builder()); // NodeHandle(0)
g2.connect(third.out("value"), only.in_("lhs"));
assert_eq!(g2.build().unwrap_err(), BuildError::BadHandle { node: 2 });
}
#[test]
fn ambiguous_in_port_is_reported() {
// a composite with two roles both named "x" derives two input ports named "x"
let inner = Composite::new(
"dup_in",
vec![Sub::builder().into()],
vec![],
vec![
Role { name: "x".into(), targets: vec![Target { node: 0, slot: 0 }], source: None },
Role { name: "x".into(), targets: vec![Target { node: 0, slot: 1 }], source: None },
],
vec![OutField { node: 0, field: 0, name: "o".into() }],
);
let mut g = GraphBuilder::new("outer");
let c = g.add(inner);
let src = g.source_role("s", ScalarKind::F64);
g.feed(src, [c.in_("x")]);
assert_eq!(
g.build().unwrap_err(),
BuildError::AmbiguousInPort { node: 0, name: "x".into() }
);
}
#[test]
fn ambiguous_out_port_is_reported() {
// a composite re-exporting the same interior field twice under one name
let inner = Composite::new(
"dup_out",
vec![Sub::builder().into()],
vec![],
vec![],
vec![
OutField { node: 0, field: 0, name: "o".into() },
OutField { node: 0, field: 0, name: "o".into() },
],
);
let mut g = GraphBuilder::new("outer");
let c = g.add(inner);
let snk = g.add(Sub::builder());
g.connect(c.out("o"), snk.in_("lhs"));
assert_eq!(
g.build().unwrap_err(),
BuildError::AmbiguousOutPort { node: 0, name: "o".into() }
);
}
```
- [ ] **Step 2: Run the error tests to verify they pass**
Run: `cargo test -p aura-engine unknown_in_port_is_reported_at_build`
Expected: PASS
Run: `cargo test -p aura-engine unknown_out_port_is_reported_at_build`
Expected: PASS
Run: `cargo test -p aura-engine out_of_range_handle_is_bad_handle`
Expected: PASS
Run: `cargo test -p aura-engine ambiguous_in_port_is_reported`
Expected: PASS
Run: `cargo test -p aura-engine ambiguous_out_port_is_reported`
Expected: PASS
---
### Task 5: #21 legibility + full-suite/coexistence/clippy gate
**Files:**
- Test: `crates/aura-engine/src/builder.rs` (`mod tests`)
- [ ] **Step 1: Write the #21 legibility test**
Append inside the `#[cfg(test)] mod tests` of `crates/aura-engine/src/builder.rs`:
```rust
#[test]
fn simbroker_legs_resolve_by_name_and_a_typo_is_caught() {
// exposure/price (both f64, slot 0/1) addressed by NAME — the #21 legibility win
let mut ok = GraphBuilder::new("ok");
let expo = ok.add(Exposure::builder());
let broker = ok.add(SimBroker::builder(0.0001));
let src = ok.source_role("p", ScalarKind::F64);
ok.feed(src, [expo.in_("signal"), broker.in_("price")]);
ok.connect(expo.out("exposure"), broker.in_("exposure"));
ok.expose(broker.out("equity"), "eq");
assert!(ok.build().is_ok());
// a transposed/typo'd port name is caught, where a bare slot index is not
let mut typo = GraphBuilder::new("typo");
let expo2 = typo.add(Exposure::builder());
let broker2 = typo.add(SimBroker::builder(0.0001));
typo.connect(expo2.out("exposure"), broker2.in_("pirce"));
assert_eq!(
typo.build().unwrap_err(),
BuildError::UnknownInPort { node: 1, name: "pirce".into() }
);
}
```
- [ ] **Step 2: Run the legibility test**
Run: `cargo test -p aura-engine simbroker_legs_resolve_by_name_and_a_typo_is_caught`
Expected: PASS
- [ ] **Step 3: Coexistence — full workspace test suite green (existing index-form tests unchanged)**
Run: `cargo test --workspace`
Expected: PASS — all tests, including the pre-existing raw-index `Composite::new`
tests in `crates/aura-engine/src/blueprint.rs` and `crates/aura-engine/src/sweep.rs`,
which are untouched.
- [ ] **Step 4: Build + lint gate**
Run: `cargo build --workspace`
Expected: PASS (0 errors)
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: PASS (0 warnings)