//! `GraphBuilder` — name-based blueprint authoring. //! //! 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, CompileError, Composite, Gang, GangFault, GangMember, 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, } /// An unresolved param endpoint: a node handle's index plus a param name /// (resolved to a structural `GangMember` at [`GraphBuilder::build`]). #[derive(Clone, Copy, Debug)] pub struct ParamRef { node: usize, name: &'static str, } impl NodeHandle { /// Address an input port of this node by name (resolved at [`GraphBuilder::build`]). pub fn input(self, name: &'static str) -> InPort { InPort { node: self.0, name } } /// Address this node's output field by name (resolved at [`GraphBuilder::build`]). pub fn output(self, name: &'static str) -> OutPort { OutPort { node: self.0, name } } /// Address an open param of this node by name (resolved at [`GraphBuilder::build`]). pub fn param(self, name: &'static str) -> ParamRef { ParamRef { 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 }, /// No open param of `node` is named `name` (gang member resolution). UnknownParam { node: usize, name: String }, /// More than one open param of `node` is named `name`. AmbiguousParam { node: usize, name: String }, /// The assembled gang table failed the structural gate. BadGang(GangFault), } /// A fluent, additive accumulator that authors a [`Composite`] by typed handles /// and port/field names. See the module docs. pub struct GraphBuilder { name: String, nodes: Vec, schemas: Vec, edges: Vec<(OutPort, InPort)>, roles: Vec<(String, Option, Vec)>, out: Vec<(String, OutPort)>, gangs: Vec<(String, Vec)>, } 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(), gangs: 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)); } /// Fuse two or more sibling params into one public knob (resolved at build). pub fn gang(&mut self, name: &str, members: impl IntoIterator) { self.gangs.push((name.to_string(), members.into_iter().collect())); } /// 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() }); } let mut gangs = Vec::with_capacity(self.gangs.len()); for (name, refs) in &self.gangs { let mut members = Vec::with_capacity(refs.len()); let mut kind = None; for r in refs { let node = self.nodes.get(r.node).ok_or(BuildError::BadHandle { node: r.node })?; let BlueprintNode::Primitive(b) = node else { // A composite node is a legitimate handle (composites are // addable, `impl From for BlueprintNode`) — not a // bad handle. Push a placeholder member and let the shared // structural gate (`check_gangs`, below) report the true // fault (`GangFault::NotAPrimitive`) instead of this loop // pre-empting it with a misleading `BadHandle`. members.push(GangMember { node: r.node, pos: 0, name: r.name.to_string() }); continue; }; let hits: Vec = b .params() .iter() .enumerate() .filter(|(_, p)| p.name == r.name) .map(|(i, _)| i) .collect(); let idx = match hits.as_slice() { [i] => *i, [] => { return Err(BuildError::UnknownParam { node: r.node, name: r.name.to_string() }) } _ => { return Err(BuildError::AmbiguousParam { node: r.node, name: r.name.to_string() }) } }; kind.get_or_insert(b.params()[idx].kind); members.push(GangMember { node: r.node, pos: b.original_pos(idx), name: r.name.to_string() }); } // `kind` is `None` only when every member above was a composite // handle (each such member `continue`s without ever reaching // `kind.get_or_insert`) or `refs` was empty — and in both cases // `check_gangs` below rejects the gang (`NotAPrimitive` on the // first non-primitive member, `TooFewMembers` on <2 members) // before `Gang::kind` is ever read. The fallback value is // therefore unobservable; it exists only to satisfy the type. let kind = kind.unwrap_or(ScalarKind::F64); gangs.push(Gang { name: name.clone(), kind, members }); } let composite = Composite::new(self.name, self.nodes, edges, roles, output); match composite.with_gangs(gangs) { Ok(c) => Ok(c), Err(CompileError::BadGang(f)) => Err(BuildError::BadGang(f)), Err(_) => unreachable!("with_gangs emits only BadGang"), } } /// 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 })?; match resolve_input_slot(schema, p.name) { Ok(i) => Ok((p.node, i)), Err(PortResolveError::Unknown) => { Err(BuildError::UnknownInPort { node: p.node, name: p.name.to_string() }) } Err(PortResolveError::Ambiguous) => { 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 })?; match resolve_output_field(schema, p.name) { Ok(i) => Ok(i), Err(PortResolveError::Unknown) => { Err(BuildError::UnknownOutPort { node: p.node, name: p.name.to_string() }) } Err(PortResolveError::Ambiguous) => { Err(BuildError::AmbiguousOutPort { node: p.node, name: p.name.to_string() }) } } } } /// A name→index resolution outcome, shared by `GraphBuilder` (mapped to /// `BuildError`) and `GraphSession` (mapped to `OpError`). Over `&str`, so it /// serves both `&'static str` literals and runtime document names. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum PortResolveError { Unknown, Ambiguous, } /// Resolve an input-port name to its slot index by exactly-one-match. pub(crate) fn resolve_input_slot(schema: &NodeSchema, name: &str) -> Result { let m: Vec = schema .inputs .iter() .enumerate() .filter(|(_, port)| port.name == name) .map(|(i, _)| i) .collect(); match m.as_slice() { [i] => Ok(*i), [] => Err(PortResolveError::Unknown), _ => Err(PortResolveError::Ambiguous), } } /// Resolve an output-field name to its field index by exactly-one-match. pub(crate) fn resolve_output_field(schema: &NodeSchema, name: &str) -> Result { let m: Vec = schema .output .iter() .enumerate() .filter(|(_, f)| f.name == name) .map(|(i, _)| i) .collect(); match m.as_slice() { [i] => Ok(*i), [] => Err(PortResolveError::Unknown), _ => Err(PortResolveError::Ambiguous), } } #[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::{Bias, 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.input("series"), slow.input("series")]); g.connect(fast.output("value"), sub.input("lhs")); g.connect(slow.output("value"), sub.input("rhs")); g.expose(sub.output("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, bias.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(Bias::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.input("price"), broker.input("price")]); g.connect(xross.output("out"), expo.input("signal")); g.connect(expo.output("bias"), broker.input("exposure")); g.connect(broker.output("equity"), eq.input("col[0]")); g.connect(expo.output("bias"), ex.input("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 unconnected_port_rejected_on_builder_surface() { use crate::CompileError; use aura_core::Scalar; // The forgotten-exposure-leg harness: every port/field name resolves, so // build() succeeds; but broker.input("exposure") is never connected, so the // wiring-totality check rejects it at compile (broker is node 1, slot 0). let (tx_eq, _r1) = mpsc::channel(); let mut g = GraphBuilder::new("root"); let expo = g.add(Bias::builder()); let broker = g.add(SimBroker::builder(0.0001)); let eq = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq)); let price = g.source_role("price", ScalarKind::F64); g.feed(price, [expo.input("signal"), broker.input("price")]); // BUG: g.connect(expo.output("bias"), broker.input("exposure")) missing. g.connect(broker.output("equity"), eq.input("col[0]")); let compiled = g .build() .expect("all port/field names resolve") .compile_with_params(&[Scalar::f64(0.5)]); assert_eq!( compiled.err(), Some(CompileError::UnconnectedPort { node: 1, slot: 0 }) ); } #[test] fn shared_port_resolution_matches_exactly_one() { use super::{resolve_input_slot, resolve_output_field, PortResolveError}; use crate::BlueprintNode; use aura_std::Sub; let schema = BlueprintNode::from(Sub::builder()).signature(); // lhs, rhs -> value assert_eq!(resolve_input_slot(&schema, "lhs"), Ok(0)); assert_eq!(resolve_input_slot(&schema, "rhs"), Ok(1)); assert_eq!(resolve_input_slot(&schema, "nope"), Err(PortResolveError::Unknown)); assert_eq!(resolve_output_field(&schema, "value"), Ok(0)); assert_eq!(resolve_output_field(&schema, "nope"), Err(PortResolveError::Unknown)); } #[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.output("value"), b.input("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.output("nope"), b.input("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.output("value"), only.input("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.input("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.output("o"), snk.input("lhs")); assert_eq!( g.build().err(), Some(BuildError::AmbiguousOutPort { node: 0, name: "o".into() }) ); } /// A gang member whose node is a composite (not a primitive) is reported as /// the true structural fault `GangFault::NotAPrimitive`, not the unrelated /// `BadHandle` (composites are addable via `add`, so this path is reachable, /// not merely a defensive stub). #[test] fn gang_member_on_composite_node_reports_not_a_primitive() { use crate::GangFault; let mut g = GraphBuilder::new("g"); let comp = g.add(built_sma_cross()); let a = g.add(Sma::builder().named("a")); g.gang("length", [comp.param("length"), a.param("length")]); assert!( matches!( g.build().err(), Some(BuildError::BadGang(GangFault::NotAPrimitive { node: 0, .. })) ), "expected NotAPrimitive for the composite member" ); } /// An unknown param name in a gang member surfaces as `UnknownParam` (the /// same collect-then-reject posture as port resolution), not silently /// ignored or mis-attributed to the wrong node. #[test] fn unknown_param_in_gang_is_reported_at_build() { let mut g = GraphBuilder::new("g"); let a = g.add(Sma::builder().named("a")); let b = g.add(Sma::builder().named("b")); g.gang("length", [a.param("length"), b.param("nope")]); assert_eq!( g.build().err(), Some(BuildError::UnknownParam { node: 1, name: "nope".into() }) ); } /// A structurally invalid gang table (here: a single-member gang) surfaces /// through the `CompileError` -> `BuildError` translation as `BadGang`, /// proving the `with_gangs` structural gate is actually wired into the /// builder's `build()`, not just the happy path. #[test] fn single_member_gang_reports_bad_gang() { use crate::GangFault; let mut g = GraphBuilder::new("g"); let a = g.add(Sma::builder().named("a")); g.gang("length", [a.param("length")]); assert!(matches!( g.build().err(), Some(BuildError::BadGang(GangFault::TooFewMembers { .. })) )); } /// The `gang` method fuses two handles' params into one public knob — the /// projected param space carries the gang's name once, not two node-qualified /// entries. #[test] fn builder_gang_projects_the_param_space() { let mut g = GraphBuilder::new("g"); let a = g.add(Sma::builder().named("a")); let b = g.add(Sma::builder().named("b")); g.gang("length", [a.param("length"), b.param("length")]); let built = g.build().expect("gang resolves"); let names: Vec = built.param_space().into_iter().map(|p| p.name).collect(); assert_eq!(names, ["length"]); } #[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(Bias::builder()); let broker = ok.add(SimBroker::builder(0.0001)); let src = ok.source_role("p", ScalarKind::F64); ok.feed(src, [expo.input("signal"), broker.input("price")]); ok.connect(expo.output("bias"), broker.input("exposure")); ok.expose(broker.output("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(Bias::builder()); let broker2 = typo.add(SimBroker::builder(0.0001)); typo.connect(expo2.output("bias"), broker2.input("pirce")); assert_eq!( typo.build().err(), Some(BuildError::UnknownInPort { node: 1, name: "pirce".into() }) ); } }