# 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. ✓ --- # Iteration 2 — §C CLI shell (Tasks 9-12) **Goal:** the JSON op-list surface over the iteration-1 engine core — `aura graph build` (replay a document → emit the #155 blueprint, or fail fast at the op) and `aura graph introspect` (vocabulary / a node's ports+kinds / a partial document's unwired slots), build-free. **Architecture:** all changes are in `aura-cli` — the engine `Op` stays serde-free. A CLI-side serde DTO (`OpDoc`, `#[serde(tag="op", rename_all="lowercase")]`) deserializes the document and maps into the engine `Op`; the CLI retains the parsed list to recover the op-kind label for the `op N (kind): cause` message; a CLI format helper phrases each `OpError` (the engine error types stay `Display`-free). Bind values use the typed `Scalar` form (`{"I64":2}`) and `ScalarKind` its capitalized #155 form (`"F64"`). **Tech Stack:** `crates/aura-cli/src/main.rs` (argv arms), new `crates/aura-cli/src/graph_construct.rs` (DTO + subcommand cores), new `crates/aura-cli/tests/graph_construct.rs` (E2E). `serde`/`serde_json`, `aura-engine`, `aura-std`, `aura-core` are already deps. **Files this iteration creates or modifies:** - Create: `crates/aura-cli/src/graph_construct.rs` — the `OpDoc` DTO, the `build`/`introspect` subcommand cores, the error formatter. - Modify: `crates/aura-cli/src/main.rs` — `mod graph_construct;` + two argv arms. - Test: `crates/aura-cli/tests/graph_construct.rs` — E2E driving the `aura` binary. > Fork decisions for this iteration are derived + recorded on #157 (the > wire-format comment): CLI-side DTO (engine stays serde-free); typed `Scalar` > binds; capitalized `ScalarKind`; op-kind label via the retained parsed list; > CLI-side error phrasing. ### Task 9: the `OpDoc` serde DTO + mapping into the engine `Op` **Files:** - Create: `crates/aura-cli/src/graph_construct.rs` - Modify: `crates/aura-cli/src/main.rs` (add `mod graph_construct;`) - [ ] **Step 1: Create the module with the DTO, the mapping, and a test** Create `crates/aura-cli/src/graph_construct.rs`: ```rust //! The `aura graph build` / `aura graph introspect` CLI surface (#157, §C): a //! JSON op-list document is deserialized into a CLI-side `OpDoc` (the engine //! `Op` stays serde-free), mapped into `aura_engine::Op`, and replayed through //! the injected `aura_std::std_vocabulary`. The build path emits the #155 //! blueprint or fails fast at the offending op; introspection answers //! build-free. use std::collections::BTreeMap; use aura_engine::{blueprint_to_json, replay, GraphSession, Op, OpError, Scalar, ScalarKind}; use aura_std::{std_vocabulary, std_vocabulary_types}; use serde::Deserialize; /// The wire DTO for one construction op — the document's by-identifier shape, /// internally tagged by `"op"`, mapped into the serde-free engine `Op`. Bind /// values are the typed `Scalar` form (`{"I64":2}`); `kind` the capitalized /// `ScalarKind` form (`"F64"`) — both the #155 representations. #[derive(Debug, Deserialize)] #[serde(tag = "op", rename_all = "lowercase")] enum OpDoc { Source { role: String, kind: ScalarKind }, Input { role: String }, Add { #[serde(rename = "type")] type_id: String, #[serde(rename = "as", default)] as_name: Option, #[serde(default)] bind: BTreeMap, }, Feed { role: String, into: Vec }, Connect { from: String, to: String }, Expose { from: String, #[serde(rename = "as")] as_name: String, }, } impl OpDoc { /// The op-kind label for the `op N (kind): cause` message. fn kind_label(&self) -> &'static str { match self { OpDoc::Source { .. } => "source", OpDoc::Input { .. } => "input", OpDoc::Add { .. } => "add", OpDoc::Feed { .. } => "feed", OpDoc::Connect { .. } => "connect", OpDoc::Expose { .. } => "expose", } } } impl From for Op { fn from(d: OpDoc) -> Op { match d { OpDoc::Source { role, kind } => Op::Source { role, kind }, OpDoc::Input { role } => Op::Input { role }, OpDoc::Add { type_id, as_name, bind } => { Op::Add { type_id, as_name, bind: bind.into_iter().collect() } } OpDoc::Feed { role, into } => Op::Feed { role, into }, OpDoc::Connect { from, to } => Op::Connect { from, to }, OpDoc::Expose { from, as_name } => Op::Expose { from, as_name }, } } } #[cfg(test)] mod tests { use super::*; /// The spec's worked-example document deserializes into the op vocabulary and /// maps into engine `Op`s that replay into a compilable signal blueprint — /// the DTO is a faithful front-end over the engine surface. #[test] fn opdoc_document_deserializes_and_replays() { let doc = r#"[ {"op":"source","role":"price","kind":"F64"}, {"op":"add","type":"SMA","as":"fast","bind":{"length":{"I64":2}}}, {"op":"add","type":"SMA","as":"slow","bind":{"length":{"I64":4}}}, {"op":"add","type":"Sub"}, {"op":"add","type":"Bias"}, {"op":"feed","role":"price","into":["fast.series","slow.series"]}, {"op":"connect","from":"fast.value","to":"sub.lhs"}, {"op":"connect","from":"slow.value","to":"sub.rhs"}, {"op":"connect","from":"sub.value","to":"bias.signal"}, {"op":"expose","from":"bias.bias","as":"bias"} ]"#; let docs: Vec = serde_json::from_str(doc).expect("document parses"); assert_eq!(docs.len(), 10); assert_eq!(docs[1].kind_label(), "add"); let ops: Vec = docs.into_iter().map(Op::from).collect(); // both SMA lengths are bound, so the only open param is bias.scale (f64). let composite = replay("graph", ops, &std_vocabulary).expect("replay resolves"); composite.compile_with_params(&[Scalar::f64(0.5)]).expect("compiles"); } } ``` Add `mod graph_construct;` to `crates/aura-cli/src/main.rs` — place it with the other `mod` declarations near the top (find them with `grep -n "^mod \|^use " crates/aura-cli/src/main.rs | head`). - [ ] **Step 2: Run to verify it fails, then passes** Run: `cargo test -p aura-cli opdoc_document_deserializes_and_replays` Expected: first FAIL (module/symbols absent or test red), then PASS after Step 1 lands. (The single test in this task is created and satisfied by Step 1's code.) > If `compile_with_params` rejects on param arity, the bound-length assumption is > wrong — print `replay(...).unwrap().param_space()` to read the real space and > adjust the bound `params` vector (the spec's iteration-1 acceptance test > confirmed bias carries one f64 `scale` param). ### Task 10: `aura graph build` — replay a document, emit or fail fast **Files:** - Modify: `crates/aura-cli/src/graph_construct.rs` (add `build_from_str`, `format_op_error`, `build_cmd`) - Modify: `crates/aura-cli/src/main.rs` (add the `["graph","build"]` arm) - [ ] **Step 1: Write the failing tests** Add to `graph_construct.rs`'s `mod tests`: ```rust #[test] fn build_from_str_emits_blueprint_json_for_valid_document() { let doc = r#"[ {"op":"source","role":"price","kind":"F64"}, {"op":"add","type":"SMA","as":"fast","bind":{"length":{"I64":2}}}, {"op":"add","type":"SMA","as":"slow","bind":{"length":{"I64":4}}}, {"op":"add","type":"Sub"}, {"op":"add","type":"Bias"}, {"op":"feed","role":"price","into":["fast.series","slow.series"]}, {"op":"connect","from":"fast.value","to":"sub.lhs"}, {"op":"connect","from":"slow.value","to":"sub.rhs"}, {"op":"connect","from":"sub.value","to":"bias.signal"}, {"op":"expose","from":"bias.bias","as":"bias"} ]"#; let json = super::build_from_str(doc).expect("valid document builds"); assert!(json.contains("\"format_version\":1"), "emits the #155 envelope: {json}"); assert!(json.contains("\"SMA\""), "carries the SMA nodes: {json}"); } #[test] fn build_from_str_reports_unknown_node_at_its_op_index() { let doc = r#"[{"op":"add","type":"SMA","as":"fast"},{"op":"add","type":"Nope"}]"#; let err = super::build_from_str(doc).unwrap_err(); assert_eq!(err, "op 1 (add): unknown node type \"Nope\""); } #[test] fn build_from_str_reports_unconnected_slot_at_finalize() { // sub.rhs left unwired -> the holistic 0-arm fires at the finalize step. let doc = r#"[ {"op":"source","role":"price","kind":"F64"}, {"op":"add","type":"SMA","as":"fast"}, {"op":"add","type":"SMA","as":"slow"}, {"op":"add","type":"Sub"}, {"op":"feed","role":"price","into":["fast.series","slow.series"]}, {"op":"connect","from":"fast.value","to":"sub.lhs"} ]"#; let err = super::build_from_str(doc).unwrap_err(); assert!(err.starts_with("finalize: "), "finalize fault, got: {err}"); assert!(err.contains("Unconnected"), "names the unconnected slot, got: {err}"); } ``` - [ ] **Step 2: Run to verify they fail** Run: `cargo test -p aura-cli build_from_str_emits_blueprint_json_for_valid_document` Expected: FAIL — compile error `cannot find function `build_from_str``. - [ ] **Step 3: Implement `build_from_str`, `format_op_error`, `build_cmd`** Add to `graph_construct.rs` (module scope, after the `OpDoc` impls): ```rust /// Phrase one `OpError` as a human cause (the engine error types are /// `Display`-free by convention; this is the CLI's presentation layer). The /// exhaustive match means a new `OpError` variant forces an update here. fn format_op_error(e: &OpError) -> String { match e { OpError::UnknownNodeType(t) => format!("unknown node type {t:?}"), OpError::DuplicateIdentifier(s) => format!("duplicate identifier {s:?}"), OpError::DuplicateOutput(s) => format!("duplicate output name {s:?}"), OpError::DuplicateRole(s) => format!("duplicate role {s:?}"), OpError::UnknownIdentifier(s) => format!("unknown node identifier {s:?}"), OpError::UnknownRole(s) => format!("unknown role {s:?}"), OpError::MalformedPort(s) => format!("malformed port reference {s:?}"), OpError::UnknownInPort { node, name } => format!("node {node:?} has no input port {name:?}"), OpError::AmbiguousInPort { node, name } => format!("node {node:?} has ambiguous input port {name:?}"), OpError::UnknownOutPort { node, name } => format!("node {node:?} has no output field {name:?}"), OpError::AmbiguousOutPort { node, name } => format!("node {node:?} has ambiguous output field {name:?}"), OpError::KindMismatch { from, to, producer, consumer } => { format!("kind mismatch {from} -> {to} (producer {producer:?}, consumer {consumer:?})") } OpError::SlotAlreadyWired { node, slot } => format!("slot {node}.{slot} already wired"), OpError::BadParam { node, err } => format!("bad param on {node}: {err:?}"), OpError::Incomplete(ce) => format!("{ce:?}"), } } /// Parse a JSON op-list document, replay it through `std_vocabulary`, and return /// the emitted #155 blueprint JSON — or a `op N (kind): cause` message (a per-op /// fault, attributed by the retained op-kind list) / `finalize: cause` (a /// holistic fault past the last op). pub fn build_from_str(doc: &str) -> Result { let docs: Vec = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?; let labels: Vec<&'static str> = docs.iter().map(OpDoc::kind_label).collect(); let ops: Vec = docs.into_iter().map(Op::from).collect(); match replay("graph", ops, &std_vocabulary) { Ok(composite) => blueprint_to_json(&composite).map_err(|e| format!("serialize error: {e:?}")), Err((idx, err)) => { let cause = format_op_error(&err); match labels.get(idx) { Some(kind) => Err(format!("op {idx} ({kind}): {cause}")), None => Err(format!("finalize: {cause}")), } } } } /// `aura graph build`: read the op-list document from stdin, build, and print the /// blueprint to stdout — or the cause to stderr and exit non-zero. pub fn build_cmd() { use std::io::Read; let mut doc = String::new(); if let Err(e) = std::io::stdin().read_to_string(&mut doc) { eprintln!("aura: reading stdin: {e}"); std::process::exit(2); } match build_from_str(&doc) { Ok(json) => println!("{json}"), Err(msg) => { eprintln!("aura: {msg}"); std::process::exit(2); } } } ``` In `crates/aura-cli/src/main.rs`, add the arm to the argv `match` (right after the bare `["graph"] => ...` arm at `:3313`): ```rust ["graph", "build"] => graph_construct::build_cmd(), ``` - [ ] **Step 4: Run the build tests** Run: `cargo test -p aura-cli build_from_str_emits_blueprint_json_for_valid_document` Expected: PASS. Run: `cargo test -p aura-cli build_from_str_reports_unknown_node_at_its_op_index` Expected: PASS. Run: `cargo test -p aura-cli build_from_str_reports_unconnected_slot_at_finalize` Expected: PASS. ### Task 11: `aura graph introspect` — vocabulary / node / unwired **Files:** - Modify: `crates/aura-cli/src/graph_construct.rs` (add `introspect_node`, `introspect_unwired`, `introspect_cmd`) - Modify: `crates/aura-cli/src/main.rs` (add the `["graph","introspect", ..]` arm) - [ ] **Step 1: Write the failing tests** Add to `graph_construct.rs`'s `mod tests`: ```rust #[test] fn introspect_node_lists_ports_kinds_and_params() { let out = super::introspect_node("SMA").expect("SMA is in the vocabulary"); assert!(out.contains("series"), "lists the input port: {out}"); assert!(out.contains("value"), "lists the output field: {out}"); assert!(out.contains("length"), "lists the param path: {out}"); assert!(super::introspect_node("Nope").is_err(), "rejects an unknown type"); } #[test] fn introspect_unwired_reports_open_slots_of_a_partial_document() { let doc = r#"[ {"op":"add","type":"SMA","as":"fast"}, {"op":"add","type":"Sub"}, {"op":"connect","from":"fast.value","to":"sub.lhs"} ]"#; let out = super::introspect_unwired(doc).expect("partial document introspects"); assert!(out.contains("sub.rhs"), "sub.rhs is still open: {out}"); assert!(out.contains("fast.series"), "fast.series is still open: {out}"); assert!(!out.contains("sub.lhs"), "sub.lhs is covered: {out}"); } ``` - [ ] **Step 2: Run to verify they fail** Run: `cargo test -p aura-cli introspect_node_lists_ports_kinds_and_params` Expected: FAIL — compile error `cannot find function `introspect_node``. - [ ] **Step 3: Implement the introspection cores + `introspect_cmd`** Add to `graph_construct.rs`: ```rust /// `aura graph introspect --node `: a type's ports + kinds + param paths, /// read off the pre-build schema (no graph built). `Err` if `T` is not in the /// closed vocabulary. pub fn introspect_node(type_id: &str) -> Result { let builder = std_vocabulary(type_id).ok_or_else(|| format!("unknown node type {type_id:?}"))?; let schema = builder.schema(); let mut out = format!("{}\n", builder.label()); for port in &schema.inputs { out.push_str(&format!(" in {}:{:?}\n", port.name, port.kind)); } for field in &schema.output { out.push_str(&format!(" out {}:{:?}\n", field.name, field.kind)); } for p in builder.params() { out.push_str(&format!(" param {}:{:?}\n", p.name, p.kind)); } Ok(out) } /// `aura graph introspect --unwired`: the still-open interior slots of a partial /// op-list document, by-identifier (applies the ops, does NOT finalize). pub fn introspect_unwired(doc: &str) -> Result { let docs: Vec = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?; let mut session = GraphSession::new("introspect", &std_vocabulary); for (i, d) in docs.into_iter().enumerate() { session.apply(Op::from(d)).map_err(|e| format!("op {i}: {}", format_op_error(&e)))?; } let mut out = String::new(); for (slot, kind) in session.unwired() { out.push_str(&format!("{slot}:{kind:?}\n")); } Ok(out) } /// `aura graph introspect`: dispatch the three read-only queries. pub fn introspect_cmd(rest: &[&str]) { match rest { ["--vocabulary"] => { for t in std_vocabulary_types() { println!("{t}"); } } ["--node", type_id] => match introspect_node(type_id) { Ok(s) => print!("{s}"), Err(m) => { eprintln!("aura: {m}"); std::process::exit(2); } }, ["--unwired"] => { use std::io::Read; let mut doc = String::new(); if let Err(e) = std::io::stdin().read_to_string(&mut doc) { eprintln!("aura: reading stdin: {e}"); std::process::exit(2); } match introspect_unwired(&doc) { Ok(s) => print!("{s}"), Err(m) => { eprintln!("aura: {m}"); std::process::exit(2); } } } _ => { eprintln!("aura: usage: aura graph introspect --vocabulary | --node | --unwired"); std::process::exit(2); } } } ``` In `crates/aura-cli/src/main.rs`, add the arm right after the `["graph", "build"]` arm: ```rust ["graph", "introspect", rest @ ..] => graph_construct::introspect_cmd(rest), ``` - [ ] **Step 4: Run the introspection tests** Run: `cargo test -p aura-cli introspect_node_lists_ports_kinds_and_params` Expected: PASS. Run: `cargo test -p aura-cli introspect_unwired_reports_open_slots_of_a_partial_document` Expected: PASS. ### Task 12: E2E — drive the `aura` binary end-to-end **Files:** - Create: `crates/aura-cli/tests/graph_construct.rs` - [ ] **Step 1: Write the E2E integration test** Create `crates/aura-cli/tests/graph_construct.rs` (models the binary-driving pattern of `crates/aura-cli/tests/cli_run.rs` + the stdin-piping of `crates/aura-cli/tests/cli_broken_pipe.rs`): ```rust //! End-to-end coverage for the `aura graph build` / `aura graph introspect` //! op-script CLI (#157, §C): drives the built `aura` binary over stdin/argv, //! the exact surface a data-level author uses. use std::io::Write; use std::process::{Command, Stdio}; const BIN: &str = env!("CARGO_BIN_EXE_aura"); /// Run `aura ` with `stdin_doc` piped in; return (stdout, stderr, success). fn run(args: &[&str], stdin_doc: &str) -> (String, String, bool) { let mut child = Command::new(BIN) .args(args) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .expect("spawn aura"); child.stdin.take().unwrap().write_all(stdin_doc.as_bytes()).unwrap(); let out = child.wait_with_output().expect("wait aura"); ( String::from_utf8_lossy(&out.stdout).into_owned(), String::from_utf8_lossy(&out.stderr).into_owned(), out.status.success(), ) } const SIGNAL_DOC: &str = r#"[ {"op":"source","role":"price","kind":"F64"}, {"op":"add","type":"SMA","as":"fast","bind":{"length":{"I64":2}}}, {"op":"add","type":"SMA","as":"slow","bind":{"length":{"I64":4}}}, {"op":"add","type":"Sub"}, {"op":"add","type":"Bias"}, {"op":"feed","role":"price","into":["fast.series","slow.series"]}, {"op":"connect","from":"fast.value","to":"sub.lhs"}, {"op":"connect","from":"slow.value","to":"sub.rhs"}, {"op":"connect","from":"sub.value","to":"bias.signal"}, {"op":"expose","from":"bias.bias","as":"bias"} ]"#; #[test] fn graph_build_emits_blueprint_for_a_valid_document() { let (stdout, _stderr, ok) = run(&["graph", "build"], SIGNAL_DOC); assert!(ok, "exit success"); assert!(stdout.contains("\"format_version\":1"), "emits the #155 envelope: {stdout}"); assert!(stdout.contains("\"SMA\""), "carries the nodes: {stdout}"); } #[test] fn graph_build_fails_fast_at_the_offending_op() { let doc = r#"[{"op":"add","type":"SMA","as":"fast"},{"op":"add","type":"Nope"}]"#; let (stdout, stderr, ok) = run(&["graph", "build"], doc); assert!(!ok, "non-zero exit"); assert!(stdout.is_empty(), "no blueprint emitted on error: {stdout}"); assert!(stderr.contains("op 1 (add)"), "names the failing op: {stderr}"); assert!(stderr.contains("Nope"), "names the cause: {stderr}"); } #[test] fn graph_introspect_vocabulary_lists_the_node_types() { let (stdout, _stderr, ok) = run(&["graph", "introspect", "--vocabulary"], ""); assert!(ok, "exit success"); assert!(stdout.contains("SMA"), "lists SMA: {stdout}"); assert!(stdout.contains("Bias"), "lists Bias: {stdout}"); assert!(!stdout.contains("Recorder"), "a sink is not in the zero-arg vocabulary: {stdout}"); } #[test] fn graph_introspect_node_answers_ports_without_a_build() { let (stdout, _stderr, ok) = run(&["graph", "introspect", "--node", "SMA"], ""); assert!(ok, "exit success"); assert!(stdout.contains("series"), "lists the input port: {stdout}"); assert!(stdout.contains("value"), "lists the output field: {stdout}"); } #[test] fn graph_introspect_unwired_reports_open_slots() { let doc = r#"[ {"op":"add","type":"SMA","as":"fast"}, {"op":"add","type":"Sub"}, {"op":"connect","from":"fast.value","to":"sub.lhs"} ]"#; let (stdout, _stderr, ok) = run(&["graph", "introspect", "--unwired"], doc); assert!(ok, "exit success"); assert!(stdout.contains("sub.rhs"), "sub.rhs still open: {stdout}"); assert!(!stdout.contains("sub.lhs"), "sub.lhs is covered: {stdout}"); } ``` - [ ] **Step 2: Run the E2E suite** Run: `cargo test -p aura-cli --test graph_construct` Expected: PASS (5 tests). - [ ] **Step 3: Full workspace gates** Run: `cargo build --workspace` Expected: clean build. Run: `cargo test --workspace` Expected: all pass (iteration-1 tests + the new aura-cli tests; no regressions). Run: `cargo clippy --workspace --all-targets -- -D warnings` Expected: clean. ## Self-review (iteration 2, planner Step 5) 1. **Spec coverage:** §C maps to: DTO + serde encoding → Task 9; `aura graph build` (replay → emit / fail-fast) → Task 10; `aura graph introspect` (`--vocabulary` / `--node` / `--unwired`) → Task 11; E2E → Task 12. The spec's three acceptance criteria are now demonstrated through the CLI (Task 12). ✓ 2. **Placeholder scan:** no "TBD"/"TODO"/"implement later"/"similar to"/"add appropriate". ✓ 3. **Type consistency:** `OpDoc`, `build_from_str`, `format_op_error`, `build_cmd`, `introspect_node`, `introspect_unwired`, `introspect_cmd`, `graph_construct` — identical across the tasks and the `main.rs` arms. ✓ 4. **Step granularity:** each step is one test, one impl block, or one run. ✓ 5. **No commit steps:** none. ✓ 6. **Pin/replacement contiguity:** the E2E pins (`"op 1 (add)"`, `"\"format_version\":1"`) are matched against the exact strings `build_from_str` and `blueprint_to_json` emit — `op {idx} ({kind})` with idx=1/kind="add", and the #155 envelope `blueprint_serde` already emits (iteration-1 golden). No soft-wrap split. ✓ 7. **Compile-gate vs deferred-caller ordering:** each argv arm is added in the same task as the handler fn it calls (Task 10 adds the `build` arm + `build_cmd`; Task 11 adds the `introspect` arm + `introspect_cmd`), so every task's tree compiles. No signature change to an existing fn (the engine is untouched; the DTO + cores are net-new). ✓ 8. **Verification-filter strings resolve:** every Run filter names a test created in this iteration (Tasks 9-12) — `opdoc_document_deserializes_and_replays`, `build_from_str_*`, `introspect_*`, and the `--test graph_construct` target. The full-workspace gate (Task 12) is unfiltered with a no-regression expectation. The engine `Op`/`replay`/`blueprint_to_json` symbols the tests import were all confirmed exported this session (commit `27ac4dc`). ✓