diff --git a/crates/aura-core/src/node.rs b/crates/aura-core/src/node.rs index 4b7f089..a83df7e 100644 --- a/crates/aura-core/src/node.rs +++ b/crates/aura-core/src/node.rs @@ -324,7 +324,10 @@ impl PrimitiveBuilder { /// A fallible-bind fault — the `Result` twin of `bind`'s panics, for the /// data-level construction surface (`GraphSession`, #157). `bind` keeps its /// panic contract (and its pinned messages); `try_bind` reports the same three -/// conditions as values. +/// conditions as values. `AlreadyGanged` is a fourth condition `try_bind` +/// itself never raises — only `Composite::bind_path`'s gang guard (#317 +/// follow-up) does, refusing a ganged member's raw path at the use seam +/// before it silently de-fuses the gang. #[derive(Clone, Debug, PartialEq, Eq)] pub enum BindOpError { /// No still-open param has this name. @@ -333,6 +336,12 @@ pub enum BindOpError { AmbiguousParam(String), /// The value's kind does not equal the param's declared kind. KindMismatch { param: String, expected: ScalarKind, got: ScalarKind }, + /// `param` (the full qualified path) is a member of the gang named + /// `gang` — binding it directly would freeze that one member while the + /// gang's public knob keeps driving its siblings. Bind the gang's own + /// public knob instead (accepted residue: unbindable at the use seam, + /// #317). + AlreadyGanged { param: String, gang: String }, } /// A node's declared interface: its inputs (in order) and its output record — an diff --git a/crates/aura-engine/src/blueprint.rs b/crates/aura-engine/src/blueprint.rs index f554b21..c6a5b72 100644 --- a/crates/aura-engine/src/blueprint.rs +++ b/crates/aura-engine/src/blueprint.rs @@ -15,7 +15,8 @@ //! C23) and no external dependency (C16). use aura_core::{ - Cell, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, Scalar, ScalarKind, + BindOpError, Cell, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, Scalar, + ScalarKind, }; use crate::harness::{BootstrapError, Edge, FlatGraph, FlatTap, Harness, SourceSpec, Target}; @@ -240,6 +241,37 @@ impl Composite { pub fn doc(&self) -> Option<&str> { self.doc.as_deref() } + /// Rename this composite's render symbol in place (#317, `Op::Use`): the + /// interior — nodes, edges, roles, output — is untouched; only `name()` + /// changes, so a fetched, registered subgraph renders (and path-prefixes + /// its `param_space()`, via `collect_params`'s composite arm) under the + /// caller-chosen instance identifier instead of its own authored name. + pub(crate) fn renamed(mut self, name: impl Into) -> Composite { + self.name = name.into(); + self + } + /// Apply one path-qualified bind after `Op::Use`'s splice (#317): the + /// same underlying mechanic every bind goes through + /// (`PrimitiveBuilder::try_bind`), reached by walking `path` down through + /// nested composite frames by node/composite-name prefix — the segmentation + /// `reopen_in` (#246) also walks, but binding an open param instead of + /// unbinding a bound one. A path matching no node at any level is + /// `BindOpError::UnknownParam(path)` (the WHOLE qualified path — no open + /// param lives there); a path that reaches a primitive but whose leaf + /// param name itself is unknown/ambiguous/ill-kinded rewrites `try_bind`'s + /// own fault to carry the full qualified path in place of the bare leaf + /// name, so every fault out of this walk names the same thing: the path + /// the caller wrote. A path landing on a GANGED member's own param is + /// `BindOpError::AlreadyGanged` (review finding, #317 follow-up) — binding + /// it directly would silently de-fuse the gang (the member frozen while + /// the gang's public knob keeps driving its siblings); the gang's own + /// public knob stays unbindable at the use seam (accepted residue). On + /// `Err` `self` is dropped (consumed) — the same discard-on-ambiguity + /// posture `reopen` uses. + pub(crate) fn bind_path(mut self, path: &str, value: Scalar) -> Result { + bind_path_in(&mut self.nodes, &self.gangs, path, path, value)?; + Ok(self) + } /// Install declared measurement taps (the output-side twin of `input_roles`). /// Fluent, mirroring `with_doc`. Empty by default. pub fn with_taps(mut self, taps: Vec) -> Self { @@ -1097,6 +1129,92 @@ fn reopen_in(items: &mut [BlueprintNode], path: &str) -> usize { hits } +/// Recursive mutable walk for `Composite::bind_path` (#317): mirrors +/// `reopen_in`'s node/composite-name prefix segmentation, but BINDS an open +/// param (`PrimitiveBuilder::try_bind`) instead of unbinding a bound one. +/// `full_path` is the whole original path (named in every fault this +/// produces); `rest` is the remaining suffix at this recursion depth. `gangs` +/// is the CURRENT frame's gang table (mirrors `collect_params`'s `gangs` +/// parameter) — before a matched primitive's leaf param reaches `try_bind`, +/// its (node, original-pos) is checked against every gang member; a hit +/// refuses with `BindOpError::AlreadyGanged` rather than silently freezing +/// the member out from under its gang's public knob (review finding, +/// #317 follow-up). Uses `Vec::remove`/`insert` (not `&mut` in place) because +/// `try_bind` consumes its receiver — the same reason `lower_items` moves +/// `BlueprintNode`s by value rather than mutating through a reference. +fn bind_path_in( + items: &mut Vec, + gangs: &[Gang], + full_path: &str, + rest: &str, + value: Scalar, +) -> Result<(), BindOpError> { + // The prefix this frame has already consumed off `full_path` (composite + // names and up) — used to qualify a gang's public name the same way + // `collect_params` does, so the refusal below names the SAME address + // `param_space()` would show for that gang. + let prefix = &full_path[..full_path.len() - rest.len()]; + for i in 0..items.len() { + let child_rest = match &items[i] { + BlueprintNode::Primitive(b) => rest.strip_prefix(&format!("{}.", b.node_name())), + BlueprintNode::Composite(c) => rest.strip_prefix(&format!("{}.", c.name())), + }; + let Some(child_rest) = child_rest else { continue }; + let child_rest = child_rest.to_string(); + // Gang guard (#317 follow-up review finding): a raw path landing on a + // GANGED member's own param must not silently freeze it while the + // gang's public knob keeps driving its siblings — refuse by + // identifier before ever reaching `try_bind`. The gang's own public + // knob staying unbindable at the use seam is accepted residue. + if let BlueprintNode::Primitive(b) = &items[i] + && let Some(pos) = b.params().iter().position(|p| p.name == child_rest).map(|idx| b.original_pos(idx)) + && let Some(g) = gangs.iter().find(|g| g.members.iter().any(|m| m.node == i && m.pos == pos)) + { + return Err(BindOpError::AlreadyGanged { + param: full_path.to_string(), + gang: format!("{prefix}{}", g.name), + }); + } + let item = items.remove(i); + return match item { + BlueprintNode::Primitive(b) => match b.try_bind(&child_rest, value) { + Ok(b2) => { + items.insert(i, BlueprintNode::Primitive(b2)); + Ok(()) + } + Err(e) => Err(qualify_bind_error(e, full_path)), + }, + BlueprintNode::Composite(mut c) => { + let r = bind_path_in(&mut c.nodes, &c.gangs, full_path, &child_rest, value); + items.insert(i, BlueprintNode::Composite(c)); + r + } + }; + } + // no node at any level matched `rest`'s leading segment: no open param + // lives at this path. + Err(BindOpError::UnknownParam(full_path.to_string())) +} + +/// Rewrite a leaf `try_bind` fault to carry the FULL qualified path (#317) +/// in place of the bare leaf param name `try_bind` only ever sees (it has no +/// visibility past the one primitive it was called on). +fn qualify_bind_error(e: BindOpError, full_path: &str) -> BindOpError { + match e { + BindOpError::UnknownParam(_) => BindOpError::UnknownParam(full_path.to_string()), + BindOpError::AmbiguousParam(_) => BindOpError::AmbiguousParam(full_path.to_string()), + BindOpError::KindMismatch { expected, got, .. } => { + BindOpError::KindMismatch { param: full_path.to_string(), expected, got } + } + // `try_bind` never raises this one (it has no gang awareness) — the + // gang guard above constructs it directly, already fully qualified. + // Kept here only so this match stays total over `BindOpError`. + BindOpError::AlreadyGanged { gang, .. } => { + BindOpError::AlreadyGanged { param: full_path.to_string(), gang } + } + } +} + /// Read-only twin of `collect_params` over the BOUND surface (#246): same /// recursion shape and prefix rules, but enumerating `bound_params()` (with /// values) instead of the open `params()`. Gangs are irrelevant here — a gang diff --git a/crates/aura-engine/src/construction.rs b/crates/aura-engine/src/construction.rs index a6606ab..bc731ef 100644 --- a/crates/aura-engine/src/construction.rs +++ b/crates/aura-engine/src/construction.rs @@ -2,9 +2,12 @@ //! (#157). A `GraphSession` applies `Op`s one at a time, running the //! eagerly-decidable checks (name resolution, edge kind-match, the >1-cover //! double-wire arm, param bind) at the offending op; the only-at-end-decidable -//! checks (0-cover totality, param-namespace injectivity, root-role boundness) -//! run holistically in `finish`. Both cadences call the SAME §A predicates — no -//! second validator (C24). The node vocabulary is an injected closed resolver +//! checks (0-cover totality, param-namespace injectivity) run holistically in +//! `finish`. Both cadences call the SAME §A predicates — no second validator +//! (C24). Root-role boundness is a *runnability* gate, not a construction gate: +//! `finish()` does not run it (#317, open patterns), so an op-script may +//! finish with an open root role; only `compile`/bootstrap enforce it. The +//! node vocabulary is an injected closed resolver //! (`Fn(&str)->Option`), so the engine stays domain-free //! (invariant 9). @@ -13,8 +16,8 @@ use std::collections::{HashMap, HashSet}; use aura_core::{BindOpError, NodeSchema, PrimitiveBuilder, Scalar, ScalarKind}; use crate::blueprint::{ - check_param_namespace_injective, check_root_roles_bound, edge_kind_check, validate_wiring, - BlueprintNode, Composite, Gang, GangMember, OutField, Role, Tap, TapWire, + check_param_namespace_injective, edge_kind_check, validate_wiring, BlueprintNode, Composite, + Gang, GangMember, OutField, Role, Tap, TapWire, }; use crate::builder::{resolve_input_slot, resolve_output_field, PortResolveError}; use crate::harness::{Edge, Target}; @@ -46,6 +49,15 @@ pub enum Op { /// twin of the builder's `.doc(...)`. At most one per script; a second /// is a fault (a declarative script carries no last-wins ambiguity). Doc { text: String }, + /// Splice a registered blueprint into the building graph as a nested + /// composite under an instance identifier (#317). `ref_id` is the full + /// content id the injected `subgraph` resolver is keyed by — the engine + /// never sees a label, a content-id prefix, or the registry (CLI-side + /// resolution happens at DTO conversion, before replay). `name` is the + /// instance identifier (`None` -> the fetched composite's own `name()`); + /// `bind` is applied path-qualified (e.g. `"sma.length"`), after the + /// splice. + Use { ref_id: String, name: Option, bind: Vec<(String, Scalar)> }, } /// A per-op construction fault, by-identifier so the cause names the op. @@ -85,8 +97,6 @@ pub enum OpError { /// Holistic role-kind fault, by-identifier: a root input role fans into slots /// of differing scalar kinds. RoleKindMismatch { role: String }, - /// Holistic root fault, by-identifier: a root input role has no bound source. - UnboundRootRole { role: String }, /// Gang members must share one scalar kind. GangKindMismatch { member: String, expected: ScalarKind, got: ScalarKind }, /// The param is already claimed by an earlier gang. @@ -98,6 +108,11 @@ pub enum OpError { Incomplete(CompileError), /// A second `doc` op — the meaning line is declared at most once. DuplicateDoc, + /// `Op::Use`'s injected `subgraph` resolver found no composite for + /// `ref_id` (#317) — by-identifier. Unreachable from the CLI (which + /// resolves and fetches before replay, refusing there on a miss), but + /// total: a bare `replay`/`GraphSession` caller can still hit it. + UnknownSubgraph { ref_id: String }, } /// A per-op-fallible blueprint accumulator. Holds the same interior data a @@ -106,6 +121,13 @@ pub enum OpError { pub struct GraphSession<'v> { name: String, vocab: &'v dyn Fn(&str) -> Option, + /// Second injected resolver (#317), mirroring `vocab`'s closed-lookup + /// posture: `Op::Use` fetches a registered blueprint by full content id. + /// The engine stays store-free — CLI-side resolution (label/prefix, + /// C29 doc gate, the store fetch itself) happens at DTO conversion, + /// before replay; this closure is a pure lookup into an already-fetched + /// id->`Composite` cache. Build-free introspection paths pass `&|_| None`. + subgraph: &'v dyn Fn(&str) -> Option, nodes: Vec, schemas: Vec, ids: Vec, @@ -124,11 +146,17 @@ pub struct GraphSession<'v> { impl<'v> GraphSession<'v> { /// Start a session for a composite named `name`, resolving node types through - /// the injected closed vocabulary `vocab`. - pub fn new(name: impl Into, vocab: &'v dyn Fn(&str) -> Option) -> Self { + /// the injected closed vocabulary `vocab` and registered-blueprint splices + /// (`Op::Use`, #317) through the injected `subgraph` lookup. + pub fn new( + name: impl Into, + vocab: &'v dyn Fn(&str) -> Option, + subgraph: &'v dyn Fn(&str) -> Option, + ) -> Self { Self { name: name.into(), vocab, + subgraph, nodes: Vec::new(), schemas: Vec::new(), ids: Vec::new(), @@ -165,9 +193,47 @@ impl<'v> GraphSession<'v> { self.doc = Some(text); Ok(()) } + Op::Use { ref_id, name, bind } => self.use_subgraph(ref_id, name, bind), } } + /// `Op::Use` (#317): fetch a registered blueprint through the injected + /// `subgraph` resolver, rename it to the instance identifier, apply its + /// path-qualified `bind` entries, and push it as an ordinary + /// `BlueprintNode::Composite` — sharing the node-identifier namespace + /// with every `Add`ed primitive (so an instance and a primitive collide + /// the same way two primitives do). A miss on `ref_id` is a by-identifier + /// `UnknownSubgraph`; a `bind` path with no matching open param anywhere + /// in the instance reuses the existing `BadParam` bind-fault shape, + /// naming the instance (`node`) and the qualified within-instance path + /// (`err`'s carried name) — the pair spells out the full qualified path. + fn use_subgraph( + &mut self, + ref_id: String, + name: Option, + bind: Vec<(String, Scalar)>, + ) -> Result<(), OpError> { + let composite = (self.subgraph)(&ref_id).ok_or_else(|| OpError::UnknownSubgraph { ref_id: ref_id.clone() })?; + let id = name.unwrap_or_else(|| composite.name().to_string()); + if self.names.contains_key(&id) { + return Err(OpError::DuplicateIdentifier(id)); + } + let mut composite = composite.renamed(&id); + for (path, value) in bind { + composite = composite + .bind_path(&path, value) + .map_err(|err| OpError::BadParam { node: id.clone(), err })?; + } + let node = BlueprintNode::Composite(composite); + let schema = node.signature(); + let idx = self.nodes.len(); + self.names.insert(id.clone(), idx); + self.ids.push(id); + self.nodes.push(node); + self.schemas.push(schema); + Ok(()) + } + fn add_role(&mut self, role: String, kind: Option) -> Result<(), OpError> { if self.role_names.contains_key(&role) { return Err(OpError::DuplicateRole(role)); @@ -368,7 +434,16 @@ impl<'v> GraphSession<'v> { let (node_name, param_name) = Self::split_port(port)?; let ti = self.node_index(&node_name)?; let BlueprintNode::Primitive(b) = &self.nodes[ti] else { - unreachable!("sessions only ever add primitives") + // #317: `Op::Use` makes a session node legitimately a + // Composite, not just a Primitive — reachable now, not a + // defect. A gang fuses a PRIMITIVE's raw (name, pos) param + // slot; an instance has no such flat slot (its params are + // nested/path-qualified), so this is the ordinary + // no-such-open-param refusal, not a panic. + return Err(OpError::BadParam { + node: node_name, + err: BindOpError::UnknownParam(param_name), + }); }; let hits: Vec = b .params() @@ -426,13 +501,17 @@ impl<'v> GraphSession<'v> { open } - /// Assemble the `Composite` and run the holistic gates (totality, - /// param-namespace injectivity, root-role boundness) — the SAME engine - /// predicates the eager path shares, now at end-of-document cadence. The - /// index-carrying `CompileError`s the gates return are translated back to the - /// surface's by-identifier `OpError` variants (slot/role names via the session's - /// retained `ids`/`schemas` and the composite's roles); a fault with no - /// by-identifier mapping falls through to `OpError::Incomplete`. + /// Assemble the `Composite` and run the holistic construction gates (totality, + /// param-namespace injectivity) — the SAME engine predicates the eager path + /// shares, now at end-of-document cadence. Root-role boundness is + /// deliberately NOT checked here (#317, open patterns): a `finish()`ed + /// composite may still carry an open root role, declaring a reusable + /// pattern's formal parameters; only `compile`/bootstrap (the runnability + /// gate) refuse an unbound root role. The index-carrying `CompileError`s the + /// gates return are translated back to the surface's by-identifier `OpError` + /// variants (slot/role names via the session's retained `ids`/`schemas` and + /// the composite's roles); a fault with no by-identifier mapping falls + /// through to `OpError::Incomplete`. pub fn finish(self) -> Result { let roles: Vec = self .roles @@ -458,25 +537,22 @@ impl<'v> GraphSession<'v> { }, other => OpError::Incomplete(other), })?; - check_root_roles_bound(c.input_roles()).map_err(|e| match e { - CompileError::UnboundRootRole { role } => OpError::UnboundRootRole { - role: c.input_roles()[role].name.clone(), - }, - other => OpError::Incomplete(other), - })?; Ok(c) } } /// Replay a document of ops from scratch, stopping at the first failing op and /// naming it by `(op_index, OpError)`; a holistic finalize fault is attributed to -/// index `ops.len()` (the implicit finalize step). +/// index `ops.len()` (the implicit finalize step). `subgraph` is the injected +/// registered-blueprint lookup `Op::Use` (#317) resolves splices through — pass +/// `&|_| None` when no store is in play. pub fn replay( name: impl Into, ops: Vec, vocab: &dyn Fn(&str) -> Option, + subgraph: &dyn Fn(&str) -> Option, ) -> Result { - let mut s = GraphSession::new(name, vocab); + let mut s = GraphSession::new(name, vocab, subgraph); let n = ops.len(); for (i, op) in ops.into_iter().enumerate() { s.apply(op).map_err(|e| (i, e))?; @@ -487,11 +563,12 @@ pub fn replay( #[cfg(test)] mod tests { use super::{GraphSession, Op, OpError}; + use crate::blueprint::{BlueprintNode, Composite, Role}; use aura_core::{BindOpError, Scalar, ScalarKind}; use aura_vocabulary::std_vocabulary; fn session(name: &str) -> GraphSession<'static> { - GraphSession::new(name, &std_vocabulary) + GraphSession::new(name, &std_vocabulary, &|_: &str| None) } #[test] @@ -752,7 +829,7 @@ mod tests { Op::Tap { from: "fast.value".into(), as_name: "fast_ma".into() }, Op::Expose { from: "sub.value".into(), as_name: "bias".into() }, ]; - let flat = super::replay("g", ops, &aura_vocabulary::std_vocabulary) + let flat = super::replay("g", ops, &aura_vocabulary::std_vocabulary, &|_: &str| None) .expect("replays") .compile_with_params(&[Scalar::i64(2), Scalar::i64(4)]) .expect("compiles"); @@ -774,7 +851,7 @@ mod tests { Op::Connect { from: "b.value".into(), to: "sub.rhs".into() }, Op::Expose { from: "sub.value".into(), as_name: "bias".into() }, ]; - let c = super::replay("sig", ops, &std_vocabulary).expect("replays"); + let c = super::replay("sig", ops, &std_vocabulary, &|_: &str| None).expect("replays"); let names: Vec = c.param_space().into_iter().map(|p| p.name).collect(); assert_eq!(names, ["length"]); } @@ -820,7 +897,7 @@ mod tests { Op::Feed { role: "trigger".into(), into: vec!["session.trigger".into()] }, Op::Expose { from: "session.bars_since_open".into(), as_name: "bars".into() }, ]; - super::replay("session_blueprint", ops, &std_vocabulary) + super::replay("session_blueprint", ops, &std_vocabulary, &|_: &str| None) .expect("the preset resolves, wires and builds through an op-script"); } @@ -922,18 +999,26 @@ mod tests { "finalize names the open slot by-identifier, got {err:?}"); } - /// A reserved `Input` root role that is fed but never source-bound finishes with - /// the by-identifier `UnboundRootRole` (not a raw role index). + /// #317 (open patterns): `finish()` no longer runs the root-role gate — a + /// reserved `Input` root role that is fed but never source-bound now + /// finishes cleanly (the composite declares an open formal parameter, the + /// pattern's reusable interior). The runnability gate moves entirely to + /// `compile`, which refuses the SAME composite with the unchanged + /// `CompileError::UnboundRootRole` — the split this cycle ratified: finish + /// = construction-complete, compile = runnable. #[test] - fn finish_reports_unbound_input_root_role_by_identifier() { + fn finish_accepts_an_open_input_role_and_compile_refuses_it() { let mut s = session("g"); s.apply(Op::Input { role: "ext".into() }).unwrap(); s.apply(Op::Add { type_id: "Sub".into(), as_name: Some("sub".into()), bind: vec![] }).unwrap(); s.apply(Op::Feed { role: "ext".into(), into: vec!["sub.lhs".into(), "sub.rhs".into()] }).unwrap(); s.apply(Op::Expose { from: "sub.value".into(), as_name: "out".into() }).unwrap(); - let err = s.finish().err().unwrap(); - assert!(matches!(&err, OpError::UnboundRootRole { role } if role == "ext"), - "an unbound Input root role finishes by-identifier, got {err:?}"); + let c = s.finish().expect("finish() no longer gates root-role boundness"); + assert_eq!( + c.compile().err(), + Some(crate::CompileError::UnboundRootRole { role: 0 }), + "compile() is the runnability gate that refuses the open root role" + ); } /// A root role fed into two slots of differing scalar kinds (an `f64` `Sub.lhs` @@ -991,7 +1076,7 @@ mod tests { let finalize = ops.len(); // `.err()` not `.unwrap_err()`: the Ok arm `Composite` holds build closures // and is not `Debug`. - let (idx, _err) = super::replay("g", ops, &aura_vocabulary::std_vocabulary) + let (idx, _err) = super::replay("g", ops, &aura_vocabulary::std_vocabulary, &|_: &str| None) .err() .expect("a cyclic op-list must be rejected (DAG invariant 5 / C9)"); // An eager realisation attributes the fault to the closing connect op; a @@ -1018,7 +1103,7 @@ mod tests { Op::Expose { from: "s.value".into(), as_name: "out".into() }, ]; assert!( - super::replay("g", ops, &aura_vocabulary::std_vocabulary).is_err(), + super::replay("g", ops, &aura_vocabulary::std_vocabulary, &|_: &str| None).is_err(), "a self-loop op-list must be rejected (DAG invariant 5 / C9)" ); } @@ -1032,7 +1117,7 @@ mod tests { ]; // `.err().unwrap()` rather than `.unwrap_err()`: the Ok arm `Composite` is // not `Debug` (it holds build closures), which `unwrap_err` would require. - let err = super::replay("g", ops, &aura_vocabulary::std_vocabulary).err().unwrap(); + let err = super::replay("g", ops, &aura_vocabulary::std_vocabulary, &|_: &str| None).err().unwrap(); assert_eq!(err, (1, OpError::UnknownNodeType("Nope".into()))); } @@ -1066,7 +1151,7 @@ mod tests { Op::Connect { from: "sub.value".into(), to: "bias.signal".into() }, Op::Expose { from: "bias.bias".into(), as_name: "bias".into() }, ]; - let replay_flat = super::replay("root", ops, &aura_vocabulary::std_vocabulary) + let replay_flat = super::replay("root", ops, &aura_vocabulary::std_vocabulary, &|_: &str| None) .expect("replay resolves") .compile_with_params(¶ms) .expect("replay compiles"); @@ -1113,7 +1198,7 @@ mod tests { Op::Connect { from: "b.value".into(), to: "sub.rhs".into() }, Op::Expose { from: "sub.value".into(), as_name: "bias".into() }, ]; - let replayed = super::replay("g", ops, &std_vocabulary).expect("replays"); + let replayed = super::replay("g", ops, &std_vocabulary, &|_: &str| None).expect("replays"); // (b) the GraphBuilder twin — same adds/feeds/connects/expose + g.gang(...). let mut g = GraphBuilder::new("g"); @@ -1165,7 +1250,7 @@ mod tests { Op::Tap { from: "sub.value".into(), as_name: "spread".into() }, Op::Expose { from: "sub.value".into(), as_name: "bias".into() }, ]; - let replayed = super::replay("g", ops, &std_vocabulary).expect("replays"); + let replayed = super::replay("g", ops, &std_vocabulary, &|_: &str| None).expect("replays"); // (b) the GraphBuilder twin — same adds/feeds/connects + g.tap(...) / g.expose(...). let mut g = GraphBuilder::new("g"); @@ -1191,4 +1276,353 @@ mod tests { assert_eq!(replay_flat.taps, built_flat.taps); assert_eq!(replay_flat.edges, built_flat.edges); } + + // ---- Op::Use (#317) ----------------------------------------------- + + /// A small two-node fixture composite for the `Op::Use` tests (#317): an + /// open `price` input role feeds an `Sma`, whose `value` feeds a `Bias`; + /// `Bias.bias` re-exports as `out`. Built fresh via `GraphBuilder` on every + /// call — mirroring how a CLI-side registry lookup hands back a freshly + /// loaded `Composite` each fetch — never shared or cloned (`Composite` + /// holds build closures and is not `Clone`). + fn use_fixture() -> Composite { + use crate::GraphBuilder; + use aura_std::Sma; + use aura_strategy::Bias; + let mut g = GraphBuilder::new("fixture"); + let sma = g.add(Sma::builder().named("sma")); + let bias = g.add(Bias::builder().named("bias")); + let price = g.input_role("price"); + g.feed(price, [sma.input("series")]); + g.connect(sma.output("value"), bias.input("signal")); + g.expose(bias.output("bias"), "out"); + g.build().expect("fixture resolves") + } + + /// Wrap a sink-free signal (one open input role, one exposed field) in a + /// runnable root that records the exposed field via a channel-backed + /// `Recorder`, feeds a fixed synthetic price stream through it, and + /// returns the recorded trace. Mirrors `blueprint_serde.rs`'s + /// `run_recording` test helper (same shape); duplicated here rather than + /// shared, since that one is private to its own file. + fn run_recording(signal: Composite) -> Vec<(aura_core::Timestamp, Vec)> { + use crate::harness::{Edge, Target}; + use aura_core::Firing; + use aura_std::Recorder; + use std::sync::mpsc; + let (tx, rx) = mpsc::channel(); + let root = Composite::new( + "h", + vec![ + BlueprintNode::Composite(signal), + Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx).into(), + ], + vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }], + vec![Role { + name: "src".into(), + targets: vec![Target { node: 0, slot: 0 }], + source: Some(ScalarKind::F64), + }], + vec![], + ); + // only `bias.scale` is open (every fixture in this module's tests binds + // `sma.length` at the use seam), so one F64 param point. + let mut h = root.bootstrap_with_params(vec![Scalar::f64(0.5)]).expect("bootstraps"); + h.run(vec![Box::new(crate::VecSource::new(crate::test_fixtures::synthetic_prices()))]); + rx.try_iter().collect() + } + + /// C1 twin (#317 headline): an op-script using `Op::Use` splices a + /// registered-style subgraph into the building graph byte-identically to + /// the same splice authored directly on `GraphBuilder` + /// (`g.add(BlueprintNode::Composite(x))` plus the same rename/bind) — the + /// correctness oracle for the whole splice path. Mirrors + /// `replay_built_signal_compiles_identically_to_graphbuilder` (topology + /// identity) and `blueprint_serde::serialized_blueprint_runs_bit_identical_ + /// to_rust_built` (bit-identical run). + #[test] + fn use_op_splices_byte_identical_to_the_rust_built_twin() { + use crate::blueprint_serde::blueprint_to_json; + use crate::GraphBuilder; + + let subgraph = |ref_id: &str| (ref_id == "fixture-id").then(use_fixture); + + // (a) op-script: a bound `price` source feeds the spliced instance's + // own "price" port (`Op::Source`, not `Op::Input` — this test + // bootstraps and runs the result, and only compile/bootstrap gate + // root-role boundness now, #317; finish() itself no longer cares); + // the instance's "out" field re-exports as "bias". + let ops = vec![ + Op::Source { role: "price".into(), kind: ScalarKind::F64 }, + Op::Use { + ref_id: "fixture-id".into(), + name: Some("gate".into()), + bind: vec![("sma.length".into(), Scalar::i64(5))], + }, + Op::Feed { role: "price".into(), into: vec!["gate.price".into()] }, + Op::Expose { from: "gate.out".into(), as_name: "bias".into() }, + ]; + let replayed = super::replay("root", ops, &std_vocabulary, &subgraph).expect("replays"); + + // (b) the GraphBuilder twin — the identical splice: the SAME fixture, + // renamed to "gate", the SAME bind, added the way a Rust author would. + let mut g = GraphBuilder::new("root"); + let price = g.source_role("price", ScalarKind::F64); + let spliced = use_fixture() + .renamed("gate") + .bind_path("sma.length", Scalar::i64(5)) + .expect("binds"); + let gate = g.add(spliced); + g.feed(price, [gate.input("price")]); + g.expose(gate.output("out"), "bias"); + let built = g.build().expect("root resolves"); + + assert_eq!( + blueprint_to_json(&replayed).expect("replay serializes"), + blueprint_to_json(&built).expect("builder serializes"), + ); + + // bootstrap + run bit-identical (only bias.scale is open: sma.length + // was bound at the use seam). + let trace_replayed = run_recording(replayed); + let trace_built = run_recording(built); + assert_eq!( + trace_replayed, trace_built, + "the op-script splice diverged from the Rust-built twin at run time" + ); + assert!(!trace_replayed.is_empty(), "trace must be populated (non-degenerate)"); + } + + /// A miss on the injected `subgraph` resolver is a by-identifier fault + /// naming the `ref_id` (#317) — reachable directly against `GraphSession`/ + /// `replay` even though the CLI (Task 3) always pre-resolves. + #[test] + fn use_op_unknown_subgraph_is_a_by_identifier_fault() { + let mut s = GraphSession::new("g", &std_vocabulary, &|_: &str| None); + assert_eq!( + s.apply(Op::Use { ref_id: "ghost".into(), name: None, bind: vec![] }), + Err(OpError::UnknownSubgraph { ref_id: "ghost".into() }) + ); + } + + /// An instance identifier colliding with an already-added node is the + /// existing `DuplicateIdentifier` refusal — instance names share the + /// interior node-identifier namespace with primitives (#317). + #[test] + fn use_op_duplicate_instance_identifier_rejected() { + let subgraph = |_: &str| Some(use_fixture()); + let mut s = GraphSession::new("g", &std_vocabulary, &subgraph); + s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("gate".into()), bind: vec![] }).unwrap(); + assert_eq!( + s.apply(Op::Use { ref_id: "fixture".into(), name: Some("gate".into()), bind: vec![] }), + Err(OpError::DuplicateIdentifier("gate".into())) + ); + } + + /// A post-splice `bind` path with no matching open param anywhere in the + /// instance faults through the existing bind-fault shape (`BadParam`), + /// naming the instance and the qualified within-instance path (#317). + #[test] + fn use_op_bind_of_a_closed_path_faults_with_the_qualified_path() { + let subgraph = |_: &str| Some(use_fixture()); + let mut s = GraphSession::new("g", &std_vocabulary, &subgraph); + assert_eq!( + s.apply(Op::Use { + ref_id: "fixture".into(), + name: Some("gate".into()), + bind: vec![("ghost.length".into(), Scalar::i64(2))], + }), + Err(OpError::BadParam { + node: "gate".into(), + err: BindOpError::UnknownParam("ghost.length".into()), + }) + ); + } + + /// A gang-bearing fixture for the ganged-member-bind refusal test below + /// (review finding, #317 follow-up): two `SMA`s ganged on `length` under + /// one public `channel_length` knob, feeding a `Sub` whose difference + /// re-exports as `out`. Mirrors `gang_op_projects_the_param_space`'s + /// op-script shape; built fresh on every call like `use_fixture` (never + /// shared/cloned — `Composite` holds build closures and is not `Clone`). + fn use_gang_fixture() -> Composite { + let ops = vec![ + Op::Input { role: "price".into() }, + Op::Add { type_id: "SMA".into(), as_name: Some("channel_hi".into()), bind: vec![] }, + Op::Add { type_id: "SMA".into(), as_name: Some("channel_lo".into()), bind: vec![] }, + Op::Gang { + as_name: "channel_length".into(), + into: vec!["channel_hi.length".into(), "channel_lo.length".into()], + }, + Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] }, + Op::Feed { + role: "price".into(), + into: vec!["channel_hi.series".into(), "channel_lo.series".into()], + }, + Op::Connect { from: "channel_hi.value".into(), to: "sub.lhs".into() }, + Op::Connect { from: "channel_lo.value".into(), to: "sub.rhs".into() }, + Op::Expose { from: "sub.value".into(), as_name: "out".into() }, + ]; + super::replay("gang_fixture", ops, &std_vocabulary, &|_: &str| None).expect("gang fixture replays") + } + + /// Silent gang de-fuse guard (review finding, #317 follow-up): binding a + /// GANGED member's raw path through `Op::Use`'s post-splice `bind` must + /// not quietly freeze that one member while the gang's public knob keeps + /// driving its sibling — refused by identifier, naming the full + /// qualified path and the gang's own public name. The gang's own public + /// knob (`gate.channel_length`) staying unbindable at the use seam is + /// accepted residue, not covered here. + #[test] + fn use_op_bind_of_a_ganged_member_path_refuses_by_identifier() { + let subgraph = |_: &str| Some(use_gang_fixture()); + let mut s = GraphSession::new("g", &std_vocabulary, &subgraph); + assert_eq!( + s.apply(Op::Use { + ref_id: "fixture".into(), + name: Some("gate".into()), + bind: vec![("channel_hi.length".into(), Scalar::i64(20))], + }), + Err(OpError::BadParam { + node: "gate".into(), + err: BindOpError::AlreadyGanged { + param: "channel_hi.length".into(), + gang: "channel_length".into(), + }, + }) + ); + } + + /// DAG invariant (domain invariant 5 / C9), through an instance: a + /// `connect` closing a cycle through a spliced instance is rejected + /// eagerly, the instance treated as one opaque node (#317) — the same + /// `closes_cycle` gate `replay_rejects_dataflow_cycle` pins for primitives. + #[test] + fn use_op_connect_closing_a_cycle_through_an_instance_is_rejected_eagerly() { + let subgraph = |_: &str| Some(use_fixture()); + let mut s = GraphSession::new("g", &std_vocabulary, &subgraph); + s.apply(Op::Add { type_id: "Sub".into(), as_name: Some("s".into()), bind: vec![] }).unwrap(); + s.apply(Op::Use { ref_id: "fixture".into(), name: Some("gate".into()), bind: vec![] }).unwrap(); + s.apply(Op::Connect { from: "s.value".into(), to: "gate.price".into() }).unwrap(); + assert_eq!( + s.apply(Op::Connect { from: "gate.out".into(), to: "s.lhs".into() }), + Err(OpError::WouldCycle { from: "gate.out".into(), to: "s.lhs".into() }) + ); + } + + /// An unfed instance in-port finishes with the by-identifier + /// `UnconnectedPort`, naming the instance and the instance's own role name + /// (rendered `.` by the CLI's `format_op_error`) — the + /// instance's open input roles ARE its in-ports (#317). + #[test] + fn use_op_unfed_instance_role_reports_the_qualified_identifier() { + let subgraph = |_: &str| Some(use_fixture()); + let mut s = GraphSession::new("g", &std_vocabulary, &subgraph); + s.apply(Op::Use { ref_id: "fixture".into(), name: Some("gate".into()), bind: vec![] }).unwrap(); + s.apply(Op::Expose { from: "gate.out".into(), as_name: "bias".into() }).unwrap(); + // gate.price deliberately left unfed. + let err = s.finish().err().unwrap(); + assert!( + matches!(&err, OpError::UnconnectedPort { node, slot } if node == "gate" && slot == "price"), + "an unfed instance role finishes by-identifier as ., got {err:?}" + ); + } + + /// An instance's still-open interior params surface path-qualified under + /// the instance identifier (`..`, #317) — the + /// existing `collect_params` composite-arm prefix rule, now exercised + /// through `Op::Use`'s rename-on-splice (extends the + /// `param_space_is_flat_path_qualified_and_slot_disambiguated` family). + #[test] + fn use_op_instance_params_surface_path_qualified() { + let subgraph = |_: &str| Some(use_fixture()); + let mut s = GraphSession::new("g", &std_vocabulary, &subgraph); + // a bound root role (mirroring the headline twin test's shape; + // `finish()` itself no longer cares either way, #317). + s.apply(Op::Source { role: "price".into(), kind: ScalarKind::F64 }).unwrap(); + s.apply(Op::Use { ref_id: "fixture".into(), name: Some("gate".into()), bind: vec![] }).unwrap(); + s.apply(Op::Feed { role: "price".into(), into: vec!["gate.price".into()] }).unwrap(); + s.apply(Op::Expose { from: "gate.out".into(), as_name: "bias".into() }).unwrap(); + let c = s.finish().expect("finishes"); + let names: Vec = c.param_space().into_iter().map(|p| p.name).collect(); + assert_eq!(names, ["gate.sma.length", "gate.bias.scale"]); + } + + /// Open patterns end-to-end (#317, spec §"Open patterns"): a pattern + /// declaring its formal parameter as an `input` role `finish()`es even + /// though nothing ever binds it (the root-role gate no longer runs in + /// `finish`), survives a real serialize/deserialize round trip through + /// the stored JSON form (the same bytes a registry fetch would hand + /// back), and a source-rooted consumer script splices the RELOADED + /// pattern by ref id through the injected `subgraph` lookup — the outer + /// script compiles and runs, proving the whole author's round trip + /// (build open pattern -> store -> reload -> `use` -> run), not just an + /// in-memory splice. + #[test] + fn open_input_pattern_finishes_registers_shaped_and_splices() { + use crate::blueprint_serde::{blueprint_from_json, blueprint_to_json}; + + // (a) the reusable pattern: an open `x` input role feeds an Sma, whose + // value re-exports as `out`. + let pattern_ops = vec![ + Op::Input { role: "x".into() }, + Op::Add { + type_id: "SMA".into(), + as_name: Some("sma".into()), + bind: vec![("length".into(), Scalar::i64(3))], + }, + Op::Feed { role: "x".into(), into: vec!["sma.series".into()] }, + Op::Expose { from: "sma.value".into(), as_name: "out".into() }, + ]; + let pattern = super::replay("pattern", pattern_ops, &std_vocabulary, &|_: &str| None) + .expect("an input-role pattern finishes without root-role boundness"); + + // (b) round-trip through the serialized store form. + let json = blueprint_to_json(&pattern).expect("an open pattern serializes"); + + // (c) a source-rooted outer script splices the RELOADED pattern by ref + // id (the injected `subgraph` lookup re-parses the stored JSON on + // every call, mirroring a fresh registry fetch — `Composite` is not + // `Clone`). + let subgraph = |ref_id: &str| { + (ref_id == "pattern-id") + .then(|| blueprint_from_json(&json, &std_vocabulary).expect("an open pattern deserializes")) + }; + let outer_ops = vec![ + Op::Source { role: "price".into(), kind: ScalarKind::F64 }, + Op::Use { ref_id: "pattern-id".into(), name: Some("p".into()), bind: vec![] }, + Op::Feed { role: "price".into(), into: vec!["p.x".into()] }, + Op::Expose { from: "p.out".into(), as_name: "bias".into() }, + ]; + let outer = super::replay("root", outer_ops, &std_vocabulary, &subgraph) + .expect("the outer script splices the reloaded open pattern"); + + // (d) compiles and runs — the same recording-harness shape + // `run_recording` uses, but with zero open params (this pattern binds + // `sma.length` at the pattern seam, unlike the shared fixture, so + // `run_recording`'s hardcoded one-F64-param point does not apply). + use crate::harness::{Edge, Target}; + use aura_core::Firing; + use aura_std::Recorder; + use std::sync::mpsc; + let (tx, rx) = mpsc::channel(); + let root = Composite::new( + "h", + vec![ + BlueprintNode::Composite(outer), + Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx).into(), + ], + vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }], + vec![Role { + name: "src".into(), + targets: vec![Target { node: 0, slot: 0 }], + source: Some(ScalarKind::F64), + }], + vec![], + ); + let mut h = root.bootstrap().expect("bootstraps with no open params"); + h.run(vec![Box::new(crate::VecSource::new(crate::test_fixtures::synthetic_prices()))]); + let trace: Vec<_> = rx.try_iter().collect(); + assert!(!trace.is_empty(), "the spliced open pattern must actually produce output"); + } } diff --git a/crates/aura-engine/tests/construction_e2e.rs b/crates/aura-engine/tests/construction_e2e.rs index 649c9f8..73d7208 100644 --- a/crates/aura-engine/tests/construction_e2e.rs +++ b/crates/aura-engine/tests/construction_e2e.rs @@ -58,7 +58,7 @@ fn signal_ops() -> Vec { #[test] fn replayed_construction_compiles_identically_to_graphbuilder() { // (a) op-script replay through the public construction surface. - let replay_flat = replay("root", signal_ops(), &std_vocabulary) + let replay_flat = replay("root", signal_ops(), &std_vocabulary, &|_: &str| None) .expect("op-script resolves") .compile_with_params(¶ms()) .expect("replay compiles"); @@ -103,7 +103,7 @@ fn replay_attributes_eager_fault_to_its_op_index() { ]; // `.err().unwrap()` (not `unwrap_err`): the Ok arm `Composite` holds build // closures and is not `Debug`, which `unwrap_err`'s bound would require. - let err = replay("g", ops, &std_vocabulary).err().unwrap(); + let err = replay("g", ops, &std_vocabulary, &|_: &str| None).err().unwrap(); assert_eq!(err, (2, OpError::UnknownNodeType("Nope".into()))); } @@ -128,7 +128,7 @@ fn replay_attributes_holistic_fault_to_finalize_step() { // sub.rhs deliberately left unwired -> only finalize can decide totality. ]; let finalize_index = ops.len(); // 6 — the implicit finalize step - let err = replay("g", ops, &std_vocabulary).err().unwrap(); + let err = replay("g", ops, &std_vocabulary, &|_: &str| None).err().unwrap(); assert!(matches!(&err, (i, OpError::UnconnectedPort { .. }) if *i == finalize_index), "the holistic fault is still attributed to the finalize step, got {err:?}"); }