"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.
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/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) -> NodeHandlepushes aBlueprintNodeinto an internalVec, caches itsNodeSchemaviaBlueprintNode::signature()(uniform across the primitive and nested-composite arms,crates/aura-engine/src/blueprint.rs:54), and returns aNodeHandle(idx)whereidxis the position — i.e. the futurenodes-Vec index, identity unchanged from today.input_role(name) -> RoleHandle/source_role(name, kind) -> RoleHandlereserve aRoleslot 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 returningInPort { 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 (thePrimitiveBuilder::bindposture,crates/aura-core/src/node.rs:170, but returningErrinstead of panicking), assemblesVec<Edge>/Vec<Role>/Vec<OutField>, and hands them to the unchangedComposite::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.
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()returnsResult<Composite, BuildError>. The wiring methods (add/connect/feed/expose/input_role/source_role) are infallible accumulators — all faults are deferred to the singlebuild()resolution point, mirroringBinder(accumulate withwith, fail atbootstrap).BuildErrorvariants:UnknownInPort/UnknownOutPort(noPortSpec.name/FieldSpec.namematches),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 existingvalidate_wiring/Harness::bootstrapkind-check (crates/aura-engine/src/harness.rs:170), exactly as a hand-wiredCompositedoes 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/AmbiguousOutPortrather 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
- Parity (the headline acceptance). Author
sma_crossandcomposite_sma_cross_harnessthroughGraphBuilder; assert the producedCompositecompiles to aFlatGraphwith edges and sources equal to the hand-wired index form incrates/aura-engine/src/test_fixtures.rs. This extends the existingcomposite_sma_cross_runs_bit_identical_to_hand_wiredpattern one level up (builder-authored vs hand-authored). - Resolution errors. A
connect/feedto a non-existent port name yieldsUnknownInPort/UnknownOutPortatbuild(); a node with two same-named ports yieldsAmbiguousInPort. - Nested composite addressing. Build
sma_crossvia the builder,addit 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). - Coexistence. Existing raw-index
Composite::newcall sites and their tests keep compiling and passing unchanged; the two authoring forms interoperate (a builder canaddaComposite::new-built composite, and vice versa viabuild()?). - #21 legibility. A SimBroker leg wired by
in_("exposure")/in_("price")resolves to the correct slots; a transposed name (in_("pirce")) is anUnknownInPort, where the bare-index form would have been silently accepted.
Acceptance criteria
- A
GraphBuilder-authoredCompositeis byte-identical (equalFlatGraphedges and sources) to the hand-wired index form, pinned by a parity test on the sharedsma_crossfixtures. - Port/field-name resolution is exactly-one-match with a recoverable
BuildErrorsurface (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::newstays 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, andcargo clippy --workspace --all-targets -- -D warningsare green.