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