spec: 0039 graphbuilder-name-based-wiring (boss-signed)
Add a typed-handle GraphBuilder authoring surface for blueprint topology: node references become Copy NodeHandle values, ports/fields resolve by name against the existing PortSpec.name/FieldSpec.name at a single fallible build() terminal, lowering to the unchanged index-wired Composite (C23 holds by construction — names never reach the compilat, mirroring param-name resolution). Spec auto-signed under /boss: all objective gates green (precondition, self-review, grounding-check PASS) and a unanimous five-lens spec-skeptic panel (criterion, grounding, scope-fork, ambiguity, plan-readiness all SOUND). The structural close of the SimBroker exposure/price swap (#21) is explicitly out of scope, tracked as #65. refs #64
This commit is contained in:
@@ -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<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 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<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 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.
|
||||
Reference in New Issue
Block a user