diff --git a/crates/aura-cli/src/graph_construct.rs b/crates/aura-cli/src/graph_construct.rs index dae4ec5..a1949eb 100644 --- a/crates/aura-cli/src/graph_construct.rs +++ b/crates/aura-cli/src/graph_construct.rs @@ -1,8 +1,8 @@ //! The CLI-side serde front-end for the construction op-script (#157, §C): a //! JSON op-list document deserializes into the `OpDoc` DTO (so the engine `Op` //! stays serde-free), which maps 1:1 into `aura_engine::Op`. The `aura graph -//! build` / `aura graph introspect` surfaces that drive these ops through the -//! injected `aura_std::std_vocabulary` land in later tasks. +//! build` / `aura graph introspect` subcommands here drive these ops through the +//! injected `aura_std::std_vocabulary`. use std::collections::BTreeMap; diff --git a/crates/aura-engine/src/blueprint.rs b/crates/aura-engine/src/blueprint.rs index 703ab78..929708e 100644 --- a/crates/aura-engine/src/blueprint.rs +++ b/crates/aura-engine/src/blueprint.rs @@ -209,11 +209,7 @@ impl Composite { let space = self.param_space(); check_param_namespace_injective(&space)?; validate_wiring(&self.nodes, &self.edges, &self.input_roles, &self.output)?; - for (r, role) in self.input_roles.iter().enumerate() { - if role.source.is_none() { - return Err(CompileError::UnboundRootRole { role: r }); - } - } + check_root_roles_bound(&self.input_roles)?; if point.len() != space.len() { return Err(CompileError::ParamArity { expected: space.len(), got: point.len() }); @@ -573,6 +569,21 @@ pub(crate) fn check_param_namespace_injective(space: &[ParamSpec]) -> Result<(), Ok(()) } +/// Every root input role must be source-bound (`source: Some`): an open role +/// (`None`) at the root has no enclosing graph to wire it, so only a fully +/// source-bound composite is runnable. Shared by `compile_with_cells` (the +/// cell-side compile base) and `GraphSession::finish` (the op-script finalize), +/// so the root-role gate has a single definition across both cadences — the last +/// holistic check to be deduplicated (cycle 0088 audit). +pub(crate) fn check_root_roles_bound(roles: &[Role]) -> Result<(), CompileError> { + for (r, role) in roles.iter().enumerate() { + if role.source.is_none() { + return Err(CompileError::UnboundRootRole { role: r }); + } + } + Ok(()) +} + /// Resolve named bindings to a positional `Vec` in `param_space()` slot /// order. Thin caller over [`resolve_into`]: a single scalar has no claim-time /// rejection and kind-checks the one value. diff --git a/crates/aura-engine/src/construction.rs b/crates/aura-engine/src/construction.rs index e661147..97717df 100644 --- a/crates/aura-engine/src/construction.rs +++ b/crates/aura-engine/src/construction.rs @@ -8,22 +8,13 @@ //! (`Fn(&str)->Option`), so the engine stays domain-free //! (invariant 9). -// `construction` is a private module whose public surface — `Op` / `OpError` / -// `GraphSession` (with `add` / `source` / `input` / `connect` / `feed` / -// `expose` / `unwired` / `finish`) and the free `replay` fn, plus the session -// accumulator fields (`nodes` / `ids` / `edges` / `out` / `name`) — has no -// non-test consumer yet: the iteration-2 CLI (`aura graph build`/`introspect`) -// is what wires it in. Until then the compiler sees the whole surface as dead, -// so this allow is still load-bearing; it is removed when that consumer lands. -#![allow(dead_code)] - use std::collections::{HashMap, HashSet}; use aura_core::{BindOpError, NodeSchema, PrimitiveBuilder, Scalar, ScalarKind}; use crate::blueprint::{ - check_param_namespace_injective, edge_kind_check, validate_wiring, BlueprintNode, Composite, - OutField, Role, + check_param_namespace_injective, check_root_roles_bound, edge_kind_check, validate_wiring, + BlueprintNode, Composite, OutField, Role, }; use crate::builder::{resolve_input_slot, resolve_output_field, PortResolveError}; use crate::harness::{Edge, Target}; @@ -288,11 +279,7 @@ impl<'v> GraphSession<'v> { 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 })); - } - } + check_root_roles_bound(c.input_roles()).map_err(OpError::Incomplete)?; Ok(c) } } diff --git a/docs/design/INDEX.md b/docs/design/INDEX.md index 56bb002..f39b62b 100644 --- a/docs/design/INDEX.md +++ b/docs/design/INDEX.md @@ -1780,7 +1780,8 @@ thread) force the **value**. The engine already treats topology as a runtime val (C9 graph-as-data, C19 "cheap graph re-compilation, not a code recompile", C23 the flat graph as the optimisation target); C24 only adds that the value **serializes out of Rust and loads back in**. -**Status (2026-06-29; first cut shipped — cycle 0087 / #155, `d5602ec`).** The +**Status (2026-06-29; first cut shipped — cycle 0087 / #155, `d5602ec`; +construction service shipped — cycle 0088 / #157).** The **principle** is settled (this contract, ratified in an in-context design discussion — the #109 resolution). The **first cut now ships**: a `Composite` blueprint serializes to a **canonical, versioned** data value (`format_version` envelope, @@ -1791,12 +1792,25 @@ whose concrete closed `match` over the `aura-std` vocabulary lives outside the e (`aura-std::std_vocabulary`) — the engine stays domain-free, no node registry (invariant 9). Acceptance met: a serialized blueprint runs **bit-identical** (C1) to its Rust-built twin. `model_to_json` (C9) remains the render half; this closes the -loop for the round-trippable vocabulary. **Remaining** (each its own milestone -issue): the forward-compat *must-understand* gate (#156); the introspectable -construction service that *emits* blueprints (#157); content-addressed topology -identity in the manifest (#158, C18); retiring the pre-C24 hard-wired `aura-cli` -harnesses (`HarnessKind`, `run_stage1_r`, `*_sweep_family`) once the construction + -project-as-crate layers land (#159). **Out of the first cut's round-trippable set** +loop for the round-trippable vocabulary. **Cycle 0088 (#157) adds the +introspectable construction service**: a declarative, replayable by-identifier +op-script (`aura graph build` / `introspect` over a JSON op-list, the engine +`GraphSession` / `replay`) builds a runnable blueprint through validated ops — the +engine's construction gates split into *eager* per-op checks (name resolution, edge +kind-match, the double-wire arm, param bind) and *holistic* finalize checks (wiring +totality, param-namespace injectivity, root-role boundness), **both cadences calling +the same extracted predicates** (`edge_kind_check`, the shared resolution helpers, +`check_root_roles_bound` — no second validator) — plus build-free introspection over +the closed vocabulary (types, a node's ports/kinds, a partial document's unwired +slots). It **emits** the #155 blueprint; acceptance met: a graph built purely through +the ops compiles identical (C1) to its Rust-built twin, an invalid op is rejected at +the op naming the cause, introspection answers without a build. The engine `Op` stays +serde-free (the wire DTO is CLI-side); the vocabulary's enumerable companion +(`std_vocabulary_types`) lives in `aura-std` (no registry, invariant 9). **Remaining** +(each its own milestone issue): the forward-compat *must-understand* gate (#156); +content-addressed topology identity in the manifest (#158, C18); retiring the pre-C24 +hard-wired `aura-cli` harnesses (`HarnessKind`, `run_stage1_r`, `*_sweep_family`) once +the project-as-crate layer lands (#159, paired with #157's data-authoring surface). **Out of the first cut's round-trippable set** (deliberate; fails clean as `UnknownNodeType`, never a silent wrong graph): recording sinks (capture an `mpsc::Sender` — runtime identity, not param-generic data, C19) and construction-arg builders (`LinComb` / `CostSum` / `SimBroker` / `Session` — diff --git a/docs/plans/0088-construction-op-script.md b/docs/plans/0088-construction-op-script.md deleted file mode 100644 index 6617c23..0000000 --- a/docs/plans/0088-construction-op-script.md +++ /dev/null @@ -1,1799 +0,0 @@ -# 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`). ✓ diff --git a/docs/specs/0088-construction-op-script.md b/docs/specs/0088-construction-op-script.md deleted file mode 100644 index 70352aa..0000000 --- a/docs/specs/0088-construction-op-script.md +++ /dev/null @@ -1,431 +0,0 @@ -# Introspectable, fail-fast construction service — Design Spec - -**Date:** 2026-06-29 -**Status:** Draft — awaiting user spec review -**Authors:** orchestrator + Claude -**Issue:** #157 (depends on #155, landed `d5602ec`) - -## Goal - -Deliver an **introspectable, fail-fast construction service** that lets a -data-level author (Claude Code, or a small LLM) build a runnable blueprint -through validated ops — without hand-writing the raw-index serialized format -(#155) and without authoring a many-invariant artifact blind. The service emits -a blueprint (the #155 value), reusing the engine's *existing* construction gates -rather than introducing a second validator (C24). - -The surface, settled in #157's discussion, is a **declarative, replayable, -by-identifier op-script**: a document of construction ops, replayed op-by-op -from scratch, stopping at the first op that does not resolve and naming it. The -document *is* the artifact (a Strudel-style document-re-eval model — the script -replayed from scratch is deterministic (C1) and a World-owned topology value -(C24)), with no held process state that can drift from the history that produced -it. MCP is out of scope (deferred, consumer-strength-dependent). - -The work has a **surface-agnostic core** (the value of #157) and a thin CLI -shell over it: - -- **Core (engine):** split the construction gates into *eager* per-op checks - and *holistic* end-of-document checks, both calling the same extracted - predicates; a per-op-fallible construction surface that drives them; and a - build-free introspection API over the closed `aura-std` vocabulary and over a - partial script. -- **Shell (CLI):** a JSON op-list encoding and the `aura graph build` / - `aura graph introspect` subcommands. - -This retires, in concert with #159, the hard-wired `aura-cli` harness -(`sample_blueprint`, `crates/aura-cli/src/main.rs:3313`) — topology as Rust -source is exactly what C24 forbids; this issue supplies the data-authoring -surface that replaces it. - -## Feature-acceptance criterion (applied, aura CLAUDE.md) - -aura's deliverable is the **engine**, and its north star (C24) is *topology as -data for every author*. The criterion for this feature: - -1. **A data-level author naturally reaches for it.** The author writes a - document of ops (shown below) instead of Rust builder source or raw-index - JSON — and is never authoring blind, because introspection answers "what - nodes exist, what are this node's ports/kinds, what is still unwired" without - a build. -2. **It measurably improves correctness / removes redundancy.** A constructed - graph runs **identically** to its Rust-built twin (C1); the hard-wired CLI - harness it replaces is removed source. No second validator is introduced — - the per-op and holistic checks are the *same* engine predicates at two - cadences. -3. **It reintroduces no class of failure the core constraint exists to - eliminate.** The failure mode that sank the RustAst DSL was *unaided - authoring of a many-invariant artifact*. The op-script does not reintroduce - it: every op is checked fail-fast against the engine's own gates, and - introspection means the author never authors blind. The document carries no - logic — only constructive ops with literal params — so it is data, not an - applied/Turing-complete DSL (C24, invariant 10). - -The worked author program under **Concrete code shapes** is this criterion's -empirical evidence. - -## Architecture - -Three layers, named §A/§B/§C; §A+§B are the surface-agnostic engine core -(iteration 1), §C the CLI shell (iteration 2). - -### §A — Eager / holistic gate split (the shared predicates) - -Today, *all* structural validation runs at finalize, in two stages: - -- `GraphBuilder::build()` (`crates/aura-engine/src/builder.rs:131`) resolves - port/field **names** to indices, yielding `BuildError` for unknown/ambiguous - ports and bad handles. It does **not** check edge kinds — the module doc - (`builder.rs:50-52`) states a name-resolved edge with mismatched kinds - surfaces only downstream. -- `Composite::compile_with_params` →`compile_with_cells` - (`blueprint.rs:262`, `:207`) runs the three structural gates: - `check_param_namespace_injective` (`:566`), `validate_wiring` (`:628`, which - includes the edge **kind-match**, raised as `Bootstrap(KindMismatch)` at - `:644`), and `check_ports_connected` (`:688`). - -The op-script needs some of these checks to fire **per op** (acceptance 2: -"rejected at the op"), while others are only decidable once the document ends. -The split is forced by *what is decidable when*: - -| Check | Today (finalize) | Op-script cadence | Why | -|---|---|---|---| -| Name resolution (port/field → index) | `build()` | **eager** (per op) | the referenced node is already added | -| Edge kind-match (producer field kind == consumer slot kind) | `validate_wiring` `:638-649` | **eager** (per op) | both schemas known at the `connect` op | -| Slot covered **>1** (`DoubleWiredPort`) | `check_ports_connected` `:708` | **eager** (per op) | a second cover is visible the moment it happens | -| Param bind name/kind | `bind` panics (`node.rs`) | **eager** (per op) | the node's schema is known at `add` | -| Slot covered **0** (`UnconnectedPort`) | `check_ports_connected` `:707` | **holistic** (finalize) | a mid-document graph is *legitimately* incomplete | -| Param-namespace injective (`DuplicateParamPath`) | `check_param_namespace_injective` | **holistic** (finalize) | only decidable once all nodes + names exist | -| Role-kind consistency, root-role boundness, output range | `compile_with_*` | **holistic** (finalize) | whole-graph properties | - -The principle is sharpest at `check_ports_connected`, whose match has exactly -two failure arms: the **>1** arm (`DoubleWiredPort`) is eagerly decidable; the -**0** arm (`UnconnectedPort`) is not. The op-script front-runs the >1 arm and -defers the 0 arm. - -**No second validator.** The eager path does not re-implement these checks: the -edge-kind check is **extracted** from `validate_wiring`'s inline loop into a -shared predicate called by *both* the eager op and the unchanged holistic -`validate_wiring`; the eager coverage tracker shares the "exactly-once" -invariant with the unchanged `check_ports_connected` (which remains the holistic -backstop, still catching both arms). The name-resolution helpers -(`builder.rs:160-198`, `resolve_slot`/`resolve_field`) become shared -(`pub(crate)`) and are called eagerly per op. - -### §B — Per-op-fallible construction surface + introspection (engine) - -A new engine surface (planner names the type; provisionally `GraphSession`) -drives the eager checks. It is **not** an extension of `GraphBuilder`: -`GraphBuilder`'s documented contract is *infallible accumulate, one fallible -`build()`* (`builder.rs:1-9`, `:67-68`); the op-script's stop-at-first-failing-op -is a different, per-op-fallible discipline. The new surface reuses -`GraphBuilder`'s resolution helpers and the §A predicates over shared internals, -rather than overloading one type with two contracts. - -It owns: the in-progress nodes/schemas/edges/roles/outputs (as `GraphBuilder` -does), an **incremental coverage map** `(node, slot) → count` (the eager -double-wire tracker), and a **name→handle map** for the document's `as NAME` -identifiers. Each op is applied fallibly; a terminal `finish` runs the holistic -gates and returns the `Composite` (→ `blueprint_to_json` for the emitted -blueprint; → `compile_with_params` for the runnable `FlatGraph`). - -**Introspection** is read-only and build-free, in two modes: - -- **Static (type-level):** enumerate the vocabulary; for a type, its ports + - kinds (`NodeSchema.inputs`/`.output`, `PortSpec`/`FieldSpec` carry `name` + - `kind`) and its param paths (`PrimitiveBuilder::params`). All declared - pre-build (C8). This requires an **enumerable** companion to the closed - `std_vocabulary` match (see §Components) — a `fn(&str)->Option<…>` resolver - alone cannot be listed. -- **Partial-script (instance-level):** the still-**unwired** slots of an - in-progress session = every added node's declared input slots minus the - covered ones, read straight off the coverage map. - -### §C — JSON op-list encoding + CLI shell - -The document's canonical encoding is a **JSON op-list** (serde-native, mirroring -#155's blueprint JSON), so the core carries no hand-written parser/lexer (the -smallest "no new language" surface). A line-oriented human text front-end is a -**deferred** ergonomic sugar (not this cycle). - -`aura graph build < doc.json` deserializes the op-list, replays it through a -`GraphSession`, and on success emits the blueprint JSON (and/or compiles to a -`FlatGraph`); on the first failing op it prints the op index + cause and exits -non-zero. `aura graph introspect` answers the static + partial-script queries. -Both extend the existing flat argv `match` (`main.rs:3292`); the bare -`["graph"]` HTML-render arm is unaffected for now (its `sample_blueprint` -removal is #159's concern, with which this pairs). - -## Concrete code shapes - -### Worked author program (the acceptance evidence) - -The author builds the `sma_cross` **signal blueprint** — `price → fast/slow SMA -→ Sub → Bias`, exposed as `bias`. Every node is in the zero-arg vocabulary -(`SMA`, `Sub`, `Bias`), so it round-trips today. (A *full* harness adds -`SimBroker`/`Recorder`, whose construction-arg / `mpsc::Sender` builders are -deliberately outside the #155 vocabulary — #156; so the op-script's near-term -reach is signal blueprints, not full harnesses, exactly as #157's sequencing -note states.) - -Canonical document (`sma_cross.json`): - -```json -[ - {"op": "source", "role": "price", "kind": "f64"}, - {"op": "add", "type": "SMA", "as": "fast", "bind": {"length": 2}}, - {"op": "add", "type": "SMA", "as": "slow", "bind": {"length": 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"} -] -``` - -```console -$ aura graph build < sma_cross.json -# stdout: the #155 blueprint JSON (format_version + blueprint{nodes,edges,…}) -``` - -Deferred text-sugar equivalent (illustrative only, **not** in this cycle): - -``` -source price: f64 -add SMA(length=2) as fast -add SMA(length=4) as slow -add Sub -add Bias -feed price -> fast.series, slow.series -connect fast.value -> sub.lhs -connect slow.value -> sub.rhs -connect sub.value -> bias.signal -expose bias.bias as bias -``` - -Fail-fast rejection (acceptance 2) — each names the offending op + cause, and -nothing after it runs: - -```console -$ echo '[{"op":"add","type":"Nope"}]' | aura graph build -error: op 0 (add): unknown node type "Nope" # not in std_vocabulary - -# kind mismatch — a bool field into an f64 slot, rejected at the connect op -$ aura graph build < kindbad.json -error: op 4 (connect gt.value -> bias.signal): kind mismatch (producer bool, consumer f64) - -# double-wire — a second producer into an already-wired slot -$ aura graph build < doublewire.json -error: op 5 (connect slow.value -> sub.lhs): slot sub.lhs already wired -``` - -Introspection (acceptance 3) — build-free: - -```console -$ aura graph introspect --vocabulary -Add And Bias CarryCost ConstantCost Delay EMA EqConst FixedStop Gt Latch -LongOnly Mul PositionManagement Resample RollingMax RollingMin Sizer SMA -Sqrt Sub VolSlippageCost - -$ aura graph introspect --node SMA -SMA inputs: series:f64 output: value:f64 params: length:i64 - -# the still-unwired slots of a partial document -$ aura graph introspect --unwired < partial.json -sub.rhs:f64 bias.signal:f64 -``` - -### Engine shapes (secondary, before → after) - -**(1) Extract the edge-kind predicate** — shared by the eager op and the -unchanged holistic `validate_wiring`. - -```rust -// NEW shared predicate (pub(crate)); the body is lifted verbatim from -// validate_wiring's edge loop (blueprint.rs:638-649), so the variant is -// unchanged (Bootstrap(KindMismatch)) and 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(()) -} -// validate_wiring's edge loop now calls edge_kind_check (after its index lookups); -// the eager connect op calls the same predicate. ONE check, two cadences. -``` - -**(2) `check_ports_connected` is unchanged** (`blueprint.rs:688`) — it stays the -holistic backstop catching the **0** arm (`UnconnectedPort`) and, defensively, -the **>1** arm. The eager surface front-runs the **>1** arm via its incremental -coverage map: - -```rust -// inside the per-op connect/feed handler (eager half of the exactly-once invariant) -let n = *self.coverage.entry((to_node, slot)).or_insert(0); -if n >= 1 { return Err(OpError::SlotAlreadyWired { node: to_node, slot }); } // DoubleWiredPort, raised early -self.coverage.insert((to_node, slot), n + 1); -``` - -**(3) A non-panicking bind** — today `PrimitiveBuilder::bind` *panics* on an -unknown/ambiguous/kind-wrong param (`node.rs`, the `debug_assert`/`expect` -path). The `add … bind{…}` op needs a fallible variant so a bad param is -rejected *at the op*, not a panic: - -```rust -// NEW: the fallible twin of bind() (same exactly-one-match + kind rule, Result not panic) -pub fn try_bind(self, slot: &str, value: Scalar) -> Result { … } -``` - -**(4) The per-op surface (engine) — op enum + drive/finish:** - -```rust -pub enum Op { // serde-deserialized from the JSON op-list (§C) - Source { role: String, kind: ScalarKind }, - Input { role: String }, - Add { type_id: String, as_name: Option, bind: Vec<(String, Scalar)> }, - Feed { role: String, into: Vec }, // "node.slot" identifiers - Connect { from: String, to: String }, // "node.field" -> "node.slot" - Expose { from: String, as_name: String }, -} - -pub struct GraphSession { /* nodes, schemas, edges, roles, out, coverage, name->handle */ } - -impl GraphSession { - pub fn new(name: &str, vocab: &dyn Fn(&str) -> Option) -> Self; - pub fn apply(&mut self, op: Op) -> Result<(), OpError>; // eager checks (§A) - pub fn finish(self) -> Result; // holistic gates → Composite - // build-free introspection over the partial session: - pub fn unwired(&self) -> Vec<(String /*node.slot*/, ScalarKind)>; -} - -// replay driver: stop at first failing op, naming it (acceptance 2) -pub fn replay(name: &str, ops: Vec, vocab: …) -> Result; -``` - -`OpError` carries by-identifier causes (`UnknownNodeType(String)`, -`UnknownPort{node,name}`, `KindMismatch{…}`, `SlotAlreadyWired{…}`, -`DuplicateIdentifier(String)`, `BadParam(BindOpError)`, and a holistic -`Incomplete(CompileError)` wrapping the finalize gate). The eager variants reuse -§A's predicates and `builder.rs`'s resolution; `Incomplete` wraps the unchanged -`compile_with_params`/finalize path. - -### §C — enumerable vocabulary companion - -```rust -// aura-std/src/vocabulary.rs — companion to the existing std_vocabulary match (:30) -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"] -} -// introspection maps each type → std_vocabulary(type).schema() for ports/kinds/params. -// (The same iterable surface would let #160's guard test iterate; #160 stays separate.) -``` - -## Components - -- **`aura-engine` (§A):** extract `edge_kind_check` (and the index-range lookups - it needs) from `validate_wiring`; make `resolve_slot`/`resolve_field` shared - (`pub(crate)`); add `PrimitiveBuilder::try_bind` (fallible twin of `bind`). - `check_ports_connected` and `compile_with_params` are **unchanged**. -- **`aura-engine` (§B):** the `GraphSession` per-op surface + `Op`/`OpError` - enums + the `replay` driver + `unwired` introspection. New module - (e.g. `construction.rs`). -- **`aura-std` (§C):** `std_vocabulary_types()` — the enumerable companion. -- **`aura-cli` (§C):** serde `Deserialize` for `Op` (the JSON op-list); the - `["graph","build", …]` and `["graph","introspect", …]` argv arms over - `main.rs:3292`; rendering of `OpError` / introspection results to - stdout/stderr with the op index. - -## Data flow - -``` -document (JSON op-list) [§C] - └─ serde → Vec - └─ replay(name, ops, std_vocabulary) ──── [§B] - ├─ per op: apply(op) → eager checks [§A predicates] ──┐ stop at first Err, - │ │ naming op index + cause - └─ finish() → holistic gates → Composite ┘ - ├─ blueprint_to_json(&composite) → emitted blueprint (#155) [title: "emits blueprints"] - └─ composite.compile_with_params(params) → FlatGraph - └─ identical to the Rust-built twin's FlatGraph [acceptance 1, C1] - -introspection (no build): - --vocabulary → std_vocabulary_types() - --node T → std_vocabulary(T).schema() (ports/kinds) + .params() - --unwired → replay partial → GraphSession::unwired() -``` - -## Error handling - -- **Eager (per op):** `apply` returns `OpError` with a by-identifier cause; the - `replay` driver returns `(op_index, OpError)`; the CLI prints `op N (kind): - cause` and exits non-zero. Stop-at-first — no op after the failing one runs. -- **Holistic (finish):** `Incomplete(CompileError)` wraps the unchanged finalize - gates (`UnconnectedPort`, `DuplicateParamPath`, `UnboundRootRole`, …). An - `UnconnectedPort` is reported against the document as a whole, not an op. -- **No second validator:** every eager rejection traces to a §A predicate that - the holistic gate also uses; the holistic gates remain the backstop. A bug in - a predicate fails both cadences identically (a single fix site). - -## Testing strategy - -Iteration 1 (engine, §A+§B) — driven directly through `GraphSession`/`replay`, -no CLI: - -- **Acceptance 1 (identical run):** author `sma_cross` (the worked example) via - `replay`; author the same via `GraphBuilder`; compile both with the same - params; assert `flat.edges` and `flat.sources` equal — modeled on the green - `builder_harness_compiles_identically_to_hand_wired` (`builder.rs:234`). -- **Acceptance 2 (per-op rejection):** one test per cause — unknown node - (`add Nope` → op 0), edge kind-mismatch (bool→f64 → at the connect op), - double-wire (second connect into a slot → at that op), bad bind param. Each - asserts the failing op index + the `OpError` variant. -- **Eager/holistic boundary:** a document missing a connection passes every - per-op `apply` but `finish` returns `Incomplete(UnconnectedPort{…})` — proving - totality is *not* front-run (the legitimately-incomplete mid-document state). -- **No-second-validator:** a kind-mismatch caught eagerly and the same wiring - caught by `validate_wiring` both resolve to `edge_kind_check` (the eager - `OpError::KindMismatch` and the holistic `Bootstrap(KindMismatch)` agree). -- **Introspection:** `std_vocabulary_types()` lists exactly the resolvable - keys (cross-check against `std_vocabulary` — a key present in one but not the - other fails); `--node SMA` shape; `unwired()` on a partial session. - -Iteration 2 (CLI, §C) — E2E: `aura graph build < doc.json` emits the blueprint; -an invalid op prints `op N` + cause and exits non-zero; `aura graph introspect` -answers each query. `cargo test --workspace` green; `clippy --all-targets -D -warnings` clean. - -## Acceptance criteria - -- [ ] **(1)** A `sma_cross` signal blueprint built purely through the ops - compiles to a `FlatGraph` identical (edges + sources) to its `GraphBuilder`-built - twin (C1). -- [ ] **(2)** An invalid op — unknown node, edge kind-mismatch, double-wire, bad - bind param — is rejected **at that op**, naming the op index + cause; no later - op runs. -- [ ] **(3)** Introspection answers the node vocabulary, a node's ports/kinds + - param paths, and a partial document's unwired slots — all without a build. -- [ ] No second validator: the eager and holistic checks call the same extracted - predicates (`edge_kind_check`, the exactly-once coverage invariant); the - finalize gates (`compile_with_params`) are unchanged. -- [ ] `aura graph build` emits the #155 blueprint JSON for a valid document. -- [ ] `cargo test --workspace` green; `cargo clippy --workspace --all-targets -D - warnings` clean. - -## Iteration scope (for the planner) - -- **Iteration 1 — engine core (§A + §B):** the gate split + shared predicates, - `GraphSession`/`Op`/`replay`, `try_bind`, the introspection API + enumerable - vocabulary companion. Fully testable through the engine API; no CLI. -- **Iteration 2 — CLI shell (§C):** the JSON op-list serde encoding and the - `aura graph build` / `aura graph introspect` subcommands; E2E tests.