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

14 KiB

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/withbootstrap, 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_wiringlower_items/inline_compositerewrite_edge/resolve_targetFlatGraph. 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.

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):

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):

#[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:

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):

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