Files
Aura/crates/aura-engine/src/builder.rs
T
Brummel bc88e18247 feat(engine,cli): GraphBuilder gang cadence + render carrier (#61 tasks 5-6)
Builder: NodeHandle::param -> ParamRef, GraphBuilder::gang, build() resolves
the gang table (collect-then-reject, BuildError::{UnknownParam,AmbiguousParam,
BadGang}) and terminates in Composite::with_gangs — the same predicate the
op-script and serde boundaries use. C24 lockstep extended: an op-script gang
replay serializes byte-identically to the GraphBuilder twin (FlatGraph edges +
sources + canonical JSON).

Render: model_to_json emits a per-scope gangs fragment (name, kind, [node,pos,
"param"] members) for ganged scopes only — the gang-free model stays
byte-identical (model_golden untouched). graph-viewer.js annotates a ganged
open param with its public knob name; guarded by a viewer_gang_param.{mjs,rs}
pair RED-first per the established viewer_bound_param convention (a welcome
extra beyond the plan's file list).

Ratified deviation: the model's kind string uses the serde form ("I64") — the
plan's implementation snippet (kind_str -> "i64") contradicted its own test;
resolved toward the test + the blueprint_serde form. Two quality nits held as
principled plan-holds (inline resolution block; the unreachable unwrap_or
placeholder check_gangs refuses first).

Verified: aura-engine lib 264/0 (model_golden, lockstep, builder, carrier all
green), viewer_gang_param 1/0, workspace clippy -D warnings clean. Slice
resumed across a transient API failure via workflow resume (cached prefix).

refs #61
2026-07-10 13:03:10 +02:00

596 lines
24 KiB
Rust

//! `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<BlueprintNode>,
schemas: Vec<NodeSchema>,
edges: Vec<(OutPort, InPort)>,
roles: Vec<(String, Option<ScalarKind>, Vec<InPort>)>,
out: Vec<(String, OutPort)>,
gangs: Vec<(String, Vec<ParamRef>)>,
}
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(),
gangs: 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));
}
/// Fuse two or more sibling params into one public knob (resolved at build).
pub fn gang(&mut self, name: &str, members: impl IntoIterator<Item = ParamRef>) {
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<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() });
}
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<Composite> 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<usize> = 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<usize, BuildError> {
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<usize, PortResolveError> {
let m: Vec<usize> = 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<usize, PortResolveError> {
let m: Vec<usize> = 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(&params).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(&params)
.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<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.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<String> = 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() })
);
}
}