# Construction op-script — Iteration 1 (engine core) — Implementation Plan > **Parent spec:** `docs/specs/0088-construction-op-script.md` > > **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run > this plan. Steps use `- [ ]` checkboxes for tracking. **Goal:** Build the surface-agnostic engine core of the introspectable, fail-fast construction service (#157, §A + §B): split the construction gates into shared per-op (eager) + holistic predicates, add a per-op-fallible `GraphSession`/`replay` surface that emits a `Composite`, and the build-free introspection it needs — all driven through the engine API, no CLI. **Architecture:** §A extracts the edge-kind predicate and the name-resolution helpers into `pub(crate)` shared functions (called by both the eager op and the unchanged holistic gates — no second validator) and adds a fallible `PrimitiveBuilder::try_bind`. §B adds a new `aura-engine::construction` module: `Op`/`OpError`, a `GraphSession` that applies ops fallibly with eager kind + double-wire checks, a `finish` that runs the unchanged holistic gates, a `replay` driver that stops at the first failing op, and `unwired` introspection. §C's engine-facing piece is the enumerable `std_vocabulary_types()` companion in aura-std. `compile_with_params` / `check_ports_connected` stay behaviourally unchanged. **Tech Stack:** `aura-engine` (`blueprint.rs`, `builder.rs`, new `construction.rs`, `lib.rs`), `aura-core` (`node.rs`), `aura-std` (`vocabulary.rs`, `lib.rs`). --- **Files this plan creates or modifies:** - Modify: `crates/aura-engine/src/blueprint.rs` — extract `pub(crate) fn edge_kind_check`; widen `validate_wiring` + `check_param_namespace_injective` to `pub(crate)`. - Modify: `crates/aura-engine/src/builder.rs` — extract `pub(crate)` name-resolution helpers `resolve_input_slot` / `resolve_output_field`; rewrite `GraphBuilder::resolve_slot` / `resolve_field` to delegate. - Modify: `crates/aura-core/src/node.rs` — add `pub fn try_bind` + `pub enum BindOpError` (keep `bind` verbatim). - Modify: `crates/aura-core/src/lib.rs` — re-export `BindOpError`. - Modify: `crates/aura-std/src/vocabulary.rs` — add `pub fn std_vocabulary_types()`. - Modify: `crates/aura-std/src/lib.rs:86` — re-export `std_vocabulary_types`. - Create: `crates/aura-engine/src/construction.rs` — `Op` / `OpError` / `GraphSession` / `replay` / `unwired` (§B). - Modify: `crates/aura-engine/src/lib.rs` — `mod construction;` + `pub use`. - Test: all tests live in the `#[cfg(test)] mod tests` of the file they cover. --- ### Task 1: §A — extract the shared `edge_kind_check` predicate **Files:** - Modify: `crates/aura-engine/src/blueprint.rs:628-680` (`validate_wiring`), `:566` (`check_param_namespace_injective`) - [ ] **Step 1: Write the failing test** Add to `blueprint.rs`'s `#[cfg(test)] mod tests` (find it with `grep -n "mod tests" crates/aura-engine/src/blueprint.rs`): ```rust #[test] fn edge_kind_check_accepts_match_and_rejects_mismatch() { use super::edge_kind_check; use aura_core::{FieldSpec, NodeSchema, PortSpec, ScalarKind}; // producer: one f64 output field; consumer: slot 0 is f64, slot 1 is bool. let from = NodeSchema { inputs: vec![], output: vec![FieldSpec { name: "v".into(), kind: ScalarKind::F64 }], params: vec![], }; let to = NodeSchema { inputs: vec![ PortSpec { kind: ScalarKind::F64, firing: aura_core::Firing::Any, name: "a".into() }, PortSpec { kind: ScalarKind::Bool, firing: aura_core::Firing::Any, name: "b".into() }, ], output: vec![], params: vec![], }; assert!(edge_kind_check(&from, 0, &to, 0).is_ok()); assert_eq!( edge_kind_check(&from, 0, &to, 1), Err(CompileError::Bootstrap(BootstrapError::KindMismatch { producer: ScalarKind::F64, consumer: ScalarKind::Bool, })) ); } ``` > Note: confirm `NodeSchema` field names with `grep -n "pub struct NodeSchema" > -A4 crates/aura-core/src/node.rs` and `PortSpec` with `grep -n "pub struct > PortSpec" -A5 crates/aura-core/src/node.rs`. The struct literal above must > match exactly (`PortSpec { kind, firing, name }`, `FieldSpec { name, kind }`, > `NodeSchema { inputs, output, params }`). If any field name differs, fix the > literal here before running. - [ ] **Step 2: Run test to verify it fails** Run: `cargo test -p aura-engine edge_kind_check_accepts_match_and_rejects_mismatch` Expected: FAIL — compile error `cannot find function `edge_kind_check` in this scope`. - [ ] **Step 3: Extract the predicate and rewire `validate_wiring`** In `blueprint.rs`, add this free function next to `validate_wiring` (its body is lifted verbatim from the current edge loop at `:638-649`): ```rust /// The per-edge kind predicate, shared by `validate_wiring` (holistic) and the /// eager `connect` op (`construction.rs`) — one check, two cadences (no second /// validator). Looks the producer field + consumer slot up by index and rejects /// a kind mismatch with the SAME variant bootstrap uses, so existing /// compiled-graph tests stay green. pub(crate) fn edge_kind_check( from: &NodeSchema, from_field: usize, to: &NodeSchema, slot: usize, ) -> Result<(), CompileError> { let f = from.output.get(from_field).ok_or(CompileError::BadInteriorIndex)?; let s = to.inputs.get(slot).ok_or(CompileError::BadInteriorIndex)?; if f.kind != s.kind { return Err(CompileError::Bootstrap(BootstrapError::KindMismatch { producer: f.kind, consumer: s.kind, })); } Ok(()) } ``` Then replace the edge loop body inside `validate_wiring` (`:638-649`) — the current block: ```rust for e in edges { let from = nodes.get(e.from).ok_or(CompileError::BadInteriorIndex)?.signature(); let to = nodes.get(e.to).ok_or(CompileError::BadInteriorIndex)?.signature(); let f = from.output.get(e.from_field).ok_or(CompileError::BadInteriorIndex)?; let s = to.inputs.get(e.slot).ok_or(CompileError::BadInteriorIndex)?; if f.kind != s.kind { return Err(CompileError::Bootstrap(BootstrapError::KindMismatch { producer: f.kind, consumer: s.kind, })); } } ``` with: ```rust for e in edges { let from = nodes.get(e.from).ok_or(CompileError::BadInteriorIndex)?.signature(); let to = nodes.get(e.to).ok_or(CompileError::BadInteriorIndex)?.signature(); edge_kind_check(&from, e.from_field, &to, e.slot)?; } ``` Also widen the two holistic gates `finish` will reuse (Task 7): change `fn validate_wiring` (`:628`) to `pub(crate) fn validate_wiring` and `fn check_param_namespace_injective` (`:566`) to `pub(crate) fn check_param_namespace_injective`. - [ ] **Step 4: Run the new test + the existing kind-mismatch gate** Run: `cargo test -p aura-engine edge_kind_check_accepts_match_and_rejects_mismatch` Expected: PASS (1 passed). Run: `cargo test -p aura-engine compile_rejects_kind_mismatch_without_building` Expected: PASS (the holistic edge-kind gate is behaviourally unchanged). --- ### Task 2: §A — shared name-resolution helpers **Files:** - Modify: `crates/aura-engine/src/builder.rs:160-198` - [ ] **Step 1: Write the failing test** Add to `builder.rs`'s `#[cfg(test)] mod tests` (`:201`): ```rust #[test] fn shared_port_resolution_matches_exactly_one() { use super::{resolve_input_slot, resolve_output_field, PortResolveError}; use aura_std::Sub; use crate::BlueprintNode; 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)); } ``` - [ ] **Step 2: Run test to verify it fails** Run: `cargo test -p aura-engine shared_port_resolution_matches_exactly_one` Expected: FAIL — compile error `cannot find function `resolve_input_slot``. - [ ] **Step 3: Extract the helpers and delegate** In `builder.rs`, add (above `impl GraphBuilder`'s `resolve_slot`, or at module scope after the `GraphBuilder` impl): ```rust /// 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), } } ``` Then rewrite `GraphBuilder::resolve_slot` (`:160-177`) and `resolve_field` (`:181-198`) to delegate, preserving the exact `BuildError` variants: ```rust 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() }) } } } 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() }) } } } ``` - [ ] **Step 4: Run the new test + the existing builder resolution tests** Run: `cargo test -p aura-engine shared_port_resolution_matches_exactly_one` Expected: PASS (1 passed). Run: `cargo test -p aura-engine unknown_in_port_is_reported_at_build` Expected: PASS (BuildError variants unchanged). Run: `cargo test -p aura-engine ambiguous_in_port_is_reported` Expected: PASS. --- ### Task 3: §A — fallible `try_bind` + `BindOpError` **Files:** - Modify: `crates/aura-core/src/node.rs:189-237` (add `try_bind` after `bind`), `crates/aura-core/src/lib.rs` (re-export) - [ ] **Step 1: Write the failing test** Add to `node.rs`'s `#[cfg(test)] mod tests` (`grep -n "mod tests" crates/aura-core/src/node.rs`): ```rust #[test] fn try_bind_ok_and_typed_errors() { use super::BindOpError; // A probe with one i64 param `length` (a real std node like `Sma` would // pull in aura-std, but aura-core cannot dev-depend on it without a // dev-cycle that compiles aura-core twice — incompatible `Scalar` types). let probe = || { PrimitiveBuilder::new( "Probe", NodeSchema { inputs: vec![], output: vec![], params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }], }, |_| Box::new(Bare), ) }; // ok: exact name, matching kind assert!(probe().try_bind("length", Scalar::i64(3)).is_ok()); // unknown param name assert_eq!( probe().try_bind("nope", Scalar::i64(3)).err(), Some(BindOpError::UnknownParam("nope".into())) ); // kind mismatch (f64 into an i64 param) assert_eq!( probe().try_bind("length", Scalar::f64(3.0)).err(), Some(BindOpError::KindMismatch { param: "length".into(), expected: ScalarKind::I64, got: ScalarKind::F64, }) ); } ``` > `.err()` not `.unwrap_err()`: the `Ok` type `PrimitiveBuilder` is not `Debug` > (it holds a build closure), mirroring `builder.rs`'s tests. > > **Plan correction (orchestrator, post-block):** the original Step-1 test > referenced `aura_std::Sma`, infeasible inside `aura-core` (aura-std depends on > aura-core; the reverse edge is a dependency cycle). Replaced with a local > `PrimitiveBuilder::new` probe carrying identical assertions — `Bare` is the > test module's existing zero-output node. - [ ] **Step 2: Run test to verify it fails** Run: `cargo test -p aura-core try_bind_ok_and_typed_errors` Expected: FAIL — compile error `cannot find type `BindOpError`` / `no method `try_bind``. - [ ] **Step 3: Add `BindOpError` and `try_bind`** In `node.rs`, add the error type near the other error/struct definitions (module scope): ```rust /// 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. #[derive(Clone, Debug, PartialEq, Eq)] pub enum BindOpError { /// No still-open param has this name. UnknownParam(String), /// More than one still-open param has this name. AmbiguousParam(String), /// The value's kind does not equal the param's declared kind. KindMismatch { param: String, expected: ScalarKind, got: ScalarKind }, } ``` Then add `try_bind` immediately after `bind` (inside `impl PrimitiveBuilder`). Its body mirrors `bind` (`:189-237`) but returns `Result` instead of panicking — deliberately a separate method, not a refactor of `bind`, so `bind`'s pinned panic messages (`bind_unknown_slot_panics` etc.) are untouched: ```rust /// The fallible twin of [`bind`](Self::bind): same exactly-one-match + kind /// rule, returning [`BindOpError`] instead of panicking. Used by the op-script /// construction surface, where a bad param is rejected at the op, not a panic. pub fn try_bind(mut self, slot: &str, value: Scalar) -> Result { let matches: Vec = self .schema .params .iter() .enumerate() .filter(|(_, p)| p.name == slot) .map(|(i, _)| i) .collect(); let pos = match matches.as_slice() { [pos] => *pos, [] => return Err(BindOpError::UnknownParam(slot.to_string())), _ => return Err(BindOpError::AmbiguousParam(slot.to_string())), }; if value.kind() != self.schema.params[pos].kind { return Err(BindOpError::KindMismatch { param: slot.to_string(), expected: self.schema.params[pos].kind, got: value.kind(), }); } let name = self.schema.params[pos].name.clone(); let kind = self.schema.params[pos].kind; let mut orig = pos; let mut prior: Vec = self.bound.iter().map(|b| b.pos).collect(); prior.sort_unstable(); for p in prior { if p <= orig { orig += 1; } } self.bound.push(BoundParam { pos: orig, name, kind, value }); self.schema.params.remove(pos); let inner = self.build; self.build = Box::new(move |open: &[Cell]| { let mut full = open.to_vec(); full.insert(pos, value.cell()); inner(&full) }); Ok(self) } ``` Re-export from `aura-core/src/lib.rs`: find the `pub use node::{...}` line (`grep -n "pub use node" crates/aura-core/src/lib.rs`) and add `BindOpError` to its brace list. - [ ] **Step 4: Run the new test + existing bind-panic tests** Run: `cargo test -p aura-core try_bind_ok_and_typed_errors` Expected: PASS (1 passed). Run: `cargo test -p aura-core bind_unknown_slot_panics` Expected: PASS (bind's panic contract unchanged). Run: `cargo test -p aura-core bind_kind_mismatch_panics` Expected: PASS. --- ### Task 4: §C companion — enumerable `std_vocabulary_types()` **Files:** - Modify: `crates/aura-std/src/vocabulary.rs:30-56`, `crates/aura-std/src/lib.rs:86` - [ ] **Step 1: Write the failing test** Add to `vocabulary.rs`'s `#[cfg(test)] mod tests` (`:58`): ```rust #[test] fn std_vocabulary_types_lists_exactly_the_resolvable_keys() { use super::{std_vocabulary, std_vocabulary_types}; // every listed type resolves to a builder carrying that exact label for t in std_vocabulary_types() { let b = std_vocabulary(t).unwrap_or_else(|| panic!("{t} listed but unresolvable")); assert_eq!(&b.label(), t, "listed type must round-trip to its label"); } // the list is the WHOLE resolvable set: a couple of known non-members stay out assert!(!std_vocabulary_types().contains(&"LinComb")); // construction-arg node assert!(!std_vocabulary_types().contains(&"Recorder")); // sink assert!(!std_vocabulary_types().contains(&"nope")); // count guard: exactly the 22 zero-arg keys (a new arm without a list entry trips this) assert_eq!(std_vocabulary_types().len(), 22); } ``` - [ ] **Step 2: Run test to verify it fails** Run: `cargo test -p aura-std std_vocabulary_types_lists_exactly_the_resolvable_keys` Expected: FAIL — compile error `cannot find function `std_vocabulary_types``. - [ ] **Step 3: Add the enumerable companion + re-export** In `vocabulary.rs`, add directly after `std_vocabulary` (the third lockstep site; it must list EXACTLY the match keys at `:32-53`): ```rust /// The enumerable companion to [`std_vocabulary`]: the closed set of zero-arg /// type identities the resolver answers. A `fn(&str)->Option<…>` resolver cannot /// be listed, so build-free vocabulary introspection (#157) reads this. Lockstep /// with the `std_vocabulary` match arms — a type added there must be added here. pub fn std_vocabulary_types() -> &'static [&'static str] { &[ "Add", "And", "Bias", "CarryCost", "ConstantCost", "Delay", "EMA", "EqConst", "FixedStop", "Gt", "Latch", "LongOnly", "Mul", "PositionManagement", "Resample", "RollingMax", "RollingMin", "Sizer", "SMA", "Sqrt", "Sub", "VolSlippageCost", ] } ``` Re-export: in `aura-std/src/lib.rs`, find the existing vocabulary re-export (`grep -n "vocabulary::" crates/aura-std/src/lib.rs`) — currently `pub use vocabulary::std_vocabulary;` (around `:86`) — and extend it to: ```rust pub use vocabulary::{std_vocabulary, std_vocabulary_types}; ``` - [ ] **Step 4: Run the lockstep test** Run: `cargo test -p aura-std std_vocabulary_types_lists_exactly_the_resolvable_keys` Expected: PASS (1 passed). Run: `cargo test -p aura-std std_vocabulary_resolves_known_and_rejects_unknown` Expected: PASS (existing resolver test unchanged). --- ### Task 5: §B — `Op` / `OpError` / `GraphSession` skeleton + `add`/`source`/`input` **Files:** - Create: `crates/aura-engine/src/construction.rs` - Modify: `crates/aura-engine/src/lib.rs:45-53` (add `mod construction;`) - [ ] **Step 1: Create the module with types, `new`, and the node/role ops** Create `crates/aura-engine/src/construction.rs`: ```rust //! Per-op-fallible blueprint construction — the eager half of the §A gate split //! (#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 //! (`Fn(&str)->Option`), so the engine stays domain-free //! (invariant 9). use std::collections::HashMap; use aura_core::{BindOpError, NodeSchema, PrimitiveBuilder, Scalar, ScalarKind}; use crate::blueprint::{ check_param_namespace_injective, edge_kind_check, validate_wiring, BlueprintNode, Composite, OutField, Role, }; use crate::builder::{resolve_input_slot, resolve_output_field, PortResolveError}; use crate::harness::{Edge, Target}; use crate::CompileError; /// One construction act — the engine-side op vocabulary, 1:1 with the document. /// Port references are dotted by-identifier (`"node.field"` / `"node.slot"`), /// split at the first `.`. (serde `Deserialize` is iteration 2.) #[derive(Clone, Debug, PartialEq)] pub enum Op { /// Reserve a bound root source role of `kind`. Source { role: String, kind: ScalarKind }, /// Reserve an open input role (wired by an enclosing graph). Input { role: String }, /// Add a node of `type_id`, optionally named `as_name`, with `bind` params. Add { type_id: String, as_name: Option, bind: Vec<(String, Scalar)> }, /// Fan a role into one or more `"node.slot"` consumers. Feed { role: String, into: Vec }, /// Wire `"node.field"` -> `"node.slot"`. Connect { from: String, to: String }, /// Re-export `"node.field"` at the boundary under `as_name`. Expose { from: String, as_name: String }, } /// A per-op construction fault, by-identifier so the cause names the op. #[derive(Clone, Debug, PartialEq)] pub enum OpError { UnknownNodeType(String), DuplicateIdentifier(String), DuplicateRole(String), UnknownIdentifier(String), UnknownRole(String), MalformedPort(String), UnknownInPort { node: String, name: String }, AmbiguousInPort { node: String, name: String }, UnknownOutPort { node: String, name: String }, AmbiguousOutPort { node: String, name: String }, KindMismatch { from: String, to: String, producer: ScalarKind, consumer: ScalarKind }, SlotAlreadyWired { node: String, slot: String }, BadParam { node: String, err: BindOpError }, /// A holistic finalize fault (totality / injectivity / unbound root role), /// wrapping the unchanged engine gate's `CompileError`. Incomplete(CompileError), } /// A per-op-fallible blueprint accumulator. Holds the same interior data a /// `Composite` is built from, plus the incremental coverage map (eager /// double-wire front-run) and the identifier→index maps the document references. pub struct GraphSession<'v> { name: String, vocab: &'v dyn Fn(&str) -> Option, nodes: Vec, schemas: Vec, ids: Vec, names: HashMap, edges: Vec, roles: Vec<(String, Option, Vec)>, role_names: HashMap, out: Vec, out_names: HashMap, coverage: HashMap<(usize, usize), usize>, } 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 { Self { name: name.into(), vocab, nodes: Vec::new(), schemas: Vec::new(), ids: Vec::new(), names: HashMap::new(), edges: Vec::new(), roles: Vec::new(), role_names: HashMap::new(), out: Vec::new(), out_names: HashMap::new(), coverage: HashMap::new(), } } /// Apply one op, running the eager checks. The first `Err` is the op-script's /// stop point (`replay` attributes it to the op index). pub fn apply(&mut self, op: Op) -> Result<(), OpError> { match op { Op::Source { role, kind } => self.add_role(role, Some(kind)), Op::Input { role } => self.add_role(role, None), Op::Add { type_id, as_name, bind } => self.add_node(type_id, as_name, bind), Op::Feed { role, into } => self.feed(role, into), Op::Connect { from, to } => self.connect(from, to), Op::Expose { from, as_name } => self.expose(from, as_name), } } fn add_role(&mut self, role: String, kind: Option) -> Result<(), OpError> { if self.role_names.contains_key(&role) { return Err(OpError::DuplicateRole(role)); } let idx = self.roles.len(); self.role_names.insert(role.clone(), idx); self.roles.push((role, kind, Vec::new())); Ok(()) } fn add_node( &mut self, type_id: String, as_name: Option, bind: Vec<(String, Scalar)>, ) -> Result<(), OpError> { let mut builder = (self.vocab)(&type_id).ok_or_else(|| OpError::UnknownNodeType(type_id.clone()))?; let id = as_name.unwrap_or_else(|| builder.node_name()); for (slot, value) in bind { builder = builder .try_bind(&slot, value) .map_err(|err| OpError::BadParam { node: id.clone(), err })?; } if self.names.contains_key(&id) { return Err(OpError::DuplicateIdentifier(id)); } let node = BlueprintNode::from(builder); 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(()) } // feed / connect / expose / finish / unwired follow in Task 6 and Task 7. } ``` > Confirm `BlueprintNode: From` and `BlueprintNode::signature` > exist (`grep -n "impl From\|fn signature" crates/aura-engine/src/blueprint.rs`). > The `builder.into()` in `builder.rs:93` and `node.signature()` at `:94` prove > both; `BlueprintNode::from(builder)` is the same conversion. Add the module to `lib.rs` (`:45-53` block): insert `mod construction;` after `mod builder;`. - [ ] **Step 2: Write the failing tests for `add` / `source`** Append a `#[cfg(test)] mod tests` to `construction.rs`: ```rust #[cfg(test)] mod tests { use super::{GraphSession, Op, OpError}; use aura_core::{BindOpError, Scalar, ScalarKind}; use aura_std::std_vocabulary; fn session(name: &str) -> GraphSession<'static> { GraphSession::new(name, &std_vocabulary) } #[test] fn add_unknown_type_rejected_at_op() { let mut s = session("g"); assert_eq!( s.apply(Op::Add { type_id: "Nope".into(), as_name: None, bind: vec![] }), Err(OpError::UnknownNodeType("Nope".into())) ); } #[test] fn add_duplicate_identifier_rejected() { let mut s = session("g"); s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("x".into()), bind: vec![] }).unwrap(); assert_eq!( s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("x".into()), bind: vec![] }), Err(OpError::DuplicateIdentifier("x".into())) ); } #[test] fn add_bad_bind_param_rejected_at_op() { let mut s = session("g"); assert_eq!( s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![("length".into(), Scalar::f64(2.0))], // wrong kind (i64 param) }), Err(OpError::BadParam { node: "fast".into(), err: BindOpError::KindMismatch { param: "length".into(), expected: ScalarKind::I64, got: ScalarKind::F64, }, }) ); } #[test] fn duplicate_role_rejected() { let mut s = session("g"); s.apply(Op::Source { role: "price".into(), kind: ScalarKind::F64 }).unwrap(); assert_eq!( s.apply(Op::Source { role: "price".into(), kind: ScalarKind::F64 }), Err(OpError::DuplicateRole("price".into())) ); } } ``` - [ ] **Step 3: Run to verify they fail, then pass** Run: `cargo build -p aura-engine` Expected: clean build (the module + ops compile). Run: `cargo test -p aura-engine add_unknown_type_rejected_at_op` Expected: PASS. Run: `cargo test -p aura-engine add_bad_bind_param_rejected_at_op` Expected: PASS. > The four tests in this module are all driven by the code written in Step 1. > Run each individually (one filter per Run step): `add_duplicate_identifier_rejected`, > `duplicate_role_rejected` — both Expected: PASS. --- ### Task 6: §B — eager `connect` / `feed` / `expose` **Files:** - Modify: `crates/aura-engine/src/construction.rs` - [ ] **Step 1: Write the failing tests** Add to `construction.rs`'s `mod tests`: ```rust // helper: a fast/slow/sub scaffold sharing a price source fn scaffold() -> GraphSession<'static> { let mut s = session("g"); s.apply(Op::Source { role: "price".into(), kind: ScalarKind::F64 }).unwrap(); s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] }).unwrap(); s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), bind: vec![] }).unwrap(); s.apply(Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] }).unwrap(); s } #[test] fn connect_unknown_out_port_rejected_at_op() { let mut s = scaffold(); assert_eq!( s.apply(Op::Connect { from: "fast.nope".into(), to: "sub.lhs".into() }), Err(OpError::UnknownOutPort { node: "fast".into(), name: "nope".into() }) ); } #[test] fn connect_double_wire_rejected_at_op() { let mut s = scaffold(); s.apply(Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() }).unwrap(); assert_eq!( s.apply(Op::Connect { from: "slow.value".into(), to: "sub.lhs".into() }), Err(OpError::SlotAlreadyWired { node: "sub".into(), slot: "lhs".into() }) ); } #[test] fn connect_kind_mismatch_rejected_at_op() { // Gt produces bool; Bias.signal consumes f64 -> eager kind mismatch let mut s = session("g"); s.apply(Op::Add { type_id: "Gt".into(), as_name: None, bind: vec![] }).unwrap(); s.apply(Op::Add { type_id: "Bias".into(), as_name: None, bind: vec![] }).unwrap(); let r = s.apply(Op::Connect { from: "gt.value".into(), to: "bias.signal".into() }); assert!(matches!( r, Err(OpError::KindMismatch { producer: ScalarKind::Bool, consumer: ScalarKind::F64, .. }) )); } #[test] fn feed_into_unknown_role_rejected() { let mut s = scaffold(); assert_eq!( s.apply(Op::Feed { role: "ghost".into(), into: vec!["fast.series".into()] }), Err(OpError::UnknownRole("ghost".into())) ); } ``` > Verify `Gt`'s output field is named `value` and is `bool`, and `Bias.signal` > is `f64`: `grep -rn "value\|signal\|Bool\|F64" crates/aura-std/src/gt.rs > crates/aura-std/src/bias.rs`. If the field name differs, fix the test literal. - [ ] **Step 2: Run to verify they fail** Run: `cargo test -p aura-engine connect_double_wire_rejected_at_op` Expected: FAIL — compile error `no method `connect`` / missing arm. - [ ] **Step 3: Implement `connect` / `feed` / `expose` + the dotted-port helper** Add these methods inside `impl<'v> GraphSession<'v>` (after `add_node`): ```rust /// Split a dotted `"node.port"` reference at the first `.`. fn split_port(&self, dotted: &str) -> Result<(String, String), OpError> { match dotted.split_once('.') { Some((node, port)) if !node.is_empty() && !port.is_empty() => { Ok((node.to_string(), port.to_string())) } _ => Err(OpError::MalformedPort(dotted.to_string())), } } fn node_index(&self, id: &str) -> Result { self.names.get(id).copied().ok_or_else(|| OpError::UnknownIdentifier(id.to_string())) } /// Mark `(node, slot)` covered, rejecting a second cover eagerly (the >1 arm /// of the exactly-once invariant — the holistic 0 arm is deferred to finish). fn cover(&mut self, node: usize, node_id: &str, slot: usize, slot_name: &str) -> Result<(), OpError> { let c = self.coverage.entry((node, slot)).or_insert(0); if *c >= 1 { return Err(OpError::SlotAlreadyWired { node: node_id.to_string(), slot: slot_name.to_string() }); } *c += 1; Ok(()) } fn connect(&mut self, from: String, to: String) -> Result<(), OpError> { let (from_node, from_field_name) = self.split_port(&from)?; let (to_node, to_slot_name) = self.split_port(&to)?; let fi = self.node_index(&from_node)?; let ti = self.node_index(&to_node)?; let from_field = resolve_output_field(&self.schemas[fi], &from_field_name).map_err(|e| match e { PortResolveError::Unknown => OpError::UnknownOutPort { node: from_node.clone(), name: from_field_name.clone() }, PortResolveError::Ambiguous => OpError::AmbiguousOutPort { node: from_node.clone(), name: from_field_name.clone() }, })?; let slot = resolve_input_slot(&self.schemas[ti], &to_slot_name).map_err(|e| match e { PortResolveError::Unknown => OpError::UnknownInPort { node: to_node.clone(), name: to_slot_name.clone() }, PortResolveError::Ambiguous => OpError::AmbiguousInPort { node: to_node.clone(), name: to_slot_name.clone() }, })?; edge_kind_check(&self.schemas[fi], from_field, &self.schemas[ti], slot).map_err(|e| match e { CompileError::Bootstrap(crate::BootstrapError::KindMismatch { producer, consumer }) => { OpError::KindMismatch { from: from.clone(), to: to.clone(), producer, consumer } } other => OpError::Incomplete(other), })?; self.cover(ti, &to_node, slot, &to_slot_name)?; self.edges.push(Edge { from: fi, to: ti, slot, from_field }); Ok(()) } fn feed(&mut self, role: String, into: Vec) -> Result<(), OpError> { let ri = *self.role_names.get(&role).ok_or_else(|| OpError::UnknownRole(role.clone()))?; for target in into { let (to_node, to_slot_name) = self.split_port(&target)?; let ti = self.node_index(&to_node)?; let slot = resolve_input_slot(&self.schemas[ti], &to_slot_name).map_err(|e| match e { PortResolveError::Unknown => OpError::UnknownInPort { node: to_node.clone(), name: to_slot_name.clone() }, PortResolveError::Ambiguous => OpError::AmbiguousInPort { node: to_node.clone(), name: to_slot_name.clone() }, })?; self.cover(ti, &to_node, slot, &to_slot_name)?; self.roles[ri].2.push(Target { node: ti, slot }); } Ok(()) } fn expose(&mut self, from: String, as_name: String) -> Result<(), OpError> { let (from_node, from_field_name) = self.split_port(&from)?; let fi = self.node_index(&from_node)?; let field = resolve_output_field(&self.schemas[fi], &from_field_name).map_err(|e| match e { PortResolveError::Unknown => OpError::UnknownOutPort { node: from_node.clone(), name: from_field_name.clone() }, PortResolveError::Ambiguous => OpError::AmbiguousOutPort { node: from_node.clone(), name: from_field_name.clone() }, })?; if self.out_names.insert(as_name.clone(), ()).is_some() { return Err(OpError::DuplicateIdentifier(as_name)); } self.out.push(OutField { node: fi, field, name: as_name }); Ok(()) } ``` > `crate::BootstrapError` is re-exported at `lib.rs:68`; confirm the > `KindMismatch { producer, consumer }` field names with `grep -n "KindMismatch" > crates/aura-engine/src/harness.rs`. - [ ] **Step 4: Run the eager-op tests** Run: `cargo test -p aura-engine connect_double_wire_rejected_at_op` Expected: PASS. Run: `cargo test -p aura-engine connect_kind_mismatch_rejected_at_op` Expected: PASS. Run: `cargo test -p aura-engine connect_unknown_out_port_rejected_at_op` Expected: PASS. Run: `cargo test -p aura-engine feed_into_unknown_role_rejected` Expected: PASS. --- ### Task 7: §B — `finish` (holistic gates), `replay`, `unwired` **Files:** - Modify: `crates/aura-engine/src/construction.rs` - [ ] **Step 1: Write the failing tests** Add to `construction.rs`'s `mod tests`: ```rust #[test] fn finish_reports_incomplete_on_unwired_slot() { // a fully-named scaffold missing the sub.rhs connection -> holistic 0-arm let mut s = scaffold(); s.apply(Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] }).unwrap(); s.apply(Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() }).unwrap(); // sub.rhs deliberately left unwired let err = s.finish().unwrap_err(); assert!(matches!(err, OpError::Incomplete(CompileError::UnconnectedPort { .. }))); } #[test] fn unwired_lists_open_slots() { let mut s = scaffold(); s.apply(Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() }).unwrap(); let open = s.unwired(); // fast.series, slow.series, sub.rhs are still open; sub.lhs is covered assert!(open.iter().any(|(p, _)| p == "sub.rhs")); assert!(open.iter().any(|(p, _)| p == "fast.series")); assert!(!open.iter().any(|(p, _)| p == "sub.lhs")); } #[test] fn replay_stops_at_first_failing_op_naming_index() { let ops = vec![ Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] }, Op::Add { type_id: "Nope".into(), as_name: None, bind: vec![] }, // op 1 fails Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] }, ]; let err = super::replay("g", ops, &aura_std::std_vocabulary).unwrap_err(); assert_eq!(err, (1, OpError::UnknownNodeType("Nope".into()))); } ``` - [ ] **Step 2: Run to verify they fail** Run: `cargo test -p aura-engine replay_stops_at_first_failing_op_naming_index` Expected: FAIL — compile error `cannot find function `replay`` / `no method `finish``. - [ ] **Step 3: Implement `finish`, `unwired`, and `replay`** Add to `impl<'v> GraphSession<'v>`: ```rust /// The still-unwired interior input slots (build-free introspection): every /// added node's declared input slots minus the covered ones, by-identifier. pub fn unwired(&self) -> Vec<(String, ScalarKind)> { let mut open = Vec::new(); for (n, schema) in self.schemas.iter().enumerate() { for (slot, port) in schema.inputs.iter().enumerate() { if self.coverage.get(&(n, slot)).copied().unwrap_or(0) == 0 { open.push((format!("{}.{}", self.ids[n], port.name), port.kind)); } } } 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. Any /// fault wraps as `OpError::Incomplete`. pub fn finish(self) -> Result { let roles: Vec = self .roles .into_iter() .map(|(name, source, targets)| Role { name, targets, source }) .collect(); let c = Composite::new(self.name, self.nodes, self.edges, roles, self.out); check_param_namespace_injective(&c.param_space()).map_err(OpError::Incomplete)?; validate_wiring(c.nodes(), c.edges(), c.input_roles(), c.output()).map_err(OpError::Incomplete)?; for (r, role) in c.input_roles().iter().enumerate() { if role.source.is_none() { return Err(OpError::Incomplete(CompileError::UnboundRootRole { role: r })); } } 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). pub fn replay( name: impl Into, ops: Vec, vocab: &dyn Fn(&str) -> Option, ) -> Result { let mut s = GraphSession::new(name, vocab); let n = ops.len(); for (i, op) in ops.into_iter().enumerate() { s.apply(op).map_err(|e| (i, e))?; } s.finish().map_err(|e| (n, e)) } ``` > `Composite::param_space` (`blueprint.rs:195`), `nodes()`/`edges()`/ > `input_roles()`/`output()` accessors (`:166-187`), and `CompileError:: > UnboundRootRole`/`UnconnectedPort` (`:614`/`:617`) are all already in scope via > the Task-1 `use` lines + the `pub(crate)` widening from Task 1. - [ ] **Step 4: Run the finalize/replay/introspection tests** Run: `cargo test -p aura-engine finish_reports_incomplete_on_unwired_slot` Expected: PASS. Run: `cargo test -p aura-engine unwired_lists_open_slots` Expected: PASS. Run: `cargo test -p aura-engine replay_stops_at_first_failing_op_naming_index` Expected: PASS. --- ### Task 8: §B — acceptance-1 (replay ≡ GraphBuilder) + public exports **Files:** - Modify: `crates/aura-engine/src/construction.rs` (the integration test), `crates/aura-engine/src/lib.rs:55-88` (public `pub use`) - [ ] **Step 1: Write the failing acceptance-1 test** Add to `construction.rs`'s `mod tests` — a flat, sink-free `price → fast/slow SMA → Sub → Bias` signal root authored two ways, compiled, and compared (models the green `builder_harness_compiles_identically_to_hand_wired`, `builder.rs:234`; no SimBroker/Recorder, which are outside the zero-arg vocabulary): ```rust #[test] fn replay_built_signal_compiles_identically_to_graphbuilder() { use crate::GraphBuilder; use aura_core::Scalar; use aura_std::{Bias, Sma, Sub}; // param space of the flat root: fast.length(i64), slow.length(i64), // bias.scale(f64) — same vector applied to both sides, so it cancels. let params = [Scalar::i64(2), Scalar::i64(4), Scalar::f64(0.5)]; // (a) op-script replay let ops = vec![ Op::Source { role: "price".into(), kind: ScalarKind::F64 }, Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] }, Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), bind: vec![] }, Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] }, Op::Add { type_id: "Bias".into(), as_name: None, bind: vec![] }, Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] }, Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() }, Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() }, 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_std::std_vocabulary) .expect("replay resolves") .compile_with_params(¶ms) .expect("replay compiles"); // (b) the GraphBuilder twin — identical topology let mut g = GraphBuilder::new("root"); let fast = g.add(Sma::builder().named("fast")); let slow = g.add(Sma::builder().named("slow")); let sub = g.add(Sub::builder()); let bias = g.add(Bias::builder()); let price = g.source_role("price", ScalarKind::F64); 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.connect(sub.output("value"), bias.input("signal")); g.expose(bias.output("bias"), "bias"); let builder_flat = g.build().expect("root resolves").compile_with_params(¶ms).expect("compiles"); assert_eq!(replay_flat.edges, builder_flat.edges); assert_eq!(replay_flat.sources, builder_flat.sources); } ``` > Port names confirmed from `builder.rs` tests: SMA `series`→`value`, Sub > `lhs`/`rhs`→`value`, Bias `signal`→`bias`. If `compile_with_params` rejects on > arity, print `replay(...).unwrap().param_space()` to read the actual space and > adjust `params` (same vector both sides — the comparison is param-independent). - [ ] **Step 2: Run to verify it fails (then passes after exports compile)** Run: `cargo test -p aura-engine replay_built_signal_compiles_identically_to_graphbuilder` Expected: PASS (all of §B is implemented by Task 7; this test only adds an assertion over existing code). If it FAILs on a param-arity mismatch, follow the note above. - [ ] **Step 3: Add the public exports** In `lib.rs`'s `pub use` region (`:55-88`), add: ```rust pub use construction::{replay, GraphSession, Op, OpError}; ``` (`BindOpError` is exported from `aura-core`; re-export it from the engine root too for the CLI's convenience by adding `BindOpError` to the existing `pub use aura_core::{...}` line at `lib.rs:88`.) - [ ] **Step 4: Full workspace gates** Run: `cargo build --workspace` Expected: clean build. Run: `cargo test --workspace` Expected: all pass (the prior `748` + the new tests; no regressions). Run: `cargo clippy --workspace --all-targets -- -D warnings` Expected: clean (no warnings). --- ## Self-review (planner Step 5) 1. **Spec coverage:** §A → Tasks 1 (edge_kind_check) + 2 (resolution) + 3 (try_bind); §C companion → Task 4; §B `Op`/`OpError`/`GraphSession`/eager → Tasks 5+6; `finish`/`replay`/`unwired` introspection → Task 7; acceptance-1 + exports → Task 8. Acceptance criteria 1/2/3 of the spec all have tests. JSON op-list + CLI = iteration 2, correctly excluded. ✓ 2. **Placeholder scan:** no "TBD"/"TODO"/"implement later"/"similar to Task"/ "add appropriate". The one forward-reference ("feed/connect/expose follow in Task 6") is a code comment marking a deliberate task boundary, not a plan placeholder — the methods are fully specified in Task 6. ✓ 3. **Type consistency:** `GraphSession`, `Op`, `OpError`, `replay`, `edge_kind_check`, `resolve_input_slot`/`resolve_output_field`, `PortResolveError`, `try_bind`, `BindOpError`, `std_vocabulary_types` — names identical across every task that references them. ✓ 4. **Step granularity:** each step is one test, one extraction, or one run. ✓ 5. **No commit steps:** none present. ✓ 6. **Pin/replacement contiguity:** Task 4's count guard (`len() == 22`) pins the list in Task 4's own replacement body (the 22 entries) — contiguous, same task. No cross-task pin/replacement pair split. ✓ 7. **Compile-gate vs deferred-caller ordering:** Task 1's edge-loop replacement threads its only caller (`validate_wiring` itself) in-task; Task 2's resolution extraction rewrites both call sites (`resolve_slot`/`resolve_field`) in-task. No signature change leaves a caller unthreaded across a build gate. The new `Op`/`OpError`/`BindOpError` enums have no pre-existing match sites. Task 5 adds `mod construction;` in the same task that creates the file (the module compiles standalone; `feed`/`connect`/`expose`/`finish` are added in Tasks 6-7 but Task 5's code does not call them, so each task's tree builds). ✓ 8. **Verification-filter strings resolve:** every Run filter is a test named in this plan (Tasks 1-8) or a verified existing green test — `compile_rejects_kind_mismatch_without_building`, `unknown_in_port_is_reported_at_build`, `ambiguous_in_port_is_reported`, `bind_unknown_slot_panics`, `bind_kind_mismatch_panics`, `std_vocabulary_resolves_known_and_rejects_unknown`, `builder_harness_compiles_identically_to_hand_wired` (all confirmed present in the tree this session). The full-workspace gate (Task 8) is unfiltered with a no-regression expectation. ✓