feat(aura-engine): name-based blueprint wiring via GraphBuilder
Add an additive, fluent GraphBuilder authoring surface that wires a blueprint's topology by typed node handles and port/field names instead of raw positional indices. Node references are Copy NodeHandle values (a node reference cannot be mistyped); only port/field names are strings, resolved against each node's cached NodeSchema by exactly-one-match at a single fallible build() -> Result<Composite, BuildError> — the Binder posture, one level up. build() lowers to the unchanged Composite::new, so the compilat stays index-wired (C23): names resolve at the authoring boundary and never reach FlatGraph. A new From<Composite> for BlueprintNode lets add() accept nested composites. Verified: builder-authored sma_cross is structurally equal to the hand-wired fixture, and the full builder-authored harness lowers to a byte-identical FlatGraph (equal edges + sources). The five BuildError variants (Unknown/Ambiguous In/Out, BadHandle) are covered, and the SimBroker exposure/price legs are now addressable by name (the #21 legibility win). The structural close of the exposure/price swap (#21) is deliberately out of scope — this builder makes the swap legible, not impossible — tracked as #65. Two plan-test corrections applied during implementation (both in-scope, no behaviour change): the harness parity test uses compile_with_params with a topology-invariant param vector on both sides (the harness declares three params, so a no-param compile() would trip ParamArity), and the error asserts use .err()/Some(...) rather than unwrap_err() (Composite is not Debug). aura-engine 123 tests green (+9); full workspace green; clippy --all-targets -D warnings clean. Existing index-form Composite::new sites and tests untouched (coexistence). closes #64
This commit is contained in:
@@ -47,6 +47,14 @@ impl From<PrimitiveBuilder> for BlueprintNode {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
|
||||
impl BlueprintNode {
|
||||
/// The node's declared signature, pre-build, uniform across both arms — a
|
||||
/// primitive returns its builder's declared schema; a composite derives it from
|
||||
@@ -2229,4 +2237,17 @@ mod tests {
|
||||
let ex = rx_ex.try_iter().collect::<Vec<_>>();
|
||||
assert!(!ex.is_empty(), "the cured, by-name-bound cross must run to a populated exposure trace");
|
||||
}
|
||||
|
||||
#[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(_)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
//! `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 compilat 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> {
|
||||
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() }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_harness_compiles_identically_to_hand_wired() {
|
||||
use crate::test_fixtures::composite_sma_cross_harness;
|
||||
use aura_core::Scalar;
|
||||
|
||||
// Shared param vector for the harness param-space (sma_cross.fast.length,
|
||||
// sma_cross.slow.length, exposure.scale) — applied to both sides, so it
|
||||
// cancels in the comparison. Params size buffers, not topology (C11), so
|
||||
// edges/sources are param-independent; a fixed vector just satisfies the
|
||||
// arity gate compile() (no-param) trips on this value-empty harness.
|
||||
let params = [Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)];
|
||||
|
||||
// hand-wired reference harness -> FlatGraph
|
||||
let (hand_bp, _rx_eq, _rx_ex) = composite_sma_cross_harness();
|
||||
let hand_flat = hand_bp.compile_with_params(¶ms).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_with_params(¶ms)
|
||||
.expect("built compiles");
|
||||
|
||||
assert_eq!(built_flat.edges, hand_flat.edges);
|
||||
assert_eq!(built_flat.sources, hand_flat.sources);
|
||||
}
|
||||
|
||||
#[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"));
|
||||
// `.err()`, not `.unwrap_err()`: the latter would require `Composite: Debug`
|
||||
// (the Ok type), which it is not (it holds a `Box<dyn Fn>` builder closure).
|
||||
assert_eq!(
|
||||
g.build().err(),
|
||||
Some(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().err(),
|
||||
Some(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().err(), Some(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().err(),
|
||||
Some(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().err(),
|
||||
Some(BuildError::AmbiguousOutPort { node: 0, name: "o".into() })
|
||||
);
|
||||
}
|
||||
|
||||
#[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().err(),
|
||||
Some(BuildError::UnknownInPort { node: 1, name: "pirce".into() })
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@
|
||||
//! Visualization is never here: it is a downstream consumer node on the streams.
|
||||
|
||||
mod blueprint;
|
||||
mod builder;
|
||||
mod graph_model;
|
||||
mod harness;
|
||||
mod report;
|
||||
@@ -41,6 +42,7 @@ pub use blueprint::{
|
||||
BindError, Binder, BlueprintNode, CompileError, Composite, OutField, Role,
|
||||
SweepBinder,
|
||||
};
|
||||
pub use builder::{BuildError, GraphBuilder, InPort, NodeHandle, OutPort, RoleHandle};
|
||||
pub use graph_model::model_to_json;
|
||||
pub use harness::{BootstrapError, Edge, FlatGraph, Harness, SourceSpec, Target};
|
||||
pub use report::{f64_field, summarize, RunManifest, RunMetrics, RunReport};
|
||||
|
||||
Reference in New Issue
Block a user