Files
Aura/docs/specs/0039-graphbuilder-name-based-wiring.md
T
Brummel 21c1621bd0 docs,engine: drop coined "compilat", use FlatGraph / "flat graph"
"Compilat" (German "Kompilat") was a coined noun for the product of the
bootstrap compilation — neither English nor a natural fit. The runtime
artifact already has a code identifier for exactly this thing: the
`FlatGraph` struct (harness.rs). Replace the coinage with that identifier:

- prose mentions          -> "flat graph" (mirrors the type, reads plainly)
- definitional anchors    -> `FlatGraph` (C11, C23, the running-graph line)
- `render_compilat`       -> `render_flat_graph` (historical render symbol;
                             keeps the `render_blueprint` / `render_flat_graph`
                             source-vs-product pairing)
- `intra-compilat`        -> `intra-graph`
- `from_compilat` (test)  -> `from_flat`

"compilation" / "re-compilation" / "bootstrap-as-compilation" (the process,
ordinary English) are deliberately left untouched. Behaviour-preserving:
only comments, design ledger, specs/plans, one test-local variable and its
assert messages change. Full workspace test suite green; clippy clean.
2026-06-14 17:02:15 +02:00

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 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.