diff --git a/crates/aura-engine/src/blueprint.rs b/crates/aura-engine/src/blueprint.rs index edb2bf0..ab0d645 100644 --- a/crates/aura-engine/src/blueprint.rs +++ b/crates/aura-engine/src/blueprint.rs @@ -47,6 +47,14 @@ impl From 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 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::>(); 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(_))); + } } diff --git a/crates/aura-engine/src/builder.rs b/crates/aura-engine/src/builder.rs new file mode 100644 index 0000000..2260be4 --- /dev/null +++ b/crates/aura-engine/src/builder.rs @@ -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, + schemas: Vec, + edges: Vec<(OutPort, InPort)>, + roles: Vec<(String, Option, Vec)>, + 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) -> 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) -> 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) { + 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 { + 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 = 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 { + let schema = self + .schemas + .get(p.node) + .ok_or(BuildError::BadHandle { node: p.node })?; + let m: Vec = 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` 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() }) + ); + } +} diff --git a/crates/aura-engine/src/lib.rs b/crates/aura-engine/src/lib.rs index 5f53c52..5f97c1d 100644 --- a/crates/aura-engine/src/lib.rs +++ b/crates/aura-engine/src/lib.rs @@ -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};