f271e40d17
Iteration-2 tasks (9-12) appended to the cycle-0088 plan: the JSON op-list serde DTO (CLI-side, engine Op stays serde-free), `aura graph build` (replay -> emit #155 / fail fast at op N), `aura graph introspect` (--vocabulary / --node / --unwired), and an E2E suite driving the aura binary. All changes in aura-cli; the engine core (ea1ca32+27ac4dc) is consumed unchanged. Wire-format forks derived + recorded on #157. refs #157
1800 lines
72 KiB
Markdown
1800 lines
72 KiB
Markdown
# 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<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:
|
|
|
|
```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<usize, BuildError> {
|
|
let schema = self.schemas.get(p.node).ok_or(BuildError::BadHandle { node: p.node })?;
|
|
match resolve_output_field(schema, p.name) {
|
|
Ok(i) => Ok(i),
|
|
Err(PortResolveError::Unknown) => {
|
|
Err(BuildError::UnknownOutPort { node: p.node, name: p.name.to_string() })
|
|
}
|
|
Err(PortResolveError::Ambiguous) => {
|
|
Err(BuildError::AmbiguousOutPort { node: p.node, name: p.name.to_string() })
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run the new test + the existing builder resolution tests**
|
|
|
|
Run: `cargo test -p aura-engine shared_port_resolution_matches_exactly_one`
|
|
Expected: PASS (1 passed).
|
|
|
|
Run: `cargo test -p aura-engine unknown_in_port_is_reported_at_build`
|
|
Expected: PASS (BuildError variants unchanged).
|
|
|
|
Run: `cargo test -p aura-engine ambiguous_in_port_is_reported`
|
|
Expected: PASS.
|
|
|
|
---
|
|
|
|
### Task 3: §A — fallible `try_bind` + `BindOpError`
|
|
|
|
**Files:**
|
|
- Modify: `crates/aura-core/src/node.rs:189-237` (add `try_bind` after `bind`),
|
|
`crates/aura-core/src/lib.rs` (re-export)
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Add to `node.rs`'s `#[cfg(test)] mod tests` (`grep -n "mod tests"
|
|
crates/aura-core/src/node.rs`):
|
|
|
|
```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<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`):
|
|
|
|
```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<PrimitiveBuilder>`), so the engine stays domain-free
|
|
//! (invariant 9).
|
|
|
|
use std::collections::HashMap;
|
|
|
|
use aura_core::{BindOpError, NodeSchema, PrimitiveBuilder, Scalar, ScalarKind};
|
|
|
|
use crate::blueprint::{
|
|
check_param_namespace_injective, edge_kind_check, validate_wiring, BlueprintNode, Composite,
|
|
OutField, Role,
|
|
};
|
|
use crate::builder::{resolve_input_slot, resolve_output_field, PortResolveError};
|
|
use crate::harness::{Edge, Target};
|
|
use crate::CompileError;
|
|
|
|
/// One construction act — the engine-side op vocabulary, 1:1 with the document.
|
|
/// Port references are dotted by-identifier (`"node.field"` / `"node.slot"`),
|
|
/// split at the first `.`. (serde `Deserialize` is iteration 2.)
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub enum Op {
|
|
/// Reserve a bound root source role of `kind`.
|
|
Source { role: String, kind: ScalarKind },
|
|
/// Reserve an open input role (wired by an enclosing graph).
|
|
Input { role: String },
|
|
/// Add a node of `type_id`, optionally named `as_name`, with `bind` params.
|
|
Add { type_id: String, as_name: Option<String>, bind: Vec<(String, Scalar)> },
|
|
/// Fan a role into one or more `"node.slot"` consumers.
|
|
Feed { role: String, into: Vec<String> },
|
|
/// Wire `"node.field"` -> `"node.slot"`.
|
|
Connect { from: String, to: String },
|
|
/// Re-export `"node.field"` at the boundary under `as_name`.
|
|
Expose { from: String, as_name: String },
|
|
}
|
|
|
|
/// A per-op construction fault, by-identifier so the cause names the op.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub enum OpError {
|
|
UnknownNodeType(String),
|
|
DuplicateIdentifier(String),
|
|
DuplicateRole(String),
|
|
UnknownIdentifier(String),
|
|
UnknownRole(String),
|
|
MalformedPort(String),
|
|
UnknownInPort { node: String, name: String },
|
|
AmbiguousInPort { node: String, name: String },
|
|
UnknownOutPort { node: String, name: String },
|
|
AmbiguousOutPort { node: String, name: String },
|
|
KindMismatch { from: String, to: String, producer: ScalarKind, consumer: ScalarKind },
|
|
SlotAlreadyWired { node: String, slot: String },
|
|
BadParam { node: String, err: BindOpError },
|
|
/// A holistic finalize fault (totality / injectivity / unbound root role),
|
|
/// wrapping the unchanged engine gate's `CompileError`.
|
|
Incomplete(CompileError),
|
|
}
|
|
|
|
/// A per-op-fallible blueprint accumulator. Holds the same interior data a
|
|
/// `Composite` is built from, plus the incremental coverage map (eager
|
|
/// double-wire front-run) and the identifier→index maps the document references.
|
|
pub struct GraphSession<'v> {
|
|
name: String,
|
|
vocab: &'v dyn Fn(&str) -> Option<PrimitiveBuilder>,
|
|
nodes: Vec<BlueprintNode>,
|
|
schemas: Vec<NodeSchema>,
|
|
ids: Vec<String>,
|
|
names: HashMap<String, usize>,
|
|
edges: Vec<Edge>,
|
|
roles: Vec<(String, Option<ScalarKind>, Vec<Target>)>,
|
|
role_names: HashMap<String, usize>,
|
|
out: Vec<OutField>,
|
|
out_names: HashMap<String, ()>,
|
|
coverage: HashMap<(usize, usize), usize>,
|
|
}
|
|
|
|
impl<'v> GraphSession<'v> {
|
|
/// Start a session for a composite named `name`, resolving node types through
|
|
/// the injected closed vocabulary `vocab`.
|
|
pub fn new(name: impl Into<String>, vocab: &'v dyn Fn(&str) -> Option<PrimitiveBuilder>) -> Self {
|
|
Self {
|
|
name: name.into(),
|
|
vocab,
|
|
nodes: Vec::new(),
|
|
schemas: Vec::new(),
|
|
ids: Vec::new(),
|
|
names: HashMap::new(),
|
|
edges: Vec::new(),
|
|
roles: Vec::new(),
|
|
role_names: HashMap::new(),
|
|
out: Vec::new(),
|
|
out_names: HashMap::new(),
|
|
coverage: HashMap::new(),
|
|
}
|
|
}
|
|
|
|
/// Apply one op, running the eager checks. The first `Err` is the op-script's
|
|
/// stop point (`replay` attributes it to the op index).
|
|
pub fn apply(&mut self, op: Op) -> Result<(), OpError> {
|
|
match op {
|
|
Op::Source { role, kind } => self.add_role(role, Some(kind)),
|
|
Op::Input { role } => self.add_role(role, None),
|
|
Op::Add { type_id, as_name, bind } => self.add_node(type_id, as_name, bind),
|
|
Op::Feed { role, into } => self.feed(role, into),
|
|
Op::Connect { from, to } => self.connect(from, to),
|
|
Op::Expose { from, as_name } => self.expose(from, as_name),
|
|
}
|
|
}
|
|
|
|
fn add_role(&mut self, role: String, kind: Option<ScalarKind>) -> Result<(), OpError> {
|
|
if self.role_names.contains_key(&role) {
|
|
return Err(OpError::DuplicateRole(role));
|
|
}
|
|
let idx = self.roles.len();
|
|
self.role_names.insert(role.clone(), idx);
|
|
self.roles.push((role, kind, Vec::new()));
|
|
Ok(())
|
|
}
|
|
|
|
fn add_node(
|
|
&mut self,
|
|
type_id: String,
|
|
as_name: Option<String>,
|
|
bind: Vec<(String, Scalar)>,
|
|
) -> Result<(), OpError> {
|
|
let mut builder =
|
|
(self.vocab)(&type_id).ok_or_else(|| OpError::UnknownNodeType(type_id.clone()))?;
|
|
let id = as_name.unwrap_or_else(|| builder.node_name());
|
|
for (slot, value) in bind {
|
|
builder = builder
|
|
.try_bind(&slot, value)
|
|
.map_err(|err| OpError::BadParam { node: id.clone(), err })?;
|
|
}
|
|
if self.names.contains_key(&id) {
|
|
return Err(OpError::DuplicateIdentifier(id));
|
|
}
|
|
let node = BlueprintNode::from(builder);
|
|
let schema = node.signature();
|
|
let idx = self.nodes.len();
|
|
self.names.insert(id.clone(), idx);
|
|
self.ids.push(id);
|
|
self.nodes.push(node);
|
|
self.schemas.push(schema);
|
|
Ok(())
|
|
}
|
|
|
|
// feed / connect / expose / finish / unwired follow in Task 6 and Task 7.
|
|
}
|
|
```
|
|
|
|
> Confirm `BlueprintNode: From<PrimitiveBuilder>` and `BlueprintNode::signature`
|
|
> exist (`grep -n "impl From<PrimitiveBuilder>\|fn signature" crates/aura-engine/src/blueprint.rs`).
|
|
> The `builder.into()` in `builder.rs:93` and `node.signature()` at `:94` prove
|
|
> both; `BlueprintNode::from(builder)` is the same conversion.
|
|
|
|
Add the module to `lib.rs` (`:45-53` block): insert `mod construction;` after
|
|
`mod builder;`.
|
|
|
|
- [ ] **Step 2: Write the failing tests for `add` / `source`**
|
|
|
|
Append a `#[cfg(test)] mod tests` to `construction.rs`:
|
|
|
|
```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<usize, OpError> {
|
|
self.names.get(id).copied().ok_or_else(|| OpError::UnknownIdentifier(id.to_string()))
|
|
}
|
|
|
|
/// Mark `(node, slot)` covered, rejecting a second cover eagerly (the >1 arm
|
|
/// of the exactly-once invariant — the holistic 0 arm is deferred to finish).
|
|
fn cover(&mut self, node: usize, node_id: &str, slot: usize, slot_name: &str) -> Result<(), OpError> {
|
|
let c = self.coverage.entry((node, slot)).or_insert(0);
|
|
if *c >= 1 {
|
|
return Err(OpError::SlotAlreadyWired { node: node_id.to_string(), slot: slot_name.to_string() });
|
|
}
|
|
*c += 1;
|
|
Ok(())
|
|
}
|
|
|
|
fn connect(&mut self, from: String, to: String) -> Result<(), OpError> {
|
|
let (from_node, from_field_name) = self.split_port(&from)?;
|
|
let (to_node, to_slot_name) = self.split_port(&to)?;
|
|
let fi = self.node_index(&from_node)?;
|
|
let ti = self.node_index(&to_node)?;
|
|
let from_field = resolve_output_field(&self.schemas[fi], &from_field_name).map_err(|e| match e {
|
|
PortResolveError::Unknown => OpError::UnknownOutPort { node: from_node.clone(), name: from_field_name.clone() },
|
|
PortResolveError::Ambiguous => OpError::AmbiguousOutPort { node: from_node.clone(), name: from_field_name.clone() },
|
|
})?;
|
|
let slot = resolve_input_slot(&self.schemas[ti], &to_slot_name).map_err(|e| match e {
|
|
PortResolveError::Unknown => OpError::UnknownInPort { node: to_node.clone(), name: to_slot_name.clone() },
|
|
PortResolveError::Ambiguous => OpError::AmbiguousInPort { node: to_node.clone(), name: to_slot_name.clone() },
|
|
})?;
|
|
edge_kind_check(&self.schemas[fi], from_field, &self.schemas[ti], slot).map_err(|e| match e {
|
|
CompileError::Bootstrap(crate::BootstrapError::KindMismatch { producer, consumer }) => {
|
|
OpError::KindMismatch { from: from.clone(), to: to.clone(), producer, consumer }
|
|
}
|
|
other => OpError::Incomplete(other),
|
|
})?;
|
|
self.cover(ti, &to_node, slot, &to_slot_name)?;
|
|
self.edges.push(Edge { from: fi, to: ti, slot, from_field });
|
|
Ok(())
|
|
}
|
|
|
|
fn feed(&mut self, role: String, into: Vec<String>) -> Result<(), OpError> {
|
|
let ri = *self.role_names.get(&role).ok_or_else(|| OpError::UnknownRole(role.clone()))?;
|
|
for target in into {
|
|
let (to_node, to_slot_name) = self.split_port(&target)?;
|
|
let ti = self.node_index(&to_node)?;
|
|
let slot = resolve_input_slot(&self.schemas[ti], &to_slot_name).map_err(|e| match e {
|
|
PortResolveError::Unknown => OpError::UnknownInPort { node: to_node.clone(), name: to_slot_name.clone() },
|
|
PortResolveError::Ambiguous => OpError::AmbiguousInPort { node: to_node.clone(), name: to_slot_name.clone() },
|
|
})?;
|
|
self.cover(ti, &to_node, slot, &to_slot_name)?;
|
|
self.roles[ri].2.push(Target { node: ti, slot });
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn expose(&mut self, from: String, as_name: String) -> Result<(), OpError> {
|
|
let (from_node, from_field_name) = self.split_port(&from)?;
|
|
let fi = self.node_index(&from_node)?;
|
|
let field = resolve_output_field(&self.schemas[fi], &from_field_name).map_err(|e| match e {
|
|
PortResolveError::Unknown => OpError::UnknownOutPort { node: from_node.clone(), name: from_field_name.clone() },
|
|
PortResolveError::Ambiguous => OpError::AmbiguousOutPort { node: from_node.clone(), name: from_field_name.clone() },
|
|
})?;
|
|
if self.out_names.insert(as_name.clone(), ()).is_some() {
|
|
return Err(OpError::DuplicateIdentifier(as_name));
|
|
}
|
|
self.out.push(OutField { node: fi, field, name: as_name });
|
|
Ok(())
|
|
}
|
|
```
|
|
|
|
> `crate::BootstrapError` is re-exported at `lib.rs:68`; confirm the
|
|
> `KindMismatch { producer, consumer }` field names with `grep -n "KindMismatch"
|
|
> crates/aura-engine/src/harness.rs`.
|
|
|
|
- [ ] **Step 4: Run the eager-op tests**
|
|
|
|
Run: `cargo test -p aura-engine connect_double_wire_rejected_at_op`
|
|
Expected: PASS.
|
|
|
|
Run: `cargo test -p aura-engine connect_kind_mismatch_rejected_at_op`
|
|
Expected: PASS.
|
|
|
|
Run: `cargo test -p aura-engine connect_unknown_out_port_rejected_at_op`
|
|
Expected: PASS.
|
|
|
|
Run: `cargo test -p aura-engine feed_into_unknown_role_rejected`
|
|
Expected: PASS.
|
|
|
|
---
|
|
|
|
### Task 7: §B — `finish` (holistic gates), `replay`, `unwired`
|
|
|
|
**Files:**
|
|
- Modify: `crates/aura-engine/src/construction.rs`
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Add to `construction.rs`'s `mod tests`:
|
|
|
|
```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<Composite, OpError> {
|
|
let roles: Vec<Role> = self
|
|
.roles
|
|
.into_iter()
|
|
.map(|(name, source, targets)| Role { name, targets, source })
|
|
.collect();
|
|
let c = Composite::new(self.name, self.nodes, self.edges, roles, self.out);
|
|
check_param_namespace_injective(&c.param_space()).map_err(OpError::Incomplete)?;
|
|
validate_wiring(c.nodes(), c.edges(), c.input_roles(), c.output()).map_err(OpError::Incomplete)?;
|
|
for (r, role) in c.input_roles().iter().enumerate() {
|
|
if role.source.is_none() {
|
|
return Err(OpError::Incomplete(CompileError::UnboundRootRole { role: r }));
|
|
}
|
|
}
|
|
Ok(c)
|
|
}
|
|
}
|
|
|
|
/// Replay a document of ops from scratch, stopping at the first failing op and
|
|
/// naming it by `(op_index, OpError)`; a holistic finalize fault is attributed to
|
|
/// index `ops.len()` (the implicit finalize step).
|
|
pub fn replay(
|
|
name: impl Into<String>,
|
|
ops: Vec<Op>,
|
|
vocab: &dyn Fn(&str) -> Option<PrimitiveBuilder>,
|
|
) -> Result<Composite, (usize, OpError)> {
|
|
let mut s = GraphSession::new(name, vocab);
|
|
let n = ops.len();
|
|
for (i, op) in ops.into_iter().enumerate() {
|
|
s.apply(op).map_err(|e| (i, e))?;
|
|
}
|
|
s.finish().map_err(|e| (n, e))
|
|
}
|
|
```
|
|
|
|
> `Composite::param_space` (`blueprint.rs:195`), `nodes()`/`edges()`/
|
|
> `input_roles()`/`output()` accessors (`:166-187`), and `CompileError::
|
|
> UnboundRootRole`/`UnconnectedPort` (`:614`/`:617`) are all already in scope via
|
|
> the Task-1 `use` lines + the `pub(crate)` widening from Task 1.
|
|
|
|
- [ ] **Step 4: Run the finalize/replay/introspection tests**
|
|
|
|
Run: `cargo test -p aura-engine finish_reports_incomplete_on_unwired_slot`
|
|
Expected: PASS.
|
|
|
|
Run: `cargo test -p aura-engine unwired_lists_open_slots`
|
|
Expected: PASS.
|
|
|
|
Run: `cargo test -p aura-engine replay_stops_at_first_failing_op_naming_index`
|
|
Expected: PASS.
|
|
|
|
---
|
|
|
|
### Task 8: §B — acceptance-1 (replay ≡ GraphBuilder) + public exports
|
|
|
|
**Files:**
|
|
- Modify: `crates/aura-engine/src/construction.rs` (the integration test),
|
|
`crates/aura-engine/src/lib.rs:55-88` (public `pub use`)
|
|
|
|
- [ ] **Step 1: Write the failing acceptance-1 test**
|
|
|
|
Add to `construction.rs`'s `mod tests` — a flat, sink-free `price → fast/slow
|
|
SMA → Sub → Bias` signal root authored two ways, compiled, and compared (models
|
|
the green `builder_harness_compiles_identically_to_hand_wired`, `builder.rs:234`;
|
|
no SimBroker/Recorder, which are outside the zero-arg vocabulary):
|
|
|
|
```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<String>,
|
|
#[serde(default)]
|
|
bind: BTreeMap<String, Scalar>,
|
|
},
|
|
Feed { role: String, into: Vec<String> },
|
|
Connect { from: String, to: String },
|
|
Expose {
|
|
from: String,
|
|
#[serde(rename = "as")]
|
|
as_name: String,
|
|
},
|
|
}
|
|
|
|
impl OpDoc {
|
|
/// The op-kind label for the `op N (kind): cause` message.
|
|
fn kind_label(&self) -> &'static str {
|
|
match self {
|
|
OpDoc::Source { .. } => "source",
|
|
OpDoc::Input { .. } => "input",
|
|
OpDoc::Add { .. } => "add",
|
|
OpDoc::Feed { .. } => "feed",
|
|
OpDoc::Connect { .. } => "connect",
|
|
OpDoc::Expose { .. } => "expose",
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<OpDoc> for Op {
|
|
fn from(d: OpDoc) -> Op {
|
|
match d {
|
|
OpDoc::Source { role, kind } => Op::Source { role, kind },
|
|
OpDoc::Input { role } => Op::Input { role },
|
|
OpDoc::Add { type_id, as_name, bind } => {
|
|
Op::Add { type_id, as_name, bind: bind.into_iter().collect() }
|
|
}
|
|
OpDoc::Feed { role, into } => Op::Feed { role, into },
|
|
OpDoc::Connect { from, to } => Op::Connect { from, to },
|
|
OpDoc::Expose { from, as_name } => Op::Expose { from, as_name },
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// The spec's worked-example document deserializes into the op vocabulary and
|
|
/// maps into engine `Op`s that replay into a compilable signal blueprint —
|
|
/// the DTO is a faithful front-end over the engine surface.
|
|
#[test]
|
|
fn opdoc_document_deserializes_and_replays() {
|
|
let doc = r#"[
|
|
{"op":"source","role":"price","kind":"F64"},
|
|
{"op":"add","type":"SMA","as":"fast","bind":{"length":{"I64":2}}},
|
|
{"op":"add","type":"SMA","as":"slow","bind":{"length":{"I64":4}}},
|
|
{"op":"add","type":"Sub"},
|
|
{"op":"add","type":"Bias"},
|
|
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
|
|
{"op":"connect","from":"fast.value","to":"sub.lhs"},
|
|
{"op":"connect","from":"slow.value","to":"sub.rhs"},
|
|
{"op":"connect","from":"sub.value","to":"bias.signal"},
|
|
{"op":"expose","from":"bias.bias","as":"bias"}
|
|
]"#;
|
|
let docs: Vec<OpDoc> = serde_json::from_str(doc).expect("document parses");
|
|
assert_eq!(docs.len(), 10);
|
|
assert_eq!(docs[1].kind_label(), "add");
|
|
let ops: Vec<Op> = docs.into_iter().map(Op::from).collect();
|
|
// both SMA lengths are bound, so the only open param is bias.scale (f64).
|
|
let composite = replay("graph", ops, &std_vocabulary).expect("replay resolves");
|
|
composite.compile_with_params(&[Scalar::f64(0.5)]).expect("compiles");
|
|
}
|
|
}
|
|
```
|
|
|
|
Add `mod graph_construct;` to `crates/aura-cli/src/main.rs` — place it with the
|
|
other `mod` declarations near the top (find them with `grep -n "^mod \|^use "
|
|
crates/aura-cli/src/main.rs | head`).
|
|
|
|
- [ ] **Step 2: Run to verify it fails, then passes**
|
|
|
|
Run: `cargo test -p aura-cli opdoc_document_deserializes_and_replays`
|
|
Expected: first FAIL (module/symbols absent or test red), then PASS after Step 1
|
|
lands. (The single test in this task is created and satisfied by Step 1's code.)
|
|
|
|
> If `compile_with_params` rejects on param arity, the bound-length assumption is
|
|
> wrong — print `replay(...).unwrap().param_space()` to read the real space and
|
|
> adjust the bound `params` vector (the spec's iteration-1 acceptance test
|
|
> confirmed bias carries one f64 `scale` param).
|
|
|
|
### Task 10: `aura graph build` — replay a document, emit or fail fast
|
|
|
|
**Files:**
|
|
- Modify: `crates/aura-cli/src/graph_construct.rs` (add `build_from_str`,
|
|
`format_op_error`, `build_cmd`)
|
|
- Modify: `crates/aura-cli/src/main.rs` (add the `["graph","build"]` arm)
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Add to `graph_construct.rs`'s `mod tests`:
|
|
|
|
```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<String, String> {
|
|
let docs: Vec<OpDoc> = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?;
|
|
let labels: Vec<&'static str> = docs.iter().map(OpDoc::kind_label).collect();
|
|
let ops: Vec<Op> = docs.into_iter().map(Op::from).collect();
|
|
match replay("graph", ops, &std_vocabulary) {
|
|
Ok(composite) => blueprint_to_json(&composite).map_err(|e| format!("serialize error: {e:?}")),
|
|
Err((idx, err)) => {
|
|
let cause = format_op_error(&err);
|
|
match labels.get(idx) {
|
|
Some(kind) => Err(format!("op {idx} ({kind}): {cause}")),
|
|
None => Err(format!("finalize: {cause}")),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `aura graph build`: read the op-list document from stdin, build, and print the
|
|
/// blueprint to stdout — or the cause to stderr and exit non-zero.
|
|
pub fn build_cmd() {
|
|
use std::io::Read;
|
|
let mut doc = String::new();
|
|
if let Err(e) = std::io::stdin().read_to_string(&mut doc) {
|
|
eprintln!("aura: reading stdin: {e}");
|
|
std::process::exit(2);
|
|
}
|
|
match build_from_str(&doc) {
|
|
Ok(json) => println!("{json}"),
|
|
Err(msg) => {
|
|
eprintln!("aura: {msg}");
|
|
std::process::exit(2);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
In `crates/aura-cli/src/main.rs`, add the arm to the argv `match` (right after
|
|
the bare `["graph"] => ...` arm at `:3313`):
|
|
|
|
```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 <T>`: a type's ports + kinds + param paths,
|
|
/// read off the pre-build schema (no graph built). `Err` if `T` is not in the
|
|
/// closed vocabulary.
|
|
pub fn introspect_node(type_id: &str) -> Result<String, String> {
|
|
let builder = std_vocabulary(type_id).ok_or_else(|| format!("unknown node type {type_id:?}"))?;
|
|
let schema = builder.schema();
|
|
let mut out = format!("{}\n", builder.label());
|
|
for port in &schema.inputs {
|
|
out.push_str(&format!(" in {}:{:?}\n", port.name, port.kind));
|
|
}
|
|
for field in &schema.output {
|
|
out.push_str(&format!(" out {}:{:?}\n", field.name, field.kind));
|
|
}
|
|
for p in builder.params() {
|
|
out.push_str(&format!(" param {}:{:?}\n", p.name, p.kind));
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// `aura graph introspect --unwired`: the still-open interior slots of a partial
|
|
/// op-list document, by-identifier (applies the ops, does NOT finalize).
|
|
pub fn introspect_unwired(doc: &str) -> Result<String, String> {
|
|
let docs: Vec<OpDoc> = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?;
|
|
let mut session = GraphSession::new("introspect", &std_vocabulary);
|
|
for (i, d) in docs.into_iter().enumerate() {
|
|
session.apply(Op::from(d)).map_err(|e| format!("op {i}: {}", format_op_error(&e)))?;
|
|
}
|
|
let mut out = String::new();
|
|
for (slot, kind) in session.unwired() {
|
|
out.push_str(&format!("{slot}:{kind:?}\n"));
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// `aura graph introspect`: dispatch the three read-only queries.
|
|
pub fn introspect_cmd(rest: &[&str]) {
|
|
match rest {
|
|
["--vocabulary"] => {
|
|
for t in std_vocabulary_types() {
|
|
println!("{t}");
|
|
}
|
|
}
|
|
["--node", type_id] => match introspect_node(type_id) {
|
|
Ok(s) => print!("{s}"),
|
|
Err(m) => {
|
|
eprintln!("aura: {m}");
|
|
std::process::exit(2);
|
|
}
|
|
},
|
|
["--unwired"] => {
|
|
use std::io::Read;
|
|
let mut doc = String::new();
|
|
if let Err(e) = std::io::stdin().read_to_string(&mut doc) {
|
|
eprintln!("aura: reading stdin: {e}");
|
|
std::process::exit(2);
|
|
}
|
|
match introspect_unwired(&doc) {
|
|
Ok(s) => print!("{s}"),
|
|
Err(m) => {
|
|
eprintln!("aura: {m}");
|
|
std::process::exit(2);
|
|
}
|
|
}
|
|
}
|
|
_ => {
|
|
eprintln!("aura: usage: aura graph introspect --vocabulary | --node <T> | --unwired");
|
|
std::process::exit(2);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
In `crates/aura-cli/src/main.rs`, add the arm right after the
|
|
`["graph", "build"]` arm:
|
|
|
|
```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 <args>` with `stdin_doc` piped in; return (stdout, stderr, success).
|
|
fn run(args: &[&str], stdin_doc: &str) -> (String, String, bool) {
|
|
let mut child = Command::new(BIN)
|
|
.args(args)
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.expect("spawn aura");
|
|
child.stdin.take().unwrap().write_all(stdin_doc.as_bytes()).unwrap();
|
|
let out = child.wait_with_output().expect("wait aura");
|
|
(
|
|
String::from_utf8_lossy(&out.stdout).into_owned(),
|
|
String::from_utf8_lossy(&out.stderr).into_owned(),
|
|
out.status.success(),
|
|
)
|
|
}
|
|
|
|
const SIGNAL_DOC: &str = r#"[
|
|
{"op":"source","role":"price","kind":"F64"},
|
|
{"op":"add","type":"SMA","as":"fast","bind":{"length":{"I64":2}}},
|
|
{"op":"add","type":"SMA","as":"slow","bind":{"length":{"I64":4}}},
|
|
{"op":"add","type":"Sub"},
|
|
{"op":"add","type":"Bias"},
|
|
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
|
|
{"op":"connect","from":"fast.value","to":"sub.lhs"},
|
|
{"op":"connect","from":"slow.value","to":"sub.rhs"},
|
|
{"op":"connect","from":"sub.value","to":"bias.signal"},
|
|
{"op":"expose","from":"bias.bias","as":"bias"}
|
|
]"#;
|
|
|
|
#[test]
|
|
fn graph_build_emits_blueprint_for_a_valid_document() {
|
|
let (stdout, _stderr, ok) = run(&["graph", "build"], SIGNAL_DOC);
|
|
assert!(ok, "exit success");
|
|
assert!(stdout.contains("\"format_version\":1"), "emits the #155 envelope: {stdout}");
|
|
assert!(stdout.contains("\"SMA\""), "carries the nodes: {stdout}");
|
|
}
|
|
|
|
#[test]
|
|
fn graph_build_fails_fast_at_the_offending_op() {
|
|
let doc = r#"[{"op":"add","type":"SMA","as":"fast"},{"op":"add","type":"Nope"}]"#;
|
|
let (stdout, stderr, ok) = run(&["graph", "build"], doc);
|
|
assert!(!ok, "non-zero exit");
|
|
assert!(stdout.is_empty(), "no blueprint emitted on error: {stdout}");
|
|
assert!(stderr.contains("op 1 (add)"), "names the failing op: {stderr}");
|
|
assert!(stderr.contains("Nope"), "names the cause: {stderr}");
|
|
}
|
|
|
|
#[test]
|
|
fn graph_introspect_vocabulary_lists_the_node_types() {
|
|
let (stdout, _stderr, ok) = run(&["graph", "introspect", "--vocabulary"], "");
|
|
assert!(ok, "exit success");
|
|
assert!(stdout.contains("SMA"), "lists SMA: {stdout}");
|
|
assert!(stdout.contains("Bias"), "lists Bias: {stdout}");
|
|
assert!(!stdout.contains("Recorder"), "a sink is not in the zero-arg vocabulary: {stdout}");
|
|
}
|
|
|
|
#[test]
|
|
fn graph_introspect_node_answers_ports_without_a_build() {
|
|
let (stdout, _stderr, ok) = run(&["graph", "introspect", "--node", "SMA"], "");
|
|
assert!(ok, "exit success");
|
|
assert!(stdout.contains("series"), "lists the input port: {stdout}");
|
|
assert!(stdout.contains("value"), "lists the output field: {stdout}");
|
|
}
|
|
|
|
#[test]
|
|
fn graph_introspect_unwired_reports_open_slots() {
|
|
let doc = r#"[
|
|
{"op":"add","type":"SMA","as":"fast"},
|
|
{"op":"add","type":"Sub"},
|
|
{"op":"connect","from":"fast.value","to":"sub.lhs"}
|
|
]"#;
|
|
let (stdout, _stderr, ok) = run(&["graph", "introspect", "--unwired"], doc);
|
|
assert!(ok, "exit success");
|
|
assert!(stdout.contains("sub.rhs"), "sub.rhs still open: {stdout}");
|
|
assert!(!stdout.contains("sub.lhs"), "sub.lhs is covered: {stdout}");
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run the E2E suite**
|
|
|
|
Run: `cargo test -p aura-cli --test graph_construct`
|
|
Expected: PASS (5 tests).
|
|
|
|
- [ ] **Step 3: Full workspace gates**
|
|
|
|
Run: `cargo build --workspace`
|
|
Expected: clean build.
|
|
|
|
Run: `cargo test --workspace`
|
|
Expected: all pass (iteration-1 tests + the new aura-cli tests; no regressions).
|
|
|
|
Run: `cargo clippy --workspace --all-targets -- -D warnings`
|
|
Expected: clean.
|
|
|
|
## Self-review (iteration 2, planner Step 5)
|
|
|
|
1. **Spec coverage:** §C maps to: DTO + serde encoding → Task 9; `aura graph
|
|
build` (replay → emit / fail-fast) → Task 10; `aura graph introspect`
|
|
(`--vocabulary` / `--node` / `--unwired`) → Task 11; E2E → Task 12. The spec's
|
|
three acceptance criteria are now demonstrated through the CLI (Task 12). ✓
|
|
2. **Placeholder scan:** no "TBD"/"TODO"/"implement later"/"similar to"/"add
|
|
appropriate". ✓
|
|
3. **Type consistency:** `OpDoc`, `build_from_str`, `format_op_error`,
|
|
`build_cmd`, `introspect_node`, `introspect_unwired`, `introspect_cmd`,
|
|
`graph_construct` — identical across the tasks and the `main.rs` arms. ✓
|
|
4. **Step granularity:** each step is one test, one impl block, or one run. ✓
|
|
5. **No commit steps:** none. ✓
|
|
6. **Pin/replacement contiguity:** the E2E pins (`"op 1 (add)"`,
|
|
`"\"format_version\":1"`) are matched against the exact strings
|
|
`build_from_str` and `blueprint_to_json` emit — `op {idx} ({kind})` with
|
|
idx=1/kind="add", and the #155 envelope `blueprint_serde` already emits
|
|
(iteration-1 golden). No soft-wrap split. ✓
|
|
7. **Compile-gate vs deferred-caller ordering:** each argv arm is added in the
|
|
same task as the handler fn it calls (Task 10 adds the `build` arm + `build_cmd`;
|
|
Task 11 adds the `introspect` arm + `introspect_cmd`), so every task's tree
|
|
compiles. No signature change to an existing fn (the engine is untouched; the
|
|
DTO + cores are net-new). ✓
|
|
8. **Verification-filter strings resolve:** every Run filter names a test created
|
|
in this iteration (Tasks 9-12) — `opdoc_document_deserializes_and_replays`,
|
|
`build_from_str_*`, `introspect_*`, and the `--test graph_construct` target.
|
|
The full-workspace gate (Task 12) is unfiltered with a no-regression
|
|
expectation. The engine `Op`/`replay`/`blueprint_to_json` symbols the tests
|
|
import were all confirmed exported this session (commit `27ac4dc`). ✓
|