Files
Aura/docs/plans/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

22 KiB

GraphBuilder — name-based blueprint wiring — Implementation Plan

Parent spec: docs/specs/0039-graphbuilder-name-based-wiring.md

For agentic workers: REQUIRED SUB-SKILL: use the implement skill to run this plan. Steps use - [ ] checkboxes for tracking.

Goal: Add an additive, fluent GraphBuilder that authors blueprint topology by typed node handles + port/field names, resolving them to the existing index-wired Composite at a single fallible build().

Architecture: A new crates/aura-engine/src/builder.rs module accumulates typed handles (NodeHandle/RoleHandle) and unresolved (handle, name) endpoints (InPort/OutPort); build() -> Result<Composite, BuildError> resolves every name against each node's cached NodeSchema by exactly-one-match (the PrimitiveBuilder::bind posture, but fallible) and hands the resolved Vec<Edge>/Vec<Role>/Vec<OutField> to the unchanged Composite::new. A new impl From<Composite> for BlueprintNode lets add accept nested composites. Names never reach the flat graph (C23 holds by construction).

Tech Stack: Rust, aura-engine (blueprint/harness), aura-core (NodeSchema/ PortSpec/FieldSpec/ScalarKind), aura-std nodes for tests.


Files this plan creates or modifies:

  • Create: crates/aura-engine/src/builder.rs — the GraphBuilder accumulator, NodeHandle/RoleHandle/InPort/OutPort, BuildError, and the build() resolver, plus a #[cfg(test)] mod tests.
  • Modify: crates/aura-engine/src/blueprint.rs:44 — add impl From<Composite> for BlueprintNode beside the existing From<PrimitiveBuilder>.
  • Modify: crates/aura-engine/src/lib.rs:34-43mod builder; plus the pub use builder::{...} re-export.
  • Test: crates/aura-engine/src/builder.rs (mod tests) — structural parity, harness FlatGraph parity, resolution errors, nested addressing, #21 legibility.

Task 1: From<Composite> for BlueprintNode lift

Files:

  • Modify: crates/aura-engine/src/blueprint.rs:44-48

  • Step 1: Write the failing test

Append to the existing #[cfg(test)] mod tests in crates/aura-engine/src/blueprint.rs (the module that starts at blueprint.rs:753, which already has use super::*; and use aura_std::{... Sub ...};):

    #[test]
    fn composite_lifts_into_blueprint_node_via_from() {
        let c = Composite::new(
            "leaf",
            vec![Sub::builder().into()],
            vec![],
            vec![],
            vec![OutField { node: 0, field: 0, name: "o".into() }],
        );
        let bn: BlueprintNode = c.into();
        assert!(matches!(bn, BlueprintNode::Composite(_)));
    }
  • Step 2: Run test to verify it fails

Run: cargo test -p aura-engine composite_lifts_into_blueprint_node_via_from Expected: FAIL — compile error the trait Fromis not implemented forBlueprintNode`` (the .into() does not resolve).

  • Step 3: Write minimal implementation

Immediately after the existing impl From<PrimitiveBuilder> for BlueprintNode { ... } block at crates/aura-engine/src/blueprint.rs:44-48, add:

/// Ergonomic lift: a nested composite becomes a `Composite` blueprint item, so a
/// builder's `add` can accept a sub-graph the same way it accepts a primitive.
impl From<Composite> for BlueprintNode {
    fn from(c: Composite) -> Self {
        BlueprintNode::Composite(c)
    }
}
  • Step 4: Run test to verify it passes

Run: cargo test -p aura-engine composite_lifts_into_blueprint_node_via_from Expected: PASS


Task 2: The GraphBuilder module + structural parity

Files:

  • Create: crates/aura-engine/src/builder.rs

  • Modify: crates/aura-engine/src/lib.rs:34-43

  • Step 1: Create the module skeleton (types + accumulators + build() stub)

Create crates/aura-engine/src/builder.rs with:

//! `GraphBuilder` — name-based blueprint authoring (spec 0039).
//!
//! A fluent, additive authoring surface that wires a blueprint by typed node
//! handles and port/field *names*, resolving them to the raw-index `Composite`
//! at a single fallible `build()`. The flat graph stays index-wired (C23): names
//! are resolved at the authoring boundary and never reach `FlatGraph` — the same
//! posture param-name resolution already has (`Binder`, blueprint.rs). Resolution
//! mirrors `PrimitiveBuilder::bind`'s collect-then-reject (node.rs), but returns
//! `Err(BuildError)` instead of panicking.

use aura_core::{NodeSchema, ScalarKind};

use crate::blueprint::{BlueprintNode, Composite, OutField, Role};
use crate::harness::{Edge, Target};

/// A typed, `Copy` reference to a node added to a [`GraphBuilder`], carrying the
/// node's index in the builder's `nodes` Vec.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct NodeHandle(usize);

/// A typed, `Copy` reference to a role reserved on a [`GraphBuilder`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RoleHandle(usize);

/// An unresolved consumer endpoint: a node handle's index plus an input-port name.
#[derive(Clone, Copy, Debug)]
pub struct InPort {
    node: usize,
    name: &'static str,
}

/// An unresolved producer endpoint: a node handle's index plus an output-field name.
#[derive(Clone, Copy, Debug)]
pub struct OutPort {
    node: usize,
    name: &'static str,
}

impl NodeHandle {
    /// Address an input port of this node by name (resolved at [`GraphBuilder::build`]).
    pub fn in_(self, name: &'static str) -> InPort {
        InPort { node: self.0, name }
    }
    /// Address this node's output field by name (resolved at [`GraphBuilder::build`]).
    pub fn out(self, name: &'static str) -> OutPort {
        OutPort { node: self.0, name }
    }
}

/// An authoring-layer fault surfaced at [`GraphBuilder::build`]. Resolution by
/// name is necessary, not sufficient: a name-resolved edge that connects
/// mismatched scalar kinds still surfaces downstream as a bootstrap kind-check.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BuildError {
    /// A handle whose index is out of range (i.e. minted by a different builder).
    BadHandle { node: usize },
    /// No input port of `node` is named `name`.
    UnknownInPort { node: usize, name: String },
    /// More than one input port of `node` is named `name`.
    AmbiguousInPort { node: usize, name: String },
    /// No output field of `node` is named `name`.
    UnknownOutPort { node: usize, name: String },
    /// More than one output field of `node` is named `name`.
    AmbiguousOutPort { node: usize, name: String },
}

/// A fluent, additive accumulator that authors a [`Composite`] by typed handles
/// and port/field names. See the module docs and spec 0039.
pub struct GraphBuilder {
    name: String,
    nodes: Vec<BlueprintNode>,
    schemas: Vec<NodeSchema>,
    edges: Vec<(OutPort, InPort)>,
    roles: Vec<(String, Option<ScalarKind>, Vec<InPort>)>,
    out: Vec<(String, OutPort)>,
}

impl GraphBuilder {
    /// Start a builder for a composite named `name` (a non-load-bearing render symbol).
    pub fn new(name: impl Into<String>) -> Self {
        Self {
            name: name.into(),
            nodes: Vec::new(),
            schemas: Vec::new(),
            edges: Vec::new(),
            roles: Vec::new(),
            out: Vec::new(),
        }
    }

    /// Add a node (primitive builder or nested composite); returns its handle.
    pub fn add(&mut self, item: impl Into<BlueprintNode>) -> NodeHandle {
        let node = item.into();
        let schema = node.signature();
        let idx = self.nodes.len();
        self.nodes.push(node);
        self.schemas.push(schema);
        NodeHandle(idx)
    }

    /// Reserve an open input role (wired by an enclosing graph); returns its handle.
    pub fn input_role(&mut self, name: &str) -> RoleHandle {
        let idx = self.roles.len();
        self.roles.push((name.to_string(), None, Vec::new()));
        RoleHandle(idx)
    }

    /// Reserve a bound ingestion role of `kind` (a root source); returns its handle.
    pub fn source_role(&mut self, name: &str, kind: ScalarKind) -> RoleHandle {
        let idx = self.roles.len();
        self.roles.push((name.to_string(), Some(kind), Vec::new()));
        RoleHandle(idx)
    }

    /// Wire a producer's output field into a consumer's input port (resolved at build).
    pub fn connect(&mut self, from: OutPort, to: InPort) {
        self.edges.push((from, to));
    }

    /// Fan a role's value into one or more consumer input ports.
    pub fn feed(&mut self, role: RoleHandle, into: impl IntoIterator<Item = InPort>) {
        self.roles[role.0].2.extend(into);
    }

    /// Re-export a producer's output field at the composite boundary under `name`.
    pub fn expose(&mut self, from: OutPort, name: &str) {
        self.out.push((name.to_string(), from));
    }

    /// Resolve every accumulated name to an index and assemble the [`Composite`].
    pub fn build(self) -> Result<Composite, BuildError> {
        unimplemented!("GraphBuilder::build is filled in the next step")
    }
}
  • Step 2: Wire the module into the crate

In crates/aura-engine/src/lib.rs, add mod builder; to the module block (after mod blueprint; at line 34, keeping alphabetical order):

mod blueprint;
mod builder;
mod graph_model;
mod harness;
mod report;
mod sweep;

Then add the re-export immediately after the pub use blueprint::{...}; block (which ends at line 43):

pub use builder::{BuildError, GraphBuilder, InPort, NodeHandle, OutPort, RoleHandle};
  • Step 3: Write the failing structural-parity test

Append a test module at the end of crates/aura-engine/src/builder.rs:

#[cfg(test)]
mod tests {
    use super::{BuildError, GraphBuilder};
    use crate::test_fixtures::sma_cross as hand_sma_cross;
    use crate::{Composite, OutField, Role, Target};
    use aura_core::{Firing, ScalarKind};
    use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
    use std::sync::mpsc;

    /// The `sma_cross` sub-composite authored through the builder.
    fn built_sma_cross() -> Composite {
        let mut g = GraphBuilder::new("sma_cross");
        let fast = g.add(Sma::builder().named("fast"));
        let slow = g.add(Sma::builder().named("slow"));
        let sub = g.add(Sub::builder());
        let price = g.input_role("price");
        g.feed(price, [fast.in_("series"), slow.in_("series")]);
        g.connect(fast.out("value"), sub.in_("lhs"));
        g.connect(slow.out("value"), sub.in_("rhs"));
        g.expose(sub.out("value"), "out");
        g.build().expect("sma_cross resolves")
    }

    #[test]
    fn builder_sma_cross_structurally_equals_hand_wired() {
        let built = built_sma_cross();
        let hand = hand_sma_cross();
        assert_eq!(built.edges(), hand.edges());
        assert_eq!(built.input_roles(), hand.input_roles());
        assert_eq!(built.output(), hand.output());
    }
}
  • Step 4: Run test to verify it fails

Run: cargo test -p aura-engine builder_sma_cross_structurally_equals_hand_wired Expected: FAIL — panic not implemented: GraphBuilder::build is filled in the next step (the unimplemented!() body of build()).

  • Step 5: Implement build() and the resolvers

Replace the build() stub body in crates/aura-engine/src/builder.rs with the real resolution, and add the two private resolver methods inside the same impl GraphBuilder block (after build):

    /// Resolve every accumulated name to an index and assemble the [`Composite`].
    pub fn build(self) -> Result<Composite, BuildError> {
        let mut edges = Vec::with_capacity(self.edges.len());
        for (from, to) in &self.edges {
            let from_field = self.resolve_field(from)?;
            let (to_node, slot) = self.resolve_slot(to)?;
            edges.push(Edge { from: from.node, to: to_node, slot, from_field });
        }

        let mut roles = Vec::with_capacity(self.roles.len());
        for (name, source, ports) in &self.roles {
            let mut targets = Vec::with_capacity(ports.len());
            for p in ports {
                let (node, slot) = self.resolve_slot(p)?;
                targets.push(Target { node, slot });
            }
            roles.push(Role { name: name.clone(), targets, source: *source });
        }

        let mut output = Vec::with_capacity(self.out.len());
        for (name, from) in &self.out {
            let field = self.resolve_field(from)?;
            output.push(OutField { node: from.node, field, name: name.clone() });
        }

        Ok(Composite::new(self.name, self.nodes, edges, roles, output))
    }

    /// Resolve an `InPort` to `(node-index, slot)` by exactly-one-match on the
    /// node's input-port names (the `PrimitiveBuilder::bind` posture, fallible).
    fn resolve_slot(&self, p: &InPort) -> Result<(usize, usize), BuildError> {
        let schema = self
            .schemas
            .get(p.node)
            .ok_or(BuildError::BadHandle { node: p.node })?;
        let m: Vec<usize> = schema
            .inputs
            .iter()
            .enumerate()
            .filter(|(_, port)| port.name == p.name)
            .map(|(i, _)| i)
            .collect();
        match m.as_slice() {
            [i] => Ok((p.node, *i)),
            [] => Err(BuildError::UnknownInPort { node: p.node, name: p.name.to_string() }),
            _ => Err(BuildError::AmbiguousInPort { node: p.node, name: p.name.to_string() }),
        }
    }

    /// Resolve an `OutPort` to a `from_field` by exactly-one-match on the node's
    /// output-field names.
    fn resolve_field(&self, p: &OutPort) -> Result<usize, BuildError> {
        let schema = self
            .schemas
            .get(p.node)
            .ok_or(BuildError::BadHandle { node: p.node })?;
        let m: Vec<usize> = schema
            .output
            .iter()
            .enumerate()
            .filter(|(_, f)| f.name == p.name)
            .map(|(i, _)| i)
            .collect();
        match m.as_slice() {
            [i] => Ok(*i),
            [] => Err(BuildError::UnknownOutPort { node: p.node, name: p.name.to_string() }),
            _ => Err(BuildError::AmbiguousOutPort { node: p.node, name: p.name.to_string() }),
        }
    }
  • Step 6: Run test to verify it passes

Run: cargo test -p aura-engine builder_sma_cross_structurally_equals_hand_wired Expected: PASS


Task 3: Harness FlatGraph parity (headline acceptance)

Files:

  • Test: crates/aura-engine/src/builder.rs (mod tests)

  • Step 1: Write the parity test

Append inside the #[cfg(test)] mod tests of crates/aura-engine/src/builder.rs:

    #[test]
    fn builder_harness_compiles_identically_to_hand_wired() {
        use crate::test_fixtures::composite_sma_cross_harness;

        // hand-wired reference harness -> FlatGraph
        let (hand_bp, _rx_eq, _rx_ex) = composite_sma_cross_harness();
        let hand_flat = hand_bp.compile().expect("hand harness compiles");

        // builder-authored harness (nests a builder-authored sma_cross) -> FlatGraph
        let (tx_eq, _r1) = mpsc::channel();
        let (tx_ex, _r2) = mpsc::channel();
        let mut g = GraphBuilder::new("root");
        let xross = g.add(built_sma_cross());
        let expo = g.add(Exposure::builder());
        let broker = g.add(SimBroker::builder(0.0001));
        let eq = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq));
        let ex = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex));
        let src = g.source_role("src", ScalarKind::F64);
        g.feed(src, [xross.in_("price"), broker.in_("price")]);
        g.connect(xross.out("out"), expo.in_("signal"));
        g.connect(expo.out("exposure"), broker.in_("exposure"));
        g.connect(broker.out("equity"), eq.in_("col[0]"));
        g.connect(expo.out("exposure"), ex.in_("col[0]"));
        let built_flat = g.build().expect("root resolves").compile().expect("built compiles");

        assert_eq!(built_flat.edges, hand_flat.edges);
        assert_eq!(built_flat.sources, hand_flat.sources);
    }
  • Step 2: Run test to verify it passes

Run: cargo test -p aura-engine builder_harness_compiles_identically_to_hand_wired Expected: PASS (the builder-authored harness lowers to the same raw-index FlatGraph edges and sources as the hand-wired fixture).


Task 4: Resolution-error surface

Files:

  • Test: crates/aura-engine/src/builder.rs (mod tests)

  • Step 1: Write the error tests

Append inside the #[cfg(test)] mod tests of crates/aura-engine/src/builder.rs:

    #[test]
    fn unknown_in_port_is_reported_at_build() {
        let mut g = GraphBuilder::new("x");
        let a = g.add(Sma::builder());
        let b = g.add(Sub::builder());
        g.connect(a.out("value"), b.in_("nope"));
        assert_eq!(
            g.build().unwrap_err(),
            BuildError::UnknownInPort { node: 1, name: "nope".into() }
        );
    }

    #[test]
    fn unknown_out_port_is_reported_at_build() {
        let mut g = GraphBuilder::new("x");
        let a = g.add(Sma::builder());
        let b = g.add(Sub::builder());
        g.connect(a.out("nope"), b.in_("lhs"));
        assert_eq!(
            g.build().unwrap_err(),
            BuildError::UnknownOutPort { node: 0, name: "nope".into() }
        );
    }

    #[test]
    fn out_of_range_handle_is_bad_handle() {
        // g1 mints index 2; g2 has only index 0 — using g1's handle in g2 is out of range.
        let mut g1 = GraphBuilder::new("g1");
        g1.add(Sma::builder());
        g1.add(Sma::builder());
        let third = g1.add(Sub::builder()); // NodeHandle(2)
        let mut g2 = GraphBuilder::new("g2");
        let only = g2.add(Sub::builder()); // NodeHandle(0)
        g2.connect(third.out("value"), only.in_("lhs"));
        assert_eq!(g2.build().unwrap_err(), BuildError::BadHandle { node: 2 });
    }

    #[test]
    fn ambiguous_in_port_is_reported() {
        // a composite with two roles both named "x" derives two input ports named "x"
        let inner = Composite::new(
            "dup_in",
            vec![Sub::builder().into()],
            vec![],
            vec![
                Role { name: "x".into(), targets: vec![Target { node: 0, slot: 0 }], source: None },
                Role { name: "x".into(), targets: vec![Target { node: 0, slot: 1 }], source: None },
            ],
            vec![OutField { node: 0, field: 0, name: "o".into() }],
        );
        let mut g = GraphBuilder::new("outer");
        let c = g.add(inner);
        let src = g.source_role("s", ScalarKind::F64);
        g.feed(src, [c.in_("x")]);
        assert_eq!(
            g.build().unwrap_err(),
            BuildError::AmbiguousInPort { node: 0, name: "x".into() }
        );
    }

    #[test]
    fn ambiguous_out_port_is_reported() {
        // a composite re-exporting the same interior field twice under one name
        let inner = Composite::new(
            "dup_out",
            vec![Sub::builder().into()],
            vec![],
            vec![],
            vec![
                OutField { node: 0, field: 0, name: "o".into() },
                OutField { node: 0, field: 0, name: "o".into() },
            ],
        );
        let mut g = GraphBuilder::new("outer");
        let c = g.add(inner);
        let snk = g.add(Sub::builder());
        g.connect(c.out("o"), snk.in_("lhs"));
        assert_eq!(
            g.build().unwrap_err(),
            BuildError::AmbiguousOutPort { node: 0, name: "o".into() }
        );
    }
  • Step 2: Run the error tests to verify they pass

Run: cargo test -p aura-engine unknown_in_port_is_reported_at_build Expected: PASS

Run: cargo test -p aura-engine unknown_out_port_is_reported_at_build Expected: PASS

Run: cargo test -p aura-engine out_of_range_handle_is_bad_handle Expected: PASS

Run: cargo test -p aura-engine ambiguous_in_port_is_reported Expected: PASS

Run: cargo test -p aura-engine ambiguous_out_port_is_reported Expected: PASS


Task 5: #21 legibility + full-suite/coexistence/clippy gate

Files:

  • Test: crates/aura-engine/src/builder.rs (mod tests)

  • Step 1: Write the #21 legibility test

Append inside the #[cfg(test)] mod tests of crates/aura-engine/src/builder.rs:

    #[test]
    fn simbroker_legs_resolve_by_name_and_a_typo_is_caught() {
        // exposure/price (both f64, slot 0/1) addressed by NAME — the #21 legibility win
        let mut ok = GraphBuilder::new("ok");
        let expo = ok.add(Exposure::builder());
        let broker = ok.add(SimBroker::builder(0.0001));
        let src = ok.source_role("p", ScalarKind::F64);
        ok.feed(src, [expo.in_("signal"), broker.in_("price")]);
        ok.connect(expo.out("exposure"), broker.in_("exposure"));
        ok.expose(broker.out("equity"), "eq");
        assert!(ok.build().is_ok());

        // a transposed/typo'd port name is caught, where a bare slot index is not
        let mut typo = GraphBuilder::new("typo");
        let expo2 = typo.add(Exposure::builder());
        let broker2 = typo.add(SimBroker::builder(0.0001));
        typo.connect(expo2.out("exposure"), broker2.in_("pirce"));
        assert_eq!(
            typo.build().unwrap_err(),
            BuildError::UnknownInPort { node: 1, name: "pirce".into() }
        );
    }
  • Step 2: Run the legibility test

Run: cargo test -p aura-engine simbroker_legs_resolve_by_name_and_a_typo_is_caught Expected: PASS

  • Step 3: Coexistence — full workspace test suite green (existing index-form tests unchanged)

Run: cargo test --workspace Expected: PASS — all tests, including the pre-existing raw-index Composite::new tests in crates/aura-engine/src/blueprint.rs and crates/aura-engine/src/sweep.rs, which are untouched.

  • Step 4: Build + lint gate

Run: cargo build --workspace Expected: PASS (0 errors)

Run: cargo clippy --workspace --all-targets -- -D warnings Expected: PASS (0 warnings)