Files
Aura/docs/plans/0088-construction-op-script.md
T
Brummel f271e40d17 plan: 0088 construction op-script — iteration 2 (§C CLI shell)
Iteration-2 tasks (9-12) appended to the cycle-0088 plan: the JSON op-list serde
DTO (CLI-side, engine Op stays serde-free), `aura graph build` (replay -> emit
#155 / fail fast at op N), `aura graph introspect` (--vocabulary / --node /
--unwired), and an E2E suite driving the aura binary. All changes in aura-cli;
the engine core (ea1ca32 + 27ac4dc) is consumed unchanged. Wire-format forks
derived + recorded on #157.

refs #157
2026-06-29 20:42:16 +02:00

72 KiB

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.rsOp / OpError / GraphSession / replay / unwired (§B).
  • Modify: crates/aura-engine/src/lib.rsmod 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):

#[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):

/// 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:

    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:

    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):

#[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):

/// A name→index resolution outcome, shared by `GraphBuilder` (mapped to
/// `BuildError`) and `GraphSession` (mapped to `OpError`). Over `&str`, so it
/// serves both `&'static str` literals and runtime document names.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum PortResolveError {
    Unknown,
    Ambiguous,
}

/// Resolve an input-port name to its slot index by exactly-one-match.
pub(crate) fn resolve_input_slot(schema: &NodeSchema, name: &str) -> Result<usize, PortResolveError> {
    let m: Vec<usize> = schema
        .inputs
        .iter()
        .enumerate()
        .filter(|(_, port)| port.name == name)
        .map(|(i, _)| i)
        .collect();
    match m.as_slice() {
        [i] => Ok(*i),
        [] => Err(PortResolveError::Unknown),
        _ => Err(PortResolveError::Ambiguous),
    }
}

/// Resolve an output-field name to its field index by exactly-one-match.
pub(crate) fn resolve_output_field(schema: &NodeSchema, name: &str) -> Result<usize, PortResolveError> {
    let m: Vec<usize> = schema
        .output
        .iter()
        .enumerate()
        .filter(|(_, f)| f.name == name)
        .map(|(i, _)| i)
        .collect();
    match m.as_slice() {
        [i] => Ok(*i),
        [] => Err(PortResolveError::Unknown),
        _ => Err(PortResolveError::Ambiguous),
    }
}

Then rewrite GraphBuilder::resolve_slot (:160-177) and resolve_field (:181-198) to delegate, preserving the exact BuildError variants:

    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<usize, BuildError> {
        let schema = self.schemas.get(p.node).ok_or(BuildError::BadHandle { node: p.node })?;
        match resolve_output_field(schema, p.name) {
            Ok(i) => Ok(i),
            Err(PortResolveError::Unknown) => {
                Err(BuildError::UnknownOutPort { node: p.node, name: p.name.to_string() })
            }
            Err(PortResolveError::Ambiguous) => {
                Err(BuildError::AmbiguousOutPort { node: p.node, name: p.name.to_string() })
            }
        }
    }
  • 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):

#[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):

/// 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:

/// 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<Self, BindOpError> {
    let matches: Vec<usize> = 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<usize> = 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):

#[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):

/// 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:

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:

//! 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<PrimitiveBuilder>`), 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<String>, bind: Vec<(String, Scalar)> },
    /// Fan a role into one or more `"node.slot"` consumers.
    Feed { role: String, into: Vec<String> },
    /// 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<PrimitiveBuilder>,
    nodes: Vec<BlueprintNode>,
    schemas: Vec<NodeSchema>,
    ids: Vec<String>,
    names: HashMap<String, usize>,
    edges: Vec<Edge>,
    roles: Vec<(String, Option<ScalarKind>, Vec<Target>)>,
    role_names: HashMap<String, usize>,
    out: Vec<OutField>,
    out_names: HashMap<String, ()>,
    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<String>, vocab: &'v dyn Fn(&str) -> Option<PrimitiveBuilder>) -> 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<ScalarKind>) -> 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<String>,
        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<PrimitiveBuilder> and BlueprintNode::signature exist (grep -n "impl From<PrimitiveBuilder>\|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:

#[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:

    // 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):

    /// 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<usize, OpError> {
        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<String>) -> 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:

    #[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>:

    /// 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<Composite, OpError> {
        let roles: Vec<Role> = 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<String>,
    ops: Vec<Op>,
    vocab: &dyn Fn(&str) -> Option<PrimitiveBuilder>,
) -> Result<Composite, (usize, OpError)> {
    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):

    #[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(&params)
            .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(&params).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 seriesvalue, Sub lhs/rhsvalue, Bias signalbias. 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:

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.rsmod 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:

//! 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<String>,
        #[serde(default)]
        bind: BTreeMap<String, Scalar>,
    },
    Feed { role: String, into: Vec<String> },
    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<OpDoc> 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<OpDoc> = serde_json::from_str(doc).expect("document parses");
        assert_eq!(docs.len(), 10);
        assert_eq!(docs[1].kind_label(), "add");
        let ops: Vec<Op> = 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:

    #[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):

/// 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<String, String> {
    let docs: Vec<OpDoc> = 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<Op> = 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):

        ["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:

    #[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:

/// `aura graph introspect --node <T>`: 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<String, String> {
    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<String, String> {
    let docs: Vec<OpDoc> = 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 <T> | --unwired");
            std::process::exit(2);
        }
    }
}

In crates/aura-cli/src/main.rs, add the arm right after the ["graph", "build"] arm:

        ["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):

//! 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 <args>` 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). ✓