292c95756f
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
278 lines
14 KiB
Markdown
278 lines
14 KiB
Markdown
# 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.
|