chore: retire docs/specs and docs/plans as committed artifacts

Per-cycle specs/plans are step-scoped: every file maps to already-shipped
code, their snippets drift hard against current APIs (InputSpec->PortSpec,
Sim->Harness, schema() removed, C16 zero-dep reversed, ascii-dag retired),
and a stale spec read as an API reference misleads agents into non-compiling
code. No durable knowledge lost — rationale lives in the ledger + code
rustdoc, history in git + the tracker.

- gitignore docs/specs/ and docs/plans/
- delete all 117 existing specs/plans (recoverable via git history)
- CLAUDE.md: specs/plans are local-only ephemeral artifacts
This commit is contained in:
2026-06-18 11:06:37 +02:00
parent f66036bd61
commit 28958f2110
121 changed files with 10 additions and 51632 deletions
View File
-679
View File
@@ -1,679 +0,0 @@
# Core Streaming Substrate — Implementation Plan
> **Parent spec:** `docs/specs/0001-core-streaming-substrate.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Deliver the `aura-core` streaming substrate — the four scalar base
types, the bounded-lookback financial-indexed `Column`/`Window`, the type-erased
`AnyColumn` edge, and the `KindMismatch` guard — fully built, tested, and lint-clean.
**Architecture:** Four new private modules in `aura-core` (`scalar`, `column`,
`any`, `error`), each with its own inline `#[cfg(test)] mod tests`, re-exported
from `lib.rs`. No dependencies added. Interior-mutability-free: a sim is
single-threaded (C1), so columns are owned `&mut` on push, borrowed `&` on read.
**Tech Stack:** Rust (edition 2024, `aura-core` crate), `cargo build/test/clippy
--workspace`.
**Module ordering (by dependency):** `scalar` (standalone) → `column`
(generic, standalone) → `error` (uses `scalar::ScalarKind`) → `any` (uses all
three) → final workspace gate. Each task adds its file, its `mod`+`pub use` line
in `lib.rs`, and its tests, so the crate compiles green after every task.
**Greenfield note:** every type here is new; the tests pin each module's contract
and run green on first build. There is no pre-existing behaviour to RED against —
the must-fail guard (`wrong_kind_push_is_rejected`, Task 4) is the one test whose
*correct behaviour is rejection*.
**Files this plan creates or modifies:**
- Create: `crates/aura-core/src/scalar.rs``Timestamp`, `Scalar`, `ScalarKind`, `kind()`, four `From` impls.
- Create: `crates/aura-core/src/column.rs``Column<T>` ring + zero-copy `Window<'_, T>` with `Index`.
- Create: `crates/aura-core/src/error.rs``KindMismatch { expected, got }`.
- Create: `crates/aura-core/src/any.rs``AnyColumn` type-erased edge.
- Modify: `crates/aura-core/src/lib.rs:1-19` — module declarations + re-exports (doc prose updated).
- Test: inline `#[cfg(test)] mod tests` in each of the four module files.
---
## Task 1: The scalar set (`scalar.rs`)
**Files:**
- Create: `crates/aura-core/src/scalar.rs`
- Modify: `crates/aura-core/src/lib.rs`
- Test: inline in `crates/aura-core/src/scalar.rs`
- [ ] **Step 1: Create `crates/aura-core/src/scalar.rs` with the scalar set**
```rust
//! The closed four-type scalar set streamed on aura's hot path (C7):
//! `i64`, `f64`, `bool`, and `Timestamp` (epoch-ns UTC). The carrier `Scalar`
//! is a `Copy` POD enum — no type-erased payloads, no heap, no reference counting.
/// Canonical engine time: epoch-nanoseconds UTC. Newtype over `i64` (C7).
/// `Default` (epoch 0) lets `Column<Timestamp>` pre-size its ring.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Timestamp(pub i64);
/// The kind tag of a scalar / column, used for the edge-time type check (C7).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ScalarKind {
I64,
F64,
Bool,
Timestamp,
}
/// The four scalar base types, type-erased into one `Copy` carrier (C7).
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Scalar {
I64(i64),
F64(f64),
Bool(bool),
Ts(Timestamp),
}
impl Scalar {
/// The kind tag of this scalar.
pub fn kind(self) -> ScalarKind {
match self {
Scalar::I64(_) => ScalarKind::I64,
Scalar::F64(_) => ScalarKind::F64,
Scalar::Bool(_) => ScalarKind::Bool,
Scalar::Ts(_) => ScalarKind::Timestamp,
}
}
}
impl From<i64> for Scalar {
fn from(v: i64) -> Self {
Scalar::I64(v)
}
}
impl From<f64> for Scalar {
fn from(v: f64) -> Self {
Scalar::F64(v)
}
}
impl From<bool> for Scalar {
fn from(v: bool) -> Self {
Scalar::Bool(v)
}
}
impl From<Timestamp> for Scalar {
fn from(v: Timestamp) -> Self {
Scalar::Ts(v)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kind_matches_variant() {
assert_eq!(Scalar::I64(1).kind(), ScalarKind::I64);
assert_eq!(Scalar::F64(1.0).kind(), ScalarKind::F64);
assert_eq!(Scalar::Bool(true).kind(), ScalarKind::Bool);
assert_eq!(Scalar::Ts(Timestamp(1)).kind(), ScalarKind::Timestamp);
}
#[test]
fn from_widenings() {
assert_eq!(Scalar::from(7i64), Scalar::I64(7));
assert_eq!(Scalar::from(2.5f64), Scalar::F64(2.5));
assert_eq!(Scalar::from(true), Scalar::Bool(true));
assert_eq!(Scalar::from(Timestamp(9)), Scalar::Ts(Timestamp(9)));
}
#[test]
fn timestamp_orders_and_defaults() {
assert!(Timestamp(1) < Timestamp(2));
assert_eq!(Timestamp::default(), Timestamp(0));
}
#[test]
fn scalar_is_copy() {
let s = Scalar::F64(1.0);
let a = s;
let b = s; // Copy: `s` still usable after move-by-value
assert_eq!(a, b);
assert_eq!(s, a);
}
}
```
- [ ] **Step 2: Rewrite `lib.rs` doc and declare the `scalar` module only**
Each task adds only its own `mod` + `pub use` lines so the crate compiles green
after every task (the `mod` lines accumulate; by Task 4 all four are present).
Replace the entire current body of `crates/aura-core/src/lib.rs` (19 lines,
doc-only) with:
```rust
//! `aura-core` — the shared contract every aura component links against.
//!
//! This crate is the boundary forced by the cdylib hot-reload model: both the
//! engine and every hot-reloadable node cdylib depend on it, so it stays small
//! and stable.
//!
//! Delivered so far (cycle 0001 — the streaming substrate):
//!
//! - the four scalar base types streamed as SoA: [`Scalar`] / [`ScalarKind`] /
//! [`Timestamp`] (C7);
//! - [`Column`] — a fixed-capacity, bounded-lookback ring with financial
//! indexing (index 0 = newest) and a `run_count` freshness primitive (C5/C8),
//! read zero-copy through a [`Window`];
//! - [`AnyColumn`] — the type-erased edge over the four column kinds, with an
//! edge-time kind check ([`KindMismatch`]) (C7).
//!
//! Still to come (subsequent cycles): the `Node` trait and its `schema`/`eval`,
//! the evaluation context `Ctx`, the firing policies (A: fire-on-any-fresh +
//! hold; B: all-fresh barrier), and the deterministic sim loop.
mod scalar;
pub use scalar::{Scalar, ScalarKind, Timestamp};
```
The intra-doc links to `Column`/`Window`/`AnyColumn`/`KindMismatch` resolve once
Tasks 24 re-export them; they are not checked by `cargo build`/`test`/`clippy`
(only `cargo doc`), so they break no gate in the interim.
- [ ] **Step 3: Build and test the scalar module**
Run: `cargo test -p aura-core scalar::`
Expected: the build succeeds and the four `scalar::tests::*` tests report `ok`;
0 failed.
---
## Task 2: The column and its window (`column.rs`)
**Files:**
- Create: `crates/aura-core/src/column.rs`
- Test: inline in `crates/aura-core/src/column.rs`
- [ ] **Step 1: Create `crates/aura-core/src/column.rs`**
```rust
//! `Column<T>` — a fixed-capacity, bounded-lookback SoA ring with financial
//! indexing (index 0 = newest), and its zero-copy read view `Window` (C5/C8).
/// A fixed-capacity bounded-lookback column. Pre-sized at construction
/// (C8: no realloc in the hot loop); index 0 = newest; carries a monotonic
/// `run_count` of total pushes — the freshness primitive (C5).
pub struct Column<T> {
buf: Box<[T]>,
head: usize, // next write slot
len: usize, // valid elements, saturates at capacity
run_count: u64, // monotonic total pushes (C5)
}
impl<T: Copy + Default> Column<T> {
/// Create a column sized to `lookback` (must be >= 1). The engine sizes this
/// once at wiring; it is never reallocated (C8).
pub fn with_capacity(lookback: usize) -> Self {
assert!(lookback >= 1, "Column lookback must be >= 1");
Column {
buf: vec![T::default(); lookback].into_boxed_slice(),
head: 0,
len: 0,
run_count: 0,
}
}
/// Append the newest value. O(1), allocation-free. On overflow the oldest
/// value is overwritten (true ring). Bumps `run_count`.
pub fn push(&mut self, v: T) {
let cap = self.buf.len();
self.buf[self.head] = v;
self.head = (self.head + 1) % cap;
if self.len < cap {
self.len += 1;
}
self.run_count += 1;
}
/// Financial index: 0 = newest, `len()-1` = oldest. `None` if `k >= len`
/// (C8: not-yet-warmed-up / out of range). Zero-copy.
pub fn get(&self, k: usize) -> Option<T> {
if k >= self.len {
return None;
}
let cap = self.buf.len();
Some(self.buf[(self.head + cap - 1 - k) % cap])
}
pub fn len(&self) -> usize {
self.len
}
pub fn is_empty(&self) -> bool {
self.len == 0
}
pub fn capacity(&self) -> usize {
self.buf.len()
}
/// The monotonic push counter — the freshness primitive (C5). Never moved
/// by a read.
pub fn run_count(&self) -> u64 {
self.run_count
}
/// A zero-copy read view handed to a reading node (C8).
pub fn window(&self) -> Window<'_, T> {
Window { col: self }
}
}
/// Read-only, zero-copy view into a [`Column`] — what a reading node sees (C8).
/// Carries no storage, only a borrow.
pub struct Window<'a, T> {
col: &'a Column<T>,
}
impl<'a, T: Copy + Default> Window<'a, T> {
/// Financial index, 0 = newest; `None` if cold / out of range.
pub fn get(&self, k: usize) -> Option<T> {
self.col.get(k)
}
pub fn len(&self) -> usize {
self.col.len()
}
pub fn is_empty(&self) -> bool {
self.col.is_empty()
}
pub fn run_count(&self) -> u64 {
self.col.run_count()
}
}
/// `window[k]` — newest-at-0 indexed access; panics on out-of-range like a
/// slice. Nodes that may be cold use [`Window::get`] instead.
impl<'a, T: Copy + Default> core::ops::Index<usize> for Window<'a, T> {
type Output = T;
fn index(&self, k: usize) -> &T {
let col = self.col;
assert!(k < col.len, "Window index {k} out of range (len {})", col.len);
let cap = col.buf.len();
&col.buf[(col.head + cap - 1 - k) % cap]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn newest_at_zero_oldest_at_len_minus_one() {
let mut c = Column::<i64>::with_capacity(4);
c.push(10);
c.push(20);
c.push(30);
assert_eq!(c.len(), 3);
assert_eq!(c.get(0), Some(30)); // newest
assert_eq!(c.get(1), Some(20));
assert_eq!(c.get(2), Some(10)); // oldest
assert_eq!(c.get(3), None); // beyond len
}
#[test]
fn wraparound_keeps_newest_capacity_values() {
let mut c = Column::<i64>::with_capacity(3);
for v in [1, 2, 3, 4, 5] {
c.push(v);
}
assert_eq!(c.len(), 3); // saturated
assert_eq!(c.capacity(), 3);
assert_eq!(c.get(0), Some(5)); // newest
assert_eq!(c.get(1), Some(4));
assert_eq!(c.get(2), Some(3)); // oldest retained
assert_eq!(c.get(3), None); // 1 and 2 were dropped
}
#[test]
fn run_count_counts_pushes_not_len() {
let mut c = Column::<i64>::with_capacity(2);
assert_eq!(c.run_count(), 0);
for v in [1, 2, 3, 4, 5] {
c.push(v);
}
assert_eq!(c.run_count(), 5); // counts pushes, past the ring wrap
assert_eq!(c.len(), 2); // while len stays at capacity
let _ = c.get(0);
assert_eq!(c.run_count(), 5); // a read never moves it
}
#[test]
fn window_is_a_view_matching_the_column() {
let mut c = Column::<f64>::with_capacity(4);
c.push(1.0);
c.push(2.0);
let cap_before = c.capacity();
let w = c.window();
assert_eq!(w.len(), 2);
assert_eq!(w.get(0), Some(2.0));
assert_eq!(w[1], 1.0); // Index, newest-at-0
assert_eq!(w.run_count(), 2);
assert_eq!(c.capacity(), cap_before); // taking a window did not realloc
}
#[test]
fn empty_column_reads_none() {
let c = Column::<bool>::with_capacity(2);
assert!(c.is_empty());
assert_eq!(c.get(0), None);
assert_eq!(c.run_count(), 0);
}
}
```
- [ ] **Step 2: Declare and re-export the `column` module in `lib.rs`**
Add to `crates/aura-core/src/lib.rs`, after the `mod scalar;` line and after the
`pub use scalar::...;` line respectively:
```rust
mod column;
```
```rust
pub use column::{Column, Window};
```
- [ ] **Step 3: Test the column module**
Run: `cargo test -p aura-core column::`
Expected: the five `column::tests::*` tests report `ok`; 0 failed.
---
## Task 3: The guard error (`error.rs`)
**Files:**
- Create: `crates/aura-core/src/error.rs`
- Test: inline in `crates/aura-core/src/error.rs`
- [ ] **Step 1: Create `crates/aura-core/src/error.rs`**
```rust
//! `KindMismatch` — the wiring-time guard error for a wrong-kind edge push (C7).
use crate::scalar::ScalarKind;
/// Returned by [`crate::AnyColumn::push`] when a `Scalar`'s kind does not match
/// the column's kind; the column is left untouched.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct KindMismatch {
/// The kind the column holds.
pub expected: ScalarKind,
/// The kind of the value that was offered.
pub got: ScalarKind,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kind_mismatch_is_a_plain_value() {
let e = KindMismatch { expected: ScalarKind::I64, got: ScalarKind::F64 };
assert_eq!(e, KindMismatch { expected: ScalarKind::I64, got: ScalarKind::F64 });
assert_ne!(e, KindMismatch { expected: ScalarKind::F64, got: ScalarKind::I64 });
}
}
```
- [ ] **Step 2: Declare and re-export the `error` module in `lib.rs`**
Add to `crates/aura-core/src/lib.rs`:
```rust
mod error;
```
```rust
pub use error::KindMismatch;
```
- [ ] **Step 3: Test the error module**
Run: `cargo test -p aura-core error::`
Expected: `error::tests::kind_mismatch_is_a_plain_value` reports `ok`.
---
## Task 4: The type-erased edge (`any.rs`)
**Files:**
- Create: `crates/aura-core/src/any.rs`
- Test: inline in `crates/aura-core/src/any.rs` (incl. the must-fail guard)
- [ ] **Step 1: Create `crates/aura-core/src/any.rs`**
```rust
//! `AnyColumn` — the type-erased edge: the four concrete `Column<T>` behind one
//! enum. The kind check is paid at the edge (wiring time); the hot loop grabs
//! the concrete column once (`as_*_mut`) and pushes monomorphically (C7).
use crate::column::Column;
use crate::error::KindMismatch;
use crate::scalar::{Scalar, ScalarKind, Timestamp};
/// A type-erased edge over the four scalar columns (C7).
pub enum AnyColumn {
I64(Column<i64>),
F64(Column<f64>),
Bool(Column<bool>),
Ts(Column<Timestamp>),
}
impl AnyColumn {
/// Create a type-erased column of `kind`, sized to `lookback` (C8).
pub fn with_capacity(kind: ScalarKind, lookback: usize) -> Self {
match kind {
ScalarKind::I64 => AnyColumn::I64(Column::with_capacity(lookback)),
ScalarKind::F64 => AnyColumn::F64(Column::with_capacity(lookback)),
ScalarKind::Bool => AnyColumn::Bool(Column::with_capacity(lookback)),
ScalarKind::Timestamp => AnyColumn::Ts(Column::with_capacity(lookback)),
}
}
pub fn kind(&self) -> ScalarKind {
match self {
AnyColumn::I64(_) => ScalarKind::I64,
AnyColumn::F64(_) => ScalarKind::F64,
AnyColumn::Bool(_) => ScalarKind::Bool,
AnyColumn::Ts(_) => ScalarKind::Timestamp,
}
}
pub fn len(&self) -> usize {
match self {
AnyColumn::I64(c) => c.len(),
AnyColumn::F64(c) => c.len(),
AnyColumn::Bool(c) => c.len(),
AnyColumn::Ts(c) => c.len(),
}
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn run_count(&self) -> u64 {
match self {
AnyColumn::I64(c) => c.run_count(),
AnyColumn::F64(c) => c.run_count(),
AnyColumn::Bool(c) => c.run_count(),
AnyColumn::Ts(c) => c.run_count(),
}
}
/// Edge-time typed push. `Err(KindMismatch)` if `v`'s kind != this column's
/// kind; the column is left untouched (C7 guard). The hot path bypasses this
/// by taking the concrete `Column<T>` once via `as_*_mut`.
pub fn push(&mut self, v: Scalar) -> Result<(), KindMismatch> {
match (self, v) {
(AnyColumn::I64(c), Scalar::I64(x)) => {
c.push(x);
Ok(())
}
(AnyColumn::F64(c), Scalar::F64(x)) => {
c.push(x);
Ok(())
}
(AnyColumn::Bool(c), Scalar::Bool(x)) => {
c.push(x);
Ok(())
}
(AnyColumn::Ts(c), Scalar::Ts(x)) => {
c.push(x);
Ok(())
}
(this, v) => Err(KindMismatch { expected: this.kind(), got: v.kind() }),
}
}
/// Read one type-erased value (0 = newest). `None` if cold / out of range.
pub fn get(&self, k: usize) -> Option<Scalar> {
match self {
AnyColumn::I64(c) => c.get(k).map(Scalar::I64),
AnyColumn::F64(c) => c.get(k).map(Scalar::F64),
AnyColumn::Bool(c) => c.get(k).map(Scalar::Bool),
AnyColumn::Ts(c) => c.get(k).map(Scalar::Ts),
}
}
/// Concrete-column accessor for the monomorphic hot path; `Some` only for
/// the matching kind.
pub fn as_i64_mut(&mut self) -> Option<&mut Column<i64>> {
match self {
AnyColumn::I64(c) => Some(c),
_ => None,
}
}
pub fn as_f64_mut(&mut self) -> Option<&mut Column<f64>> {
match self {
AnyColumn::F64(c) => Some(c),
_ => None,
}
}
pub fn as_bool_mut(&mut self) -> Option<&mut Column<bool>> {
match self {
AnyColumn::Bool(c) => Some(c),
_ => None,
}
}
pub fn as_ts_mut(&mut self) -> Option<&mut Column<Timestamp>> {
match self {
AnyColumn::Ts(c) => Some(c),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn same_kind_push_round_trips() {
let mut e = AnyColumn::with_capacity(ScalarKind::F64, 4);
e.push(Scalar::F64(1.5)).unwrap();
e.push(Scalar::F64(2.5)).unwrap();
assert_eq!(e.kind(), ScalarKind::F64);
assert_eq!(e.len(), 2);
assert_eq!(e.run_count(), 2);
assert_eq!(e.get(0), Some(Scalar::F64(2.5))); // newest
assert_eq!(e.get(1), Some(Scalar::F64(1.5)));
assert_eq!(e.get(2), None);
}
#[test]
fn wrong_kind_push_is_rejected() {
let mut edge = AnyColumn::with_capacity(ScalarKind::I64, 8);
let err = edge.push(Scalar::F64(1.5)).unwrap_err(); // f64 into an i64 edge
assert_eq!(err, KindMismatch { expected: ScalarKind::I64, got: ScalarKind::F64 });
assert_eq!(edge.len(), 0); // nothing was stored
assert_eq!(edge.run_count(), 0); // and the counter did not move
}
#[test]
fn concrete_accessor_matches_only_its_kind() {
let mut e = AnyColumn::with_capacity(ScalarKind::F64, 2);
assert!(e.as_f64_mut().is_some());
assert!(e.as_i64_mut().is_none());
assert!(e.as_bool_mut().is_none());
assert!(e.as_ts_mut().is_none());
// hot-path push through the concrete column, monomorphic:
e.as_f64_mut().unwrap().push(3.0);
assert_eq!(e.get(0), Some(Scalar::F64(3.0)));
assert_eq!(e.run_count(), 1);
}
#[test]
fn timestamp_edge_round_trips() {
let mut e = AnyColumn::with_capacity(ScalarKind::Timestamp, 2);
e.push(Scalar::Ts(Timestamp(1_700_000_000_000_000_000))).unwrap();
assert_eq!(e.get(0), Some(Scalar::Ts(Timestamp(1_700_000_000_000_000_000))));
}
}
```
- [ ] **Step 2: Declare and re-export the `any` module in `lib.rs`**
Add to `crates/aura-core/src/lib.rs` (this completes the four modules):
```rust
mod any;
```
```rust
pub use any::AnyColumn;
```
- [ ] **Step 3: Test the any module**
Run: `cargo test -p aura-core any::`
Expected: the four `any::tests::*` tests report `ok`, including
`wrong_kind_push_is_rejected`.
---
## Task 5: Workspace gate (build + full test + lint)
**Files:** none (verification only).
- [ ] **Step 1: Full workspace build**
Run: `cargo build --workspace`
Expected: `Finished` with 0 errors, 0 warnings.
- [ ] **Step 2: Full workspace test with a count assertion**
Run: `cargo test --workspace`
Expected: all tests pass; the `aura-core` unit-test binary reports
`test result: ok. 14 passed; 0 failed` (4 scalar + 5 column + 1 error + 4 any).
If the count is not 14, a test was lost or misnamed — investigate before
proceeding.
- [ ] **Step 3: Lint clean under `-D warnings`**
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: `Finished` with no clippy diagnostics (exit 0).
- [ ] **Step 4: Surface-purity check — no `RefCell`/`Rc`/`dyn Any` leaked**
Run: `! grep -rnE 'RefCell|Rc<|dyn Any' crates/aura-core/src`
Expected: exit 0 (no matches). The substrate must carry none of these on its
surface (C1/C7). A match is a regression to fix before the cycle closes.
-545
View File
@@ -1,545 +0,0 @@
# Node Contract and Ctx — Implementation Plan
> **Parent spec:** `docs/specs/0002-node-contract-and-ctx.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Add the `Node` contract (`schema`/`eval`), the `Ctx` read-side, the
read-side `AnyColumn` accessors, and one worked `Sma` node — each task leaving the
workspace green.
**Architecture:** Two new modules in `aura-core` (`ctx`, `node`) plus read-side
accessors on `AnyColumn`; one worked node (`Sma`) in `aura-std`. Tasks are ordered
so every compilation unit builds after each task: read-side accessors first (self
-contained), then `Ctx` (needs the accessors), then `Node` (needs `Ctx`), then
`Sma` (needs both), then the workspace gate.
**Tech Stack:** `aura-core` (no deps), `aura-std` (depends on `aura-core`), Rust
2024, `cargo build/test/clippy --workspace`.
---
**Files this plan creates or modifies:**
- Modify: `crates/aura-core/src/any.rs` — add read-side `as_f64/as_i64/as_bool/as_ts` inside `impl AnyColumn` (before the closing brace at `:123`), + a read-accessor test in the existing `mod tests`.
- Create: `crates/aura-core/src/ctx.rs``Ctx<'a>` borrow-wrapper + typed window accessors + tests.
- Create: `crates/aura-core/src/node.rs``Node` trait, `NodeSchema`, `InputSpec`.
- Modify: `crates/aura-core/src/lib.rs``mod`/`pub use` for `ctx` then `node`; roadmap doc-comment update.
- Create: `crates/aura-std/src/sma.rs``Sma` worked node + hand-driven tests.
- Modify: `crates/aura-std/src/lib.rs:15-17` — replace the "no API yet" paragraph with `mod sma; pub use sma::Sma;`.
---
### Task 1: `AnyColumn` read-side accessors
**Files:**
- Modify: `crates/aura-core/src/any.rs`
- [ ] **Step 1: Add the four read-side accessors**
In `crates/aura-core/src/any.rs`, inside the `impl AnyColumn` block, immediately
after the `as_ts_mut` method (ends at `:122`) and before the block's closing brace
(`:123`), insert:
```rust
/// Read-side concrete-column accessor (the symmetric partner of
/// `as_*_mut`); `Some` only for the matching kind. `Ctx` uses these to hand
/// a typed window from a type-erased edge.
pub fn as_i64(&self) -> Option<&Column<i64>> {
match self {
AnyColumn::I64(c) => Some(c),
_ => None,
}
}
pub fn as_f64(&self) -> Option<&Column<f64>> {
match self {
AnyColumn::F64(c) => Some(c),
_ => None,
}
}
pub fn as_bool(&self) -> Option<&Column<bool>> {
match self {
AnyColumn::Bool(c) => Some(c),
_ => None,
}
}
pub fn as_ts(&self) -> Option<&Column<Timestamp>> {
match self {
AnyColumn::Ts(c) => Some(c),
_ => None,
}
}
```
(`Column` and `Timestamp` are already imported at `any.rs:5` and `:7`.)
- [ ] **Step 2: Add a read-accessor test**
In `crates/aura-core/src/any.rs`, inside the existing `#[cfg(test)] mod tests`
(which already has `use super::*;` at `:127`), before its closing brace, add:
```rust
#[test]
fn read_accessor_matches_only_its_kind() {
let f = AnyColumn::with_capacity(ScalarKind::F64, 2);
assert!(f.as_f64().is_some());
assert!(f.as_i64().is_none());
assert!(f.as_bool().is_none());
assert!(f.as_ts().is_none());
let i = AnyColumn::with_capacity(ScalarKind::I64, 2);
assert!(i.as_i64().is_some());
assert!(i.as_f64().is_none());
}
```
- [ ] **Step 3: Verify the crate builds and the new test passes**
Run: `cargo test -p aura-core read_accessor_matches_only_its_kind`
Expected: PASS (`test result: ok. 1 passed`).
- [ ] **Step 4: Verify nothing else regressed**
Run: `cargo test -p aura-core`
Expected: PASS — 15 tests (the prior 14 + the new one).
---
### Task 2: `Ctx` — the read-side evaluation context
**Files:**
- Create: `crates/aura-core/src/ctx.rs`
- Modify: `crates/aura-core/src/lib.rs`
- [ ] **Step 1: Create `crates/aura-core/src/ctx.rs`**
```rust
//! The evaluation context (C8): the read-side window access a node sees in
//! `eval`. The engine sizes and types each input from the node's `schema` at
//! wiring, so the typed accessors below treat a kind mismatch as an engine bug
//! (panic), not a user-facing error.
use crate::{AnyColumn, Timestamp, Window};
/// Read-only, zero-copy view of a node's inputs for one `eval`, in schema
/// order. `Copy` because it is just a borrow of the input slice.
#[derive(Clone, Copy)]
pub struct Ctx<'a> {
inputs: &'a [AnyColumn],
}
impl<'a> Ctx<'a> {
/// Wrap the per-input columns (in schema-declared order) for one `eval`.
pub fn new(inputs: &'a [AnyColumn]) -> Self {
Self { inputs }
}
/// Zero-copy `f64` window into input `i` (index 0 = newest). Panics if input
/// `i` is not an `f64` edge — a wiring bug, never reachable from a correctly
/// wired graph.
pub fn f64_in(&self, i: usize) -> Window<'a, f64> {
let inputs: &'a [AnyColumn] = self.inputs;
inputs[i]
.as_f64()
.expect("input kind mismatch (checked at wiring) — engine bug")
.window()
}
/// Zero-copy `i64` window into input `i` (index 0 = newest). See `f64_in`.
pub fn i64_in(&self, i: usize) -> Window<'a, i64> {
let inputs: &'a [AnyColumn] = self.inputs;
inputs[i]
.as_i64()
.expect("input kind mismatch (checked at wiring) — engine bug")
.window()
}
/// Zero-copy `bool` window into input `i` (index 0 = newest). See `f64_in`.
pub fn bool_in(&self, i: usize) -> Window<'a, bool> {
let inputs: &'a [AnyColumn] = self.inputs;
inputs[i]
.as_bool()
.expect("input kind mismatch (checked at wiring) — engine bug")
.window()
}
/// Zero-copy `timestamp` window into input `i` (index 0 = newest). See
/// `f64_in`.
pub fn ts_in(&self, i: usize) -> Window<'a, Timestamp> {
let inputs: &'a [AnyColumn] = self.inputs;
inputs[i]
.as_ts()
.expect("input kind mismatch (checked at wiring) — engine bug")
.window()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Scalar, ScalarKind};
#[test]
fn ctx_hands_financial_indexed_windows() {
let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 4)];
for v in [10.0_f64, 20.0, 30.0] {
inputs[0].push(Scalar::F64(v)).unwrap();
}
let ctx = Ctx::new(&inputs);
let w = ctx.f64_in(0);
assert_eq!(w.len(), 3);
assert_eq!(w[0], 30.0); // newest
assert_eq!(w[2], 10.0); // oldest
}
#[test]
fn ctx_addresses_multiple_inputs() {
let mut inputs = vec![
AnyColumn::with_capacity(ScalarKind::F64, 2),
AnyColumn::with_capacity(ScalarKind::I64, 2),
];
inputs[0].push(Scalar::F64(1.5)).unwrap();
inputs[1].push(Scalar::I64(42)).unwrap();
let ctx = Ctx::new(&inputs);
assert_eq!(ctx.f64_in(0)[0], 1.5);
assert_eq!(ctx.i64_in(1)[0], 42);
}
#[test]
#[should_panic(expected = "engine bug")]
fn ctx_panics_on_kind_mismatch() {
let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::I64, 2)];
inputs[0].push(Scalar::I64(7)).unwrap();
let ctx = Ctx::new(&inputs);
let _ = ctx.f64_in(0); // wrong kind → panic
}
}
```
- [ ] **Step 2: Wire `ctx` into `lib.rs`**
In `crates/aura-core/src/lib.rs`, add `mod ctx;` to the module block. Replace:
```rust
mod any;
mod column;
mod error;
mod scalar;
```
with:
```rust
mod any;
mod column;
mod ctx;
mod error;
mod scalar;
```
Then add the re-export. Replace:
```rust
pub use any::AnyColumn;
pub use column::{Column, Window};
pub use error::KindMismatch;
pub use scalar::{Scalar, ScalarKind, Timestamp};
```
with:
```rust
pub use any::AnyColumn;
pub use column::{Column, Window};
pub use ctx::Ctx;
pub use error::KindMismatch;
pub use scalar::{Scalar, ScalarKind, Timestamp};
```
- [ ] **Step 3: Verify the new context tests pass**
Run: `cargo test -p aura-core ctx_`
Expected: PASS — 3 tests (`ctx_hands_financial_indexed_windows`,
`ctx_addresses_multiple_inputs`, `ctx_panics_on_kind_mismatch`).
- [ ] **Step 4: Verify the crate still builds clean**
Run: `cargo test -p aura-core`
Expected: PASS — 18 tests (15 from Task 1 + 3 new).
---
### Task 3: The `Node` trait
**Files:**
- Create: `crates/aura-core/src/node.rs`
- Modify: `crates/aura-core/src/lib.rs`
- [ ] **Step 1: Create `crates/aura-core/src/node.rs`**
```rust
//! The node contract (C8): the interface every node implements. A node declares
//! its inputs and output kind via `schema`, and computes one cycle's output via
//! `eval`. Firing policy (C6) and tunable params (C12/C19) are deliberately not
//! part of the schema yet — see spec 0002's "Out of scope".
use crate::{Ctx, Scalar, ScalarKind};
/// One declared input of a node: its scalar kind and the lookback depth the
/// engine must pre-size for it (must be >= 1).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct InputSpec {
pub kind: ScalarKind,
pub lookback: usize,
}
/// A node's declared interface: its inputs (in order) and its single output
/// kind. Built once at wiring, never on the hot path — the `Vec` is fine here.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NodeSchema {
pub inputs: Vec<InputSpec>,
pub output: ScalarKind,
}
/// The universal composable dataflow unit (C8): at most one output, a producer
/// or transformer. `schema` declares the interface; `eval` computes one cycle's
/// output (`None` = filter / not-yet-warmed-up). `&mut self` because a node may
/// keep its own derived state.
pub trait Node {
fn schema(&self) -> NodeSchema;
fn eval(&mut self, ctx: Ctx<'_>) -> Option<Scalar>;
}
```
- [ ] **Step 2: Wire `node` into `lib.rs`**
In `crates/aura-core/src/lib.rs`, add `mod node;` to the module block. Replace:
```rust
mod any;
mod column;
mod ctx;
mod error;
mod scalar;
```
with:
```rust
mod any;
mod column;
mod ctx;
mod error;
mod node;
mod scalar;
```
Then add the re-export. Replace:
```rust
pub use any::AnyColumn;
pub use column::{Column, Window};
pub use ctx::Ctx;
pub use error::KindMismatch;
pub use scalar::{Scalar, ScalarKind, Timestamp};
```
with:
```rust
pub use any::AnyColumn;
pub use column::{Column, Window};
pub use ctx::Ctx;
pub use error::KindMismatch;
pub use node::{InputSpec, Node, NodeSchema};
pub use scalar::{Scalar, ScalarKind, Timestamp};
```
- [ ] **Step 3: Update the roadmap doc-comment**
In `crates/aura-core/src/lib.rs`, replace the "Still to come" paragraph:
```rust
//! Still to come (subsequent cycles): the `Node` trait and its `schema`/`eval`,
//! the evaluation context `Ctx`, the firing policies (A: fire-on-any-fresh +
//! hold; B: all-fresh barrier), and the deterministic sim loop.
```
with:
```rust
//! Delivered in cycle 0002 — the node contract:
//!
//! - [`Node`] — the `schema`/`eval` contract every node implements (C8), with
//! [`NodeSchema`] / [`InputSpec`] declaring inputs (kind + lookback) and the
//! single output kind;
//! - [`Ctx`] — the per-`eval` read-side: zero-copy, financial-indexed [`Window`]
//! access into each input (closing the cycle-0001 read-side gap on
//! [`AnyColumn`]).
//!
//! Still to come (subsequent cycles): the firing policies (A: fire-on-any-fresh
//! + hold; B: all-fresh barrier), the deterministic sim loop, sources, and
//! ingestion.
```
- [ ] **Step 4: Verify `aura-core` builds with the trait present**
Run: `cargo build -p aura-core`
Expected: `Finished` — 0 errors, 0 warnings.
- [ ] **Step 5: Verify the full core suite still passes**
Run: `cargo test -p aura-core`
Expected: PASS — 18 tests (unchanged; `node.rs` adds types, no new core test —
the node is exercised from `aura-std` in Task 4).
---
### Task 4: `Sma` — the worked producer node
**Files:**
- Create: `crates/aura-std/src/sma.rs`
- Modify: `crates/aura-std/src/lib.rs`
- [ ] **Step 1: Create `crates/aura-std/src/sma.rs`**
```rust
//! `Sma` — simple moving average over the last `length` values of one f64
//! input. The walking skeleton's first worked node: it proves the `aura-core`
//! `Node` contract is authorable from a downstream crate and evaluable with no
//! engine present (the test drives it by hand, as the sim loop later will).
use aura_core::{Ctx, InputSpec, Node, NodeSchema, Scalar, ScalarKind};
/// Simple moving average over the last `length` values of one f64 input.
pub struct Sma {
length: usize,
}
impl Sma {
/// Build an SMA of window `length` (must be >= 1).
pub fn new(length: usize) -> Self {
assert!(length >= 1, "SMA length must be >= 1");
Self { length }
}
}
impl Node for Sma {
fn schema(&self) -> NodeSchema {
NodeSchema {
inputs: vec![InputSpec { kind: ScalarKind::F64, lookback: self.length }],
output: ScalarKind::F64,
}
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<Scalar> {
let w = ctx.f64_in(0);
if w.len() < self.length {
return None; // not yet warmed up
}
let mut sum = 0.0;
for k in 0..self.length {
sum += w[k]; // index 0 = newest (financial indexing)
}
Some(Scalar::F64(sum / self.length as f64))
}
}
#[cfg(test)]
mod tests {
use super::*;
use aura_core::AnyColumn;
#[test]
fn sma_warms_up_then_tracks_the_window_mean() {
let mut sma = Sma::new(3);
let schema = sma.schema();
// size the input column from the schema, as the engine will at wiring
let mut inputs = vec![AnyColumn::with_capacity(
schema.inputs[0].kind,
schema.inputs[0].lookback,
)];
let feed = [1.0_f64, 2.0, 3.0, 4.0, 5.0];
// means of [1,2,3], [2,3,4], [3,4,5] once warmed up
let expect = [None, None, Some(2.0), Some(3.0), Some(4.0)];
for (v, want) in feed.iter().zip(expect) {
inputs[0].push(Scalar::F64(*v)).unwrap();
assert_eq!(sma.eval(Ctx::new(&inputs)), want.map(Scalar::F64));
}
}
#[test]
fn sma_length_one_is_identity() {
let mut sma = Sma::new(1);
let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
inputs[0].push(Scalar::F64(7.0)).unwrap();
assert_eq!(sma.eval(Ctx::new(&inputs)), Some(Scalar::F64(7.0)));
inputs[0].push(Scalar::F64(9.0)).unwrap();
assert_eq!(sma.eval(Ctx::new(&inputs)), Some(Scalar::F64(9.0)));
}
}
```
- [ ] **Step 2: Wire `sma` into `aura-std/src/lib.rs`**
In `crates/aura-std/src/lib.rs`, replace the final doc paragraph (`:15-17`):
```rust
//! Types are intentionally absent until the first spec needs them (the
//! walking-skeleton milestone). This crate documents intent; it does not yet
//! define API.
```
with:
```rust
//! The first block lands with the walking skeleton: [`Sma`], the simple moving
//! average — a worked producer node proving the `aura-core` `Node` contract.
mod sma;
pub use sma::Sma;
```
- [ ] **Step 3: Verify the SMA tests pass**
Run: `cargo test -p aura-std`
Expected: PASS — 2 tests (`sma_warms_up_then_tracks_the_window_mean`,
`sma_length_one_is_identity`).
---
### Task 5: Workspace gate
**Files:** none (verification only).
- [ ] **Step 1: Full workspace build**
Run: `cargo build --workspace`
Expected: `Finished` — 0 errors, 0 warnings.
- [ ] **Step 2: Full workspace test**
Run: `cargo test --workspace`
Expected: PASS — 20 tests total (18 in `aura-core`, 2 in `aura-std`), 0 failed.
- [ ] **Step 3: Clippy, warnings-as-errors**
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: `Finished` — no warnings.
- [ ] **Step 4: Surface-purity grep**
Run: `grep -rnE 'RefCell|Rc<|dyn Any' crates/*/src`
Expected: no matches (exit code 1, no output) — the hot path stays free of
type-erased payloads, reference counting, and interior mutability.
-548
View File
@@ -1,548 +0,0 @@
# Deterministic Single-Source Sim Loop — Implementation Plan
> **Parent spec:** `docs/specs/0003-deterministic-sim-loop.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Make a wired DAG of nodes run deterministically: ship `aura-engine`'s
`Sim` (bootstrap + run loop) and a 2-input `Sub` node in `aura-std`, proven on a
fan-out+join graph.
**Architecture:** `Sub` lands first (self-contained, depends only on aura-core).
Then `Sim` in aura-engine, whose integration test uses both `Sma` (0002) and
`Sub`, so aura-engine gains a test-only dev-dependency on aura-std. Each node owns
its input columns (0002 shape); the loop forwards producer outputs into consumer
input columns; bootstrap sizes columns from schemas, kind-checks every edge, and
rejects cycles (Kahn).
**Tech Stack:** aura-core (Node/Ctx/AnyColumn/Scalar), aura-std (nodes), aura-engine
(the Sim runtime), Rust 2024, `cargo build/test/clippy --workspace`.
**Design decisions baked into this plan (orchestrator, from spec + recon):**
- The observed node's per-cycle output is captured **at eval time inside the loop**
(the observed node has no outgoing edge), not by reading an output column.
- `Sim` stores **only** fields `run` reads (`nodes, topo, out_edges,
source_targets, observe`). No `cycle_id`/`source_kind` field: both would be
write-only and trip `field is never read` under `-D warnings`. The C4 cycle
clock in 0003 is the record iteration itself; the explicit counter arrives in
0004 with freshness (its first reader). `source_kind` is a bootstrap param only.
- `BootstrapError::KindMismatch { producer, consumer }` carries just the two kinds
(no synthetic edge), covering both edge and source-target mismatches.
---
**Files this plan creates or modifies:**
- Create: `crates/aura-std/src/sub.rs` — `Sub` 2-input f64-difference node + tests.
- Modify: `crates/aura-std/src/lib.rs:18-19` — add `mod sub; pub use sub::Sub;`.
- Create: `crates/aura-engine/src/sim.rs` — `Sim`, `Edge`, `Target`, `BootstrapError` + tests.
- Modify: `crates/aura-engine/src/lib.rs` — replace the doc stub; add `mod sim;` + `pub use`.
- Modify: `crates/aura-engine/Cargo.toml` — add `[dev-dependencies] aura-std`.
---
### Task 1: `Sub` — the 2-input worked node
**Files:**
- Create: `crates/aura-std/src/sub.rs`
- Modify: `crates/aura-std/src/lib.rs`
- [ ] **Step 1: Create `crates/aura-std/src/sub.rs`**
```rust
//! `Sub` — two-input f64 difference (input 0 minus input 1), e.g. a fast/slow
//! spread. The walking skeleton's second worked node: it gives the sim loop a
//! real fan-out + join to run (two SMAs joining into one node), exercising
//! multi-input `Ctx` access inside a running graph.
use aura_core::{Ctx, InputSpec, Node, NodeSchema, Scalar, ScalarKind};
/// Two-input f64 difference: input 0 minus input 1. Emits `None` until both
/// inputs have a value.
#[derive(Default)]
pub struct Sub;
impl Sub {
/// Build a `Sub` node.
pub fn new() -> Self {
Self
}
}
impl Node for Sub {
fn schema(&self) -> NodeSchema {
NodeSchema {
inputs: vec![
InputSpec { kind: ScalarKind::F64, lookback: 1 },
InputSpec { kind: ScalarKind::F64, lookback: 1 },
],
output: ScalarKind::F64,
}
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<Scalar> {
let a = ctx.f64_in(0);
let b = ctx.f64_in(1);
if a.is_empty() || b.is_empty() {
return None;
}
Some(Scalar::F64(a[0] - b[0]))
}
}
#[cfg(test)]
mod tests {
use super::*;
use aura_core::AnyColumn;
#[test]
fn sub_is_difference_once_both_inputs_present() {
let mut sub = Sub::new();
let mut inputs = vec![
AnyColumn::with_capacity(ScalarKind::F64, 1),
AnyColumn::with_capacity(ScalarKind::F64, 1),
];
// only input 0 present -> None
inputs[0].push(Scalar::F64(10.0)).unwrap();
assert_eq!(sub.eval(Ctx::new(&inputs)), None);
// both present -> a - b
inputs[1].push(Scalar::F64(4.0)).unwrap();
assert_eq!(sub.eval(Ctx::new(&inputs)), Some(Scalar::F64(6.0)));
}
}
```
- [ ] **Step 2: Wire `sub` into `aura-std/src/lib.rs`**
In `crates/aura-std/src/lib.rs`, replace:
```rust
mod sma;
pub use sma::Sma;
```
with:
```rust
mod sma;
mod sub;
pub use sma::Sma;
pub use sub::Sub;
```
- [ ] **Step 3: Verify the Sub test passes**
Run: `cargo test -p aura-std sub_is_difference_once_both_inputs_present`
Expected: PASS (`test result: ok. 1 passed`).
- [ ] **Step 4: Verify the crate still builds clean**
Run: `cargo test -p aura-std`
Expected: PASS — 3 tests (2 SMA from cycle 0002 + 1 Sub).
---
### Task 2: `Sim` — the deterministic run loop
**Files:**
- Modify: `crates/aura-engine/Cargo.toml`
- Create: `crates/aura-engine/src/sim.rs`
- Modify: `crates/aura-engine/src/lib.rs`
- [ ] **Step 1: Add the test-only dependency on aura-std**
In `crates/aura-engine/Cargo.toml`, after the existing `[dependencies]` block
(which contains `aura-core = { path = "../aura-core" }` and two comment lines),
append:
```toml
[dev-dependencies]
aura-std = { path = "../aura-std" }
```
- [ ] **Step 2: Create `crates/aura-engine/src/sim.rs`**
```rust
//! The deterministic single-source sim loop. A `Sim` is a bootstrapped, frozen
//! root graph — a flat node array plus an index edge table, topologically ordered
//! — driven cycle by cycle by one source. It is the flat, monomorphized sharpening
//! of RustAst's reference-counted, interior-mutable observer push graph: no
//! reference counting, no interior mutability, no per-cycle allocation (C1/C7).
//! Each node owns its input
//! columns (the cycle-0002 shape), so `Ctx` is unchanged; a producer's `eval`
//! output is forwarded into its consumers' input columns, and a `None` forwards
//! nothing (the structural seed of sample-and-hold, realized with freshness in a
//! later cycle).
use aura_core::{AnyColumn, Ctx, Node, Scalar, ScalarKind, Timestamp};
/// A producer-output -> consumer-input-slot forwarding edge.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Edge {
pub from: usize,
pub to: usize,
pub slot: usize,
}
/// An input slot the source value is forwarded into each cycle.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Target {
pub node: usize,
pub slot: usize,
}
/// A wiring fault caught once, at bootstrap — C7's "type check paid at wiring"
/// generalized to the whole topology.
#[derive(Debug, PartialEq, Eq)]
pub enum BootstrapError {
/// An edge or source-target connects mismatched scalar kinds.
KindMismatch { producer: ScalarKind, consumer: ScalarKind },
/// A node or slot index in an edge or target (or the observe index) is out of range.
BadIndex,
/// The wiring contains a directed cycle (only an explicit delay node may close
/// a loop; that node does not exist yet).
Cycle,
}
struct NodeBox {
node: Box<dyn Node>,
inputs: Vec<AnyColumn>,
}
/// A bootstrapped, frozen root graph instance plus its deterministic run loop.
pub struct Sim {
nodes: Vec<NodeBox>,
topo: Vec<usize>,
out_edges: Vec<Vec<Edge>>,
source_targets: Vec<Target>,
observe: usize,
}
impl Sim {
/// Bind nodes + wiring into a frozen, runnable graph. Sizes each node's input
/// columns from its `schema`, kind-checks every edge and source target, and
/// topologically orders the nodes (Kahn), rejecting any directed cycle.
pub fn bootstrap(
nodes: Vec<Box<dyn Node>>,
source_targets: Vec<Target>,
edges: Vec<Edge>,
source_kind: ScalarKind,
observe: usize,
) -> Result<Sim, BootstrapError> {
let n = nodes.len();
if observe >= n {
return Err(BootstrapError::BadIndex);
}
let schemas: Vec<_> = nodes.iter().map(|nd| nd.schema()).collect();
// size each node's own input columns from its schema
let mut boxes: Vec<NodeBox> = Vec::with_capacity(n);
for (nd, schema) in nodes.into_iter().zip(schemas.iter()) {
let inputs: Vec<AnyColumn> = schema
.inputs
.iter()
.map(|spec| AnyColumn::with_capacity(spec.kind, spec.lookback))
.collect();
boxes.push(NodeBox { node: nd, inputs });
}
// source targets: the source value must match each target slot's kind
for t in &source_targets {
let s = schemas.get(t.node).ok_or(BootstrapError::BadIndex)?;
let slot = s.inputs.get(t.slot).ok_or(BootstrapError::BadIndex)?;
if slot.kind != source_kind {
return Err(BootstrapError::KindMismatch {
producer: source_kind,
consumer: slot.kind,
});
}
}
// edges: indices in range, producer output kind == consumer slot kind
let mut out_edges: Vec<Vec<Edge>> = vec![Vec::new(); n];
for &e in &edges {
let from = schemas.get(e.from).ok_or(BootstrapError::BadIndex)?;
let to = schemas.get(e.to).ok_or(BootstrapError::BadIndex)?;
let slot = to.inputs.get(e.slot).ok_or(BootstrapError::BadIndex)?;
if from.output != slot.kind {
return Err(BootstrapError::KindMismatch {
producer: from.output,
consumer: slot.kind,
});
}
out_edges[e.from].push(e);
}
// Kahn topological sort; a leftover node means a cycle
let mut indeg = vec![0usize; n];
for &e in &edges {
indeg[e.to] += 1;
}
let mut queue: Vec<usize> = (0..n).filter(|&i| indeg[i] == 0).collect();
let mut topo: Vec<usize> = Vec::with_capacity(n);
let mut head = 0;
while head < queue.len() {
let u = queue[head];
head += 1;
topo.push(u);
for e in &out_edges[u] {
indeg[e.to] -= 1;
if indeg[e.to] == 0 {
queue.push(e.to);
}
}
}
if topo.len() != n {
return Err(BootstrapError::Cycle);
}
Ok(Sim {
nodes: boxes,
topo,
out_edges,
source_targets,
observe,
})
}
/// Drive the records in timestamp order; returns the observed node's per-cycle
/// output. Allocates nothing on the per-cycle eval/forward path.
pub fn run(
&mut self,
records: impl Iterator<Item = (Timestamp, Scalar)>,
) -> Vec<Option<Scalar>> {
// disjoint field borrows so the topo walk can read `topo`/`out_edges`
// while mutating `nodes`
let Sim { nodes, topo, out_edges, source_targets, observe } = self;
let observe = *observe;
let mut out = Vec::new();
for (_ts, value) in records {
// forward the source value into its target input slots
for t in source_targets.iter() {
nodes[t.node].inputs[t.slot]
.push(value)
.expect("source kind checked at wiring");
}
// evaluate in topological order; capture the observed output; forward
let mut observed = None;
for &nidx in topo.iter() {
let result = {
let nb = &mut nodes[nidx];
nb.node.eval(Ctx::new(&nb.inputs))
};
if nidx == observe {
observed = result;
}
if let Some(v) = result {
for e in out_edges[nidx].iter() {
nodes[e.to].inputs[e.slot]
.push(v)
.expect("edge kind checked at wiring");
}
}
}
out.push(observed);
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use aura_std::{Sma, Sub};
fn f64_records(prices: &[f64]) -> impl Iterator<Item = (Timestamp, Scalar)> + '_ {
prices
.iter()
.enumerate()
.map(|(i, &p)| (Timestamp(i as i64), Scalar::F64(p)))
}
#[test]
fn chain_source_sma_runs() {
// node 0 = SMA(3); source -> SMA(3).in0; observe node 0
let nodes: Vec<Box<dyn Node>> = vec![Box::new(Sma::new(3))];
let mut sim = Sim::bootstrap(
nodes,
vec![Target { node: 0, slot: 0 }],
vec![],
ScalarKind::F64,
0,
)
.expect("valid");
let out = sim.run(f64_records(&[1.0, 2.0, 3.0, 4.0, 5.0]));
assert_eq!(
out,
vec![
None,
None,
Some(Scalar::F64(2.0)),
Some(Scalar::F64(3.0)),
Some(Scalar::F64(4.0)),
]
);
}
#[test]
fn fan_out_join_dag_runs_deterministically() {
// 0 = SMA(2), 1 = SMA(4), 2 = Sub; source fans into both SMAs; SMAs join into Sub
let build = || -> Sim {
let nodes: Vec<Box<dyn Node>> =
vec![Box::new(Sma::new(2)), Box::new(Sma::new(4)), Box::new(Sub::new())];
Sim::bootstrap(
nodes,
vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
vec![Edge { from: 0, to: 2, slot: 0 }, Edge { from: 1, to: 2, slot: 1 }],
ScalarKind::F64,
2,
)
.expect("valid DAG")
};
let prices = [10.0_f64, 12.0, 14.0, 16.0, 18.0, 20.0];
let mut sim = build();
let out = sim.run(f64_records(&prices));
// Sub fires only once SMA(4) is warm (cycle index 3): 15-13, 17-15, 19-17 -> 2.
assert_eq!(
out,
vec![
None,
None,
None,
Some(Scalar::F64(2.0)),
Some(Scalar::F64(2.0)),
Some(Scalar::F64(2.0)),
]
);
// determinism (C1): a second identical run is bit-identical
let mut sim2 = build();
let out2 = sim2.run(f64_records(&prices));
assert_eq!(out, out2);
}
#[test]
fn bootstrap_rejects_a_cycle() {
// two SMA(1) nodes wired a -> b -> a
let nodes: Vec<Box<dyn Node>> = vec![Box::new(Sma::new(1)), Box::new(Sma::new(1))];
let err = Sim::bootstrap(
nodes,
vec![],
vec![Edge { from: 0, to: 1, slot: 0 }, Edge { from: 1, to: 0, slot: 0 }],
ScalarKind::F64,
0,
)
.unwrap_err();
assert_eq!(err, BootstrapError::Cycle);
}
#[test]
fn bootstrap_rejects_a_kind_mismatch() {
// SMA(1) declares an f64 input; feeding an i64 source mismatches
let nodes: Vec<Box<dyn Node>> = vec![Box::new(Sma::new(1))];
let err = Sim::bootstrap(
nodes,
vec![Target { node: 0, slot: 0 }],
vec![],
ScalarKind::I64,
0,
)
.unwrap_err();
assert_eq!(
err,
BootstrapError::KindMismatch { producer: ScalarKind::I64, consumer: ScalarKind::F64 }
);
}
#[test]
fn bootstrap_rejects_a_bad_index() {
// observe node 5 does not exist
let nodes: Vec<Box<dyn Node>> = vec![Box::new(Sma::new(1))];
let err = Sim::bootstrap(
nodes,
vec![Target { node: 0, slot: 0 }],
vec![],
ScalarKind::F64,
5,
)
.unwrap_err();
assert_eq!(err, BootstrapError::BadIndex);
}
}
```
- [ ] **Step 3: Replace `crates/aura-engine/src/lib.rs`**
Replace the entire current file content (a doc-only stub) with:
```rust
//! `aura-engine` — the headless, UI-agnostic reactive SoA engine.
//!
//! Delivered in cycle 0003 — the deterministic single-source sim loop:
//!
//! - [`Sim`] — a bootstrapped, frozen root graph (a flat node array + an index
//! edge table, topologically ordered) and its deterministic `run` loop: one
//! source driven through a wired DAG of nodes, cycle by cycle, each output
//! forwarded into its consumers' input columns (C1/C4/C7/C8/C9).
//! - [`Edge`] / [`Target`] — producer->consumer and source->consumer wiring.
//! - [`BootstrapError`] — wiring faults caught once, at bootstrap (kind
//! mismatch, bad index, directed cycle).
//!
//! Still to come (subsequent cycles): freshness-gated recompute / sample-and-hold
//! (C5), the ingestion boundary (k-way merge of timestamped sources, C3), the
//! broker-independent position-event output and downstream broker nodes (C10),
//! and the atomic sim unit `(topology + params + data-window + seed) -> metrics`
//! that the sweep / optimize / walk-forward / Monte-Carlo axes orchestrate.
//!
//! Visualization is never here: it is a downstream consumer node on the streams.
mod sim;
pub use sim::{BootstrapError, Edge, Sim, Target};
```
- [ ] **Step 4: Verify the engine builds and its tests pass**
Run: `cargo test -p aura-engine`
Expected: PASS — 5 tests (`chain_source_sma_runs`,
`fan_out_join_dag_runs_deterministically`, `bootstrap_rejects_a_cycle`,
`bootstrap_rejects_a_kind_mismatch`, `bootstrap_rejects_a_bad_index`).
---
### Task 3: Workspace gate
**Files:** none (verification only).
- [ ] **Step 1: Full workspace build**
Run: `cargo build --workspace`
Expected: `Finished` — 0 errors, 0 warnings.
- [ ] **Step 2: Full workspace test**
Run: `cargo test --workspace`
Expected: PASS — 26 tests total, 0 failed: aura-core 18, aura-std 3 (2 SMA + 1
Sub), aura-engine 5, aura-cli 0. No doctests (the doc comments use intra-doc
links, no executable code fences).
- [ ] **Step 3: Clippy, warnings-as-errors**
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: `Finished` — no warnings.
- [ ] **Step 4: Surface-purity grep**
Run: `grep -rnE 'RefCell|Rc<|dyn Any' crates/*/src`
Expected: no matches (exit code 1, no output). `Box<dyn Node>` is the intended
node object (set at bootstrap, dispatched by vtable), not a `dyn Any` payload, and
does not match the pattern.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-503
View File
@@ -1,503 +0,0 @@
# Signal-quality loop — exposure stream + sim-optimal broker — Implementation Plan
> **Parent spec:** `docs/specs/0007-signal-quality-loop.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Ship the signal-quality loop — two `aura-std` nodes (`Exposure`,
`SimBroker`) and an end-to-end harness that backtests a moving-average-cross
signal's quality as a synthetic pip-equity curve.
**Architecture:** `Exposure { scale }` clamps a raw signal score into a bounded
exposure ∈ [-1,+1]; `SimBroker { pip_size }` integrates the return earned by the
exposure held *into* each cycle (decided at t-1, no look-ahead) into cumulative
pips. Both are plain structs in `aura-std`; the engine (`aura-core`/`aura-engine`)
is untouched — the end-to-end tests live in the existing `aura-engine` harness
test module, which already dev-depends on `aura-std`.
**Tech Stack:** `aura-std` (new node modules + re-exports), `aura-engine`
harness test module (new end-to-end tests), `aura-core` `Node`/`Ctx` contract
(used verbatim).
---
**Files this plan creates or modifies:**
- Create: `crates/aura-std/src/exposure.rs` — the `Exposure` node + unit tests.
- Create: `crates/aura-std/src/sim_broker.rs` — the `SimBroker` node + unit tests.
- Modify: `crates/aura-std/src/lib.rs:18-21` — declare + re-export the two modules.
- Modify: `crates/aura-engine/src/harness.rs` — two end-to-end tests in the
existing `#[cfg(test)] mod tests` (before its closing brace), and extend the
`use aura_std::{Sma, Sub};` import (test-module line ~340).
- Test: `crates/aura-std/src/exposure.rs` — clamp band + warm-up filter.
- Test: `crates/aura-std/src/sim_broker.rs` — lagged integration, no-look-ahead,
flat-during-warmup, first-cycle.
- Test: `crates/aura-engine/src/harness.rs` — signal-quality loop records pip
equity; loop is deterministic.
Note: the spec's worked example calls a helper named `price_stream`; the real
helper in the harness test module is `f64_stream` (`harness.rs:344`). The
end-to-end tests below use `f64_stream` — the spec name was illustrative.
---
### Task 1: `Exposure` node (aura-std)
**Files:**
- Create: `crates/aura-std/src/exposure.rs`
- Modify: `crates/aura-std/src/lib.rs:18-21`
- [ ] **Step 1: Write the `Exposure` node**
Create `crates/aura-std/src/exposure.rs`:
```rust
//! `Exposure` — shapes a raw signal score into a bounded exposure (intent).
//! The decision/sizing node of C10's chain `signals -> decision/sizing node ->
//! exposure stream`: one f64 input, one f64 output `clamp(signal / scale, -1, +1)`.
//! `scale` sets which signal magnitude maps to full exposure (sizing lives here).
use aura_core::{Ctx, FieldSpec, Firing, InputSpec, Node, NodeSchema, Scalar, ScalarKind};
/// Bounded exposure from a raw signal score: `clamp(signal / scale, -1.0, +1.0)`.
/// Emits `None` until its input is present (warm-up filter, C8).
pub struct Exposure {
scale: f64,
out: [Scalar; 1],
}
impl Exposure {
/// Build an exposure node with saturation magnitude `scale` (must be > 0).
pub fn new(scale: f64) -> Self {
assert!(scale > 0.0, "Exposure scale must be > 0");
Self { scale, out: [Scalar::F64(0.0)] }
}
}
impl Node for Exposure {
fn schema(&self) -> NodeSchema {
NodeSchema {
inputs: vec![InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }],
output: vec![FieldSpec { name: "exposure", kind: ScalarKind::F64 }],
}
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> {
let w = ctx.f64_in(0);
if w.is_empty() {
return None; // not yet warmed up (C8 filter)
}
self.out[0] = Scalar::F64((w[0] / self.scale).clamp(-1.0, 1.0));
Some(&self.out)
}
}
```
- [ ] **Step 2: Append the unit tests**
Append to `crates/aura-std/src/exposure.rs`:
```rust
#[cfg(test)]
mod tests {
use super::*;
use aura_core::{AnyColumn, Timestamp};
#[test]
fn exposure_clamps_to_unit_band() {
let mut e = Exposure::new(0.5);
let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
// (raw signal, expected clamped exposure) for scale 0.5
let cases = [
(0.1_f64, 0.2_f64), // within band
(0.5, 1.0), // at the high edge
(1.0, 1.0), // saturates high
(-0.1, -0.2), // within band, negative
(-1.0, -1.0), // saturates low
];
for (sig, want) in cases {
inputs[0].push(Scalar::F64(sig)).unwrap();
assert_eq!(
e.eval(Ctx::new(&inputs, Timestamp(0))),
Some([Scalar::F64(want)].as_slice())
);
}
}
#[test]
fn exposure_is_none_until_input_present() {
let mut e = Exposure::new(0.5);
let inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
assert_eq!(e.eval(Ctx::new(&inputs, Timestamp(0))), None);
}
}
```
- [ ] **Step 3: Register the `exposure` module in `lib.rs`**
In `crates/aura-std/src/lib.rs`, replace lines 18-21:
```rust
mod sma;
mod sub;
pub use sma::Sma;
pub use sub::Sub;
```
with:
```rust
mod exposure;
mod sma;
mod sub;
pub use exposure::Exposure;
pub use sma::Sma;
pub use sub::Sub;
```
(Only `exposure` is registered here — Task 2 adds the `sim_broker` line once
that file exists. Registering `mod sim_broker;` now, before `sim_broker.rs`
exists, would fail the Step 4 compile/test gate: a filtered `cargo test` still
compiles the whole crate.)
- [ ] **Step 4: Run the Exposure tests**
Run: `cargo test -p aura-std --lib exposure::`
Expected: PASS — `test result: ok. 2 passed` (`exposure::tests::exposure_clamps_to_unit_band`, `exposure::tests::exposure_is_none_until_input_present`).
---
### Task 2: `SimBroker` node (aura-std)
**Files:**
- Create: `crates/aura-std/src/sim_broker.rs`
- [ ] **Step 1: Write the `SimBroker` node**
Create `crates/aura-std/src/sim_broker.rs`:
```rust
//! `SimBroker` — the sim-optimal broker (class (a) of C10): deterministic,
//! frictionless, perfect-fill. Consumes an exposure stream (slot 0) + a price
//! stream (slot 1) and integrates the return earned by the exposure held INTO
//! each cycle (decided at t-1) into a cumulative synthetic pip-equity output.
//! Measures signal quality, not execution-modelled P&L.
use aura_core::{Ctx, FieldSpec, Firing, InputSpec, Node, NodeSchema, Scalar, ScalarKind};
/// Integrates `exposure * price-return` into cumulative pips. `pip_size` is
/// per-instrument reference metadata (beside the hot path, C7/C15), held here,
/// never streamed.
pub struct SimBroker {
pip_size: f64,
prev_price: Option<f64>,
prev_exposure: f64, // exposure held into this cycle (decided at t-1); 0.0 = flat
cum: f64, // cumulative pips
out: [Scalar; 1],
}
impl SimBroker {
/// Build a sim-optimal broker for an instrument whose pip is `pip_size`
/// (price units per pip; must be > 0).
pub fn new(pip_size: f64) -> Self {
assert!(pip_size > 0.0, "SimBroker pip_size must be > 0");
Self {
pip_size,
prev_price: None,
prev_exposure: 0.0,
cum: 0.0,
out: [Scalar::F64(0.0)],
}
}
}
impl Node for SimBroker {
fn schema(&self) -> NodeSchema {
NodeSchema {
inputs: vec![
InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }, // 0 exposure
InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }, // 1 price
],
output: vec![FieldSpec { name: "equity", kind: ScalarKind::F64 }],
}
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> {
let price = ctx.f64_in(1);
if price.is_empty() {
return None; // no price yet — nothing to mark
}
let price = price[0];
let expo = ctx.f64_in(0).first().copied().unwrap_or(0.0); // flat until exposure warms up
if let Some(pp) = self.prev_price {
self.cum += self.prev_exposure * (price - pp) / self.pip_size;
}
self.prev_price = Some(price);
self.prev_exposure = expo; // update AFTER taking PnL — no look-ahead (C2)
self.out[0] = Scalar::F64(self.cum);
Some(&self.out)
}
}
```
- [ ] **Step 2: Append the unit tests**
Append to `crates/aura-std/src/sim_broker.rs`:
```rust
#[cfg(test)]
mod tests {
use super::*;
use aura_core::{AnyColumn, Timestamp};
fn two_f64_inputs() -> Vec<AnyColumn> {
vec![
AnyColumn::with_capacity(ScalarKind::F64, 1),
AnyColumn::with_capacity(ScalarKind::F64, 1),
]
}
// Drive one broker cycle by hand: optionally push an exposure into slot 0
// (None models a cycle where the exposure chain has not warmed up — slot 0
// stays empty) and a price into slot 1, then eval and return the equity.
fn step(b: &mut SimBroker, inputs: &mut [AnyColumn], expo: Option<f64>, price: f64) -> f64 {
if let Some(e) = expo {
inputs[0].push(Scalar::F64(e)).unwrap();
}
inputs[1].push(Scalar::F64(price)).unwrap();
match b.eval(Ctx::new(inputs, Timestamp(0))) {
Some([Scalar::F64(v)]) => *v,
other => panic!("expected Some([F64]), got {other:?}"),
}
}
#[test]
fn sim_broker_integrates_lagged_exposure_times_return() {
let mut b = SimBroker::new(1.0);
let mut inputs = two_f64_inputs();
assert_eq!(step(&mut b, &mut inputs, Some(0.5), 100.0), 0.0); // no prev price
assert_eq!(step(&mut b, &mut inputs, Some(0.5), 110.0), 5.0); // 0.5*(110-100)
assert_eq!(step(&mut b, &mut inputs, Some(-1.0), 108.0), 4.0); // +0.5*(108-110) = -1
assert_eq!(step(&mut b, &mut inputs, Some(-1.0), 100.0), 12.0); // +(-1)*(100-108) = +8
}
#[test]
fn sim_broker_no_lookahead() {
let mut b = SimBroker::new(1.0);
let mut inputs = two_f64_inputs();
step(&mut b, &mut inputs, Some(1.0), 100.0);
// exposure flips to 0.0 THIS cycle, but the PnL must use the 1.0 held
// into it: 1.0*(110-100) = 10. Using the fresh 0.0 would give 0.
assert_eq!(step(&mut b, &mut inputs, Some(0.0), 110.0), 10.0);
}
#[test]
fn sim_broker_is_flat_during_warmup() {
let mut b = SimBroker::new(1.0);
let mut inputs = two_f64_inputs();
// exposure never pushed (slot 0 empty) -> treated as flat; equity stays 0
assert_eq!(step(&mut b, &mut inputs, None, 100.0), 0.0);
assert_eq!(step(&mut b, &mut inputs, None, 110.0), 0.0);
assert_eq!(step(&mut b, &mut inputs, None, 90.0), 0.0);
}
#[test]
fn sim_broker_first_cycle_has_no_pnl() {
let mut b = SimBroker::new(1.0);
let mut inputs = two_f64_inputs();
assert_eq!(step(&mut b, &mut inputs, Some(1.0), 100.0), 0.0); // no prev price to mark
}
}
```
- [ ] **Step 3: Register the `sim_broker` module in `lib.rs`**
In `crates/aura-std/src/lib.rs`, replace the post-Task-1 module block:
```rust
mod exposure;
mod sma;
mod sub;
pub use exposure::Exposure;
pub use sma::Sma;
pub use sub::Sub;
```
with:
```rust
mod exposure;
mod sim_broker;
mod sma;
mod sub;
pub use exposure::Exposure;
pub use sim_broker::SimBroker;
pub use sma::Sma;
pub use sub::Sub;
```
- [ ] **Step 4: Run the SimBroker tests**
Run: `cargo test -p aura-std --lib sim_broker::`
Expected: PASS — `test result: ok. 4 passed` (`sim_broker_integrates_lagged_exposure_times_return`, `sim_broker_no_lookahead`, `sim_broker_is_flat_during_warmup`, `sim_broker_first_cycle_has_no_pnl`).
---
### Task 3: End-to-end signal-quality tests (aura-engine)
**Files:**
- Modify: `crates/aura-engine/src/harness.rs` (test module `use aura_std` import + two new tests before the module's closing brace)
- [ ] **Step 1: Extend the test-module `aura_std` import**
In `crates/aura-engine/src/harness.rs`, inside `#[cfg(test)] mod tests`, replace:
```rust
use aura_std::{Sma, Sub};
```
with:
```rust
use aura_std::{Exposure, Sma, SimBroker, Sub};
```
- [ ] **Step 2: Add the pip-equity recording test**
Add inside `#[cfg(test)] mod tests` (before its closing brace) in
`crates/aura-engine/src/harness.rs`:
```rust
#[test]
fn signal_quality_loop_records_pip_equity() {
let (tx_eq, rx_eq) = mpsc::channel();
let mut h = Harness::bootstrap(
vec![
Box::new(Sma::new(2)), // 0 fast
Box::new(Sma::new(4)), // 1 slow
Box::new(Sub::new()), // 2 raw signal
Box::new(Exposure::new(0.5)), // 3 exposure
Box::new(SimBroker::new(0.0001)), // 4 pip equity
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_eq)), // 5 sink
],
vec![SourceSpec {
kind: ScalarKind::F64,
targets: vec![
Target { node: 0, slot: 0 }, // price -> SMA fast
Target { node: 1, slot: 0 }, // price -> SMA slow
Target { node: 4, slot: 1 }, // price -> broker price input
],
}],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 }, // fast -> Sub.0
Edge { from: 1, to: 2, slot: 1, from_field: 0 }, // slow -> Sub.1
Edge { from: 2, to: 3, slot: 0, from_field: 0 }, // signal -> Exposure
Edge { from: 3, to: 4, slot: 0, from_field: 0 }, // exposure -> broker.0
Edge { from: 4, to: 5, slot: 0, from_field: 0 }, // equity -> recorder
],
)
.expect("valid signal-quality harness");
h.run(vec![f64_stream(&[
(1, 1.0000),
(2, 1.0010),
(3, 1.0025),
(4, 1.0020),
(5, 1.0040),
])]);
let equity: Vec<(Timestamp, Vec<Scalar>)> = rx_eq.try_iter().collect();
// broker fires every cycle (price fresh each tick): five records, ts 1..=5
assert_eq!(equity.len(), 5);
assert_eq!(
equity.iter().map(|(t, _)| t.0).collect::<Vec<_>>(),
vec![1, 2, 3, 4, 5]
);
// flat until SMA(4) warms (cycle 4) and the held exposure meets the next
// price move (cycle 5): the first four equities are exactly 0.
for (_, row) in &equity[0..4] {
assert_eq!(row, &vec![Scalar::F64(0.0)]);
}
// cycle 5: prev_exposure 0.00175 * (1.0040 - 1.0020) / 0.0001 = 0.035 pips
let Scalar::F64(last) = equity[4].1[0] else {
panic!("equity is f64");
};
assert!((last - 0.035).abs() < 1e-9, "final equity = {last}, want ~0.035");
}
```
- [ ] **Step 3: Add the determinism test**
Add inside `#[cfg(test)] mod tests` (before its closing brace) in
`crates/aura-engine/src/harness.rs`:
```rust
#[test]
fn signal_quality_loop_is_deterministic() {
let build = || {
let (tx, rx) = mpsc::channel();
let mut h = Harness::bootstrap(
vec![
Box::new(Sma::new(2)),
Box::new(Sma::new(4)),
Box::new(Sub::new()),
Box::new(Exposure::new(0.5)),
Box::new(SimBroker::new(0.0001)),
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx)),
],
vec![SourceSpec {
kind: ScalarKind::F64,
targets: vec![
Target { node: 0, slot: 0 },
Target { node: 1, slot: 0 },
Target { node: 4, slot: 1 },
],
}],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
Edge { from: 2, to: 3, slot: 0, from_field: 0 },
Edge { from: 3, to: 4, slot: 0, from_field: 0 },
Edge { from: 4, to: 5, slot: 0, from_field: 0 },
],
)
.expect("valid harness");
h.run(vec![f64_stream(&[
(1, 1.0000),
(2, 1.0010),
(3, 1.0025),
(4, 1.0020),
(5, 1.0040),
])]);
rx.try_iter().collect::<Vec<(Timestamp, Vec<Scalar>)>>()
};
assert_eq!(build(), build());
}
```
- [ ] **Step 4: Run the end-to-end tests**
Run: `cargo test -p aura-engine --lib signal_quality`
Expected: PASS — `test result: ok. 2 passed` (`signal_quality_loop_records_pip_equity`, `signal_quality_loop_is_deterministic`).
---
### Task 4: Full workspace gates
**Files:** none (verification only)
- [ ] **Step 1: Full test suite**
Run: `cargo test --workspace`
Expected: PASS — all crates green, including the 6 new tests (2 exposure, 4 sim_broker) and 2 new engine end-to-end tests; no prior test regressed.
- [ ] **Step 2: Clippy, warnings-as-errors**
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: clean — no warnings, exit 0.
- [ ] **Step 3: Engine surface-purity grep**
Run: `git grep -nE 'dyn Any|Rc<|RefCell<' -- 'crates/aura-engine/src/*.rs' 'crates/aura-core/src/*.rs'`
Expected: no matches (exit 1) — the new nodes are plain structs in `aura-std`; `aura-engine`/`aura-core` source carries no interior mutability. (`SimBroker`/`Exposure` are defined in `aura-std`, referenced only from the engine's test module — the engine non-test surface stays domain-free.)
-356
View File
@@ -1,356 +0,0 @@
# Sum Combinators (`Add` + `LinComb`) — Implementation Plan
> **Parent spec:** `docs/specs/0008-sum-combinators.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to
> run this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Ship two new `aura-std` leaf nodes — `Add` (two-input f64 sum) and
`LinComb { weights }` (N-input weighted sum) — so the north-star "combine
signals" move (C10) is expressible from shipped blocks.
**Architecture:** Two additive leaf nodes, one file each, mirroring the existing
`sub.rs` / `sma.rs` pattern (struct + `Node` impl + co-located hand-driven
`#[cfg(test)]` tests). `Add` mirrors `sub.rs` modulo the `+` operator; `LinComb`
carries a `Vec<f64>` weight param (assert-non-empty at construction, like
`Sma::new`) and builds a variadic input schema from `weights.len()`. Both
withhold output until every input is present (no implicit cold-leg `0.0`). Each
node is module-declared and re-exported in `lib.rs`. No `aura-core` change, no
new dependency.
**Tech Stack:** `aura-core` `Node`/`Ctx`/`NodeSchema`/`Scalar` contract;
`crates/aura-std/`.
---
**Files this plan creates or modifies:**
- Create: `crates/aura-std/src/add.rs``Add` leaf node + tests.
- Create: `crates/aura-std/src/lincomb.rs``LinComb` leaf node + tests.
- Modify: `crates/aura-std/src/lib.rs:18-25` — module declarations + `pub use`
exports for both nodes (alphabetical order).
- Test: `crates/aura-std/src/add.rs` (`#[cfg(test)] mod tests`) — sum-once-both-present.
- Test: `crates/aura-std/src/lincomb.rs` (`#[cfg(test)] mod tests`) — weighted
sum, unit-weights-equal-Add identity, three-input warm-up, empty-weights panic.
Mirror templates (read-only, do not edit): `crates/aura-std/src/sub.rs:1-70`,
`crates/aura-std/src/sma.rs:16-19` (the `assert!` precedent). The `aura_core`
import set (`Ctx, FieldSpec, Firing, InputSpec, Node, NodeSchema, Scalar,
ScalarKind`, plus test-only `AnyColumn, Timestamp`) is re-exported from the
`aura_core` crate root (`crates/aura-core/src/lib.rs:38-43`). `crates/aura-std/
Cargo.toml` already depends on `aura-core` — no manifest change.
---
### Task 1: `Add` — two-input f64 sum
**Files:**
- Create: `crates/aura-std/src/add.rs`
- Modify: `crates/aura-std/src/lib.rs:18,22`
- Test: `crates/aura-std/src/add.rs`
- [ ] **Step 1: Write the failing test + declare the module**
Create `crates/aura-std/src/add.rs` with the top-level import set and the test
module only (no `Add` struct yet — that is what makes the test fail):
```rust
//! `Add` — two-input f64 sum (input 0 plus input 1), the companion to `Sub`.
//! Combines two signal streams into one — the most basic combinator for the
//! north-star "combine one signal with another" research move (C10).
use aura_core::{Ctx, FieldSpec, Firing, InputSpec, Node, NodeSchema, Scalar, ScalarKind};
#[cfg(test)]
mod tests {
use super::*;
use aura_core::{AnyColumn, Timestamp};
#[test]
fn add_is_sum_once_both_inputs_present() {
let mut add = Add::new();
let mut inputs = vec![
AnyColumn::with_capacity(ScalarKind::F64, 1),
AnyColumn::with_capacity(ScalarKind::F64, 1),
];
// only input 0 present -> None
inputs[0].push(Scalar::F64(10.0)).unwrap();
assert_eq!(add.eval(Ctx::new(&inputs, Timestamp(0))), None);
// both present -> a + b
inputs[1].push(Scalar::F64(4.0)).unwrap();
assert_eq!(add.eval(Ctx::new(&inputs, Timestamp(0))), Some([Scalar::F64(14.0)].as_slice()));
}
}
```
Then declare the module in `crates/aura-std/src/lib.rs` — insert `mod add;` as
the first line of the `mod` block (before `mod exposure;` at line 18):
```rust
mod add;
mod exposure;
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cargo test -p aura-std add_is_sum_once_both_inputs_present`
Expected: FAIL — compile error `E0433`/`E0422` "failed to resolve" / "cannot
find function, struct, or type `Add` in this scope" (the test references
`Add::new()`, which does not exist yet).
- [ ] **Step 3: Write the `Add` node + export it**
Insert the struct and impls into `crates/aura-std/src/add.rs` between the
top-level `use` line and the `#[cfg(test)]` line:
```rust
/// Two-input f64 sum: input 0 plus input 1. Emits `None` until both inputs
/// have a value.
pub struct Add {
out: [Scalar; 1],
}
impl Add {
/// Build an `Add` node.
pub fn new() -> Self {
Self { out: [Scalar::F64(0.0)] }
}
}
impl Default for Add {
fn default() -> Self {
Self::new()
}
}
impl Node for Add {
fn schema(&self) -> NodeSchema {
NodeSchema {
inputs: vec![
InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any },
InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any },
],
output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }],
}
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> {
let a = ctx.f64_in(0);
let b = ctx.f64_in(1);
if a.is_empty() || b.is_empty() {
return None;
}
self.out[0] = Scalar::F64(a[0] + b[0]);
Some(&self.out)
}
}
```
Then export it from `crates/aura-std/src/lib.rs` — insert `pub use add::Add;`
as the first line of the `pub use` block (before `pub use exposure::Exposure;`
at line 22):
```rust
pub use add::Add;
pub use exposure::Exposure;
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cargo test -p aura-std add_is_sum_once_both_inputs_present`
Expected: PASS — `test add::tests::add_is_sum_once_both_inputs_present ... ok`.
---
### Task 2: `LinComb` — N-input weighted sum
**Files:**
- Create: `crates/aura-std/src/lincomb.rs`
- Modify: `crates/aura-std/src/lib.rs:19,23`
- Test: `crates/aura-std/src/lincomb.rs`
- [ ] **Step 1: Write the failing tests + declare the module**
Create `crates/aura-std/src/lincomb.rs` with the top-level import set and the
test module only (no `LinComb` struct yet):
```rust
//! `LinComb` — weighted sum of `N` f64 inputs (`Σ weights[i] · input[i]`), the
//! general combinator for the north-star "combine signals with weights" move
//! (C10). `LinComb([1.0, 1.0])` is `Add`; `LinComb([1.0, -1.0])` is `Sub`. The
//! weights are the node's tunable parameters (C8/C12) and fix its arity.
use aura_core::{Ctx, FieldSpec, Firing, InputSpec, Node, NodeSchema, Scalar, ScalarKind};
#[cfg(test)]
mod tests {
use super::*;
use aura_core::{AnyColumn, Timestamp};
#[test]
fn lincomb_weighted_sum_once_all_present() {
let mut lc = LinComb::new(vec![0.5, 2.0]);
let mut inputs = vec![
AnyColumn::with_capacity(ScalarKind::F64, 1),
AnyColumn::with_capacity(ScalarKind::F64, 1),
];
// only input 0 present -> None
inputs[0].push(Scalar::F64(10.0)).unwrap();
assert_eq!(lc.eval(Ctx::new(&inputs, Timestamp(0))), None);
// both present -> 0.5*10 + 2.0*3 = 11.0
inputs[1].push(Scalar::F64(3.0)).unwrap();
assert_eq!(lc.eval(Ctx::new(&inputs, Timestamp(0))), Some([Scalar::F64(11.0)].as_slice()));
}
#[test]
fn lincomb_unit_weights_equal_add() {
let mut lc = LinComb::new(vec![1.0, 1.0]);
let mut inputs = vec![
AnyColumn::with_capacity(ScalarKind::F64, 1),
AnyColumn::with_capacity(ScalarKind::F64, 1),
];
inputs[0].push(Scalar::F64(7.0)).unwrap();
inputs[1].push(Scalar::F64(5.0)).unwrap();
// unit weights reproduce Add: 7 + 5
assert_eq!(lc.eval(Ctx::new(&inputs, Timestamp(0))), Some([Scalar::F64(12.0)].as_slice()));
}
#[test]
fn lincomb_three_inputs_warm_up() {
let mut lc = LinComb::new(vec![1.0, 1.0, 1.0]);
let mut inputs = vec![
AnyColumn::with_capacity(ScalarKind::F64, 1),
AnyColumn::with_capacity(ScalarKind::F64, 1),
AnyColumn::with_capacity(ScalarKind::F64, 1),
];
inputs[0].push(Scalar::F64(1.0)).unwrap();
inputs[1].push(Scalar::F64(2.0)).unwrap();
// third leg still cold -> None (withheld until every leg is present)
assert_eq!(lc.eval(Ctx::new(&inputs, Timestamp(0))), None);
inputs[2].push(Scalar::F64(3.0)).unwrap();
// all warm -> 1 + 2 + 3
assert_eq!(lc.eval(Ctx::new(&inputs, Timestamp(0))), Some([Scalar::F64(6.0)].as_slice()));
}
#[test]
#[should_panic(expected = "LinComb needs at least one weight")]
fn lincomb_empty_weights_panics() {
let _ = LinComb::new(vec![]);
}
}
```
Then declare the module in `crates/aura-std/src/lib.rs` — insert `mod lincomb;`
between `mod exposure;` and `mod sim_broker;`:
```rust
mod exposure;
mod lincomb;
mod sim_broker;
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cargo test -p aura-std lincomb`
Expected: FAIL — compile error `E0433`/`E0422` "cannot find function, struct,
or type `LinComb` in this scope" (the tests reference `LinComb::new`, which does
not exist yet).
- [ ] **Step 3: Write the `LinComb` node + export it**
Insert the struct and impls into `crates/aura-std/src/lincomb.rs` between the
top-level `use` line and the `#[cfg(test)]` line:
```rust
/// Weighted sum of `N` f64 inputs: `Σ weights[i] · input[i]`. The `weights`
/// are the node's tunable parameters and fix its arity (`weights.len()` inputs,
/// in slot order). Emits `None` until *all* inputs have a value.
pub struct LinComb {
weights: Vec<f64>,
out: [Scalar; 1],
}
impl LinComb {
/// Build a `LinComb` with one weight per input (at least one required).
///
/// # Panics
/// Panics if `weights` is empty.
pub fn new(weights: Vec<f64>) -> Self {
assert!(!weights.is_empty(), "LinComb needs at least one weight");
Self { weights, out: [Scalar::F64(0.0)] }
}
}
impl Node for LinComb {
fn schema(&self) -> NodeSchema {
NodeSchema {
inputs: self
.weights
.iter()
.map(|_| InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any })
.collect(),
output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }],
}
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> {
let mut acc = 0.0;
for (i, &w) in self.weights.iter().enumerate() {
let w_in = ctx.f64_in(i);
if w_in.is_empty() {
return None; // not yet warmed up — withhold until every leg is present
}
acc += w * w_in[0];
}
self.out[0] = Scalar::F64(acc);
Some(&self.out)
}
}
```
Then export it from `crates/aura-std/src/lib.rs` — insert `pub use
lincomb::LinComb;` between `pub use exposure::Exposure;` and `pub use
sim_broker::SimBroker;`:
```rust
pub use exposure::Exposure;
pub use lincomb::LinComb;
pub use sim_broker::SimBroker;
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cargo test -p aura-std lincomb`
Expected: PASS — all four `lincomb::tests::*` tests `... ok`
(`lincomb_weighted_sum_once_all_present`, `lincomb_unit_weights_equal_add`,
`lincomb_three_inputs_warm_up`, `lincomb_empty_weights_panics`).
---
### Task 3: Crate-wide gates
**Files:** none (verification only).
- [ ] **Step 1: Full test suite**
Run: `cargo test -p aura-std`
Expected: PASS — all existing `aura-std` tests plus the five new ones
(`add::tests::add_is_sum_once_both_inputs_present` and the four
`lincomb::tests::*`); `0 failed`.
- [ ] **Step 2: Clippy, warnings denied**
Run: `cargo clippy -p aura-std --all-targets -- -D warnings`
Expected: PASS — no warnings. (`Add` has a `Default` impl so
`clippy::new_without_default` does not fire; `LinComb::new` takes an argument so
the lint does not apply.)
- [ ] **Step 3: Doc build, warnings denied**
Run: `RUSTDOCFLAGS="-D warnings" cargo doc -p aura-std --no-deps`
Expected: PASS — clean; the new intra-doc references (`Sub`, `Add`) resolve.
-688
View File
@@ -1,688 +0,0 @@
# Run metrics + run manifest — Implementation Plan
> **Parent spec:** `docs/specs/0009-run-metrics-and-manifest.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Deliver a new `report` module in `aura-engine` that reduces a run's
recorded pip-equity + exposure streams into summary metrics and pairs them with
a caller-supplied run manifest, rendered as canonical zero-dependency JSON.
**Architecture:** A pure, post-run reduction (`summarize`) over recorded
`(Timestamp, f64)` streams plus three plain data types (`RunMetrics`,
`RunManifest`, `RunReport`) and a `Vec<Scalar>`-row adapter (`f64_field`), all in
`crates/aura-engine/src/report.rs`, exported from `lib.rs`. No engine / `Harness`
/ node-contract change; the surface is pure-additive and uses only `aura-core`
(`Scalar`, `Timestamp`). The end-to-end test reuses the cycle-0007 harness shape
under `#[cfg(test)]` (where `aura-std` is a dev-dependency).
**Tech Stack:** Rust 2024, `aura-core` (`Scalar`/`Timestamp`), `aura-engine`
(`Harness` + the cycle-0006 recording-sink mechanism), `aura-std` nodes
(`Sma`/`Sub`/`Exposure`/`SimBroker`) in the test only.
---
**Files this plan creates or modifies:**
- Create: `crates/aura-engine/src/report.rs` — the `report` module: types,
`summarize`, `f64_field`, `RunReport::to_json`, helpers, and all tests.
- Modify: `crates/aura-engine/src/lib.rs:16-26` — wire `mod report;` + re-exports
and amend the module-doc "Still to come" list.
- Test: `crates/aura-engine/src/report.rs` (`#[cfg(test)] mod tests`) — unit
tests for `summarize` / `f64_field` / `to_json` plus one end-to-end
determinism test.
Resolved (recon open question): the end-to-end test builds `SimBroker::new(0.0001)`,
so its manifest `broker` label reads `"sim-optimal(pip_size=0.0001)"` (faithful to
the actual broker). The spec's north-star snippet used `pip_size=1.0` illustratively;
the `to_json` snapshot unit test (Task 4) keeps the spec's canonical example values
verbatim via a hand-built `RunReport`, independent of any run.
---
### Task 1: Module scaffold — the three data types + lib wiring
**Files:**
- Create: `crates/aura-engine/src/report.rs`
- Modify: `crates/aura-engine/src/lib.rs:16-26`
- [ ] **Step 1: Create `report.rs` with the module doc and the three data types**
Create `crates/aura-engine/src/report.rs` with exactly this content:
```rust
//! Run summary metrics + the reproducible run manifest (C18 / C12): the
//! `(manifest, metrics)` pair a run produces "from day one". The metrics are a
//! **post-run pure reduction** over a run's recorded streams — a node cannot
//! reduce end-of-run (C8 caps a node at one record per `eval`, with no terminal
//! `eval`), so the World drains its recording sinks after [`Harness::run`](crate::Harness::run)
//! and folds them here. Output is canonical, hand-rolled JSON (C14): the schema
//! is tiny, closed, and flat, so the deliberately zero-dependency workspace
//! stays zero-dependency.
use aura_core::{Scalar, Timestamp};
/// Summary metrics reduced from a run's recorded streams — the `-> metrics`
/// half of C12's atomic sim unit. Pure function of the recorded streams.
#[derive(Clone, Debug, PartialEq)]
pub struct RunMetrics {
/// Final cumulative pip equity — the last value of the (cumulative)
/// pip-equity curve. `0.0` if the curve is empty.
pub total_pips: f64,
/// Largest peak-to-trough drop on the cumulative pip curve:
/// `max_t (running_peak(t) - equity(t))`, always `>= 0.0` (`0.0` if the
/// curve is monotonic non-decreasing or empty).
pub max_drawdown: f64,
/// Count of adjacent recorded exposure samples whose sign differs (a zero
/// exposure normalizes to sign `0`, so flat is distinct from long/short).
/// A turnover proxy: it counts long<->short reversals *and* transitions
/// into/out of flat — the plain sign-change count over the exposure series.
pub exposure_sign_flips: u64,
}
/// The reproducible run descriptor (C18). **Caller-supplied**: the engine
/// cannot introspect a git commit, an RNG seed, or a broker label — the World
/// that bootstraps and runs the harness fills these in.
#[derive(Clone, Debug, PartialEq)]
pub struct RunManifest {
/// Node/engine identity: the git commit of the frozen artifact (C18 —
/// commit = identity; the frozen bot *is* a commit).
pub commit: String,
/// The bound tuning params as ordered `name -> value` pairs — the precursor
/// to the eventual typed param-space (deferred; see spec Non-goals).
pub params: Vec<(String, f64)>,
/// The data-window: inclusive `(from, to)` epoch-ns bounds (C12).
pub window: (Timestamp, Timestamp),
/// The RNG seed (C12 seed-as-input). `0` for a seed-free synthetic run.
pub seed: u64,
/// The broker profile label, e.g. `"sim-optimal(pip_size=0.0001)"`.
pub broker: String,
}
/// A run's full structured result: the descriptor plus the metrics it
/// reproduces. The durable run record of C18 ("stores manifests + metrics,
/// re-derives full results on demand").
#[derive(Clone, Debug, PartialEq)]
pub struct RunReport {
pub manifest: RunManifest,
pub metrics: RunMetrics,
}
```
- [ ] **Step 2: Wire the module and re-exports in `lib.rs`, amend the doc**
In `crates/aura-engine/src/lib.rs`, replace the "Still to come" doc paragraph
(lines 16-20), which currently reads:
```rust
//! Still to come (subsequent cycles): the `Source` trait + data-server ingestion
//! and source-native time normalization (C3/C11), the broker-independent
//! position-event output and downstream broker nodes (C10), and the atomic sim
//! unit `(topology + params + data-window + seed) -> metrics` that the sweep /
//! optimize / walk-forward / Monte-Carlo axes orchestrate.
```
with:
```rust
//! Delivered in cycle 0009 — the run report surface:
//!
//! - [`RunMetrics`] / [`RunManifest`] / [`RunReport`] — the `(manifest, metrics)`
//! pair C18 mandates per run, with [`RunReport::to_json`] for the structured
//! C14 face;
//! - [`summarize`] — the post-run pure reduction over a run's recorded
//! pip-equity + exposure streams into [`RunMetrics`]; [`f64_field`] bridges a
//! recording sink's `Vec<Scalar>` rows to it.
//!
//! Still to come (subsequent cycles): the `Source` trait + data-server ingestion
//! and source-native time normalization (C3/C11), the broker-independent
//! position-event output and downstream broker nodes (C10), and the param
//! injection + orchestration axes of the atomic sim unit
//! (`(topology + params + data-window + seed)`, swept by optimize / walk-forward
//! / Monte-Carlo) — its `-> metrics` reduction now ships via [`summarize`].
```
Then replace the module/exports block (lines 24-26), which currently reads:
```rust
mod harness;
pub use harness::{BootstrapError, Edge, Harness, SourceSpec, Target};
```
with (re-export the **types only**`summarize` / `f64_field` are added to this
line by Task 2 and Task 3 as each function lands, so the crate compiles at every
task boundary):
```rust
mod harness;
mod report;
pub use harness::{BootstrapError, Edge, Harness, SourceSpec, Target};
pub use report::{RunManifest, RunMetrics, RunReport};
```
- [ ] **Step 3: Build gate**
Run: `cargo build -p aura-engine`
Expected: PASS — 0 errors, 0 warnings (the three types compile and are exported;
no function is named in the `pub use` yet, so the crate compiles cleanly).
---
### Task 2: `summarize` + the reduction (RED-first)
**Files:**
- Modify: `crates/aura-engine/src/report.rs`
- Modify: `crates/aura-engine/src/lib.rs` (extend the re-export)
- [ ] **Step 1: Write the failing tests**
Append to `crates/aura-engine/src/report.rs` a test module:
```rust
#[cfg(test)]
mod tests {
use super::*;
fn samples(values: &[f64]) -> Vec<(Timestamp, f64)> {
values
.iter()
.enumerate()
.map(|(i, &v)| (Timestamp(i as i64 + 1), v))
.collect()
}
#[test]
fn summarize_total_pips_is_last_cumulative_value() {
let equity = samples(&[0.0, 5.0, 4.0, 12.0]);
let m = summarize(&equity, &[]);
assert_eq!(m.total_pips, 12.0);
}
#[test]
fn summarize_is_zero_on_empty_streams() {
let m = summarize(&[], &[]);
assert_eq!(m.total_pips, 0.0);
assert_eq!(m.max_drawdown, 0.0);
assert_eq!(m.exposure_sign_flips, 0);
}
#[test]
fn summarize_max_drawdown_is_worst_peak_to_trough() {
// peak 10 then trough 5 (drop 5), recovers to 8; worst drop is 5,
// not the final drop (10 -> 8 = 2).
let equity = samples(&[0.0, 10.0, 5.0, 8.0]);
let m = summarize(&equity, &[]);
assert_eq!(m.max_drawdown, 5.0);
}
#[test]
fn summarize_max_drawdown_zero_on_monotonic_curve() {
let equity = samples(&[0.0, 1.0, 2.0, 3.0]);
let m = summarize(&equity, &[]);
assert_eq!(m.max_drawdown, 0.0);
}
#[test]
fn summarize_sign_flips_counts_signum_changes() {
// signum series: + + - 0 - -> flips at +->-, -->0, 0->- = 3.
let exposure = samples(&[0.5, 0.5, -0.5, 0.0, -0.5]);
let m = summarize(&[], &exposure);
assert_eq!(m.exposure_sign_flips, 3);
}
#[test]
fn summarize_sign_flips_zero_on_constant_sign() {
let exposure = samples(&[0.2, 0.5, 1.0, 0.7]);
let m = summarize(&[], &exposure);
assert_eq!(m.exposure_sign_flips, 0);
}
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `cargo test -p aura-engine summarize`
Expected: FAIL — compile error `cannot find function summarize in this scope`
(the function does not exist yet).
- [ ] **Step 3: Write the reduction**
In `crates/aura-engine/src/report.rs`, after the `RunReport` struct and before
the `#[cfg(test)]` module, add:
```rust
/// Reduce a run's recorded pip-equity + exposure streams into summary metrics.
/// Pure — identical inputs yield identical metrics (C1/C12). Timestamps are
/// carried in the input to match exactly what a sink records; the reduction
/// itself is value-only (it does not read the timestamps).
pub fn summarize(
equity: &[(Timestamp, f64)],
exposure: &[(Timestamp, f64)],
) -> RunMetrics {
// total pips: the last cumulative equity value (0.0 if empty).
let total_pips = equity.last().map(|&(_, v)| v).unwrap_or(0.0);
// max drawdown: the largest running-peak-minus-value, always >= 0.0.
let mut peak = f64::NEG_INFINITY;
let mut max_drawdown = 0.0_f64;
for &(_, v) in equity {
if v > peak {
peak = v;
}
let dd = peak - v;
if dd > max_drawdown {
max_drawdown = dd;
}
}
// exposure sign-flips: adjacent samples whose normalized sign differs.
let mut exposure_sign_flips = 0u64;
let mut prev: Option<f64> = None;
for &(_, v) in exposure {
let s = sign0(v);
if let Some(p) = prev {
if s != p {
exposure_sign_flips += 1;
}
}
prev = Some(s);
}
RunMetrics { total_pips, max_drawdown, exposure_sign_flips }
}
/// Three-way sign: `-1.0` / `0.0` / `+1.0`. Unlike `f64::signum` (which returns
/// `+1.0` for `+0.0`), a zero exposure maps to `0.0` so flat is distinct from
/// long/short in the sign-flip count.
fn sign0(v: f64) -> f64 {
if v > 0.0 {
1.0
} else if v < 0.0 {
-1.0
} else {
0.0
}
}
```
Then extend the re-export in `crates/aura-engine/src/lib.rs`:
```rust
pub use report::{summarize, RunManifest, RunMetrics, RunReport};
```
- [ ] **Step 4: Run the tests to verify they pass**
Run: `cargo test -p aura-engine summarize`
Expected: PASS — 6 tests run, 0 failed (all six `summarize_*` tests match the
`summarize` filter).
---
### Task 3: `f64_field` adapter (RED-first)
**Files:**
- Modify: `crates/aura-engine/src/report.rs`
- Modify: `crates/aura-engine/src/lib.rs` (extend the re-export)
- [ ] **Step 1: Write the failing tests**
Inside the existing `#[cfg(test)] mod tests` in `report.rs`, add:
```rust
#[test]
fn f64_field_projects_the_named_field() {
let rows = vec![
(Timestamp(1), vec![Scalar::F64(1.5), Scalar::I64(9)]),
(Timestamp(2), vec![Scalar::F64(2.5), Scalar::I64(8)]),
];
assert_eq!(
f64_field(&rows, 0),
vec![(Timestamp(1), 1.5), (Timestamp(2), 2.5)],
);
}
#[test]
#[should_panic(expected = "not an f64 scalar")]
fn f64_field_panics_on_kind_mismatch() {
let rows = vec![(Timestamp(1), vec![Scalar::I64(7)])];
let _ = f64_field(&rows, 0);
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `cargo test -p aura-engine f64_field`
Expected: FAIL — compile error `cannot find function f64_field in this scope`.
- [ ] **Step 3: Write the adapter**
In `crates/aura-engine/src/report.rs`, after `summarize`/`sign0` and before the
test module, add:
```rust
/// Bridge a recording sink's recorded `(ts, row)` stream to [`summarize`]:
/// extract one `f64` field of each row into `(ts, f64)` samples. Panics if a
/// row has no such field or the field is not an `f64` scalar — a wiring bug (a
/// sink's declared kinds are fixed at bootstrap, so a correctly-wired
/// equity/exposure sink always yields `f64` at field 0), surfaced like the
/// engine's other "checked at wiring" contract violations rather than silently
/// dropped.
pub fn f64_field(rows: &[(Timestamp, Vec<Scalar>)], field: usize) -> Vec<(Timestamp, f64)> {
rows.iter()
.map(|(ts, row)| {
let Some(&scalar) = row.get(field) else {
panic!("f64_field: row has no field {field} (row width {})", row.len());
};
let Scalar::F64(v) = scalar else {
panic!("f64_field: field {field} is not an f64 scalar: {scalar:?}");
};
(*ts, v)
})
.collect()
}
```
Then extend the re-export in `crates/aura-engine/src/lib.rs`:
```rust
pub use report::{f64_field, summarize, RunManifest, RunMetrics, RunReport};
```
- [ ] **Step 4: Run the tests to verify they pass**
Run: `cargo test -p aura-engine f64_field`
Expected: PASS — 2 tests run, 0 failed.
---
### Task 4: `RunReport::to_json` canonical JSON (RED-first)
**Files:**
- Modify: `crates/aura-engine/src/report.rs`
- [ ] **Step 1: Write the failing test**
Inside the `#[cfg(test)] mod tests` in `report.rs`, add:
```rust
#[test]
fn to_json_renders_the_canonical_form() {
let report = RunReport {
manifest: RunManifest {
commit: "abc123".to_string(),
params: vec![
("sma_fast".to_string(), 2.0),
("sma_slow".to_string(), 4.0),
("exposure_scale".to_string(), 1.0),
],
window: (Timestamp(1), Timestamp(6)),
seed: 0,
broker: "sim-optimal(pip_size=1.0)".to_string(),
},
metrics: RunMetrics {
total_pips: 12.0,
max_drawdown: 1.0,
exposure_sign_flips: 1,
},
};
assert_eq!(
report.to_json(),
r#"{"manifest":{"commit":"abc123","params":{"sma_fast":2,"sma_slow":4,"exposure_scale":1},"window":[1,6],"seed":0,"broker":"sim-optimal(pip_size=1.0)"},"metrics":{"total_pips":12,"max_drawdown":1,"exposure_sign_flips":1}}"#,
);
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `cargo test -p aura-engine to_json`
Expected: FAIL — compile error `no method named to_json found for ... RunReport`.
- [ ] **Step 3: Write `to_json` + the string-escape helper**
In `crates/aura-engine/src/report.rs`, after the `RunReport` struct definition,
add an `impl` block and a free helper (place the helper next to `sign0`):
```rust
impl RunReport {
/// Render canonical, machine-readable JSON (C14). Hand-rolled — the schema
/// is tiny, closed, and flat, so the deliberately zero-dependency workspace
/// stays so. Field order is fixed; `f64` uses the round-trippable `{}`
/// shortest form (finite values only — pip equity and exposure are finite
/// by construction); `params` renders as a JSON object in insertion order.
pub fn to_json(&self) -> String {
let m = &self.manifest;
let mut params = String::from("{");
for (i, (name, value)) in m.params.iter().enumerate() {
if i > 0 {
params.push(',');
}
params.push_str(&json_str(name));
params.push(':');
params.push_str(&value.to_string());
}
params.push('}');
format!(
"{{\"manifest\":{{\"commit\":{commit},\"params\":{params},\"window\":[{from},{to}],\"seed\":{seed},\"broker\":{broker}}},\"metrics\":{{\"total_pips\":{pips},\"max_drawdown\":{dd},\"exposure_sign_flips\":{flips}}}}}",
commit = json_str(&m.commit),
from = m.window.0.0,
to = m.window.1.0,
seed = m.seed,
broker = json_str(&m.broker),
pips = self.metrics.total_pips,
dd = self.metrics.max_drawdown,
flips = self.metrics.exposure_sign_flips,
)
}
}
/// Minimal JSON string rendering: wrap in quotes, escape `"` and `\`. Our
/// caller-supplied labels (commit hash, param names, broker label) contain
/// neither control characters nor other JSON-significant bytes, so these two
/// escapes are sufficient.
fn json_str(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
_ => out.push(c),
}
}
out.push('"');
out
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `cargo test -p aura-engine to_json`
Expected: PASS — 1 test run, 0 failed.
---
### Task 5: End-to-end determinism test (the C18 / acceptance demonstrator)
**Files:**
- Modify: `crates/aura-engine/src/report.rs` (test module only)
- [ ] **Step 1: Add the harness fixtures + the determinism test**
Inside the `#[cfg(test)] mod tests` in `report.rs`, add the fixture imports at
the top of the module (just below `use super::*;`):
```rust
use crate::{Edge, Harness, SourceSpec, Target};
use aura_core::{Ctx, FieldSpec, Firing, InputSpec, Node, NodeSchema, ScalarKind};
use aura_std::{Exposure, SimBroker, Sma, Sub};
use std::sync::mpsc;
/// Build an f64 source stream from (timestamp, value) points (mirrors the
/// harness.rs test helper; the e2e test needs its own copy — the harness
/// test module's is private to that module).
fn f64_stream(points: &[(i64, f64)]) -> Vec<(Timestamp, Scalar)> {
points.iter().map(|&(t, v)| (Timestamp(t), Scalar::F64(v))).collect()
}
/// A recording sink that sends `(now, row)` out of the graph each fired
/// cycle and returns `None` (pure consumer, C8). Re-declared here because
/// the identically-shaped fixture in `harness.rs` is private to that test
/// module.
struct Recorder {
kinds: Vec<ScalarKind>,
firing: Firing,
tx: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
}
impl Recorder {
fn new(
kinds: &[ScalarKind],
firing: Firing,
tx: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
) -> Self {
Self { kinds: kinds.to_vec(), firing, tx }
}
}
impl Node for Recorder {
fn schema(&self) -> NodeSchema {
NodeSchema {
inputs: self
.kinds
.iter()
.map(|&kind| InputSpec { kind, lookback: 1, firing: self.firing })
.collect(),
output: vec![],
}
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> {
// this test records only f64 streams (one f64 column per sink)
let mut row = Vec::with_capacity(self.kinds.len());
for i in 0..self.kinds.len() {
let w = ctx.f64_in(i);
if w.is_empty() {
return None;
}
row.push(Scalar::F64(w[0]));
}
let _ = self.tx.send((ctx.now(), row));
None
}
}
/// Bootstrap the cycle-0007 signal-quality harness with TWO sinks: one on
/// the SimBroker equity output (node 4 -> node 5) and one on the Exposure
/// output (node 3 -> node 6). Returns the harness plus the two receivers.
#[allow(clippy::type_complexity)]
fn build_two_sink_harness() -> (
Harness,
mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
) {
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
let h = Harness::bootstrap(
vec![
Box::new(Sma::new(2)), // 0
Box::new(Sma::new(4)), // 1
Box::new(Sub::new()), // 2
Box::new(Exposure::new(0.5)), // 3
Box::new(SimBroker::new(0.0001)), // 4
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_eq)), // 5 equity sink
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_ex)), // 6 exposure sink
],
vec![SourceSpec {
kind: ScalarKind::F64,
targets: vec![
Target { node: 0, slot: 0 },
Target { node: 1, slot: 0 },
Target { node: 4, slot: 1 }, // price into the broker
],
}],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
Edge { from: 2, to: 3, slot: 0, from_field: 0 },
Edge { from: 3, to: 4, slot: 0, from_field: 0 },
Edge { from: 4, to: 5, slot: 0, from_field: 0 }, // equity -> sink 5
Edge { from: 3, to: 6, slot: 0, from_field: 0 }, // exposure -> sink 6
],
)
.expect("valid signal-quality DAG");
(h, rx_eq, rx_ex)
}
fn run_once() -> RunReport {
let (mut h, rx_eq, rx_ex) = build_two_sink_harness();
h.run(vec![f64_stream(&[
(1, 1.0000),
(2, 1.0010),
(3, 1.0025),
(4, 1.0020),
(5, 1.0040),
])]);
let eq_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_eq.try_iter().collect();
let ex_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_ex.try_iter().collect();
let equity = f64_field(&eq_rows, 0);
let exposure = f64_field(&ex_rows, 0);
let metrics = summarize(&equity, &exposure);
RunReport {
manifest: RunManifest {
commit: "test-commit".to_string(),
params: vec![
("sma_fast".to_string(), 2.0),
("sma_slow".to_string(), 4.0),
("exposure_scale".to_string(), 0.5),
],
window: (Timestamp(1), Timestamp(5)),
seed: 0,
broker: "sim-optimal(pip_size=0.0001)".to_string(),
},
metrics,
}
}
#[test]
fn report_is_deterministic_end_to_end() {
let r1 = run_once();
let r2 = run_once();
// a run actually emitted metrics over a non-empty pip curve
assert!(r1.metrics.total_pips.is_finite());
// same manifest -> same metrics (C1/C12): two runs are bit-identical
assert_eq!(r1.metrics, r2.metrics);
assert_eq!(r1.to_json(), r2.to_json());
}
```
- [ ] **Step 2: Run the test**
Run: `cargo test -p aura-engine report_is_deterministic_end_to_end`
Expected: PASS — 1 test run, 0 failed (it composes the already-built surface; it
is a property/integration test, not a RED driver for new production code).
---
### Task 6: Full workspace gates
**Files:** none (verification only).
- [ ] **Step 1: Full test suite**
Run: `cargo test --workspace`
Expected: PASS — all tests across the workspace, 0 failed. The `aura-engine`
suite now includes the cycle-0009 `report` tests (9 unit + 1 e2e) on top of the
existing harness/node tests.
- [ ] **Step 2: Clippy**
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: clean — 0 warnings.
- [ ] **Step 3: Doc build**
Run: `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps`
Expected: clean — 0 warnings (intra-doc links `[\`RunMetrics\`]`,
`[\`RunReport::to_json\`]`, `[\`summarize\`]`, `[\`f64_field\`]`,
`[\`Harness::run\`](crate::Harness::run)` all resolve).
-584
View File
@@ -1,584 +0,0 @@
# `aura run` end-to-end sample-harness CLI — Implementation Plan
> **Parent spec:** `docs/specs/0010-aura-run-cli.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Ship a reusable `aura-std::Recorder` sink node and wire an `aura run`
subcommand that bootstraps a sample SMA-cross→Exposure→SimBroker harness with two
recording sinks, runs it deterministically, and prints the cycle-0009
metrics+manifest report as canonical JSON to stdout.
**Architecture:** Two deliverables in dependency order. (1) `aura-std::Recorder`
— a pure consumer (`output: vec![]`, C8) over `kinds.len()` input columns,
holding an `mpsc::Sender<(Timestamp, Vec<Scalar>)>`, sending `(ctx.now(), row)`
each fired cycle once every column is warm and returning `None`; it mirrors the
existing `#[cfg(test)]` four-kind fixture in `harness.rs`. (2) `aura-cli` gains
`synthetic_prices` / `sample_harness` / `run_sample` / `main`: the sample harness
is authored in plain Rust over the raw `Harness::bootstrap(nodes, sources, edges)`
API (no builder DSL), and `main` hand-parses one subcommand. No `aura-engine` /
`Harness` / node-contract change; pure-additive; the workspace stays
zero-(external-)dependency.
**Tech Stack:** `aura-core` (`Node`/`Ctx`/`Scalar`/`Firing`/`Timestamp`),
`aura-engine` (`Harness::bootstrap`/`run`, the `report` surface — `summarize` /
`f64_field` / `RunManifest` / `RunReport`), `aura-std` (`Sma`/`Sub`/`Exposure`/
`SimBroker` + the new `Recorder`), `std::sync::mpsc`.
---
## Files this plan creates or modifies
- Create: `crates/aura-std/src/recorder.rs` — the shipped `Recorder` sink node
(pure consumer, four-kind, holds `mpsc::Sender`).
- Modify: `crates/aura-std/src/lib.rs:18-29` — add `mod recorder;` and
`pub use recorder::Recorder;` (alphabetical position: between `lincomb` and
`sim_broker`).
- Modify: `crates/aura-cli/Cargo.toml:12-13` — add `aura-std` and `aura-core`
path deps alongside `aura-engine`.
- Modify: `crates/aura-cli/src/main.rs:1-8` — replace the stub with
`synthetic_prices` / `sample_harness` / `run_sample` / `main` + a unit-test
module.
- Create: `crates/aura-cli/tests/cli_run.rs` — integration test driving the built
binary via `env!("CARGO_BIN_EXE_aura")`.
- Test: `crates/aura-std/src/recorder.rs` (inline `#[cfg(test)] mod tests`) —
Recorder captures a known f64 stream + returns `None` until all columns warm.
- Test: `crates/aura-cli/src/main.rs` (inline `#[cfg(test)] mod tests`) —
`run_sample` determinism + pinned metric values.
- Test: `crates/aura-cli/tests/cli_run.rs``run` → exit 0 + JSON stdout; bad
args → exit 2 + usage stderr.
---
## The chosen synthetic stream and its hand-computed metrics (load-bearing)
`synthetic_prices` is the 7-tick f64 stream below (rises through t=4, then
reverses), chosen so the demo trace is non-trivial — exactly one exposure sign
flip and a real drawdown (C22 populated trace):
```
t: 1 2 3 4 5 6 7
price: 1.0000 1.0010 1.0030 1.0060 1.0040 1.0010 0.9990
```
Tracing the cycle-0007 chain (topo order 0=Sma2, 1=Sma4, 2=Sub, 3=Exposure,
4=SimBroker, 5=equity sink, 6=exposure sink; the whole signal chain propagates
within one cycle, the broker lags exposure one cycle by its own state, C2):
- `Sma2_t = (p_t+p_{t-1})/2` (from t=2); `Sma4_t = mean(last 4)` (from t=4).
- `spread_t = Sma2_t - Sma4_t`; `exposure_t = clamp(spread/0.5, -1, +1) = 2·spread`
(in-band, no clamp): `expo = [+0.004, +0.003, -0.002, -0.005]` for t=4..7.
- Exposure node produces (and the exposure sink records) only t=4..7 → exposure
rows `[+0.004, +0.003, -0.002, -0.005]`; sign sequence `+,+,-,-` → **1 sign
flip**.
- SimBroker (`pip_size=0.0001`) fires every cycle, integrating
`prev_exposure·(price-prev_price)/pip_size`; equity recorded t=1..7 is
`[0, 0, 0, 0, -0.08, -0.17, -0.13]`:
- t5: `0.004·(1.0040-1.0060)/0.0001 = 0.004·(-20) = -0.08` → cum `-0.08`
- t6: `0.003·(1.0010-1.0040)/0.0001 = 0.003·(-30) = -0.09` → cum `-0.17`
- t7: `-0.002·(0.9990-1.0010)/0.0001 = -0.002·(-20) = +0.04` → cum `-0.13`
- **`total_pips = -0.13`** (last equity), **`max_drawdown = 0.17`** (running peak
0 minus trough -0.17), **`exposure_sign_flips = 1`**.
The integer-valued `exposure_sign_flips` is pinned exactly; the two f64 metrics
are pinned within `1e-9` (the computation's float dust is ~`1e-15`, so the
tolerance is safe by six orders of magnitude while staying a real correctness
pin). Determinism is pinned exactly (two runs, identical JSON).
---
## Task 1: `aura-std::Recorder` sink node
**Files:**
- Create: `crates/aura-std/src/recorder.rs`
- Modify: `crates/aura-std/src/lib.rs:18-29`
- Test: `crates/aura-std/src/recorder.rs` (inline `#[cfg(test)] mod tests`)
- [ ] **Step 1: Create `crates/aura-std/src/recorder.rs` with the node + its failing tests**
Write the file with exactly this content:
```rust
//! `Recorder` — a reusable recording sink (the glossary *sink* role, C8/C22):
//! a pure consumer that, each fired cycle, sends `(ctx.now(), row)` — the newest
//! value of each declared input column — to an out-of-graph `mpsc` destination it
//! holds. It produces nothing (`output: vec![]`), so it is a leaf in the DAG. The
//! `mpsc::Sender` keeps the engine's purity invariant (C7): the node carries no
//! `Rc`/`RefCell` interior mutability, only an owned channel handle. Supports all
//! four base scalar kinds so any column can be persisted; returns `None` (filters)
//! until every input column is warm.
use aura_core::{Ctx, Firing, InputSpec, Node, NodeSchema, Scalar, ScalarKind, Timestamp};
use std::sync::mpsc::Sender;
/// A recording sink over `kinds.len()` input columns. Each fired cycle it reads
/// the newest value of every column and sends the row to `tx`; it returns `None`
/// (records, forwards nothing) and `None` during warm-up until all columns have a
/// value.
pub struct Recorder {
kinds: Vec<ScalarKind>,
firing: Firing,
tx: Sender<(Timestamp, Vec<Scalar>)>,
}
impl Recorder {
/// A recorder over one input column per entry in `kinds`, each with the given
/// `firing` policy, sending recorded `(timestamp, row)` pairs to `tx`.
pub fn new(kinds: &[ScalarKind], firing: Firing, tx: Sender<(Timestamp, Vec<Scalar>)>) -> Self {
Self { kinds: kinds.to_vec(), firing, tx }
}
}
impl Node for Recorder {
fn schema(&self) -> NodeSchema {
NodeSchema {
inputs: self
.kinds
.iter()
.map(|&kind| InputSpec { kind, lookback: 1, firing: self.firing })
.collect(),
output: vec![],
}
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> {
let mut row = Vec::with_capacity(self.kinds.len());
for (i, &kind) in self.kinds.iter().enumerate() {
// newest of each column by kind; `?` returns None (warm-up) if cold.
let scalar = match kind {
ScalarKind::F64 => Scalar::F64(ctx.f64_in(i).get(0)?),
ScalarKind::I64 => Scalar::I64(ctx.i64_in(i).get(0)?),
ScalarKind::Bool => Scalar::Bool(ctx.bool_in(i).get(0)?),
ScalarKind::Timestamp => Scalar::Ts(ctx.ts_in(i).get(0)?),
};
row.push(scalar);
}
let _ = self.tx.send((ctx.now(), row));
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use aura_core::{AnyColumn, Timestamp};
use std::sync::mpsc;
#[test]
fn recorder_captures_f64_stream_after_warmup() {
let (tx, rx) = mpsc::channel();
let mut rec = Recorder::new(&[ScalarKind::F64], Firing::Any, tx);
// size the one f64 input column from the schema, as the engine would.
let schema = rec.schema();
assert!(schema.output.is_empty(), "a sink declares no output (C8)");
let mut inputs = vec![AnyColumn::with_capacity(
schema.inputs[0].kind,
schema.inputs[0].lookback,
)];
// cold: returns None and records nothing.
assert_eq!(rec.eval(Ctx::new(&inputs, Timestamp(1))), None);
assert!(rx.try_recv().is_err());
// warm: returns None (pure consumer) but records (now, [F64(newest)]).
for (t, v) in [(2_i64, 10.0_f64), (3, 20.0), (4, 30.0)] {
inputs[0].push(Scalar::F64(v)).unwrap();
assert_eq!(rec.eval(Ctx::new(&inputs, Timestamp(t))), None);
}
let rows: Vec<(Timestamp, Vec<Scalar>)> = rx.try_iter().collect();
assert_eq!(
rows,
vec![
(Timestamp(2), vec![Scalar::F64(10.0)]),
(Timestamp(3), vec![Scalar::F64(20.0)]),
(Timestamp(4), vec![Scalar::F64(30.0)]),
]
);
}
#[test]
fn recorder_is_none_until_all_columns_warm() {
let (tx, rx) = mpsc::channel();
let mut rec = Recorder::new(&[ScalarKind::F64, ScalarKind::F64], Firing::Any, tx);
let mut inputs = vec![
AnyColumn::with_capacity(ScalarKind::F64, 1),
AnyColumn::with_capacity(ScalarKind::F64, 1),
];
// only column 0 present -> None, nothing recorded.
inputs[0].push(Scalar::F64(1.0)).unwrap();
assert_eq!(rec.eval(Ctx::new(&inputs, Timestamp(1))), None);
assert!(rx.try_recv().is_err());
// both present -> records the full row (still returns None).
inputs[1].push(Scalar::F64(2.0)).unwrap();
assert_eq!(rec.eval(Ctx::new(&inputs, Timestamp(2))), None);
let rows: Vec<(Timestamp, Vec<Scalar>)> = rx.try_iter().collect();
assert_eq!(rows, vec![(Timestamp(2), vec![Scalar::F64(1.0), Scalar::F64(2.0)])]);
}
}
```
- [ ] **Step 2: Wire the module into `crates/aura-std/src/lib.rs`**
Add `mod recorder;` between `mod lincomb;` (line 20) and `mod sim_broker;`
(line 21); add `pub use recorder::Recorder;` between `pub use lincomb::LinComb;`
(line 26) and `pub use sim_broker::SimBroker;` (line 27). The `mod` block becomes:
```rust
mod add;
mod exposure;
mod lincomb;
mod recorder;
mod sim_broker;
mod sma;
mod sub;
pub use add::Add;
pub use exposure::Exposure;
pub use lincomb::LinComb;
pub use recorder::Recorder;
pub use sim_broker::SimBroker;
pub use sma::Sma;
pub use sub::Sub;
```
- [ ] **Step 3: Run the Recorder tests to verify they pass**
Run: `cargo test -p aura-std recorder`
Expected: PASS — `recorder_captures_f64_stream_after_warmup` and
`recorder_is_none_until_all_columns_warm` both green (2 tests run; the filter
`recorder` matches exactly these two named tests).
- [ ] **Step 4: Verify the crate still lints and docs clean**
Run: `cargo clippy -p aura-std --all-targets -- -D warnings`
Expected: PASS — no warnings.
---
## Task 2: `aura-cli` `run` subcommand
**Files:**
- Modify: `crates/aura-cli/Cargo.toml:12-13`
- Modify: `crates/aura-cli/src/main.rs:1-8`
- Test: `crates/aura-cli/src/main.rs` (inline `#[cfg(test)] mod tests`)
- [ ] **Step 1: Add the path deps to `crates/aura-cli/Cargo.toml`**
Replace the `[dependencies]` block (lines 12-13) with:
```toml
[dependencies]
aura-core = { path = "../aura-core" }
aura-engine = { path = "../aura-engine" }
aura-std = { path = "../aura-std" }
```
- [ ] **Step 2: Replace `crates/aura-cli/src/main.rs` with the run wiring + tests**
Write the file with exactly this content:
```rust
//! `aura` — the programmatic / CLI face of the engine (the surface the LLM and
//! automation drive: author a node, run a sim/sweep, emit structured metrics).
//!
//! The walking skeleton's closing seam: `aura run` bootstraps a built-in sample
//! signal-quality harness (synthetic source → SMA-cross → Exposure → SimBroker →
//! recording sinks), runs it deterministically (C1), and prints the run's
//! metrics + manifest (#6) as canonical JSON to stdout (the headline C14 move).
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
use aura_engine::{
f64_field, summarize, Edge, Harness, RunManifest, RunReport, SourceSpec, Target,
};
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
use std::sync::mpsc::{self, Receiver};
/// The built-in synthetic price stream: rises through t=4 then reverses, so the
/// demo trace carries one exposure sign flip and a real drawdown (C22 populated
/// trace). Deterministic and fixed (C1).
fn synthetic_prices() -> Vec<(Timestamp, Scalar)> {
[
(1_i64, 1.0000_f64),
(2, 1.0010),
(3, 1.0030),
(4, 1.0060),
(5, 1.0040),
(6, 1.0010),
(7, 0.9990),
]
.iter()
.map(|&(t, p)| (Timestamp(t), Scalar::F64(p)))
.collect()
}
/// Bootstrap the sample signal-quality harness with two recording sinks (equity
/// tapped on the SimBroker, exposure tapped on the Exposure node). Rust-authored
/// wiring (C17/C20) over the raw bootstrap API — no builder DSL this cycle. The
/// price taps both SMAs and the broker's price slot (slot 1); exposure feeds the
/// broker's slot 0 (slot order is load-bearing — both are f64).
fn sample_harness() -> (
Harness,
Receiver<(Timestamp, Vec<Scalar>)>,
Receiver<(Timestamp, Vec<Scalar>)>,
) {
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
let h = Harness::bootstrap(
vec![
Box::new(Sma::new(2)), // 0 fast SMA
Box::new(Sma::new(4)), // 1 slow SMA
Box::new(Sub::new()), // 2 spread
Box::new(Exposure::new(0.5)), // 3 exposure
Box::new(SimBroker::new(0.0001)), // 4 sim-optimal broker
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_eq)), // 5 equity sink
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_ex)), // 6 exposure sink
],
vec![SourceSpec {
kind: ScalarKind::F64,
targets: vec![
Target { node: 0, slot: 0 },
Target { node: 1, slot: 0 },
Target { node: 4, slot: 1 }, // price into the broker's price slot
],
}],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
Edge { from: 2, to: 3, slot: 0, from_field: 0 },
Edge { from: 3, to: 4, slot: 0, from_field: 0 }, // exposure into broker slot 0
Edge { from: 4, to: 5, slot: 0, from_field: 0 }, // equity -> sink 5
Edge { from: 3, to: 6, slot: 0, from_field: 0 }, // exposure -> sink 6
],
)
.expect("valid sample signal-quality DAG");
(h, rx_eq, rx_ex)
}
/// Run the sample harness and fold it into a `RunReport` (drain both sinks →
/// `f64_field` → `summarize` → pair with a `RunManifest`). Pure and deterministic
/// (C1): the same build yields the same report.
fn run_sample() -> RunReport {
let (mut h, rx_eq, rx_ex) = sample_harness();
let prices = synthetic_prices();
let window = (
prices.first().expect("non-empty stream").0,
prices.last().expect("non-empty stream").0,
);
h.run(vec![prices]);
let eq_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_eq.try_iter().collect();
let ex_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_ex.try_iter().collect();
let equity = f64_field(&eq_rows, 0);
let exposure = f64_field(&ex_rows, 0);
let metrics = summarize(&equity, &exposure);
RunReport {
manifest: RunManifest {
commit: option_env!("AURA_COMMIT").unwrap_or("unknown").to_string(),
params: vec![
("sma_fast".to_string(), 2.0),
("sma_slow".to_string(), 4.0),
("exposure_scale".to_string(), 0.5),
],
window,
seed: 0,
broker: "sim-optimal(pip_size=0.0001)".to_string(),
},
metrics,
}
}
fn main() {
let mut args = std::env::args().skip(1);
match args.next().as_deref() {
Some("run") => println!("{}", run_sample().to_json()),
_ => {
eprintln!("aura: usage: aura run");
std::process::exit(2);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn run_sample_is_deterministic_and_non_trivial() {
let r1 = run_sample();
let r2 = run_sample();
// C1 determinism: two runs are bit-identical (metrics + rendered JSON).
assert_eq!(r1.metrics, r2.metrics);
assert_eq!(r1.to_json(), r2.to_json());
let m = &r1.metrics;
// exactly one exposure sign flip in the demo trace (rises then reverses).
assert_eq!(m.exposure_sign_flips, 1);
// a non-trivial, populated trace: a real drawdown.
assert!(m.max_drawdown > 0.0);
// hand-computed magnitudes for the chosen stream (float tolerance; the
// computation's dust is ~1e-15).
assert!(
(m.max_drawdown - 0.17).abs() < 1e-9,
"max_drawdown = {}",
m.max_drawdown
);
assert!(
(m.total_pips - (-0.13)).abs() < 1e-9,
"total_pips = {}",
m.total_pips
);
// manifest carries the sample's known configuration.
let (from, to) = r1.manifest.window;
assert_eq!((from.0, to.0), (1, 7));
assert_eq!(r1.manifest.commit, "unknown");
}
}
```
- [ ] **Step 3: Run the `run_sample` unit test to verify it passes**
Run: `cargo test -p aura-cli --bin aura run_sample`
Expected: PASS — `run_sample_is_deterministic_and_non_trivial` green (1 test run;
the filter `run_sample` matches that one named test).
- [ ] **Step 4: Smoke-run the binary by hand**
Run: `cargo run -p aura-cli -- run`
Expected: a single-line JSON object on stdout beginning
`{"manifest":{"commit":"unknown","params":{"sma_fast":2,"sma_slow":4,"exposure_scale":0.5},"window":[1,7],"seed":0,"broker":"sim-optimal(pip_size=0.0001)"},"metrics":{"total_pips":`
ending with `"exposure_sign_flips":1}}`, exit code 0.
Run: `cargo run -p aura-cli 2>&1 >/dev/null`
Expected: `aura: usage: aura run` on stderr; exit code 2.
---
## Task 3: `aura-cli` CLI integration test
**Files:**
- Create: `crates/aura-cli/tests/cli_run.rs`
- [ ] **Step 1: Create `crates/aura-cli/tests/cli_run.rs` with the binary-driving tests**
Write the file with exactly this content:
```rust
//! Integration test: drive the built `aura` binary as a downstream user would,
//! asserting the `run` subcommand's stdout/exit contract and the bad-args path.
use std::process::Command;
/// Path to the freshly-built `aura` binary (Cargo sets this env var for the test
/// crate; the binary is named `aura` in `Cargo.toml`).
const BIN: &str = env!("CARGO_BIN_EXE_aura");
#[test]
fn run_prints_json_and_exits_zero() {
let out = Command::new(BIN).arg("run").output().expect("spawn aura run");
assert!(out.status.success(), "exit status: {:?}", out.status);
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
// exactly one line (the JSON object + a trailing newline from println!).
assert_eq!(stdout.lines().count(), 1, "stdout was: {stdout:?}");
let line = stdout.trim_end();
// canonical cycle-0009 JSON shape: nested manifest + metrics, stable keys.
assert!(line.starts_with("{\"manifest\":{\"commit\":\"unknown\","), "got: {line}");
assert!(line.contains("\"broker\":\"sim-optimal(pip_size=0.0001)\""), "got: {line}");
assert!(line.contains("\"window\":[1,7]"), "got: {line}");
assert!(line.contains("\"metrics\":{\"total_pips\":"), "got: {line}");
// the integer sign-flip count is stable across float renderings.
assert!(line.ends_with("\"exposure_sign_flips\":1}}"), "got: {line}");
}
#[test]
fn no_args_prints_usage_and_exits_two() {
let out = Command::new(BIN).output().expect("spawn aura");
assert_eq!(out.status.code(), Some(2), "exit status: {:?}", out.status);
assert!(out.stdout.is_empty(), "stdout should be empty on the usage path");
let stderr = String::from_utf8(out.stderr).expect("utf-8 stderr");
assert!(stderr.contains("usage"), "stderr was: {stderr:?}");
}
```
- [ ] **Step 2: Run the integration test to verify it passes**
Run: `cargo test -p aura-cli --test cli_run`
Expected: PASS — `run_prints_json_and_exits_zero` and
`no_args_prints_usage_and_exits_two` both green (2 tests run; the `--test cli_run`
target resolves to the file created in Step 1).
---
## Task 4: Full-workspace gates
**Files:** none (verification only).
- [ ] **Step 1: Full test suite**
Run: `cargo test --workspace`
Expected: PASS — all pre-existing tests plus the new Recorder (2), `run_sample`
(1), and `cli_run` (2) tests green; 0 failures.
- [ ] **Step 2: Lint gate**
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: PASS — no warnings across the workspace.
- [ ] **Step 3: Doc gate**
Run: `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps`
Expected: PASS — docs build with no warnings (the new `Recorder` rustdoc and the
`aura-cli` module/fn docs included).
---
## Self-review (planner Step 5)
1. **Spec coverage:** Recorder node (spec §Architecture 1, Components) → Task 1;
`synthetic_prices`/`sample_harness`/`run_sample`/`main` (§Architecture 2,
Components, Data flow, Error handling) → Task 2; CLI integration test (§Testing
strategy) → Task 3; the three gates (§Testing strategy) → Task 4. The
user-facing invocation/output (§Concrete code shapes) is exercised by Task 2
Step 4 + Task 3. All spec sections covered.
2. **Placeholder scan:** no "TBD"/"TODO"/"similar to"/"implement later"/"add
appropriate" — every code body is verbatim.
3. **Type consistency:** `Recorder` / `Recorder::new(kinds, firing, tx)` /
`RunReport` / `RunManifest` / `summarize` / `f64_field` / `Harness::bootstrap`
/ `Edge`/`Target`/`SourceSpec` / `Sma`/`Sub`/`Exposure`/`SimBroker` match the
recon'd signatures and are spelled identically across tasks. `Scalar::Ts` (not
`Scalar::Timestamp`) used for the `ScalarKind::Timestamp` arm. The aura-std
`pub use` list stays alphabetical.
4. **Step granularity:** each step is one file write / one wiring edit / one
command — 2-5 minutes each.
5. **No commit steps:** none present; the orchestrator commits.
6. **Pin/replacement substring contiguity:** the integration test's
`line.starts_with("{\"manifest\":{\"commit\":\"unknown\",")` and
`line.ends_with("\"exposure_sign_flips\":1}}")` are substrings the cycle-0009
`to_json` produces verbatim (manifest-first nesting, `commit` first field,
`exposure_sign_flips` last metric); `"window":[1,7]` is the `(Timestamp(1),
Timestamp(7))` rendering (window-as-2-array, documented schema). No soft-wrap
splits any pinned substring.
7. **Compile-gate vs. deferred-caller ordering:** no signature change — the work
is pure-additive (a new module + a new binary body + a new dep). Task 2 Step 1
adds the `aura-std`/`aura-core` deps *before* Step 2 introduces the `use`
statements that need them, so each task compiles at its own boundary; Task 1
(the `Recorder` it imports) precedes Task 2. No deferred caller.
8. **Verification-command filter strings resolve:** `cargo test -p aura-std
recorder` matches the two `recorder_*` tests named in Task 1; `cargo test -p
aura-cli --bin aura run_sample` matches the `run_sample_*` test named in Task 2;
`cargo test -p aura-cli --test cli_run` targets the file created in Task 3.
Each filter/target is verified against a real named test/file in this plan, not
guessed from a feature word. Task 4 runs the unfiltered workspace suite with an
explicit "0 failures / new counts" expectation.
9. **Parse-the-bytes-you-inline gate:** the profile declares no `spec_validation`
parser, so the gate is a documented no-op for this plan's non-Rust fenced
blocks (one `text` price table, the `toml` dep block, the bash Run commands);
the Rust bodies are validated by the `implement` compile gate (Tasks 1-4 build
commands). No surface-language (non-Rust) program is inlined that a configured
parser would own.
-567
View File
@@ -1,567 +0,0 @@
# data-server M1 ingestion boundary — Implementation Plan
> **Parent spec:** `docs/specs/0011-ingest-data-server-m1.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Ship a new `aura-ingest` crate that transposes data-server's AoS M1
records into SoA columns, normalizes Unix-ms to epoch-ns at the one ingestion
boundary, and feeds the engine a real close-price stream for a deterministic
backtest.
**Architecture:** A new workspace member `aura-ingest` (prod deps `aura-core` +
the `data-server` git crate; dev deps `aura-engine` + `aura-std`) holds the pure
C3/C7 boundary (`transpose_m1`, `unix_ms_to_epoch_ns`, `M1Columns::close_stream`)
plus the thin `data-server` adapter (`load_m1_window`). The boundary is the
external-dependency firewall: only this crate links `data-server` (and its
transitive `chrono`/`regex`/`zip`), keeping core/std/engine zero-external-dep.
**Tech Stack:** Rust 2024; `aura-core` (`Scalar`/`Timestamp`); `data-server`
(`M1Parsed`/`DataServer`); `aura-engine` + `aura-std` (sample harness, in the
integration test only).
---
## Files this plan creates or modifies
- Create: `crates/aura-ingest/Cargo.toml` — new member manifest; deps as above.
- Create: `crates/aura-ingest/src/lib.rs` — the C3/C7 boundary + hermetic unit tests.
- Create: `crates/aura-ingest/tests/real_bars.rs` — gated real-bars integration test.
- Modify: `Cargo.toml:11-16` — add `"crates/aura-ingest"` to `members`.
Verified external `data-server` API (from its actual source; used verbatim):
- `data_server::DataServer::new(impl AsRef<Path>) -> Self`
- `DataServer::has_symbol(&self, &str) -> bool`
- `DataServer::stream_m1_windowed(self: &Arc<Self>, symbol: &str, from_ms: Option<i64>, to_ms: Option<i64>) -> Option<SymbolChunkIter<M1Parsed>>`
- `SymbolChunkIter::<M1Parsed>::next_chunk(&mut self) -> Option<Arc<[M1Parsed]>>`
- `data_server::DEFAULT_DATA_PATH: &str` (crate root)
- `data_server::records::M1Parsed { time_ms: i64, open: f64, high: f64, low: f64, close: f64, spread: f64, volume: i64 }` derives `Copy + Clone` (plain struct, not packed — direct field access is sound).
---
## Task 1: Crate scaffold + workspace member + `unix_ms_to_epoch_ns`
Establishes the new member, proves the `data-server` git dependency resolves and
the crate compiles, and lands the first boundary function with its test.
**Files:**
- Modify: `Cargo.toml:11-16`
- Create: `crates/aura-ingest/Cargo.toml`
- Create: `crates/aura-ingest/src/lib.rs`
- [ ] **Step 1: Add the workspace member**
In `Cargo.toml`, replace the `members` array (lines 11-16):
```toml
members = [
"crates/aura-core",
"crates/aura-std",
"crates/aura-engine",
"crates/aura-cli",
"crates/aura-ingest",
]
```
- [ ] **Step 2: Write the crate manifest**
Create `crates/aura-ingest/Cargo.toml`:
```toml
[package]
name = "aura-ingest"
edition.workspace = true
version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
aura-core = { path = "../aura-core" }
# data-server: aura's first real data source AND its one external dependency
# (transitively chrono + regex + zip). Isolated in this crate — the firewall
# that keeps aura-core/std/engine zero-external-dep (spec §Architecture).
data-server = { git = "http://192.168.178.103:3000/Brummel/data-server.git", branch = "main" }
[dev-dependencies]
# the integration test bootstraps a sample harness + folds a RunReport
aura-engine = { path = "../aura-engine" }
aura-std = { path = "../aura-std" }
```
- [ ] **Step 3: Write `lib.rs` module doc + `unix_ms_to_epoch_ns` + its test**
Create `crates/aura-ingest/src/lib.rs`:
```rust
//! aura's first real data source: the C3/C7 ingestion boundary.
//!
//! Transposes [`data_server`]'s Array-of-Structs `M1Parsed` records into aura's
//! Structure-of-Arrays base columns (C7) and normalizes data-server's
//! Unix-millisecond time to aura's canonical epoch-nanosecond [`Timestamp`] at
//! this one boundary (C3). A transposed [`M1Columns`] exposes its close column
//! as the engine source-stream shape (`Vec<(Timestamp, Scalar)>`) the SMA-cross
//! sample strategy consumes.
//!
//! This crate is the workspace's **external-dependency firewall**: it is the
//! only crate that links `data-server` (and its transitive `chrono`/`regex`/
//! `zip`), so `aura-core`/`aura-std`/`aura-engine` stay zero-external-dependency.
use aura_core::{Scalar, Timestamp};
use data_server::records::M1Parsed;
use data_server::DataServer;
use std::sync::Arc;
/// Normalize data-server's Unix-millisecond time to aura's canonical epoch-ns
/// [`Timestamp`] — the single unit normalization of C3, performed at the one
/// ingestion boundary and nowhere else. The `i64` epoch-ns range covers all
/// market data through year ~2262, well beyond any real file.
pub fn unix_ms_to_epoch_ns(time_ms: i64) -> Timestamp {
Timestamp(time_ms * 1_000_000)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unix_ms_to_epoch_ns_scales_ms_to_ns() {
// data-server's own epoch fixture: 2017-03-01 00:00 UTC.
assert_eq!(
unix_ms_to_epoch_ns(1_488_326_400_000),
Timestamp(1_488_326_400_000_000_000)
);
// epoch maps to epoch.
assert_eq!(unix_ms_to_epoch_ns(0), Timestamp(0));
}
}
```
- [ ] **Step 4: Build the crate (proves the git dep resolves) and run the test**
Run: `cargo test -p aura-ingest`
Expected: PASS — compiles (fetching `data-server` from Gitea on first build, then
cached) and `unix_ms_to_epoch_ns_scales_ms_to_ns` is green. (If the Gitea fetch
fails, that is an infra/network problem to resolve, not a code failure.)
---
## Task 2: `M1Columns` + `transpose_m1`
The pure AoS→SoA transpose, the heart of the C3/C7 boundary, with hermetic tests
on hand-built records.
**Files:**
- Modify: `crates/aura-ingest/src/lib.rs`
- [ ] **Step 1: Add `M1Columns` + `with_capacity` + `transpose_m1`**
In `crates/aura-ingest/src/lib.rs`, after `unix_ms_to_epoch_ns` (before the
`#[cfg(test)]` module), insert:
```rust
/// One M1 window transposed Array-of-Structs → Structure-of-Arrays (C7): the
/// OHLCV bar as a bundle of base columns, time already normalized to epoch-ns.
/// `volume` is the one `i64` column; the price/spread columns are `f64`. All
/// columns share an index: `ts[i]` is the timestamp of `close[i]`, etc.
#[derive(Clone, Debug, PartialEq)]
pub struct M1Columns {
pub ts: Vec<Timestamp>,
pub open: Vec<f64>,
pub high: Vec<f64>,
pub low: Vec<f64>,
pub close: Vec<f64>,
pub spread: Vec<f64>,
pub volume: Vec<i64>,
}
impl M1Columns {
/// Pre-size all columns for `n` bars.
fn with_capacity(n: usize) -> Self {
Self {
ts: Vec::with_capacity(n),
open: Vec::with_capacity(n),
high: Vec::with_capacity(n),
low: Vec::with_capacity(n),
close: Vec::with_capacity(n),
spread: Vec::with_capacity(n),
volume: Vec::with_capacity(n),
}
}
}
/// Transpose data-server's AoS M1 records into aura's SoA columns (C7),
/// normalizing time at the boundary (C3). Pure: identical bars yield identical
/// columns (C1) — it reads no clock and no external state.
pub fn transpose_m1(bars: &[M1Parsed]) -> M1Columns {
let mut c = M1Columns::with_capacity(bars.len());
for b in bars {
c.ts.push(unix_ms_to_epoch_ns(b.time_ms));
c.open.push(b.open);
c.high.push(b.high);
c.low.push(b.low);
c.close.push(b.close);
c.spread.push(b.spread);
c.volume.push(b.volume);
}
c
}
```
- [ ] **Step 2: Add the transpose tests**
In the `#[cfg(test)] mod tests` block in `crates/aura-ingest/src/lib.rs`, add a
record-builder helper and the transpose tests (inside the `mod tests`, after the
existing test):
```rust
/// A hand-built M1 bar with distinct per-field values so a transpose test
/// can tell the columns apart.
fn full_bar(time_ms: i64) -> M1Parsed {
M1Parsed {
time_ms,
open: 1.0,
high: 2.0,
low: 0.5,
close: 1.5,
spread: 0.1,
volume: 100,
}
}
#[test]
fn transpose_m1_maps_every_field_to_its_column() {
let bars = [full_bar(1_000), full_bar(2_000)];
let c = transpose_m1(&bars);
// ts normalized ms -> ns; each column equal length to the input.
assert_eq!(c.ts, vec![Timestamp(1_000_000_000), Timestamp(2_000_000_000)]);
assert_eq!(c.open, vec![1.0, 1.0]);
assert_eq!(c.high, vec![2.0, 2.0]);
assert_eq!(c.low, vec![0.5, 0.5]);
assert_eq!(c.close, vec![1.5, 1.5]);
assert_eq!(c.spread, vec![0.1, 0.1]);
// volume is the one i64 column.
assert_eq!(c.volume, vec![100_i64, 100_i64]);
}
#[test]
fn transpose_m1_is_pure() {
let bars = [full_bar(1_000), full_bar(2_000), full_bar(3_000)];
assert_eq!(transpose_m1(&bars), transpose_m1(&bars));
}
#[test]
fn transpose_m1_empty_input_yields_empty_columns() {
let c = transpose_m1(&[]);
assert!(c.ts.is_empty());
assert!(c.close.is_empty());
assert!(c.volume.is_empty());
}
```
- [ ] **Step 3: Run the tests**
Run: `cargo test -p aura-ingest`
Expected: PASS — `transpose_m1_maps_every_field_to_its_column`,
`transpose_m1_is_pure`, `transpose_m1_empty_input_yields_empty_columns` green
alongside Task 1's test.
---
## Task 3: `M1Columns::close_stream`
The adapter from a transposed column to the engine's source-stream shape.
**Files:**
- Modify: `crates/aura-ingest/src/lib.rs`
- [ ] **Step 1: Add `close_stream`**
In `crates/aura-ingest/src/lib.rs`, add an `impl M1Columns` block (after the
existing `impl M1Columns { fn with_capacity … }`, or extend it) with:
```rust
impl M1Columns {
/// The close column as an engine source stream: each normalized timestamp
/// zipped with its close as a [`Scalar::F64`]. Ascending in timestamp iff
/// the bars were (the C3 ingestion precondition `Harness::run` relies on).
/// This is the price input the SMA-cross sample strategy consumes; a project
/// that wants another field reads the public columns and zips its own.
pub fn close_stream(&self) -> Vec<(Timestamp, Scalar)> {
self.ts
.iter()
.zip(&self.close)
.map(|(&t, &v)| (t, Scalar::F64(v)))
.collect()
}
}
```
- [ ] **Step 2: Add the close_stream tests**
In `#[cfg(test)] mod tests`, add:
```rust
#[test]
fn close_stream_zips_ts_with_close_in_order() {
// distinct close per bar so order is observable; ts ascending.
let bars = [
M1Parsed { time_ms: 1, open: 0.0, high: 0.0, low: 0.0, close: 10.0, spread: 0.0, volume: 0 },
M1Parsed { time_ms: 2, open: 0.0, high: 0.0, low: 0.0, close: 20.0, spread: 0.0, volume: 0 },
M1Parsed { time_ms: 3, open: 0.0, high: 0.0, low: 0.0, close: 30.0, spread: 0.0, volume: 0 },
];
let stream = transpose_m1(&bars).close_stream();
assert_eq!(
stream,
vec![
(Timestamp(1_000_000), Scalar::F64(10.0)),
(Timestamp(2_000_000), Scalar::F64(20.0)),
(Timestamp(3_000_000), Scalar::F64(30.0)),
]
);
// ascending in timestamp (the merge precondition).
assert!(stream.windows(2).all(|w| w[0].0 < w[1].0));
}
#[test]
fn close_stream_empty_on_empty_columns() {
assert!(transpose_m1(&[]).close_stream().is_empty());
}
```
- [ ] **Step 3: Run the tests**
Run: `cargo test -p aura-ingest`
Expected: PASS — `close_stream_zips_ts_with_close_in_order` and
`close_stream_empty_on_empty_columns` green alongside the prior tests.
---
## Task 4: `load_m1_window` — the data-server adapter
Drains a data-server M1 window into transposed columns. Not hermetically
unit-testable (it touches files/the cache); its correctness is exercised by the
Task 5 integration test. This task is impl + compile gate.
**Files:**
- Modify: `crates/aura-ingest/src/lib.rs`
- [ ] **Step 1: Add `load_m1_window`**
In `crates/aura-ingest/src/lib.rs`, after `transpose_m1` (outside the test
module), insert:
```rust
/// Drain a data-server M1 window into transposed SoA columns. Reads the
/// chronological chunk iterator to exhaustion into one AoS buffer, then
/// transposes once at the boundary (C3 — one merge point, not per chunk).
/// `None` if the symbol has no data in `[from_ms, to_ms]` (data-server's own
/// `None`); the inclusive Unix-ms window bounds are data-server's contract.
pub fn load_m1_window(
server: &Arc<DataServer>,
symbol: &str,
from_ms: i64,
to_ms: i64,
) -> Option<M1Columns> {
let mut it = server.stream_m1_windowed(symbol, Some(from_ms), Some(to_ms))?;
let mut bars: Vec<M1Parsed> = Vec::new();
// M1Parsed is Copy; &Arc<[M1Parsed]> derefs to &[M1Parsed] in arg position.
while let Some(chunk) = it.next_chunk() {
bars.extend_from_slice(&chunk);
}
Some(transpose_m1(&bars))
}
```
- [ ] **Step 2: Build (compiles against the real data-server API) + lint**
Run: `cargo build -p aura-ingest && cargo clippy -p aura-ingest --all-targets -- -D warnings`
Expected: PASS — `load_m1_window` compiles against data-server's real
`stream_m1_windowed`/`next_chunk` signatures with no warnings. (A signature
mismatch here surfaces as a compile error — the load-bearing external-API check.)
---
## Task 5: Gated real-bars integration test
Runs the cycle-0007 sample harness over a real AAPL.US close stream, asserting a
finite, deterministic `RunReport`. Skips cleanly where `/mnt/tickdata` is absent.
**Files:**
- Create: `crates/aura-ingest/tests/real_bars.rs`
- [ ] **Step 1: Write the integration test**
Create `crates/aura-ingest/tests/real_bars.rs`:
```rust
//! Gated integration test: a real data-server M1 close stream driven through
//! the cycle-0007 signal-quality sample harness (SMA-cross → Exposure →
//! SimBroker), folded into a `RunReport`. Skips with a note where the local
//! Pepperstone data directory is absent, so `cargo test --workspace` stays green
//! anywhere; it exercises the real ingestion path where data exists.
use std::sync::mpsc;
use std::sync::Arc;
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
use aura_engine::{
f64_field, summarize, Edge, Harness, RunManifest, RunReport, SourceSpec, Target,
};
use aura_ingest::load_m1_window;
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
use data_server::{DataServer, DEFAULT_DATA_PATH};
/// Bootstrap the cycle-0007 two-sink signal-quality harness (mirrors
/// `aura-engine`'s `report::tests::build_two_sink_harness`, with the shipped
/// `aura_std::Recorder`), run it on `prices`, and fold the recorded equity +
/// exposure into a `RunReport` whose window is the first/last real bar ts.
fn run_sample_over(prices: Vec<(Timestamp, Scalar)>) -> RunReport {
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
let mut h = Harness::bootstrap(
vec![
Box::new(Sma::new(2)), // 0
Box::new(Sma::new(4)), // 1
Box::new(Sub::new()), // 2
Box::new(Exposure::new(0.5)), // 3
Box::new(SimBroker::new(0.0001)), // 4
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_eq)), // 5 equity sink
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_ex)), // 6 exposure sink
],
vec![SourceSpec {
kind: ScalarKind::F64,
targets: vec![
Target { node: 0, slot: 0 },
Target { node: 1, slot: 0 },
Target { node: 4, slot: 1 }, // price into the broker
],
}],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
Edge { from: 2, to: 3, slot: 0, from_field: 0 },
Edge { from: 3, to: 4, slot: 0, from_field: 0 },
Edge { from: 4, to: 5, slot: 0, from_field: 0 }, // equity -> sink 5
Edge { from: 3, to: 6, slot: 0, from_field: 0 }, // exposure -> sink 6
],
)
.expect("valid signal-quality DAG");
let window = (
prices.first().map(|&(t, _)| t).unwrap_or(Timestamp(0)),
prices.last().map(|&(t, _)| t).unwrap_or(Timestamp(0)),
);
h.run(vec![prices]);
let eq_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_eq.try_iter().collect();
let ex_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_ex.try_iter().collect();
let equity = f64_field(&eq_rows, 0);
let exposure = f64_field(&ex_rows, 0);
let metrics = summarize(&equity, &exposure);
RunReport {
manifest: RunManifest {
commit: "real-bars-test".to_string(),
params: vec![
("sma_fast".to_string(), 2.0),
("sma_slow".to_string(), 4.0),
("exposure_scale".to_string(), 0.5),
],
window,
seed: 0,
broker: "sim-optimal(pip_size=0.0001)".to_string(),
},
metrics,
}
}
#[test]
fn sample_strategy_runs_over_real_m1_bars_deterministically() {
let server = Arc::new(DataServer::new(DEFAULT_DATA_PATH));
if !server.has_symbol("AAPL.US") {
eprintln!("skip: no local data at {DEFAULT_DATA_PATH} (symbol AAPL.US absent)");
return; // hermetic elsewhere; exercises the real path where files exist
}
// 2006-08 in inclusive Unix-ms (data-server skips files outside the window).
let (from_ms, to_ms) = (1_154_390_400_000_i64, 1_157_068_799_999_i64);
let load = || {
load_m1_window(&server, "AAPL.US", from_ms, to_ms)
.expect("AAPL.US has data in the 2006-08 window")
.close_stream()
};
let prices = load();
assert!(!prices.is_empty(), "window resolved to zero bars");
let r1 = run_sample_over(prices);
assert!(r1.metrics.total_pips.is_finite(), "a backtest ran over real bars");
// same window -> bit-identical report (C1).
let r2 = run_sample_over(load());
assert_eq!(r1.to_json(), r2.to_json());
}
```
- [ ] **Step 2: Run the integration test**
Run: `cargo test -p aura-ingest --test real_bars`
Expected: PASS — on this machine `/mnt/tickdata/Pepperstone` exists, so the test
runs the real path (loads AAPL.US 2006-08, runs the sample harness, asserts
finite + deterministic). On a machine without the data it prints the skip note
and passes.
---
## Task 6: Full-workspace gates
Confirm the additive crate leaves the whole workspace green under the project's
gate commands.
**Files:** none (verification only).
- [ ] **Step 1: Workspace test suite**
Run: `cargo test --workspace`
Expected: PASS — all existing crate tests plus the new `aura-ingest` unit tests
and the gated integration test. (Builds `aura-ingest` + its `data-server` tree;
the cargo cache must be populated — Task 1 already fetched it.)
- [ ] **Step 2: Lint**
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: PASS — no warnings across the workspace including `aura-ingest`.
- [ ] **Step 3: Docs**
Run: `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps`
Expected: PASS — `aura-ingest`'s rustdoc (module doc + public-item docs) builds
with no warnings.
---
## Self-review (planner Step 5)
1. **Spec coverage:** boundary (`unix_ms_to_epoch_ns` T1, `transpose_m1`/`M1Columns`
T2, `close_stream` T3, `load_m1_window` T4), hermetic tests (T2/T3), gated
integration test (T5), workspace gates (T6), crate/firewall + offline-build
(T1 manifest + members). All spec sections covered.
2. **Placeholder scan:** no TBD/TODO/"implement later"/"similar to"/"add appropriate".
3. **Type consistency:** `M1Columns`, `transpose_m1`, `unix_ms_to_epoch_ns`,
`close_stream`, `load_m1_window`, node/edge wiring identical across tasks and to
the recon-anchored `report.rs` fixture; `data-server` signatures match the
verified source.
4. **Step granularity:** each step is one edit or one command.
5. **No commit steps:** none present.
6. **Pin/replacement contiguity:** no presence-pin paired with a verbatim text
edit (the `members` edit is a whole-array replacement, self-contained).
7. **Compile-gate vs deferred-caller:** purely additive new crate; Task 1 adds the
member AND manifest AND a compiling lib.rs together, so its build gate is
satisfiable with no deferred caller. No existing signature changes.
8. **Verification-command filters resolve:** per-task gates use `-p aura-ingest`
(whole-crate, named expected tests) and `--test real_bars` (the file created in
T5); no empty-filter "0 ran" masquerade.
9. **Parse-the-bytes gate:** profile declares no `spec_validation` parser → no-op;
inlined bodies are Rust (the source language), caught by the implement compile
gate, not the planner parse gate.
@@ -1,902 +0,0 @@
# Blueprint → flat graph: composite inlining — Implementation Plan
> **Parent spec:** `docs/specs/0012-blueprint-compile-composites.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Add a `Blueprint` / `Composite` construction layer to `aura-engine` that
compiles a named graph-as-data — inlining composites by raw-index lowering — into
the flat `(nodes, sources, edges)` the unchanged `Harness::bootstrap` consumes, and
prove an SMA-cross composite runs bit-identically to today's hand-wired graph (C1).
**Architecture:** A new module `crates/aura-engine/src/blueprint.rs` sits *above*
`Harness::bootstrap`. `Blueprint::compile()` recursively inlines every `Composite`
(append interior nodes at an offset, rewrite interior edges, fan input roles out,
resolve the one output port), producing the same flat graph a hand-wiring would.
The run loop, `bootstrap`'s signature, and `Edge`/`Target`/`SourceSpec`/`Node` are
untouched; bootstrap's existing kind- and Kahn-cycle-check validate the lowered
flat graph. Optimisation passes (C23) are explicitly out of scope.
**Tech Stack:** Rust, `aura-engine` (depends on `aura-core`; `aura-std` is a
dev-dependency reachable from tests). No new external dependencies (C16).
---
**Files this plan creates or modifies:**
- Create: `crates/aura-engine/src/blueprint.rs` — the construction layer:
`OutPort`, `BlueprintNode` (+ `From<N: Node>` lift), `Composite` (`new` + derived
`schema`), `Blueprint` (`new` + `compile` + `bootstrap`), `CompileError`, the
recursive inliner, and an inline `#[cfg(test)] mod tests`.
- Modify: `crates/aura-engine/src/lib.rs:34-38` — declare `mod blueprint;` and
re-export the public construction types.
- Test: `crates/aura-engine/src/blueprint.rs` (inline `#[cfg(test)] mod tests`) —
schema derivation, inliner happy-path + nested + error paths, and the headline
bit-identical demonstrator `composite_sma_cross_runs_bit_identical_to_hand_wired`.
Reference shapes (read-only, must NOT change): `crates/aura-engine/src/harness.rs`
(`Edge`/`Target`/`SourceSpec` `:29-52`, `BootstrapError` `:56-65`,
`Harness::bootstrap` `:114-200`, run loop `:208-283`), `crates/aura-core/src/node.rs`
(`Node`/`NodeSchema`/`InputSpec`/`FieldSpec`/`Firing`), `crates/aura-cli/src/main.rs:42-78`
(the `sample_harness` wiring the demonstrator reproduces).
---
### Task 1: Construction-layer types + derived schema
**Files:**
- Create: `crates/aura-engine/src/blueprint.rs`
- Modify: `crates/aura-engine/src/lib.rs:34-38`
- Test: `crates/aura-engine/src/blueprint.rs` (inline `mod tests`)
- [ ] **Step 1: Create `blueprint.rs` with the types, the `From` lift, and derived schema**
Create `crates/aura-engine/src/blueprint.rs` with exactly this content:
```rust
//! The construction layer (C9/C19/C23): a named, param-generic graph-as-data
//! (`Blueprint`) that **compiles** to the flat, type-erased instance the run loop
//! already runs (the *flat graph*). The unit of reuse is the [`Composite`]: a
//! nestable sub-graph fragment exposing one output port (C8) and named input
//! roles, which `compile` **inlines** into the flat `(nodes, sources, edges)` the
//! unchanged [`crate::Harness::bootstrap`] consumes.
//!
//! The flat graph is wired by raw index, **not by name** (C23): a composite's
//! boundary dissolves at compile time; field/role names, where kept, are
//! non-load-bearing debug symbols (as `FieldSpec.name` already is). This module
//! adds no optimisation pass (CSE/DCE, sweep-invariant hoisting are deferred,
//! C23) and no external dependency (C16).
use aura_core::{Node, NodeSchema, ScalarKind};
use crate::harness::{BootstrapError, Edge, Harness, SourceSpec, Target};
/// Which interior `(node, output-field)` is a composite's single output port (C8).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct OutPort {
pub node: usize,
pub field: usize,
}
/// A blueprint item: a leaf node or a nested composite. Both present a declared
/// interface (typed inputs + one output) to the enclosing graph.
pub enum BlueprintNode {
Leaf(Box<dyn Node>),
Composite(Composite),
}
/// Ergonomic lift: any concrete `Node` becomes a `Leaf` blueprint item.
impl<N: Node + 'static> From<N> for BlueprintNode {
fn from(node: N) -> Self {
BlueprintNode::Leaf(Box::new(node))
}
}
impl BlueprintNode {
/// The declared interface this item presents to the enclosing graph: a leaf's
/// own `Node::schema`, or a composite's derived [`Composite::schema`].
fn schema(&self) -> NodeSchema {
match self {
BlueprintNode::Leaf(node) => node.schema(),
BlueprintNode::Composite(c) => c.schema(),
}
}
}
/// A reusable sub-graph fragment compiled away by inlining (C9/C23). It is **not**
/// a [`Node`]: it is never `eval`'d. It holds interior items (local indices),
/// interior edges (local indices), input roles (role `r` fans into the interior
/// targets `input_roles[r]`), and the one exposed output port.
pub struct Composite {
nodes: Vec<BlueprintNode>,
edges: Vec<Edge>,
input_roles: Vec<Vec<Target>>,
output: OutPort,
}
impl Composite {
/// Build a composite from its interior items, interior edges (local indices),
/// input roles, and output port.
pub fn new(
nodes: Vec<BlueprintNode>,
edges: Vec<Edge>,
input_roles: Vec<Vec<Target>>,
output: OutPort,
) -> Self {
Self { nodes, edges, input_roles, output }
}
/// The derived interface the enclosing graph wires against: input role `r`'s
/// spec is taken from its first interior target's slot; the output field is the
/// interior output port's field. This is a *derivation*, not a `Node` impl, and
/// it assumes well-formed indices — `compile` is the validator that rejects a
/// malformed composite with a typed [`CompileError`].
pub fn schema(&self) -> NodeSchema {
let inputs = self
.input_roles
.iter()
.map(|role| {
let first = role[0];
self.nodes[first.node].schema().inputs[first.slot]
})
.collect();
let out_field = self.nodes[self.output.node].schema().output[self.output.field];
NodeSchema { inputs, output: vec![out_field] }
}
}
/// The root graph-as-data, before compilation: blueprint items + sources + edges,
/// all addressing blueprint-level indices.
pub struct Blueprint {
nodes: Vec<BlueprintNode>,
sources: Vec<SourceSpec>,
edges: Vec<Edge>,
}
impl Blueprint {
/// Build a blueprint from its items, sources, and edges (blueprint-level
/// indices; a target/edge endpoint may name a composite).
pub fn new(nodes: Vec<BlueprintNode>, sources: Vec<SourceSpec>, edges: Vec<Edge>) -> Self {
Self { nodes, sources, edges }
}
}
#[cfg(test)]
mod tests {
use super::*;
use aura_core::{Ctx, FieldSpec, Firing, InputSpec, Scalar};
/// A 2-input f64 node, one f64 output. Test-local fixture (C9: examples for the
/// engine's own tests, no speculative `aura-std` surface).
struct Join2 {
out: [Scalar; 1],
}
impl Node for Join2 {
fn schema(&self) -> NodeSchema {
NodeSchema {
inputs: vec![
InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any },
InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any },
],
output: vec![FieldSpec { name: "v", kind: ScalarKind::F64 }],
}
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> {
let a = ctx.f64_in(0);
let b = ctx.f64_in(1);
if a.is_empty() || b.is_empty() {
return None;
}
self.out[0] = Scalar::F64(a[0] + b[0]);
Some(&self.out)
}
}
#[test]
fn composite_schema_derives_role_and_output_kinds() {
// one interior node (Join2: 2 f64 inputs, 1 f64 output); two roles, each
// feeding one interior slot; output port = the Join2 output field 0.
let c = Composite::new(
vec![BlueprintNode::Leaf(Box::new(Join2 { out: [Scalar::F64(0.0)] }))],
vec![],
vec![
vec![Target { node: 0, slot: 0 }],
vec![Target { node: 0, slot: 1 }],
],
OutPort { node: 0, field: 0 },
);
let schema = c.schema();
assert_eq!(schema.inputs.len(), 2);
assert_eq!(schema.inputs[0].kind, ScalarKind::F64);
assert_eq!(schema.inputs[1].kind, ScalarKind::F64);
assert_eq!(schema.output, vec![FieldSpec { name: "v", kind: ScalarKind::F64 }]);
}
}
```
- [ ] **Step 2: Declare the module and re-export the public types in `lib.rs`**
In `crates/aura-engine/src/lib.rs`, replace the module declarations and re-export
block (`:34-38`):
```rust
mod harness;
mod report;
```
becomes:
```rust
mod blueprint;
mod harness;
mod report;
```
and add a re-export line after the existing `pub use harness::{...};` (`:37`) so the
block reads:
```rust
pub use blueprint::{Blueprint, BlueprintNode, Composite, OutPort};
pub use harness::{BootstrapError, Edge, Harness, SourceSpec, Target};
pub use report::{f64_field, summarize, RunManifest, RunMetrics, RunReport};
```
- [ ] **Step 3: Run the schema test to verify it passes**
Run: `cargo test -p aura-engine blueprint::tests::composite_schema_derives_role_and_output_kinds`
Expected: PASS (`test result: ok. 1 passed`).
- [ ] **Step 4: Verify the workspace still compiles and existing tests stay green**
Run: `cargo test -p aura-engine`
Expected: PASS — the existing harness/report tests still pass and the one new
schema test passes; `0 failed`.
---
### Task 2: The recursive inliner — `compile()` + `bootstrap()`
**Files:**
- Modify: `crates/aura-engine/src/blueprint.rs` (add `CompileError`, the inliner,
and `impl Blueprint { compile, bootstrap }`)
- Modify: `crates/aura-engine/src/lib.rs` (add `CompileError` to the re-export)
- Test: `crates/aura-engine/src/blueprint.rs` (inline `mod tests`)
- [ ] **Step 1: Write the failing inliner tests**
Append these test fixtures and tests inside the existing `#[cfg(test)] mod tests`
in `crates/aura-engine/src/blueprint.rs` (after the `Join2` fixture and the schema
test):
```rust
/// A 1-input f64 node, one f64 output. Test-local fixture.
struct Pass1 {
out: [Scalar; 1],
}
impl Node for Pass1 {
fn schema(&self) -> NodeSchema {
NodeSchema {
inputs: vec![InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }],
output: vec![FieldSpec { name: "v", kind: ScalarKind::F64 }],
}
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> {
let w = ctx.f64_in(0);
if w.is_empty() {
return None;
}
self.out[0] = Scalar::F64(w[0]);
Some(&self.out)
}
}
/// A pure consumer with one f64 input and no output (sink role, C8).
struct SinkF64;
impl Node for SinkF64 {
fn schema(&self) -> NodeSchema {
NodeSchema {
inputs: vec![InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }],
output: vec![],
}
}
fn eval(&mut self, _ctx: Ctx<'_>) -> Option<&[Scalar]> {
None
}
}
/// A pure consumer with one i64 input and no output. Used to provoke a role /
/// edge kind mismatch (its slot is i64 where an f64 is fanned in).
struct SinkI64;
impl Node for SinkI64 {
fn schema(&self) -> NodeSchema {
NodeSchema {
inputs: vec![InputSpec { kind: ScalarKind::I64, lookback: 1, firing: Firing::Any }],
output: vec![],
}
}
fn eval(&mut self, _ctx: Ctx<'_>) -> Option<&[Scalar]> {
None
}
}
fn pass1() -> BlueprintNode {
BlueprintNode::Leaf(Box::new(Pass1 { out: [Scalar::F64(0.0)] }))
}
fn join2() -> BlueprintNode {
BlueprintNode::Leaf(Box::new(Join2 { out: [Scalar::F64(0.0)] }))
}
/// A composite: two Pass1 leaves feeding a Join2, role 0 fanning the source
/// into BOTH Pass1 slots, output = the Join2 field 0. The generic analogue of
/// the SMA-cross shape.
fn fan_composite() -> Composite {
Composite::new(
vec![pass1(), pass1(), join2()],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
],
vec![vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }]],
OutPort { node: 2, field: 0 },
)
}
#[test]
fn single_composite_inlines_with_offset_fan_and_output() {
// composite as item 0; a source into its role 0; an edge out of it to a sink.
let bp = Blueprint::new(
vec![BlueprintNode::Composite(fan_composite()), BlueprintNode::Leaf(Box::new(SinkF64))],
vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }],
vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
);
let (nodes, sources, edges) = bp.compile().expect("valid composite");
// 3 interior nodes (Pass1, Pass1, Join2) at flat 0..2, then SinkF64 at 3
assert_eq!(nodes.len(), 4);
// interior edges rewritten at offset 0, then the output edge resolves the
// composite's OutPort (interior node 2, field 0) to the sink (flat node 3)
assert_eq!(
edges,
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
Edge { from: 2, to: 3, slot: 0, from_field: 0 },
]
);
// the source target into role 0 fanned into BOTH Pass1 slots
assert_eq!(sources.len(), 1);
assert_eq!(
sources[0].targets,
vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }]
);
}
#[test]
fn nested_composite_inlines() {
// outer composite wraps the inner fan_composite as its only interior item,
// re-exposing the inner's role 0 (outer role 0 -> inner role 0) and the
// inner's output. A source into the outer role 0 must fan to BOTH inner
// Pass1 slots; the inner Join2 lands at flat index 2.
let inner = fan_composite();
let outer = Composite::new(
vec![BlueprintNode::Composite(inner)],
vec![],
vec![vec![Target { node: 0, slot: 0 }]],
OutPort { node: 0, field: 0 },
);
let bp = Blueprint::new(
vec![BlueprintNode::Composite(outer), BlueprintNode::Leaf(Box::new(SinkF64))],
vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }],
vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
);
let (nodes, sources, edges) = bp.compile().expect("valid nested composite");
assert_eq!(nodes.len(), 4); // Pass1, Pass1, Join2, SinkF64
assert_eq!(
sources[0].targets,
vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }]
);
// inner interior edges + the output edge from the inner Join2 (flat 2) to sink
assert_eq!(
edges,
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
Edge { from: 2, to: 3, slot: 0, from_field: 0 },
]
);
}
#[test]
fn bad_interior_index_rejected() {
// interior edge references interior node 9, which does not exist
let c = Composite::new(
vec![pass1()],
vec![Edge { from: 0, to: 9, slot: 0, from_field: 0 }],
vec![vec![Target { node: 0, slot: 0 }]],
OutPort { node: 0, field: 0 },
);
let bp = Blueprint::new(vec![BlueprintNode::Composite(c)], vec![], vec![]);
assert_eq!(bp.compile().unwrap_err(), CompileError::BadInteriorIndex);
}
#[test]
fn role_kind_mismatch_rejected() {
// role 0 fans into a Pass1 f64 slot AND a SinkI64 i64 slot -> mismatch
let c = Composite::new(
vec![pass1(), BlueprintNode::Leaf(Box::new(SinkI64))],
vec![],
vec![vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }]],
OutPort { node: 0, field: 0 },
);
let bp = Blueprint::new(vec![BlueprintNode::Composite(c)], vec![], vec![]);
assert_eq!(bp.compile().unwrap_err(), CompileError::RoleKindMismatch { role: 0 });
}
#[test]
fn output_port_out_of_range_rejected() {
// output names field 5 of a node whose output has one field
let c = Composite::new(
vec![pass1()],
vec![],
vec![vec![Target { node: 0, slot: 0 }]],
OutPort { node: 0, field: 5 },
);
let bp = Blueprint::new(vec![BlueprintNode::Composite(c)], vec![], vec![]);
assert_eq!(bp.compile().unwrap_err(), CompileError::OutputPortOutOfRange);
}
#[test]
fn bootstrap_error_is_wrapped() {
// a top-level kind mismatch: a Pass1 f64 output wired into a SinkI64 i64
// input. compile() lowers it faithfully; bootstrap's kind-check rejects it.
let bp = Blueprint::new(
vec![pass1(), BlueprintNode::Leaf(Box::new(SinkI64))],
vec![],
vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
);
match bp.bootstrap().unwrap_err() {
CompileError::Bootstrap(BootstrapError::KindMismatch { producer, consumer }) => {
assert_eq!(producer, ScalarKind::F64);
assert_eq!(consumer, ScalarKind::I64);
}
other => panic!("expected Bootstrap(KindMismatch), got {other:?}"),
}
}
```
- [ ] **Step 2: Run the inliner tests to verify they fail**
Run: `cargo test -p aura-engine blueprint::tests`
Expected: FAIL — compile error `no method named \`compile\` found` / `no method
named \`bootstrap\`` / `cannot find type \`CompileError\`` (the inliner does not
exist yet).
- [ ] **Step 3: Add `CompileError`, the lowering helpers, and `impl Blueprint`**
In `crates/aura-engine/src/blueprint.rs`, add the `CompileError` enum immediately
after the `Composite` impl block (before `pub struct Blueprint`):
```rust
/// A construction-phase fault, caught before the flat graph reaches
/// `Harness::bootstrap`.
#[derive(Debug, PartialEq, Eq)]
pub enum CompileError {
/// An interior edge, role target, or output index is out of range.
BadInteriorIndex,
/// Input role `role` fans into interior slots of differing scalar kinds.
RoleKindMismatch { role: usize },
/// The output port names a missing interior node or output field.
OutputPortOutOfRange,
/// The lowered flat graph failed `Harness::bootstrap`'s checks (kind
/// mismatch, bad index, or directed cycle).
Bootstrap(BootstrapError),
}
```
Then add the `compile` and `bootstrap` methods inside `impl Blueprint` (after
`new`):
```rust
/// Lower to the flat graph: inline every composite (recursive), offset
/// interior indices, rewrite edges, and fan input roles out. The run loop and
/// `bootstrap`'s data model are unchanged; the lowered flat graph is wired by raw
/// index (C23).
// The flat triple is exactly `Harness::bootstrap`'s argument list; naming it
// would be a speculative type alias this cycle (same call as the CLI's sample).
#[allow(clippy::type_complexity)]
pub fn compile(self) -> Result<(Vec<Box<dyn Node>>, Vec<SourceSpec>, Vec<Edge>), CompileError> {
let mut flat_nodes: Vec<Box<dyn Node>> = Vec::new();
let mut flat_edges: Vec<Edge> = Vec::new();
// lower every top-level item (recursively inlining composites)
let lowerings = lower_items(self.nodes, &mut flat_nodes, &mut flat_edges)?;
// rewrite top-level edges through the lowerings (fan-out into composites)
for e in &self.edges {
for fe in rewrite_edge(e, &lowerings, &flat_nodes)? {
flat_edges.push(fe);
}
}
// rewrite sources: each target into a composite fans into its role targets
let mut flat_sources: Vec<SourceSpec> = Vec::with_capacity(self.sources.len());
for src in &self.sources {
let mut targets: Vec<Target> = Vec::new();
for t in &src.targets {
targets.extend(resolve_target(t, &lowerings)?);
}
flat_sources.push(SourceSpec { kind: src.kind, targets });
}
Ok((flat_nodes, flat_sources, flat_edges))
}
/// Compile, then hand the flat graph to the unchanged `Harness::bootstrap`.
pub fn bootstrap(self) -> Result<Harness, CompileError> {
let (nodes, sources, edges) = self.compile()?;
Harness::bootstrap(nodes, sources, edges).map_err(CompileError::Bootstrap)
}
```
Finally add the free lowering helpers and the `ItemLowering` enum at the end of the
file (after the `impl Blueprint` block, before `#[cfg(test)] mod tests`):
```rust
/// How one blueprint item resolved into the flat graph. Edges and source
/// targets to/from an item are resolved through this.
enum ItemLowering {
/// A leaf lowered to exactly one flat node at this index.
Leaf { index: usize },
/// A composite lowered to its interior: its single output port is this flat
/// `(node, field)`, and input role `r` fans into `roles[r]` (flat targets).
Composite { output: (usize, usize), roles: Vec<Vec<Target>> },
}
/// Lower a list of blueprint items into the flat node array, appending interior
/// nodes and (for composites) their interior edges. Returns one `ItemLowering` per
/// input item, in order.
fn lower_items(
items: Vec<BlueprintNode>,
flat_nodes: &mut Vec<Box<dyn Node>>,
flat_edges: &mut Vec<Edge>,
) -> Result<Vec<ItemLowering>, CompileError> {
let mut lowerings = Vec::with_capacity(items.len());
for item in items {
match item {
BlueprintNode::Leaf(node) => {
let index = flat_nodes.len();
flat_nodes.push(node);
lowerings.push(ItemLowering::Leaf { index });
}
BlueprintNode::Composite(c) => {
lowerings.push(inline_composite(c, flat_nodes, flat_edges)?);
}
}
}
Ok(lowerings)
}
/// Inline one composite: recursively lower its interior items, rewrite its interior
/// edges, then resolve its output port and per-role flat targets.
fn inline_composite(
c: Composite,
flat_nodes: &mut Vec<Box<dyn Node>>,
flat_edges: &mut Vec<Edge>,
) -> Result<ItemLowering, CompileError> {
let Composite { nodes, edges, input_roles, output } = c;
let item_count = nodes.len();
// the output port must name an in-range interior item (field range checked
// once the item's lowering is known)
if output.node >= item_count {
return Err(CompileError::OutputPortOutOfRange);
}
// recursively lower interior items, then rewrite interior edges through them
let interior = lower_items(nodes, flat_nodes, flat_edges)?;
for e in &edges {
for fe in rewrite_edge(e, &interior, flat_nodes)? {
flat_edges.push(fe);
}
}
// resolve the output port to a flat (node, field)
let out = match &interior[output.node] {
ItemLowering::Leaf { index } => {
if output.field >= flat_nodes[*index].schema().output.len() {
return Err(CompileError::OutputPortOutOfRange);
}
(*index, output.field)
}
ItemLowering::Composite { output: nested, .. } => {
// a nested composite exposes exactly one output field
if output.field != 0 {
return Err(CompileError::OutputPortOutOfRange);
}
*nested
}
};
// resolve each input role to flat targets (a target into a nested composite
// fans further) and kind-check every role
let mut roles: Vec<Vec<Target>> = Vec::with_capacity(input_roles.len());
for (r, role) in input_roles.iter().enumerate() {
let mut flat_targets: Vec<Target> = Vec::new();
for t in role {
flat_targets.extend(resolve_target(t, &interior)?);
}
if let Some((first, rest)) = flat_targets.split_first() {
let k0 = slot_kind(*first, flat_nodes)?;
for ft in rest {
if slot_kind(*ft, flat_nodes)? != k0 {
return Err(CompileError::RoleKindMismatch { role: r });
}
}
}
roles.push(flat_targets);
}
Ok(ItemLowering::Composite { output: out, roles })
}
/// Rewrite one blueprint-level edge into flat edges. The `from` endpoint resolves
/// to a single flat producer `(node, field)`; the `to` endpoint may fan out (a
/// composite input role fans into several interior targets).
fn rewrite_edge(
e: &Edge,
lowerings: &[ItemLowering],
flat_nodes: &[Box<dyn Node>],
) -> Result<Vec<Edge>, CompileError> {
if e.from >= lowerings.len() {
return Err(CompileError::BadInteriorIndex);
}
let (from_node, from_field) = match &lowerings[e.from] {
ItemLowering::Leaf { index } => {
if e.from_field >= flat_nodes[*index].schema().output.len() {
return Err(CompileError::BadInteriorIndex);
}
(*index, e.from_field)
}
ItemLowering::Composite { output, .. } => {
// a composite exposes one output field; reading any other is malformed
if e.from_field != 0 {
return Err(CompileError::BadInteriorIndex);
}
*output
}
};
let targets = resolve_target(&Target { node: e.to, slot: e.slot }, lowerings)?;
Ok(targets
.into_iter()
.map(|t| Edge { from: from_node, to: t.node, slot: t.slot, from_field })
.collect())
}
/// Resolve a blueprint-level target `(node, slot)` into flat target(s). A target
/// into a leaf is itself (remapped index); a target into a composite fans into
/// that composite's input-role flat targets.
fn resolve_target(t: &Target, lowerings: &[ItemLowering]) -> Result<Vec<Target>, CompileError> {
if t.node >= lowerings.len() {
return Err(CompileError::BadInteriorIndex);
}
match &lowerings[t.node] {
ItemLowering::Leaf { index } => Ok(vec![Target { node: *index, slot: t.slot }]),
ItemLowering::Composite { roles, .. } => {
let role = roles.get(t.slot).ok_or(CompileError::BadInteriorIndex)?;
Ok(role.clone())
}
}
}
/// The declared scalar kind of a flat node's input slot (for role kind-checking).
fn slot_kind(t: Target, flat_nodes: &[Box<dyn Node>]) -> Result<ScalarKind, CompileError> {
flat_nodes[t.node]
.schema()
.inputs
.get(t.slot)
.map(|spec| spec.kind)
.ok_or(CompileError::BadInteriorIndex)
}
```
- [ ] **Step 4: Add `CompileError` to the `lib.rs` re-export**
In `crates/aura-engine/src/lib.rs`, extend the blueprint re-export line so it reads:
```rust
pub use blueprint::{Blueprint, BlueprintNode, CompileError, Composite, OutPort};
```
- [ ] **Step 5: Run the inliner tests to verify they pass**
Run: `cargo test -p aura-engine blueprint::tests`
Expected: PASS — `single_composite_inlines_with_offset_fan_and_output`,
`nested_composite_inlines`, `bad_interior_index_rejected`,
`role_kind_mismatch_rejected`, `output_port_out_of_range_rejected`,
`bootstrap_error_is_wrapped`, and `composite_schema_derives_role_and_output_kinds`
all pass; `0 failed`.
---
### Task 3: Headline acceptance — composite ≡ hand-wired, bit-for-bit (C1)
**Files:**
- Test: `crates/aura-engine/src/blueprint.rs` (inline `mod tests`)
- [ ] **Step 1: Write the failing bit-identity demonstrator + fixtures**
Append to the existing `#[cfg(test)] mod tests` in
`crates/aura-engine/src/blueprint.rs`. Extend the test-module imports — change the
existing `use` lines at the top of `mod tests` to also bring in the timestamp type,
the std channel, and the `aura-std` nodes:
```rust
use aura_core::{Ctx, FieldSpec, Firing, InputSpec, Scalar, Timestamp};
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
use std::sync::mpsc;
```
(The first line replaces the Task-1 `use aura_core::{Ctx, FieldSpec, Firing,
InputSpec, Scalar};` line; the two new lines are added below it.)
Then append the fixtures and the headline test:
```rust
/// The built-in synthetic price stream (a local copy of the CLI sample's
/// stream): rises through t=4 then reverses, so the trace is non-degenerate.
fn synthetic_prices() -> Vec<(Timestamp, Scalar)> {
[
(1_i64, 1.0000_f64),
(2, 1.0010),
(3, 1.0030),
(4, 1.0060),
(5, 1.0040),
(6, 1.0010),
(7, 0.9990),
]
.iter()
.map(|&(t, p)| (Timestamp(t), Scalar::F64(p)))
.collect()
}
/// Today's flat, hand-wired SMA-cross signal-quality harness (the
/// `sample_harness` wiring from `aura-cli`), with two recording sinks.
#[allow(clippy::type_complexity)]
fn hand_wired_sma_cross_harness() -> (
Harness,
mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
) {
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
let h = Harness::bootstrap(
vec![
Box::new(Sma::new(2)),
Box::new(Sma::new(4)),
Box::new(Sub::new()),
Box::new(Exposure::new(0.5)),
Box::new(SimBroker::new(0.0001)),
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_eq)),
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_ex)),
],
vec![SourceSpec {
kind: ScalarKind::F64,
targets: vec![
Target { node: 0, slot: 0 },
Target { node: 1, slot: 0 },
Target { node: 4, slot: 1 },
],
}],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
Edge { from: 2, to: 3, slot: 0, from_field: 0 },
Edge { from: 3, to: 4, slot: 0, from_field: 0 },
Edge { from: 4, to: 5, slot: 0, from_field: 0 },
Edge { from: 3, to: 6, slot: 0, from_field: 0 },
],
)
.expect("valid hand-wired DAG");
(h, rx_eq, rx_ex)
}
/// The SMA-cross signal as a reusable composite: one input role (price), one
/// output (the fast-minus-slow spread). Interior wired with raw local indices.
fn sma_cross(fast: usize, slow: usize) -> Composite {
Composite::new(
vec![Sma::new(fast).into(), Sma::new(slow).into(), Sub::new().into()],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
],
vec![vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }]],
OutPort { node: 2, field: 0 },
)
}
/// The same signal-quality harness authored as a composite blueprint.
#[allow(clippy::type_complexity)]
fn composite_sma_cross_harness() -> (
Blueprint,
mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
) {
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
let bp = Blueprint::new(
vec![
BlueprintNode::Composite(sma_cross(2, 4)),
Exposure::new(0.5).into(),
SimBroker::new(0.0001).into(),
Recorder::new(&[ScalarKind::F64], Firing::Any, tx_eq).into(),
Recorder::new(&[ScalarKind::F64], Firing::Any, tx_ex).into(),
],
vec![SourceSpec {
kind: ScalarKind::F64,
targets: vec![
Target { node: 0, slot: 0 }, // price -> sma_cross role 0
Target { node: 2, slot: 1 }, // price -> SimBroker price slot
],
}],
vec![
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // composite out -> Exposure
Edge { from: 1, to: 2, slot: 0, from_field: 0 }, // exposure -> broker slot 0
Edge { from: 2, to: 3, slot: 0, from_field: 0 }, // equity -> sink
Edge { from: 1, to: 4, slot: 0, from_field: 0 }, // exposure -> sink
],
);
(bp, rx_eq, rx_ex)
}
#[test]
fn composite_sma_cross_runs_bit_identical_to_hand_wired() {
let prices = synthetic_prices();
// (a) today's flat, hand-wired graph
let (mut flat, flat_eq, flat_ex) = hand_wired_sma_cross_harness();
flat.run(vec![prices.clone()]);
// (b) the same graph authored as a composite blueprint, compiled
let (bp, comp_eq, comp_ex) = composite_sma_cross_harness();
let mut composed = bp.bootstrap().expect("composite blueprint compiles");
composed.run(vec![prices]);
let flat_eq_v = flat_eq.try_iter().collect::<Vec<_>>();
let flat_ex_v = flat_ex.try_iter().collect::<Vec<_>>();
let comp_eq_v = comp_eq.try_iter().collect::<Vec<_>>();
let comp_ex_v = comp_ex.try_iter().collect::<Vec<_>>();
// both recording sinks captured the same equity + exposure traces, bit-for-bit
assert_eq!(flat_eq_v, comp_eq_v, "equity traces differ");
assert_eq!(flat_ex_v, comp_ex_v, "exposure traces differ");
// and the trace is populated (non-degenerate), so the equality is meaningful
assert!(!comp_eq_v.is_empty(), "equity trace must be populated");
assert!(!comp_ex_v.is_empty(), "exposure trace must be populated");
}
```
- [ ] **Step 2: Run the headline test to verify it passes**
Run: `cargo test -p aura-engine blueprint::tests::composite_sma_cross_runs_bit_identical_to_hand_wired`
Expected: PASS (`test result: ok. 1 passed`). This is an acceptance test over the
inliner built in Task 2 — the RED/GREEN boundary for the bit-identity property is
between Task 2 (no `bootstrap()`) and Task 3. To confirm the assertion is
load-bearing (not vacuously green on an empty trace), the test asserts the drained
traces are non-empty; if it ever reports `0 passed; 0 filtered`, the test name in
the filter is wrong — fall back to Step 3's unfiltered run.
- [ ] **Step 3: Run the full engine test suite to verify everything passes**
Run: `cargo test -p aura-engine`
Expected: PASS — `composite_sma_cross_runs_bit_identical_to_hand_wired` passes
alongside all Task-1/Task-2 tests and the pre-existing harness/report tests;
`0 failed`.
- [ ] **Step 4: Verify the workspace builds clean and lint is green**
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: PASS — no warnings. (Confirms no dead-code / unused-import regressions
from the new module and that the demonstrator does not perturb the rest of the
workspace.)
-798
View File
@@ -1,798 +0,0 @@
# `aura graph` ASCII-DAG render — Implementation Plan
> **Parent spec:** `docs/specs/0013-aura-graph-ascii-dag.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Render a wired graph as an ASCII DAG via an `aura graph [--compiled]`
subcommand so a mis-wiring becomes visible, with composites drawn as named
cluster boxes (blueprint view) or dissolved (compiled view).
**Architecture:** A non-load-bearing `label()` default method on the core `Node`
trait (aura-core) lets each node describe itself in one line with its params;
`Composite` gains an authored `name`; the engine exposes read-only graph-as-data
accessors and stays dependency-free; the `ascii-dag` adapter and the `aura graph`
subcommand live in aura-cli.
**Tech Stack:** aura-core (`Node` trait), aura-std (node `label()` overrides),
aura-engine (`blueprint.rs` accessors + `Composite.name`), aura-cli (`ascii-dag`
v0.9.1, new `graph.rs`), `docs/design/INDEX.md` (C8 refinement note).
---
## Files this plan creates or modifies
- Modify: `crates/aura-core/src/node.rs:65-68` — add `label()` default method to `trait Node`.
- Test: `crates/aura-core/src/node.rs:70-82` — default-label unit test.
- Modify: `docs/design/INDEX.md` (after line 238, before `### C9` at 240) — C8 refinement note.
- Modify: `crates/aura-std/src/sma.rs:22-46`, `sub.rs:27-47`, `exposure.rs:23-39`, `sim_broker.rs:59-85`, `add.rs:36-56`, `lincomb.rs:42-66`, `recorder.rs:31-58``label()` overrides.
- Test: `crates/aura-std/src/sma.rs:48` (tests module) — disambiguation test.
- Modify: `crates/aura-engine/src/blueprint.rs``Composite.name` field (54-59), widened `Composite::new` (64-71), accessors on `Composite` (61-90) and `Blueprint` (115-161), and the 7 `Composite::new` call sites (356, 433, 481, 513, 527, 541, 631).
- Create: `crates/aura-cli/src/graph.rs` — the ascii-dag adapter (`render_blueprint`, `render_flat_graph`).
- Modify: `crates/aura-cli/Cargo.toml:12-15` — add `ascii-dag = "0.9.1"`.
- Modify: `crates/aura-cli/src/main.rs` — imports (9-14), `mod graph;`, sample builders, `graph` dispatch arm + usage strings (114-126). `sample_harness` (42-78) and `run_sample` (83-112) are NOT touched.
- Test: `crates/aura-cli/src/main.rs:128-168` (tests module) — render tests.
---
## Task 1: `Node::label()` default method + C8 ledger refinement
**Files:**
- Modify: `crates/aura-core/src/node.rs:65-68`
- Test: `crates/aura-core/src/node.rs:70-82`
- Modify: `docs/design/INDEX.md` (after line 238)
- [ ] **Step 1: Write the failing test**
In `crates/aura-core/src/node.rs`, inside the existing `#[cfg(test)] mod tests` block (currently lines 70-82), add this test after `input_spec_carries_firing`:
```rust
#[test]
fn default_label_is_placeholder() {
// A node that does not override label() falls back to the placeholder.
struct Bare;
impl Node for Bare {
fn schema(&self) -> NodeSchema {
NodeSchema { inputs: vec![], output: vec![] }
}
fn eval(&mut self, _ctx: Ctx<'_>) -> Option<&[Scalar]> {
None
}
}
assert_eq!(Bare.label(), "node");
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cargo test -p aura-core default_label_is_placeholder`
Expected: FAIL — compile error `no method named 'label' found for struct 'Bare'` (the default method does not exist yet).
- [ ] **Step 3: Add the `label()` default method**
In `crates/aura-core/src/node.rs`, change the `trait Node` body (lines 65-68) from:
```rust
pub trait Node {
fn schema(&self) -> NodeSchema;
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]>;
}
```
to:
```rust
pub trait Node {
fn schema(&self) -> NodeSchema;
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]>;
/// A one-line, **non-load-bearing** render label (C23): a debug symbol for
/// tracing / graph rendering (#13), never read by the run loop and never
/// part of wiring (which is by index). Overrides SHOULD carry the node's
/// identifying params so identical node types disambiguate (`SMA(2)` vs
/// `SMA(4)`). MUST be single-line (no `\n`): ascii-dag breaks box drawing on
/// a multiline label. The default is a placeholder; every shipped node
/// overrides it. Returns an owned `String` and takes `&self`, so `Node`
/// stays object-safe and `Box<dyn Node>::label()` dispatches.
fn label(&self) -> String {
"node".to_string()
}
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cargo test -p aura-core default_label_is_placeholder`
Expected: PASS.
- [ ] **Step 5: Append the C8 refinement note to the ledger**
In `docs/design/INDEX.md`, immediately after the cycle-0006 realization paragraph that ends at line 238 (and before `### C9` at line 240), insert:
```markdown
**Refinement (Construction-layer milestone — render labels, 2026-06-05).** A node
additionally exposes `label() -> String`, a **single-line, non-load-bearing**
render symbol: a default trait method the run loop never calls (wiring is by
index, C23). Overrides carry the node's identifying params (`SMA(2)` vs `SMA(4)`)
so a graph render (C9 graph-as-data, #13) disambiguates identical node types and
surfaces a mis-wiring. Like `FieldSpec.name`, it is an informative debug symbol,
not part of the C8 dataflow contract — adding it changes no run behaviour.
```
- [ ] **Step 6: Verify the workspace still builds (default method breaks no caller)**
Run: `cargo build --workspace`
Expected: builds clean (the defaulted method adds no obligation to existing `impl Node`s).
---
## Task 2: aura-std `label()` overrides + disambiguation test
**Files:**
- Modify: `crates/aura-std/src/{sma,sub,exposure,sim_broker,add,lincomb,recorder}.rs`
- Test: `crates/aura-std/src/sma.rs:48` (tests module)
- [ ] **Step 1: Write the failing disambiguation test**
In `crates/aura-std/src/sma.rs`, inside the existing `#[cfg(test)] mod tests` (line 48), add:
```rust
#[test]
fn labels_carry_identifying_params() {
use crate::{Add, Exposure, LinComb, Recorder, SimBroker, Sub};
use aura_core::{Firing, ScalarKind};
// the load-bearing payoff: two SMAs disambiguate by window
assert_eq!(Sma::new(2).label(), "SMA(2)");
assert_eq!(Sma::new(4).label(), "SMA(4)");
// param-carrying single nodes
assert_eq!(Exposure::new(0.5).label(), "Exposure(0.5)");
assert_eq!(SimBroker::new(0.0001).label(), "SimBroker(0.0001)");
// bare-kind nodes (identity is not a mis-wiring axis here, per spec)
assert_eq!(Sub::new().label(), "Sub");
assert_eq!(Add::new().label(), "Add");
assert_eq!(LinComb::new(&[1.0, -1.0]).label(), "LinComb");
let (tx, _rx) = std::sync::mpsc::channel();
assert_eq!(Recorder::new(&[ScalarKind::F64], Firing::Any, tx).label(), "Recorder");
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cargo test -p aura-std labels_carry_identifying_params`
Expected: FAIL — assertions fail because every node currently returns the default `"node"` (e.g. `assert_eq!(Sma::new(2).label(), "SMA(2)")` sees `"node"`).
> If `LinComb::new` does not take `&[f64]`, read `crates/aura-std/src/lincomb.rs` for its real constructor signature and adjust the call in this test to construct a valid `LinComb`; the asserted label `"LinComb"` is unchanged.
- [ ] **Step 3: Add the `label()` override to each node**
In each file, add the `label()` method as the last method inside the `impl Node for <T>` block:
`crates/aura-std/src/sma.rs` (impl at 22-46, field `length` at line 10):
```rust
fn label(&self) -> String {
format!("SMA({})", self.length)
}
```
`crates/aura-std/src/sub.rs` (impl at 27-47):
```rust
fn label(&self) -> String {
"Sub".to_string()
}
```
`crates/aura-std/src/exposure.rs` (impl at 23-39, field `scale` at line 11):
```rust
fn label(&self) -> String {
format!("Exposure({})", self.scale)
}
```
`crates/aura-std/src/sim_broker.rs` (impl at 59-85, field `pip_size` at line 37):
```rust
fn label(&self) -> String {
format!("SimBroker({})", self.pip_size)
}
```
`crates/aura-std/src/add.rs` (impl at 36-56):
```rust
fn label(&self) -> String {
"Add".to_string()
}
```
`crates/aura-std/src/lincomb.rs` (impl at 42-66):
```rust
fn label(&self) -> String {
"LinComb".to_string()
}
```
`crates/aura-std/src/recorder.rs` (impl at 31-58):
```rust
fn label(&self) -> String {
"Recorder".to_string()
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cargo test -p aura-std labels_carry_identifying_params`
Expected: PASS.
- [ ] **Step 5: Verify the crate builds clean**
Run: `cargo build -p aura-std`
Expected: builds clean.
---
## Task 3: `Composite.name` + introspection accessors + thread the 7 call sites
**Files:**
- Modify: `crates/aura-engine/src/blueprint.rs` (struct 54-59, `new` 64-71, `impl Composite` 61-90, `impl Blueprint` 115-161, call sites 356/433/481/513/527/541/631)
- [ ] **Step 1: Add the `name` field to `Composite`**
In `crates/aura-engine/src/blueprint.rs`, change the `Composite` struct (lines 54-59) from:
```rust
pub struct Composite {
nodes: Vec<BlueprintNode>,
edges: Vec<Edge>,
input_roles: Vec<Vec<Target>>,
output: OutPort,
}
```
to:
```rust
pub struct Composite {
name: String,
nodes: Vec<BlueprintNode>,
edges: Vec<Edge>,
input_roles: Vec<Vec<Target>>,
output: OutPort,
}
```
- [ ] **Step 2: Widen `Composite::new` to take a name**
Change `Composite::new` (lines 64-71) from:
```rust
pub fn new(
nodes: Vec<BlueprintNode>,
edges: Vec<Edge>,
input_roles: Vec<Vec<Target>>,
output: OutPort,
) -> Self {
Self { nodes, edges, input_roles, output }
}
```
to:
```rust
/// Build a composite from its authored name, interior items, interior edges
/// (local indices), input roles, and output port. The `name` is a
/// non-load-bearing render symbol (the cluster title for #13); it does not
/// reach the flat graph (the boundary dissolves at inline, C23).
pub fn new(
name: impl Into<String>,
nodes: Vec<BlueprintNode>,
edges: Vec<Edge>,
input_roles: Vec<Vec<Target>>,
output: OutPort,
) -> Self {
Self { name: name.into(), nodes, edges, input_roles, output }
}
```
- [ ] **Step 3: Add read-only accessors to `Composite`**
In the `impl Composite` block (61-90), add these methods (e.g. after `new`, before `schema`):
```rust
/// The authored render name (cluster title, #13). Non-load-bearing.
pub fn name(&self) -> &str {
&self.name
}
/// The interior blueprint items (read-only graph-as-data, C9).
pub fn nodes(&self) -> &[BlueprintNode] {
&self.nodes
}
/// The interior edges (local indices).
pub fn edges(&self) -> &[Edge] {
&self.edges
}
/// The input roles: role `r` fans into `input_roles()[r]` interior targets.
pub fn input_roles(&self) -> &[Vec<Target>] {
&self.input_roles
}
/// The single exposed output port.
pub fn output(&self) -> OutPort {
self.output
}
```
- [ ] **Step 4: Add read-only accessors to `Blueprint`**
In the `impl Blueprint` block (115-161), add (e.g. after `new` at 118):
```rust
/// The top-level blueprint items (read-only graph-as-data, C9).
pub fn nodes(&self) -> &[BlueprintNode] {
&self.nodes
}
/// The declared sources.
pub fn sources(&self) -> &[SourceSpec] {
&self.sources
}
/// The top-level edges (blueprint-level indices).
pub fn edges(&self) -> &[Edge] {
&self.edges
}
```
- [ ] **Step 5: Thread a name into all 7 `Composite::new` call sites**
All 7 are inside the `#[cfg(test)] mod tests` block. Add a leading string argument to each:
- Line 356 (`composite_schema_derives_role_and_output_kinds`): `Composite::new(``Composite::new(\n "c",`
- Line 433 (`fan_composite()` helper): first arg `"fan"`.
- Line 481 (`outer` in `nested_composite_inlines`): first arg `"outer"`.
- Line 513 (`bad_interior_index_rejected`): first arg `"c"`.
- Line 527 (`role_kind_mismatch_rejected`): first arg `"c"`.
- Line 541 (`output_port_out_of_range_rejected`): first arg `"c"`.
- Line 631 (`sma_cross(fast, slow)` helper): first arg `"sma_cross"`.
Concretely, e.g. for `fan_composite` (433):
```rust
fn fan_composite() -> Composite {
Composite::new(
"fan",
vec![pass1(), pass1(), join2()],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
],
vec![vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }]],
OutPort { node: 2, field: 0 },
)
}
```
and for `sma_cross` (631):
```rust
fn sma_cross(fast: usize, slow: usize) -> Composite {
Composite::new(
"sma_cross",
vec![Sma::new(fast).into(), Sma::new(slow).into(), Sub::new().into()],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
],
vec![vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }]],
OutPort { node: 2, field: 0 },
)
}
```
Apply the same one-line leading-argument insertion at 356, 481, 513, 527, 541.
- [ ] **Step 6: Run the engine tests (all `Composite::new` sites threaded, accessors compile)**
Run: `cargo test -p aura-engine`
Expected: PASS — all existing blueprint tests (including `composite_sma_cross_runs_bit_identical_to_hand_wired`, `single_composite_inlines_with_offset_fan_and_output`, `nested_composite_inlines`) stay green; the crate compiles with the widened signature.
- [ ] **Step 7: Confirm the engine took no external dependency**
Run: `grep -nE '^\s*(ascii|[a-z].*=.*version|[a-z].*=.*")' crates/aura-engine/Cargo.toml`
Expected: only the `aura-core` path dependency (and `aura-std` under `[dev-dependencies]`); no `ascii-dag`, no crates.io version dep. (C16 preserved.)
---
## Task 4: aura-cli — `ascii-dag` dep, `graph.rs` adapter, sample builder, dispatch
**Files:**
- Modify: `crates/aura-cli/Cargo.toml:12-15`
- Create: `crates/aura-cli/src/graph.rs`
- Modify: `crates/aura-cli/src/main.rs` (imports 9-14, `mod graph;`, sample builders, dispatch 114-126)
- [ ] **Step 1: Add the `ascii-dag` dependency**
In `crates/aura-cli/Cargo.toml`, under `[dependencies]` (currently the three path deps at 12-15), add:
```toml
ascii-dag = "0.9.1"
```
- [ ] **Step 2: Create the adapter module `crates/aura-cli/src/graph.rs`**
```rust
//! The `aura graph` ASCII-DAG adapter (#13): turns the engine's graph-as-data
//! (C9) into an `ascii_dag::Graph` rendered to a `String`. Two views:
//! `render_blueprint` draws composites as named cluster boxes (pre-inline);
//! `render_flat_graph` draws the flat post-inline graph (boundaries dissolved,
//! C23). Rendering reads structure + node `label()`s only — never `eval`.
//!
//! ascii-dag borrows its node/subgraph labels as `&'a str`, so each function
//! first materializes the owned label `String`s (which outlive the `Graph`),
//! then borrows into them. `RenderMode::Vertical` is mandatory: Horizontal
//! collapses a fan-out onto one path.
use ascii_dag::graph::{Graph, RenderMode};
use aura_core::Node;
use aura_engine::{Blueprint, BlueprintNode, Edge, SourceSpec, Target};
/// How one top-level blueprint item maps into display nodes (blueprint view).
enum ItemDisplay {
/// A leaf is one display node at this id.
Leaf(usize),
/// A composite is a cluster of interior leaf display ids; its output port and
/// input roles resolve edges crossing its boundary.
Composite {
interior_ids: Vec<usize>,
output_interior: usize,
input_roles: Vec<Vec<Target>>,
},
}
/// The display id an edge *from* this item originates at (its producer).
fn producer_id(d: &ItemDisplay) -> usize {
match d {
ItemDisplay::Leaf(id) => *id,
ItemDisplay::Composite { interior_ids, output_interior, .. } => interior_ids[*output_interior],
}
}
/// The display id(s) an edge *into* this item at `slot` reaches (its consumers).
/// A composite input role fans into several interior targets.
fn consumer_ids(d: &ItemDisplay, slot: usize) -> Vec<usize> {
match d {
ItemDisplay::Leaf(id) => vec![*id],
ItemDisplay::Composite { interior_ids, input_roles, .. } => {
input_roles[slot].iter().map(|t| interior_ids[t.node]).collect()
}
}
}
/// Blueprint view: composites become labelled cluster boxes (pre-inline, C9).
pub fn render_blueprint(bp: &Blueprint) -> String {
let mut labels: Vec<String> = Vec::new();
let mut sg_names: Vec<String> = Vec::new();
let mut memberships: Vec<(usize, Vec<usize>)> = Vec::new();
let mut edges: Vec<(usize, usize)> = Vec::new();
let mut item_display: Vec<ItemDisplay> = Vec::with_capacity(bp.nodes().len());
// pass 1: assign display ids + labels; open a subgraph per composite; record
// interior edges (the interior ids are in hand here).
for item in bp.nodes() {
match item {
BlueprintNode::Leaf(node) => {
let id = labels.len();
labels.push(node.label());
item_display.push(ItemDisplay::Leaf(id));
}
BlueprintNode::Composite(c) => {
let mut interior_ids = Vec::with_capacity(c.nodes().len());
for inner in c.nodes() {
match inner {
BlueprintNode::Leaf(node) => {
let id = labels.len();
labels.push(node.label());
interior_ids.push(id);
}
BlueprintNode::Composite(_) => unimplemented!(
"cycle 0013 renders leaf-interior composites (the built-in \
sample); nested-composite cluster rendering is a follow-up"
),
}
}
for e in c.edges() {
edges.push((interior_ids[e.from], interior_ids[e.to]));
}
let sg = sg_names.len();
sg_names.push(c.name().to_string());
memberships.push((sg, interior_ids.clone()));
item_display.push(ItemDisplay::Composite {
interior_ids,
output_interior: c.output().node,
input_roles: c.input_roles().to_vec(),
});
}
}
}
// sources as producer display nodes (unnamed in the data model -> label by kind)
let mut source_ids: Vec<usize> = Vec::with_capacity(bp.sources().len());
for src in bp.sources() {
let id = labels.len();
labels.push(format!("source:{:?}", src.kind));
source_ids.push(id);
}
// top-level edges, resolved through composite boundaries
for e in bp.edges() {
let from = producer_id(&item_display[e.from]);
for to in consumer_ids(&item_display[e.to], e.slot) {
edges.push((from, to));
}
}
// source -> target edges
for (src, &sid) in bp.sources().iter().zip(&source_ids) {
for t in &src.targets {
for to in consumer_ids(&item_display[t.node], t.slot) {
edges.push((sid, to));
}
}
}
// build the borrowed-label Graph (labels + sg_names are final & owned)
let mut g = Graph::with_mode(RenderMode::Vertical);
for (id, l) in labels.iter().enumerate() {
g.add_node(id, l);
}
let sg_ids: Vec<usize> = sg_names.iter().map(|n| g.add_subgraph(n)).collect();
for (sg, members) in &memberships {
g.put_nodes(members).inside(sg_ids[*sg]).expect("valid subgraph placement");
}
for (from, to) in edges {
g.add_edge(from, to, None);
}
g.render()
}
/// Compiled view: the flat post-inline graph (no clusters; boundaries dissolved,
/// C23). Each `Box<dyn Node>` labels itself; node display id = node index.
pub fn render_flat_graph(nodes: &[Box<dyn Node>], sources: &[SourceSpec], edges: &[Edge]) -> String {
let mut labels: Vec<String> = nodes.iter().map(|n| n.label()).collect();
let source_base = labels.len();
for src in sources {
labels.push(format!("source:{:?}", src.kind));
}
let mut g = Graph::with_mode(RenderMode::Vertical);
for (id, l) in labels.iter().enumerate() {
g.add_node(id, l);
}
for e in edges {
g.add_edge(e.from, e.to, None);
}
for (i, src) in sources.iter().enumerate() {
for t in &src.targets {
g.add_edge(source_base + i, t.node, None);
}
}
g.render()
}
```
- [ ] **Step 3: Declare the module and widen the imports in `main.rs`**
At the top of `crates/aura-cli/src/main.rs`, after the doc comment and before the `use` block, add:
```rust
mod graph;
```
Then change the `aura_engine` import (lines 10-12) to add the blueprint types:
```rust
use aura_engine::{
f64_field, summarize, Blueprint, BlueprintNode, Composite, Edge, Harness, OutPort,
RunManifest, RunReport, SourceSpec, Target,
};
```
- [ ] **Step 4: Add the sample-blueprint builders to `main.rs`**
Add these free functions (e.g. just below `run_sample` at line 112, leaving `sample_harness`/`run_sample` untouched):
```rust
/// The SMA-cross signal as a named composite (price -> fast/slow SMA -> spread).
/// CLI-local sample builder; the engine ships no sample (the duplication with
/// `blueprint.rs`'s test helper is the dedup tracked in #14).
fn sma_cross(name: &str, fast: usize, slow: usize) -> Composite {
Composite::new(
name,
vec![Sma::new(fast).into(), Sma::new(slow).into(), Sub::new().into()],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
],
vec![vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }]],
OutPort { node: 2, field: 0 },
)
}
/// The sample signal-quality blueprint, parameterized by the SMA windows so a
/// test can author a deliberately swapped variant. Recorders need a channel to
/// construct; the receivers are dropped because the render never runs the graph.
fn build_sample(fast: usize, slow: usize) -> Blueprint {
let (tx_eq, _rx_eq) = mpsc::channel();
let (tx_ex, _rx_ex) = mpsc::channel();
Blueprint::new(
vec![
BlueprintNode::Composite(sma_cross("sma_cross", fast, slow)),
Exposure::new(0.5).into(),
SimBroker::new(0.0001).into(),
Recorder::new(&[ScalarKind::F64], Firing::Any, tx_eq).into(),
Recorder::new(&[ScalarKind::F64], Firing::Any, tx_ex).into(),
],
vec![SourceSpec {
kind: ScalarKind::F64,
targets: vec![
Target { node: 0, slot: 0 }, // price -> sma_cross role 0
Target { node: 2, slot: 1 }, // price -> SimBroker price slot
],
}],
vec![
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // spread -> Exposure
Edge { from: 1, to: 2, slot: 0, from_field: 0 }, // exposure -> broker slot 0
Edge { from: 2, to: 3, slot: 0, from_field: 0 }, // equity -> sink
Edge { from: 1, to: 4, slot: 0, from_field: 0 }, // exposure -> sink
],
)
}
/// The built-in sample rendered by `aura graph`.
fn sample_blueprint() -> Blueprint {
build_sample(2, 4)
}
```
- [ ] **Step 5: Add the `graph` dispatch arm + usage strings**
Change the `main()` match block (lines 114-126) from:
```rust
match args.next().as_deref() {
// strict: a bare `run` proceeds; a trailing token falls through to the
// usage-error path rather than masquerading as a successful run (#16).
Some("run") if args.next().is_none() => println!("{}", run_sample().to_json()),
Some("--help") | Some("-h") => println!("usage: aura run"),
_ => {
eprintln!("aura: usage: aura run");
std::process::exit(2);
}
}
```
to:
```rust
match args.next().as_deref() {
// strict: a bare `run` proceeds; a trailing token falls through to the
// usage-error path rather than masquerading as a successful run (#16).
Some("run") if args.next().is_none() => println!("{}", run_sample().to_json()),
Some("graph") => {
// `--compiled` selects the flat post-inline view; default is the
// clustered blueprint view. Strictness beyond this stays minimal (#16).
let compiled = args.next().as_deref() == Some("--compiled");
let bp = sample_blueprint();
let out = if compiled {
let (nodes, sources, edges) = bp.compile().expect("valid sample blueprint");
graph::render_flat_graph(&nodes, &sources, &edges)
} else {
graph::render_blueprint(&bp)
};
println!("{out}");
}
Some("--help") | Some("-h") => println!("usage: aura run | aura graph [--compiled]"),
_ => {
eprintln!("aura: usage: aura run | aura graph [--compiled]");
std::process::exit(2);
}
}
```
- [ ] **Step 6: Build aura-cli and smoke-test both views**
Run: `cargo build -p aura-cli`
Expected: builds clean (downloads/compiles `ascii-dag` v0.9.1 on first build).
Run: `cargo run -q -p aura-cli -- graph`
Expected: prints an ASCII DAG to stdout containing `sma_cross`, `SMA(2)`, `SMA(4)`, `Sub`, `Exposure(0.5)`, `SimBroker(0.0001)`, `Recorder`.
Run: `cargo run -q -p aura-cli -- graph --compiled`
Expected: prints an ASCII DAG containing `SMA(2)`/`SMA(4)` but NOT `sma_cross` (boundary dissolved).
---
## Task 5: render tests (concern-defining + structure pins + frozen golden)
**Files:**
- Test: `crates/aura-cli/src/main.rs:128-168` (tests module)
- [ ] **Step 1: Add the swapped-variant builder (test-only) and the failing tests**
In `crates/aura-cli/src/main.rs`, inside the existing `#[cfg(test)] mod tests` (128-168), add the test-only swapped builder and the render tests:
```rust
/// The sample authored with fast/slow SMA windows swapped — the mis-wiring
/// the render must surface. Test-only: nothing outside tests builds it.
fn sample_blueprint_swapped() -> Blueprint {
build_sample(4, 2)
}
#[test]
fn blueprint_view_shows_cluster_and_param_labels() {
let out = graph::render_blueprint(&sample_blueprint());
// the composite renders as a named cluster box
assert!(out.contains("sma_cross"), "missing composite name:\n{out}");
// param-carrying labels disambiguate the two SMAs
assert!(out.contains("SMA(2)"), "missing SMA(2):\n{out}");
assert!(out.contains("SMA(4)"), "missing SMA(4):\n{out}");
for needle in ["Sub", "Exposure(0.5)", "SimBroker(0.0001)", "Recorder"] {
assert!(out.contains(needle), "missing {needle}:\n{out}");
}
}
#[test]
fn compiled_view_dissolves_the_composite_boundary() {
let bp = sample_blueprint();
let (nodes, sources, edges) = bp.compile().expect("valid sample");
let out = graph::render_flat_graph(&nodes, &sources, &edges);
// node labels survive inlining...
assert!(out.contains("SMA(2)") && out.contains("SMA(4)"), "labels lost:\n{out}");
// ...but the composite cluster name does NOT (boundary dissolved, C23)
assert!(!out.contains("sma_cross"), "compiled view must not show the cluster:\n{out}");
}
#[test]
fn swapped_sma_inputs_render_differently() {
// the property the cycle exists to buy: a mis-wiring is no longer invisible.
let correct = graph::render_blueprint(&sample_blueprint());
let swapped = graph::render_blueprint(&sample_blueprint_swapped());
assert_ne!(correct, swapped, "a fast/slow SMA swap must change the render");
}
```
- [ ] **Step 2: Run the tests to verify they pass**
Run: `cargo test -p aura-cli blueprint_view_shows_cluster_and_param_labels compiled_view_dissolves_the_composite_boundary swapped_sma_inputs_render_differently`
Expected: PASS (all three). (`render_blueprint`/`render_flat_graph`/`sample_blueprint` already exist from Task 4; this task only adds tests + the test-only swapped builder.)
- [ ] **Step 3: Freeze the full-byte golden snapshots**
The exact rendered bytes are ascii-dag's deterministic Sugiyama layout — capture them from the now-green render rather than hand-authoring them.
Run: `cargo run -q -p aura-cli -- graph`
Copy the exact stdout. Add this test to the same `mod tests`, pasting the captured bytes as the `EXPECTED` literal (use a raw string `r#"..."#` if the output contains `"`; preserve a trailing newline if `render()` emits one):
```rust
#[test]
fn blueprint_view_golden() {
let out = graph::render_blueprint(&sample_blueprint());
let expected = "<<paste the exact captured `aura graph` stdout here>>";
assert_eq!(out, expected, "blueprint render drifted; re-capture if intended");
}
```
Then run: `cargo run -q -p aura-cli -- graph --compiled`, and add the twin:
```rust
#[test]
fn compiled_view_golden() {
let bp = sample_blueprint();
let (nodes, sources, edges) = bp.compile().expect("valid sample");
let out = graph::render_flat_graph(&nodes, &sources, &edges);
let expected = "<<paste the exact captured `aura graph --compiled` stdout here>>";
assert_eq!(out, expected, "compiled render drifted; re-capture if intended");
}
```
> The `<<paste ...>>` markers are filled with the captured deterministic output in this step — they are a capture instruction, not shipped code. Do NOT commit the test with the marker text still in place; the test must hold the real bytes and go green.
- [ ] **Step 4: Run the golden tests to verify they pass**
Run: `cargo test -p aura-cli blueprint_view_golden compiled_view_golden`
Expected: PASS (the literals match the captured render; determinism makes this stable).
- [ ] **Step 5: Full workspace gate**
Run: `cargo test --workspace`
Expected: PASS — all tests across aura-core/std/engine/cli green, including the pre-existing non-regression tests `composite_sma_cross_runs_bit_identical_to_hand_wired` and `run_sample_is_deterministic_and_non_trivial`.
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: no warnings (the test-only `sample_blueprint_swapped` is exercised by `swapped_sma_inputs_render_differently`, so no dead-code warning).
Run: `cargo build --workspace`
Expected: builds clean.
-161
View File
@@ -1,161 +0,0 @@
# Tidy 0014 — scalar re-export + Recorder-fixture dedup — Implementation Plan
> **Parent spec:** none — this is a post-cycle **tidy iteration** dispatched per
> the `audit` skill's "fix path: planner + implement for a tidy iteration". The
> source of truth is two Gitea tidy issues (#29, #14) plus the orchestrator's
> scope constraints. (#27 was the third candidate but is **out of this plan**:
> its only remaining content after the bit-identical-test protection and the
> `aura run` out-of-scope rule is a cross-crate dedup of a concrete `sma_cross`
> composite, which has no C9-clean home — orchestrator decided to narrow/close
> #27 with that rationale rather than bend C9 in a tidy.)
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Close #29 (aura-engine re-exports the core scalar vocabulary) and #14
(remove the two near-identical `#[cfg(test)]` Recorder fixtures in aura-engine,
using the shipped `aura-std::Recorder`).
**Architecture:** Two independent, behaviour-preserving tidies. (1) Add one
`pub use aura_core::{...}` line to `aura-engine`'s crate root so a Blueprint
builder needs one import surface, not two. (2) Delete the private f64/all-kind
`#[cfg(test)]` Recorder structs in `report.rs` and `harness.rs` and import the
shipped `aura_std::Recorder` instead — its constructor signature, schema, and
`try_iter()` drain are call-for-call identical to both fixtures, so **no call
site changes**; only the struct/impl deletion and one import line per file.
**Tech Stack:** `crates/aura-engine/src/{lib,report,harness}.rs`; the shipped
`aura-std::Recorder` (already a `[dev-dependencies]` of aura-engine).
---
## Files this plan creates or modifies
- Modify: `crates/aura-engine/src/lib.rs:38-40` — add `pub use aura_core::{Firing, Scalar, ScalarKind, Timestamp};` after the existing re-exports.
- Test: `crates/aura-engine/src/lib.rs` (new `#[cfg(test)]` module) — assert the four scalar symbols resolve at the aura-engine crate root.
- Modify: `crates/aura-engine/src/report.rs:203` — add `Recorder` to the existing `use aura_std::{...}`.
- Modify: `crates/aura-engine/src/report.rs:217-255` — delete the private `#[cfg(test)] struct Recorder` + its `impl Recorder::new` + `impl Node`.
- Modify: `crates/aura-engine/src/harness.rs:340` — add `Recorder` to the existing `use aura_std::{...}`.
- Modify: `crates/aura-engine/src/harness.rs:496-559` — delete the private `#[cfg(test)] struct Recorder` + its `impl Recorder::new` + `impl Node` (leave the separate `TapForward` fixture at L564-585 untouched).
---
## Task 1: #29 — aura-engine re-exports the core scalar vocabulary
**Files:**
- Modify: `crates/aura-engine/src/lib.rs:38-40`
- Test: `crates/aura-engine/src/lib.rs` (new `#[cfg(test)]` module at end of file)
- [ ] **Step 1: Add the re-export line**
In `crates/aura-engine/src/lib.rs`, the current re-exports are:
```rust
pub use blueprint::{Blueprint, BlueprintNode, CompileError, Composite, OutPort};
pub use harness::{BootstrapError, Edge, Harness, SourceSpec, Target};
pub use report::{f64_field, summarize, RunManifest, RunMetrics, RunReport};
```
Add one line immediately after `pub use report::{...};`:
```rust
// #29: re-export the core scalar vocabulary a Blueprint builder needs
// (SourceSpec.kind is a ScalarKind; sources/Recorder columns are Scalar /
// Firing / Timestamp) so a graph builder has one import surface, not two.
pub use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
```
- [ ] **Step 2: Add a test that the four symbols resolve at the crate root**
Append to `crates/aura-engine/src/lib.rs` (if the file already ends with a
`#[cfg(test)]` module, add this as a sibling module — do not merge):
```rust
#[cfg(test)]
mod reexport_tests {
// #29: the core scalar vocabulary a Blueprint builder needs is reachable
// from aura-engine alone (crate::X == external aura_engine::X), so a
// downstream author does not add a second aura-core import for ScalarKind.
#[test]
fn core_scalar_vocabulary_is_reexported_from_crate_root() {
use crate::{Firing, Scalar, ScalarKind, Timestamp};
let _k: ScalarKind = ScalarKind::F64;
let _f: Firing = Firing::Any;
let _s: Scalar = Scalar::F64(0.0);
let _t: Timestamp = Timestamp(0);
}
}
```
- [ ] **Step 3: Build + test + lint**
Run: `cargo build -p aura-engine && cargo test -p aura-engine reexport_tests && cargo clippy -p aura-engine --all-targets -- -D warnings`
Expected: build OK; `core_scalar_vocabulary_is_reexported_from_crate_root` PASS (1 test run, not 0); clippy clean (no `unused_imports`, no `ambiguous_glob_reexports`).
---
## Task 2: #14 (part 1) — report.rs uses the shipped Recorder
**Files:**
- Modify: `crates/aura-engine/src/report.rs:203` (import) and `:217-255` (delete fixture)
The private fixture's constructor is `Recorder::new(&[ScalarKind], Firing, mpsc::Sender<(Timestamp, Vec<Scalar>)>)`, identical to `aura_std::Recorder::new`; both schemas are one `InputSpec` per kind with empty output; both drain via `rx.try_iter()`. The two call sites (`report.rs:275-276`, read back at `:308-309`) construct `Recorder::new(&[ScalarKind::F64], Firing::Any, tx_*)` and stay **byte-identical** — they resolve to `aura_std::Recorder` once the private struct is gone and the import is added.
- [ ] **Step 1: Add `Recorder` to the aura-std import**
In `crates/aura-engine/src/report.rs`, the test-module import is:
```rust
use aura_std::{Exposure, SimBroker, Sma, Sub};
```
Replace it with (alphabetical, `Recorder` inserted):
```rust
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
```
- [ ] **Step 2: Delete the private Recorder fixture**
In `crates/aura-engine/src/report.rs`, delete the entire private fixture block
spanning the `#[cfg(test)] struct Recorder { ... }` declaration, its
`impl Recorder { fn new(...) -> Self { ... } }`, and its `impl Node for Recorder { ... }` (the contiguous region at lines ~217-255, ending just before the next test-support item). Delete nothing else — leave every `Recorder::new(...)` call site and every other helper intact.
- [ ] **Step 3: Build + test + lint**
Run: `cargo test -p aura-engine --lib report && cargo clippy -p aura-engine --all-targets -- -D warnings`
Expected: `report_is_deterministic_end_to_end` (report.rs:329) PASS (≥1 test run, not 0); no `unused_imports` for `Recorder`; no "cannot find type `Recorder`" error; clippy clean.
---
## Task 3: #14 (part 2) — harness.rs uses the shipped Recorder
**Files:**
- Modify: `crates/aura-engine/src/harness.rs:340` (import) and `:496-559` (delete fixture)
The private fixture here is the all-four-kinds variant (matches on `I64/F64/Bool/Timestamp`), behaviourally identical to `aura_std::Recorder`. Its ~30 call sites (enumerated in recon: L594…L1768, all `Recorder::new(&[<kinds>], <Firing>, <tx>)`, all drained via `rx.try_iter()`) stay **byte-identical**. The separate `TapForward` fixture (L564-585) is NOT a Recorder and is left untouched.
- [ ] **Step 1: Add `Recorder` to the aura-std import**
In `crates/aura-engine/src/harness.rs`, the test-module import is:
```rust
use aura_std::{Exposure, Sma, SimBroker, Sub};
```
Replace it with (`Recorder` inserted):
```rust
use aura_std::{Exposure, Recorder, Sma, SimBroker, Sub};
```
- [ ] **Step 2: Delete the private Recorder fixture**
In `crates/aura-engine/src/harness.rs`, delete the entire private fixture block
spanning the `#[cfg(test)] struct Recorder { ... }` declaration, its
`impl Recorder { fn new(...) -> Self { ... } }`, and its `impl Node for Recorder { ... }` (the contiguous region at lines ~496-559). Do NOT touch the `TapForward` struct/impl that follows at ~564-585, and delete no call site.
- [ ] **Step 3: Build + full workspace test + lint**
Run: `cargo build --workspace && cargo test --workspace && cargo clippy --workspace --all-targets -- -D warnings`
Expected: build OK; all tests PASS — in particular `composite_sma_cross_runs_bit_identical_to_hand_wired`, `run_sample_is_deterministic_and_non_trivial`, `swapped_sma_inputs_render_differently`, the aura-cli golden snapshots, the aura-std SMA disambiguation test, and every harness.rs recording test (e.g. `recorder_records_mixed_scalar_kinds`, `milestone_end_to_end_mixed_dag_records_every_stream_deterministically`, `signal_quality_loop_is_deterministic`) stay green; no `unused_imports`; clippy clean.
-525
View File
@@ -1,525 +0,0 @@
# Node Tunable-Parameter Declaration — Implementation Plan
> **Parent spec:** `docs/specs/0015-node-param-declaration.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** A node declares its tunable parameters in its C8 schema (`ParamSpec`, a
third `NodeSchema` field), and `Blueprint::param_space()` aggregates every node's
params into one flat, path-qualified, inspectable param-space.
**Architecture:** Two layers on existing machinery. `aura-core` gains the
`ParamSpec` type and the `NodeSchema.params` field; the shipped `aura-std` nodes
declare their tunable knobs. `aura-engine` gains a read-only `Blueprint::param_space()`
that walks the graph-as-data (using the already-public `Composite::name()` as path
prefix) in the same deterministic depth-first order `lower_items` uses — a parallel
projection that leaves `compile`/`inline_composite` (and the flat graph) untouched.
**Tech Stack:** Rust workspace — `aura-core` (node contract), `aura-std` (7 nodes),
`aura-engine` (blueprint/compile). Param identity is positional (slot), name a
non-load-bearing path-qualified debug symbol (C23).
**Sequencing note (compile-gate ordering):** adding the non-`Default` `params`
field makes every `NodeSchema { .. }` literal a compile error until updated. The 19
workspace literals span three crates; each crate's literals are repaired *inside*
the crate's own task, and the per-crate build gate is `-p <crate>` (a partial build)
until Task 4 finishes the workspace-wide build. `crates/aura-cli` and
`crates/aura-ingest` construct **zero** `NodeSchema` literals (recon-verified), so
they need no change and compile clean once the others do.
---
## Files this plan creates or modifies
- Modify: `crates/aura-core/src/node.rs:6-7` — stale module doc-comment update
- Modify: `crates/aura-core/src/node.rs:38-53` — add `ParamSpec`, add `NodeSchema.params`
- Modify: `crates/aura-core/src/node.rs:99-100``Bare` test fixture literal gains `params`
- Modify: `crates/aura-core/src/lib.rs:42` — re-export `ParamSpec`
- Modify: `crates/aura-std/src/{sma,exposure,lincomb}.rs` — declare params (+ `ParamSpec` import)
- Modify: `crates/aura-std/src/{sub,add,sim_broker,recorder}.rs``params: vec![]`
- Test: `crates/aura-std/src/sma.rs` — per-node declaration test (Sma/Exposure/LinComb + empty nodes)
- Modify: `crates/aura-engine/src/blueprint.rs:113``Composite::schema()` adds `params: vec![]`
- Modify: `crates/aura-engine/src/blueprint.rs:{373,419,438,453}` — 4 test fixtures gain `params: vec![]`
- Modify: `crates/aura-engine/src/harness.rs:{358,384,406,436,474,498}` — 6 test fixtures gain `params: vec![]`
- Modify: `crates/aura-engine/src/blueprint.rs:140-158` — add `Blueprint::param_space()` + `collect_params` helper
- Modify: `crates/aura-engine/src/lib.rs:44` — re-export `ParamSpec`
- Test: `crates/aura-engine/src/blueprint.rs` — nested-aggregation, top-level-unqualified, determinism, empty
---
## Task 1: `aura-core` — `ParamSpec` type + `NodeSchema.params` field
**Files:**
- Modify: `crates/aura-core/src/node.rs`
- Modify: `crates/aura-core/src/lib.rs:42`
- [ ] **Step 1: Add the `ParamSpec` type**
In `crates/aura-core/src/node.rs`, immediately after the `FieldSpec` struct (ends
at line 42), insert:
```rust
/// One declared tunable parameter of a node (C8/C12): its render name and scalar
/// kind. The name is a **non-load-bearing** debug symbol (path-qualified at
/// aggregation, as `FieldSpec.name` already is); a param's identity is its
/// positional slot in the blueprint's aggregated param-space (C23 — by index, not
/// by name). Unlike `FieldSpec.name` (`&'static str`), the name is a `String`: a
/// vector knob carries a runtime index (`weights[0]`) and aggregation prefixes the
/// composite path (`strategy.weights[0]`). Permitted kinds: `I64`/`F64`/`Bool`;
/// `Timestamp` is a structural axis (C20), never a numeric knob.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ParamSpec {
pub name: String,
pub kind: ScalarKind,
}
```
- [ ] **Step 2: Add the `params` field to `NodeSchema`**
In the same file, the `NodeSchema` struct (lines 49-53) becomes:
```rust
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NodeSchema {
pub inputs: Vec<InputSpec>,
pub output: Vec<FieldSpec>,
pub params: Vec<ParamSpec>,
}
```
- [ ] **Step 3: Update the stale module doc-comment**
In the same file, replace lines 6-7:
```rust
//! Tunable params (C12/C19) are deliberately not part of the schema yet — see
//! spec 0002's "Out of scope".
```
with:
```rust
//! Tunable params (C12/C19) are declared via `params` (cycle 0015): each node's
//! typed knobs, which `Blueprint::param_space` aggregates into the sweep's flat,
//! path-qualified param-space (C8/C23). Identity is positional (slot); the name is
//! a non-load-bearing debug symbol.
```
- [ ] **Step 4: Repair the `Bare` test fixture literal**
In the same file, the `Bare` fixture in `mod tests` (line ~99-101) constructs a
`NodeSchema`. Add the field so it reads:
```rust
fn schema(&self) -> NodeSchema {
NodeSchema { inputs: vec![], output: vec![], params: vec![] }
}
```
- [ ] **Step 5: Re-export `ParamSpec`**
In `crates/aura-core/src/lib.rs:42`, the re-export line becomes:
```rust
pub use node::{FieldSpec, Firing, InputSpec, Node, NodeSchema, ParamSpec};
```
- [ ] **Step 6: Add a declaration test pinning the new field**
In `crates/aura-core/src/node.rs` `mod tests`, add:
```rust
#[test]
fn schema_carries_declared_params() {
let s = NodeSchema {
inputs: vec![],
output: vec![],
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
};
assert_eq!(s.params.len(), 1);
assert_eq!(s.params[0].name, "length");
assert_eq!(s.params[0].kind, ScalarKind::I64);
}
```
- [ ] **Step 7: Build + test `aura-core` (partial build gate)**
Run: `cargo test -p aura-core`
Expected: PASS — `aura-core` compiles (its one fixture repaired) and all its tests
incl. `schema_carries_declared_params` pass. `aura-std`/`aura-engine` do **not**
compile yet (their literals are repaired in Tasks 2-3); that is expected — this
gate is scoped to `-p aura-core`.
---
## Task 2: `aura-std` — declare params in the 7 node schemas
**Files:**
- Modify: `crates/aura-std/src/sma.rs:6,24`
- Modify: `crates/aura-std/src/exposure.rs:25`
- Modify: `crates/aura-std/src/lincomb.rs:44`
- Modify: `crates/aura-std/src/{sub.rs:29,add.rs:38,sim_broker.rs:61,recorder.rs:33}`
- Test: `crates/aura-std/src/sma.rs`
- [ ] **Step 1: `Sma` declares `length`**
In `crates/aura-std/src/sma.rs`, add `ParamSpec` to the import (line 6):
```rust
use aura_core::{Ctx, FieldSpec, Firing, InputSpec, Node, NodeSchema, ParamSpec, Scalar, ScalarKind};
```
and the `schema()` literal (line 23-32) gains the field:
```rust
fn schema(&self) -> NodeSchema {
NodeSchema {
inputs: vec![InputSpec {
kind: ScalarKind::F64,
lookback: self.length,
firing: Firing::Any,
}],
output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }],
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
}
}
```
- [ ] **Step 2: `Exposure` declares `scale`**
In `crates/aura-std/src/exposure.rs`, add `ParamSpec` to the `aura_core` import,
and the `schema()` literal (lines 24-27) gains:
```rust
NodeSchema {
inputs: vec![InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }],
output: vec![FieldSpec { name: "exposure", kind: ScalarKind::F64 }],
params: vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }],
}
```
- [ ] **Step 3: `LinComb` declares `weights[0..N]` (flat expansion)**
In `crates/aura-std/src/lincomb.rs`, add `ParamSpec` to the `aura_core` import, and
the `schema()` literal (lines 43-49) gains the mapped params:
```rust
NodeSchema {
inputs: self
.weights
.iter()
.map(|_| InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any })
.collect(),
output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }],
params: (0..self.weights.len())
.map(|i| ParamSpec { name: format!("weights[{i}]"), kind: ScalarKind::F64 })
.collect(),
}
```
- [ ] **Step 4: The four param-less nodes declare `params: vec![]`**
Add `params: vec![]` as the final field of the `NodeSchema` literal in each of:
- `crates/aura-std/src/sub.rs` (`Sub::schema`, ~line 29)
- `crates/aura-std/src/add.rs` (`Add::schema`, ~line 38)
- `crates/aura-std/src/sim_broker.rs` (`SimBroker::schema`, ~line 61 — `pip_size` is
metadata, C10/C15, not a knob)
- `crates/aura-std/src/recorder.rs` (`Recorder::schema`, ~line 33 — wiring, not a knob)
No `ParamSpec` import is needed in these four (they use only `vec![]`).
- [ ] **Step 5: Add the per-node declaration test**
In `crates/aura-std/src/sma.rs` `mod tests` (it already imports the sibling nodes in
`labels_carry_identifying_params`), add:
```rust
#[test]
fn nodes_declare_expected_params() {
use crate::{Add, Exposure, LinComb, Recorder, SimBroker, Sub};
use aura_core::{Firing, ParamSpec, ScalarKind};
// single scalar knobs
assert_eq!(
Sma::new(3).schema().params,
vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
);
assert_eq!(
Exposure::new(0.5).schema().params,
vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }],
);
// vector knob expands flat to N indexed F64 entries
let lc = LinComb::new(vec![1.0, -1.0]).schema().params;
assert_eq!(lc.len(), 2);
assert_eq!(lc[0].name, "weights[0]");
assert_eq!(lc[1].name, "weights[1]");
assert!(lc.iter().all(|p| p.kind == ScalarKind::F64));
// param-less nodes declare empty
assert!(Sub::new().schema().params.is_empty());
assert!(Add::new().schema().params.is_empty());
assert!(SimBroker::new(0.0001).schema().params.is_empty());
let (tx, _rx) = std::sync::mpsc::channel();
assert!(Recorder::new(&[ScalarKind::F64], Firing::Any, tx).schema().params.is_empty());
}
```
- [ ] **Step 6: Build + test `aura-std` (partial build gate)**
Run: `cargo test -p aura-std`
Expected: PASS — `aura-std` compiles against the new `aura-core` and all tests incl.
`nodes_declare_expected_params` pass. `aura-engine` still does not compile (Task 3).
---
## Task 3: `aura-engine` — repair `Composite::schema` + 10 test fixtures
**Files:**
- Modify: `crates/aura-engine/src/blueprint.rs:113`
- Modify: `crates/aura-engine/src/blueprint.rs:{373,419,438,453}`
- Modify: `crates/aura-engine/src/harness.rs:{358,384,406,436,474,498}`
This task is pure compile-repair (mechanical) — every `NodeSchema` literal in
`aura-engine` gains `params: vec![]` so the crate compiles again against the new
field. No new behaviour, no new test. None of these are tunable-knob nodes.
- [ ] **Step 1: `Composite::schema()` declares empty params**
In `crates/aura-engine/src/blueprint.rs`, the `Composite::schema()` literal (line
112-113) becomes:
```rust
let out_field = self.nodes[self.output.node].schema().output[self.output.field];
NodeSchema { inputs, output: vec![out_field], params: vec![] }
```
A composite is an authoring boundary, not a node; its interior params surface
through `param_space()` (Task 4), not its derived schema.
- [ ] **Step 2: Repair the 4 `blueprint.rs` test fixtures**
In `crates/aura-engine/src/blueprint.rs` `mod tests`, append `params: vec![]` as the
final field of the `NodeSchema { .. }` literal in each fixture: `Join2` (~line 373),
`Pass1` (~419), `SinkF64` (~438), `SinkI64` (~453).
- [ ] **Step 3: Repair the 6 `harness.rs` test fixtures**
In `crates/aura-engine/src/harness.rs` `mod tests`, append `params: vec![]` as the
final field of the `NodeSchema { .. }` literal in each fixture: `AsOfSum` (~line 358),
`BarrierSum` (~384), `MixedSum` (~406), `Ohlcv` (~436), `TwoField` (~474),
`TapForward` (~498).
- [ ] **Step 4: Build `aura-engine` incl. tests (compile-repair gate)**
Run: `cargo build -p aura-engine --all-targets`
Expected: PASS — `aura-engine` lib + test targets compile (every literal repaired). If
the build names any remaining `NodeSchema` literal missing `params`, add
`params: vec![]` to it and re-run; the build error enumerates each one verbatim.
- [ ] **Step 5: Existing `aura-engine` tests stay green**
Run: `cargo test -p aura-engine`
Expected: PASS — all pre-existing tests (incl. the bit-identical
`composite_sma_cross_runs_bit_identical_to_hand_wired` and the golden render tests)
stay green: the flat graph is unchanged, only schema literals gained an empty field.
---
## Task 4: `aura-engine` — `Blueprint::param_space()` aggregation + tests
**Files:**
- Modify: `crates/aura-engine/src/blueprint.rs:140-158` (Blueprint impl) + module scope (helper)
- Modify: `crates/aura-engine/src/lib.rs:44`
- Test: `crates/aura-engine/src/blueprint.rs`
- [ ] **Step 1: Add the `param_space()` accessor**
In `crates/aura-engine/src/blueprint.rs`, inside `impl Blueprint` (after `edges()`,
which ends at line 158), add:
```rust
/// The aggregated, flat, path-qualified param-space (C12): every node's declared
/// params, concatenated in the deterministic depth-first item order `lower_items`
/// uses, so a param's slot here matches the later flat-node order (#31 binds
/// slot-by-slot). Read-only graph-as-data (C9); does not compile. Names are
/// non-load-bearing: a composite's `name()` is prefixed at each level, but
/// same-type siblings in one composite share a name — uniqueness is at the slot.
pub fn param_space(&self) -> Vec<ParamSpec> {
let mut out = Vec::new();
collect_params(&self.nodes, "", &mut out);
out
}
```
- [ ] **Step 2: Add the `collect_params` helper at module scope**
In the same file, at module scope (next to `lower_items`, e.g. after the `Blueprint`
impl block), add:
```rust
/// Recursive read-only walk for `Blueprint::param_space`: a leaf contributes its
/// declared params under the running path prefix; a composite pushes its `name()`
/// onto the path and recurses. Order mirrors `lower_items` (items in declared order,
/// composites depth-first) so a param's slot matches the later flat-node order.
fn collect_params(items: &[BlueprintNode], prefix: &str, out: &mut Vec<ParamSpec>) {
for item in items {
match item {
BlueprintNode::Leaf(node) => {
for p in node.schema().params {
let name = if prefix.is_empty() {
p.name
} else {
format!("{prefix}.{}", p.name)
};
out.push(ParamSpec { name, kind: p.kind });
}
}
BlueprintNode::Composite(c) => {
let child = if prefix.is_empty() {
c.name().to_string()
} else {
format!("{prefix}.{}", c.name())
};
collect_params(c.nodes(), &child, out);
}
}
}
}
```
- [ ] **Step 3: Import `ParamSpec` into `blueprint.rs`**
In `crates/aura-engine/src/blueprint.rs`, the `aura_core` import (line 14) gains
`ParamSpec`:
```rust
use aura_core::{Node, NodeSchema, ParamSpec, ScalarKind};
```
- [ ] **Step 4: Re-export `ParamSpec` from `aura-engine`**
In `crates/aura-engine/src/lib.rs:44`, add `ParamSpec` to the `aura_core`
re-export (consumer of `param_space()` needs one import surface, per the #29 tidy
precedent). The line is currently:
```rust
pub use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
```
and becomes:
```rust
pub use aura_core::{Firing, ParamSpec, Scalar, ScalarKind, Timestamp};
```
- [ ] **Step 5: Add the headline nested-aggregation test**
In `crates/aura-engine/src/blueprint.rs` `mod tests`, add (the test imports the
sibling `aura_std` nodes, already a dev-dependency):
```rust
#[test]
fn param_space_is_flat_path_qualified_and_slot_disambiguated() {
use aura_std::{LinComb, Sma, Sub};
// inner composite "fast_slow": two SMAs (same type → same param name) + a Sub
let fast_slow = Composite::new(
"fast_slow",
vec![Sma::new(2).into(), Sma::new(4).into(), Sub::new().into()],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
],
vec![vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }]],
OutPort { node: 2, field: 0 },
);
// outer composite "strategy": the inner composite + a LinComb([1,-1])
let strategy = Composite::new(
"strategy",
vec![BlueprintNode::Composite(fast_slow), LinComb::new(vec![1.0, -1.0]).into()],
vec![],
vec![vec![Target { node: 0, slot: 0 }]],
OutPort { node: 0, field: 0 },
);
let bp = Blueprint::new(vec![BlueprintNode::Composite(strategy)], vec![], vec![]);
let space = bp.param_space();
let names: Vec<&str> = space.iter().map(|p| p.name.as_str()).collect();
assert_eq!(
names,
[
"strategy.fast_slow.length", // slot 0 — Sma(2)
"strategy.fast_slow.length", // slot 1 — Sma(4): same name, distinct slot
"strategy.weights[0]", // slot 2 — LinComb weight 0
"strategy.weights[1]", // slot 3 — LinComb weight 1
]
);
assert_eq!(space[0].kind, ScalarKind::I64);
assert_eq!(space[2].kind, ScalarKind::F64);
}
```
- [ ] **Step 6: Add top-level-unqualified, determinism, and empty tests**
In the same `mod tests`, add:
```rust
#[test]
fn top_level_leaf_params_are_unqualified() {
use aura_std::Sma;
let bp = Blueprint::new(vec![Sma::new(3).into()], vec![], vec![]);
let space = bp.param_space();
assert_eq!(space.len(), 1);
assert_eq!(space[0].name, "length"); // no path prefix at the top level
}
#[test]
fn param_space_is_deterministic() {
use aura_std::{LinComb, Sma};
let bp = Blueprint::new(
vec![Sma::new(2).into(), LinComb::new(vec![1.0, -1.0]).into()],
vec![],
vec![],
);
assert_eq!(bp.param_space(), bp.param_space()); // pure structural function (C1)
}
#[test]
fn param_space_empty_for_paramless_and_empty_blueprints() {
use aura_std::{Add, Sub};
let only_paramless =
Blueprint::new(vec![Sub::new().into(), Add::new().into()], vec![], vec![]);
assert!(only_paramless.param_space().is_empty());
let empty = Blueprint::new(vec![], vec![], vec![]);
assert!(empty.param_space().is_empty());
}
```
- [ ] **Step 7: Test `aura-engine` + full workspace build**
Run: `cargo test -p aura-engine`
Expected: PASS — the four new tests pass; all existing tests stay green.
Run: `cargo build --workspace && cargo test --workspace`
Expected: PASS — every crate compiles (aura-cli/aura-ingest unchanged, zero
`NodeSchema` literals) and the whole suite is green.
- [ ] **Step 8: Clippy gate**
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: PASS — no warnings (the `format!`/`collect` idioms in `collect_params`
and `LinComb::schema` are clippy-clean).
---
## Notes for the implementer
- **Do not touch** `compile`, `inline_composite`, `lower_items`, or any edge/wiring
logic. `param_space()` is a *parallel* read-only projection that mirrors the
inliner's traversal order; it must not share or alter it. The spec's correctness
rests on the flat graph staying bit-identical (every existing golden / bit-identical
test stays green by construction).
- **fieldtests/ are out of scope** — they are excluded crates (own `Cargo.toml`),
not in `--workspace`, and are frozen cycle-archive snapshots. They construct the
old 2-field `NodeSchema` but never compile in this build, so they do not break the
gate and are deliberately left unchanged.
- Param identity is **positional** — name collisions (two same-type siblings sharing
a path-qualified name) are the expected, correct case, never an error.
-696
View File
@@ -1,696 +0,0 @@
# Param-set injection — Implementation Plan
> **Parent spec:** `docs/specs/0016-param-set-injection.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Make a blueprint value-empty — a leaf is a `LeafFactory` recipe
(`params → sized node`) — and bind a positional `Scalar` vector at bootstrap via a
new build-then-wire compile path, with kind + arity checks.
**Architecture:** `LeafFactory { name, params, build }` lands in aura-core; the 7
aura-std nodes expose `factory()`; aura-engine's `BlueprintNode::Leaf` becomes a
factory, `compile_with_params`/`bootstrap_with_params` build each leaf from its
kind-checked param slice while lowering (the existing structural inline/edge/source
rewrite is unchanged), and the vestigial pre-build `schema` methods are removed;
aura-cli's blueprint render reads the param-generic `LeafFactory::label()` and the
sample/goldens are re-expressed against the vector.
**Tech Stack:** Rust workspace — aura-core (Node/Scalar contract), aura-std (nodes),
aura-engine (blueprint/compile/harness), aura-cli (run/graph faces).
**Sequencing (compile gates):** The `Leaf(LeafFactory)` change breaks every
blueprint-leaf author site until repaired, so tasks are gated crate-by-crate:
Task 1 `cargo build -p aura-core`, Task 2 `-p aura-std`, Task 3 `cargo build -p
aura-engine --all-targets`, Task 4 `cargo build -p aura-cli --all-targets` then
`cargo test --workspace`. Tasks 12 are purely additive (no breakage); Task 3 is
the breaking change and repairs every aura-engine site (incl. its own tests) in
one task so its compile gate is satisfiable; Task 4 repairs aura-cli + runs the
workspace gate.
---
## Files this plan creates or modifies
- Modify: `crates/aura-core/src/node.rs` — add `LeafFactory`.
- Modify: `crates/aura-core/src/scalar.rs` — add `as_i64`/`as_f64`.
- Modify: `crates/aura-core/src/lib.rs` — re-export `LeafFactory`.
- Modify: `crates/aura-std/src/{sma,exposure,lincomb,sub,add,sim_broker,recorder}.rs`
— each gains `fn factory(...)` + a factory↔schema params test.
- Modify: `crates/aura-engine/src/blueprint.rs``Leaf(LeafFactory)`,
`From<LeafFactory>`, `collect_params`, `compile_with_params`,
`bootstrap_with_params`, `CompileError` variants, remove vestigial `schema`
methods + their test, re-express fixtures + tests.
- Modify: `crates/aura-cli/src/graph.rs``render_blueprint` uses
`LeafFactory::label()`.
- Modify: `crates/aura-cli/src/main.rs` — sample blueprint → factories + vector;
param-form call sites; re-capture blueprint-view goldens; move the swap to the
compiled view.
---
## Task 1: aura-core — `LeafFactory` + `Scalar` accessors
**Files:**
- Modify: `crates/aura-core/src/node.rs`
- Modify: `crates/aura-core/src/scalar.rs`
- Modify: `crates/aura-core/src/lib.rs`
- [ ] **Step 1: Add `LeafFactory` to `node.rs`**
After the `ParamSpec` struct (ends `node.rs:58`) and before the `NodeSchema` doc,
add (the `use` at `node.rs:11` already imports `Scalar`):
```rust
/// A param-generic blueprint leaf (C19): a node's declared tunable params plus a
/// closure that builds a sized instance through the node's own constructor (the
/// single sizing/validation gate). A blueprint holds these recipes, never built
/// instances, so it stays value-empty until a param-set is injected (C19/C23).
pub struct LeafFactory {
name: &'static str,
params: Vec<ParamSpec>,
build: Box<dyn Fn(&[Scalar]) -> Box<dyn Node>>,
}
impl LeafFactory {
/// `name` is the param-generic render label (the node type, e.g. `"SMA"`);
/// `params` the declared knobs; `build` constructs a sized node from a
/// kind-checked param slice.
pub fn new(
name: &'static str,
params: Vec<ParamSpec>,
build: impl Fn(&[Scalar]) -> Box<dyn Node> + 'static,
) -> Self {
Self { name, params, build: Box::new(build) }
}
/// The declared tunable params (read by `Blueprint::param_space`, pre-build).
pub fn params(&self) -> &[ParamSpec] {
&self.params
}
/// Build a sized node from its param slice (the slice is kind-checked by the
/// caller before this runs).
pub fn build(&self, params: &[Scalar]) -> Box<dyn Node> {
(self.build)(params)
}
/// The param-generic render label for the blueprint view (C22 "structure
/// before"): the node type plus its tunable param *names* — no values, a
/// value-empty recipe has none — e.g. `SMA(length)`, `LinComb(weights[0],
/// weights[1])`, or bare `SimBroker` when paramless.
pub fn label(&self) -> String {
if self.params.is_empty() {
self.name.to_string()
} else {
let knobs: Vec<&str> = self.params.iter().map(|p| p.name.as_str()).collect();
format!("{}({})", self.name, knobs.join(", "))
}
}
}
```
- [ ] **Step 2: Add value accessors to `scalar.rs`**
Inside the existing `impl Scalar` block (after `kind`, `scalar.rs:30-37`), add:
```rust
/// The `i64` payload, or `None` if this scalar is not an `I64`.
pub fn as_i64(self) -> Option<i64> {
if let Scalar::I64(v) = self { Some(v) } else { None }
}
/// The `f64` payload, or `None` if this scalar is not an `F64`.
pub fn as_f64(self) -> Option<f64> {
if let Scalar::F64(v) = self { Some(v) } else { None }
}
```
- [ ] **Step 3: Re-export `LeafFactory`**
In `crates/aura-core/src/lib.rs:42`, add `LeafFactory` to the `pub use node::{...}`
list (keep alphabetical): `pub use node::{FieldSpec, Firing, InputSpec, LeafFactory,
Node, NodeSchema, ParamSpec};`
- [ ] **Step 4: Tests in `node.rs` and `scalar.rs`**
In `node.rs` tests (reuse the `Bare` node already defined in that module, `node.rs`
test mod), add:
```rust
#[test]
fn leaf_factory_label_is_param_generic() {
let with = LeafFactory::new(
"SMA",
vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
|_| Box::new(Bare),
);
assert_eq!(with.label(), "SMA(length)");
let none = LeafFactory::new("Sub", vec![], |_| Box::new(Bare));
assert_eq!(none.label(), "Sub");
}
#[test]
fn leaf_factory_build_runs_the_closure() {
let f = LeafFactory::new("Bare", vec![], |_| Box::new(Bare));
assert_eq!(f.build(&[]).schema().params, Vec::<ParamSpec>::new());
}
```
In `scalar.rs` tests, add:
```rust
#[test]
fn scalar_value_accessors_are_kind_exact() {
assert_eq!(Scalar::I64(3).as_i64(), Some(3));
assert_eq!(Scalar::I64(3).as_f64(), None);
assert_eq!(Scalar::F64(0.5).as_f64(), Some(0.5));
assert_eq!(Scalar::F64(0.5).as_i64(), None);
}
```
(If `scalar.rs` has no `#[cfg(test)] mod tests`, add one with `use super::*;`.)
- [ ] **Step 5: Gate**
Run: `cargo test -p aura-core`
Expected: PASS, including `leaf_factory_label_is_param_generic`,
`leaf_factory_build_runs_the_closure`, `scalar_value_accessors_are_kind_exact`.
---
## Task 2: aura-std — `factory()` on the 7 nodes
**Files:** Modify each of
`crates/aura-std/src/{sma,exposure,lincomb,sub,add,sim_broker,recorder}.rs`.
Each `factory()` is an inherent method in the node's existing `impl <Node>` block
(beside `new`). Add `LeafFactory` to each file's `use aura_core::{...}` line.
- [ ] **Step 1: `Sma::factory` (`sma.rs`)**
```rust
/// The param-generic recipe for a blueprint leaf: declares `length` and builds
/// through `Sma::new` (the single sizing/validation gate; the slice is
/// kind-checked before `build` runs, so the typed read is total).
pub fn factory() -> LeafFactory {
LeafFactory::new(
"SMA",
vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
|p| Box::new(Sma::new(p[0].as_i64().expect("length slot is I64") as usize)),
)
}
```
- [ ] **Step 2: `Exposure::factory` (`exposure.rs`)**
```rust
pub fn factory() -> LeafFactory {
LeafFactory::new(
"Exposure",
vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }],
|p| Box::new(Exposure::new(p[0].as_f64().expect("scale slot is F64"))),
)
}
```
- [ ] **Step 3: `LinComb::factory(arity)` (`lincomb.rs`)**
The arity is topology (fixed per blueprint, C19), taken as a factory arg; only the
weight *values* are injected.
```rust
pub fn factory(arity: usize) -> LeafFactory {
let params = (0..arity)
.map(|i| ParamSpec { name: format!("weights[{i}]"), kind: ScalarKind::F64 })
.collect();
LeafFactory::new(
"LinComb",
params,
|p| Box::new(LinComb::new(
p.iter().map(|s| s.as_f64().expect("weight slot is F64")).collect(),
)),
)
}
```
- [ ] **Step 4: paramless `Sub`/`Add::factory` (`sub.rs`, `add.rs`)**
```rust
// sub.rs
pub fn factory() -> LeafFactory {
LeafFactory::new("Sub", vec![], |_| Box::new(Sub::new()))
}
// add.rs
pub fn factory() -> LeafFactory {
LeafFactory::new("Add", vec![], |_| Box::new(Add::new()))
}
```
- [ ] **Step 5: `SimBroker::factory(pip_size)` (`sim_broker.rs`)**
`pip_size` is metadata (C10/C15), not a tunable param — captured by the closure.
```rust
pub fn factory(pip_size: f64) -> LeafFactory {
LeafFactory::new("SimBroker", vec![], move |_| Box::new(SimBroker::new(pip_size)))
}
```
- [ ] **Step 6: `Recorder::factory(kinds, firing, tx)` (`recorder.rs`)**
The channel + kinds + firing are non-param construction args — captured; `tx` is
cloned per build (`mpsc::Sender: Clone`).
```rust
pub fn factory(
kinds: Vec<ScalarKind>,
firing: Firing,
tx: Sender<(Timestamp, Vec<Scalar>)>,
) -> LeafFactory {
LeafFactory::new("Recorder", vec![], move |_| {
Box::new(Recorder::new(&kinds, firing, tx.clone()))
})
}
```
- [ ] **Step 7: factory↔schema params agreement test (one per node)**
Add to each node's `#[cfg(test)] mod tests` a test asserting `factory().params()`
equals the built node's `schema().params`. Example for `sma.rs`:
```rust
#[test]
fn factory_params_match_built_node_schema() {
let f = Sma::factory();
let built = f.build(&[Scalar::I64(3)]);
assert_eq!(f.params(), built.schema().params.as_slice());
}
```
Mirror it per node with a valid sample slice: `Exposure` `&[Scalar::F64(0.5)]`;
`LinComb::factory(2)` `&[Scalar::F64(1.0), Scalar::F64(-1.0)]`; `Sub`/`Add` `&[]`;
`SimBroker::factory(0.0001)` `&[]`; `Recorder::factory(vec![ScalarKind::F64],
Firing::Any, tx)` `&[]` (make a throwaway `mpsc::channel()` for `tx`).
- [ ] **Step 8: Gate**
Run: `cargo test -p aura-std`
Expected: PASS, including the 7 `factory_params_match_built_node_schema` tests.
---
## Task 3: aura-engine — value-empty leaf, build-then-wire compile, errors
**Files:** Modify `crates/aura-engine/src/blueprint.rs`.
- [ ] **Step 1: `BlueprintNode::Leaf` + the lift**
Change the enum (`blueprint.rs:27-30`) and replace the generic `From<N>`
(`blueprint.rs:33-37`):
```rust
pub enum BlueprintNode {
Leaf(LeafFactory),
Composite(Composite),
}
impl From<LeafFactory> for BlueprintNode {
fn from(factory: LeafFactory) -> Self {
BlueprintNode::Leaf(factory)
}
}
```
Add `LeafFactory` and `Scalar` to the `use aura_core::{...}` at `blueprint.rs:14`.
- [ ] **Step 2: Remove the vestigial pre-build `schema` methods**
Delete the `impl BlueprintNode { fn schema(&self) -> NodeSchema {...} }` block
(`blueprint.rs:39-48`) and `Composite::schema` (`blueprint.rs:103-114`). Both have
no live caller — `compile` resolves every interface on the built flat nodes. Delete
the unit test `composite_schema_derives_role_and_output_kinds` (`blueprint.rs:435`).
- [ ] **Step 3: `collect_params` reads `factory.params()`**
In `collect_params` (`blueprint.rs:217-240`), the `Leaf` arm (`220-229`):
```rust
BlueprintNode::Leaf(factory) => {
for p in factory.params() {
let name = if prefix.is_empty() {
p.name.clone()
} else {
format!("{prefix}.{}", p.name)
};
out.push(ParamSpec { name, kind: p.kind });
}
}
```
(`param_space` at `blueprint.rs:166-170` is otherwise unchanged.)
- [ ] **Step 4: Two new `CompileError` variants**
In `enum CompileError` (`blueprint.rs:120-130`) add:
```rust
/// An injected param value's scalar kind does not match the slot's declared
/// kind. `slot` is the flat param-space index.
ParamKindMismatch { slot: usize, expected: ScalarKind, got: ScalarKind },
/// The injected vector's length does not equal the sum of declared params.
ParamArity { expected: usize, got: usize },
```
`ScalarKind` is already imported (`blueprint.rs:14`).
- [ ] **Step 5: Thread params+cursor through `lower_items` / `inline_composite`**
`lower_items` (`blueprint.rs:255-274`) gains `params: &[Scalar]` and `cursor: &mut
usize`, and its `Leaf` arm builds (kind-checking) instead of moving a node:
```rust
fn lower_items(
items: Vec<BlueprintNode>,
params: &[Scalar],
cursor: &mut usize,
flat_nodes: &mut Vec<Box<dyn Node>>,
flat_edges: &mut Vec<Edge>,
) -> Result<Vec<ItemLowering>, CompileError> {
let mut lowerings = Vec::with_capacity(items.len());
for item in items {
match item {
BlueprintNode::Leaf(factory) => {
let n = factory.params().len();
let slice = &params[*cursor..*cursor + n]; // in range: arity checked up front
for (i, spec) in factory.params().iter().enumerate() {
let got = slice[i].kind();
if got != spec.kind {
return Err(CompileError::ParamKindMismatch {
slot: *cursor + i,
expected: spec.kind,
got,
});
}
}
let index = flat_nodes.len();
flat_nodes.push(factory.build(slice));
*cursor += n;
lowerings.push(ItemLowering::Leaf { index });
}
BlueprintNode::Composite(c) => {
lowerings.push(inline_composite(c, params, cursor, flat_nodes, flat_edges)?);
}
}
}
Ok(lowerings)
}
```
`inline_composite` (`blueprint.rs:278-339`) gains the same `params: &[Scalar]` +
`cursor: &mut usize` params and forwards them on its recursive `lower_items` call
(`blueprint.rs:295`): `let interior = lower_items(nodes, params, cursor,
flat_nodes, flat_edges)?;`. Its signature line becomes:
```rust
fn inline_composite(
c: Composite,
params: &[Scalar],
cursor: &mut usize,
flat_nodes: &mut Vec<Box<dyn Node>>,
flat_edges: &mut Vec<Edge>,
) -> Result<ItemLowering, CompileError> {
```
- [ ] **Step 6: `compile_with_params` + `bootstrap_with_params` + thin no-param wrappers**
Replace `compile` (`blueprint.rs:179-204`) and `bootstrap` (`207-210`) with the
param-driven path plus no-param wrappers. The arity is checked up front via
`param_space().len()` so the per-leaf slices never overrun (only kind can fail):
```rust
/// Compile the value-empty recipe under an injected param vector: build each
/// leaf from its kind-checked slice while lowering, then rewrite edges/sources
/// exactly as before (structure is param-invariant, C19/C23). The vector is
/// total and positional — one value per `param_space()` slot, in slot order.
#[allow(clippy::type_complexity)]
pub fn compile_with_params(
self,
params: &[Scalar],
) -> Result<(Vec<Box<dyn Node>>, Vec<SourceSpec>, Vec<Edge>), CompileError> {
let expected = self.param_space().len();
if params.len() != expected {
return Err(CompileError::ParamArity { expected, got: params.len() });
}
let mut flat_nodes: Vec<Box<dyn Node>> = Vec::new();
let mut flat_edges: Vec<Edge> = Vec::new();
let mut cursor = 0usize;
let lowerings = lower_items(self.nodes, params, &mut cursor, &mut flat_nodes, &mut flat_edges)?;
for e in &self.edges {
for fe in rewrite_edge(e, &lowerings, &flat_nodes)? {
flat_edges.push(fe);
}
}
let mut flat_sources: Vec<SourceSpec> = Vec::with_capacity(self.sources.len());
for src in &self.sources {
let mut targets: Vec<Target> = Vec::new();
for t in &src.targets {
targets.extend(resolve_target(t, &lowerings)?);
}
flat_sources.push(SourceSpec { kind: src.kind, targets });
}
Ok((flat_nodes, flat_sources, flat_edges))
}
/// No-param compile (a blueprint that declares no params); errors `ParamArity`
/// if any param is declared.
#[allow(clippy::type_complexity)]
pub fn compile(self) -> Result<(Vec<Box<dyn Node>>, Vec<SourceSpec>, Vec<Edge>), CompileError> {
self.compile_with_params(&[])
}
/// Compile under an injected vector, then hand the flat graph to the
/// unchanged `Harness::bootstrap`.
pub fn bootstrap_with_params(self, params: Vec<Scalar>) -> Result<Harness, CompileError> {
let (nodes, sources, edges) = self.compile_with_params(&params)?;
Harness::bootstrap(nodes, sources, edges).map_err(CompileError::Bootstrap)
}
/// No-param bootstrap (paramless blueprint).
pub fn bootstrap(self) -> Result<Harness, CompileError> {
self.bootstrap_with_params(vec![])
}
```
(Keep the existing `#[allow(clippy::type_complexity)]` + comment that sat above
`compile`.)
- [ ] **Step 7: Re-express the fixtures (mechanical)**
Every blueprint-leaf author site in the test module changes by the rules below; the
hand-wired `hand_wired_sma_cross_harness` (which builds nodes directly into
`Harness::bootstrap`, not via `BlueprintNode`) is **unchanged**.
- `Sma::new(k).into()``Sma::factory().into()`; the `k` moves into the caller's
bootstrap/compile vector (`Scalar::I64(k)`).
- `Exposure::new(s).into()``Exposure::factory().into()`; `s``Scalar::F64(s)`.
- `SimBroker::new(p).into()``SimBroker::factory(p).into()` (pip captured, no
vector slot).
- `Recorder::new(&[K..], f, tx).into()``Recorder::factory(vec![K..], f, tx).into()`.
- `LinComb::new(w).into()``LinComb::factory(w.len()).into()`; the weights →
`w.iter().map(|x| Scalar::F64(*x))` in the caller's vector.
- `sma_cross(fast, slow)` builder (`blueprint.rs:721-732`) → `sma_cross()` taking no
args, building two `Sma::factory()` leaves; callers move `fast`/`slow` into their
vector.
- A test-local node `Leaf(Box::new(X))` (sites `440`, `535`, `576`, `618`, `648`)
→ an inline factory: `BlueprintNode::Leaf(LeafFactory::new("X", vec![], |_|
Box::new(X::new())))` (or `X::new().into()` if that test node is given a
`factory()`; inline is simpler for one-off test nodes).
- `bp.bootstrap()``bp.bootstrap_with_params(vec![..])` with the vector matching
the leaves' declared params in `param_space()` order; `bp.compile()`
`bp.compile_with_params(&[..])`. For a paramless fixture, `bootstrap_with_params(
vec![])` / `compile_with_params(&[])` (or the thin `bootstrap()`/`compile()`).
`composite_sma_cross_harness` (`blueprint.rs:736-766`) becomes value-empty:
`Composite(sma_cross())`, `Exposure::factory().into()`, `SimBroker::factory(0.0001)
.into()`, `Recorder::factory(vec![ScalarKind::F64], Firing::Any, tx_eq).into()`,
etc. Its declared `param_space()` is `[length:I64, length:I64, scale:F64]`, so its
point vector is `vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)]`.
- [ ] **Step 8: Re-express the load-bearing tests**
- `composite_sma_cross_runs_bit_identical_to_hand_wired` (`blueprint.rs:768-792`):
build the composite via `bp.bootstrap_with_params(vec![Scalar::I64(2),
Scalar::I64(4), Scalar::F64(0.5)])`; the hand-wired side stays `Sma::new(2)`,
`Sma::new(4)`, `Exposure::new(0.5)`. Assertions unchanged (traces bit-identical).
- `param_space_mirrors_compiled_flat_node_param_order` (`802-834`) and
`..._under_nesting` (`886-936`): call `bp.compile_with_params(&[..])` with the
matching vector instead of `bp.compile()`; the `flat_nodes.iter().flat_map(|n|
n.schema().params)` projection and the kind-by-slot assertions are unchanged
(built nodes still carry `schema().params`). For the single-level case the vector
is `[Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)]`; for the nested
`strategy → { fast_slow → [Sma, Sma, Sub], LinComb }` case it is `[Scalar::I64(2),
Scalar::I64(4), Scalar::F64(1.0), Scalar::F64(-1.0)]`.
- `param_space_is_flat_path_qualified_and_slot_disambiguated` (`837`),
`top_level_leaf_params_are_unqualified` (`876`), `param_space_is_deterministic`
(`885`), `param_space_empty_for_paramless_and_empty_blueprints` (`896`): rebuild
their blueprints with factory leaves; the `param_space()` assertions are
unchanged.
- [ ] **Step 9: New injection tests**
Add to the test module:
```rust
#[test]
fn injecting_a_different_vector_changes_the_run() {
let prices = synthetic_prices();
let (bp, eq, _ex) = composite_sma_cross_harness();
let mut a = bp.bootstrap_with_params(vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)])
.expect("compiles");
a.run(vec![prices.clone()]);
let a_eq = eq.try_iter().collect::<Vec<_>>();
let (bp2, eq2, _ex2) = composite_sma_cross_harness();
let mut b = bp2.bootstrap_with_params(vec![Scalar::I64(5), Scalar::I64(20), Scalar::F64(1.0)])
.expect("compiles");
b.run(vec![prices]);
let b_eq = eq2.try_iter().collect::<Vec<_>>();
assert!(!a_eq.is_empty() && !b_eq.is_empty(), "both traces populated");
assert_ne!(a_eq, b_eq, "a different vector must yield a different run");
}
#[test]
fn wrong_kind_is_a_param_kind_mismatch() {
let (bp, _eq, _ex) = composite_sma_cross_harness();
// slot 0 is I64 (an SMA length); inject F64 there
let err = bp.bootstrap_with_params(vec![Scalar::F64(2.0), Scalar::I64(4), Scalar::F64(0.5)])
.unwrap_err();
assert!(matches!(err, CompileError::ParamKindMismatch { slot: 0, .. }));
}
#[test]
fn wrong_arity_is_a_param_arity_error() {
let (short, _e1, _x1) = composite_sma_cross_harness();
assert!(matches!(
short.bootstrap_with_params(vec![Scalar::I64(2)]).unwrap_err(),
CompileError::ParamArity { expected: 3, got: 1 }
));
let (long, _e2, _x2) = composite_sma_cross_harness();
assert!(matches!(
long.bootstrap_with_params(
vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5), Scalar::F64(0.0)]
).unwrap_err(),
CompileError::ParamArity { expected: 3, got: 4 }
));
}
#[test]
fn same_vector_bootstraps_identically() {
let prices = synthetic_prices();
let (bp, eq, _ex) = composite_sma_cross_harness();
let mut a = bp.bootstrap_with_params(vec![Scalar::I64(3), Scalar::I64(9), Scalar::F64(0.7)])
.expect("compiles");
a.run(vec![prices.clone()]);
let (bp2, eq2, _ex2) = composite_sma_cross_harness();
let mut b = bp2.bootstrap_with_params(vec![Scalar::I64(3), Scalar::I64(9), Scalar::F64(0.7)])
.expect("compiles");
b.run(vec![prices]);
assert_eq!(eq.try_iter().collect::<Vec<_>>(), eq2.try_iter().collect::<Vec<_>>());
}
```
(If `composite_sma_cross_harness` returns a fresh blueprint+receivers per call,
each test calls it anew as shown; keep its existing return signature.)
- [ ] **Step 10: Gate**
Run: `cargo build -p aura-engine --all-targets`
Expected: 0 errors (every fixture site repaired).
Run: `cargo test -p aura-engine`
Expected: PASS — bit-identity, both mirror tests, the four new injection tests, and
all `param_space*` tests green.
---
## Task 4: aura-cli — param-generic render + sample + goldens
**Files:** Modify `crates/aura-cli/src/graph.rs`, `crates/aura-cli/src/main.rs`.
- [ ] **Step 1: `render_blueprint` reads `LeafFactory::label()` (`graph.rs`)**
The two leaf arms (`graph.rs:60-63` top-level, `:69-72` composite-interior) push
`node.label()`. The leaf is now a `LeafFactory`; push `factory.label()`:
```rust
BlueprintNode::Leaf(factory) => {
let id = labels.len();
labels.push(factory.label());
item_display.push(ItemDisplay::Leaf(id));
}
```
and inside the composite loop:
```rust
BlueprintNode::Leaf(factory) => {
let id = labels.len();
labels.push(factory.label());
interior_ids.push(id);
}
```
(`render_flat_graph` / the compiled-view renderer operates on built flat nodes via
`Node::label()` and is unchanged.)
- [ ] **Step 2: Sample blueprint → factories (`main.rs`)**
- `sma_cross(name, fast, slow)` (`main.rs:120-131`): build two `Sma::factory()`
leaves; drop `fast`/`slow` from the builder (they move to the injected vector).
Keep `name` for the composite.
- `build_sample(fast, slow)` (`main.rs:136-161`): the four `.into()` lifts become
`Exposure::factory().into()`, `SimBroker::factory(0.0001).into()`,
`Recorder::factory(...).into()` per their constructors; the composite is
`BlueprintNode::Composite(sma_cross(name))`. `build_sample` no longer bakes
`fast`/`slow`.
- `sample_blueprint` (`main.rs:164-166`) and the `bp.compile()` sites
(`main.rs:180,221,272`): supply the point vector. The sample's `param_space()` is
`[length:I64, length:I64, scale:F64]`, so its vector is `vec![Scalar::I64(2),
Scalar::I64(4), Scalar::F64(0.5)]`. Use `compile_with_params(&[..])` /
`bootstrap_with_params(vec![..])` at those sites.
- [ ] **Step 3: Move the swap to the compiled view (`main.rs`)**
`sample_blueprint_swapped` (`main.rs:201-203`) + `swapped_sma_inputs_render_differently`
(`main.rs:230`): the blueprint view is now param-generic and identical for both
orderings, so the swap is not observable there. Re-express the swap as a different
injected vector (`vec![Scalar::I64(4), Scalar::I64(2), Scalar::F64(0.5)]`) and
assert the **compiled** view differs (`render_flat_graph` of the compiled flat nodes
shows `SMA(4)`/`SMA(2)` swapped), not the blueprint view. Rename the test to its
new premise (e.g. `swapped_param_vector_changes_the_compiled_render`).
- [ ] **Step 4: Re-capture the blueprint-view goldens (`main.rs`)**
`blueprint_view_shows_cluster_and_param_labels` (`main.rs:206`), `blueprint_view_golden`
(`:238`): the blueprint-view labels become param-generic — `SMA(length)` (both
SMAs identical), `Exposure(scale)`, `SimBroker`. Update the pinned ASCII strings to
the param-generic form. The compiled-view goldens `compiled_view_dissolves_the_composite_boundary`
(`:219`) and `compiled_view_golden` (`:270`) stay valued (`SMA(2)`, `SMA(4)`,
`Exposure(0.5)`, `SimBroker(0.0001)`) — do not change them.
To get the exact new blueprint-view bytes, run the rendering in a scratch
assertion or `cargo run -- graph` (blueprint view) after Steps 1-2 compile, and
paste the produced ASCII verbatim into the golden. Do not hand-guess box-drawing
columns.
- [ ] **Step 5: Gate**
Run: `cargo build -p aura-cli --all-targets`
Expected: 0 errors.
Run: `cargo test --workspace`
Expected: PASS — all crates green, including the re-captured goldens and the
re-premised swap test.
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: clean.
@@ -1,384 +0,0 @@
# Blueprint view as main graph + composite definitions — Implementation Plan
> **Parent spec:** `docs/specs/0017-blueprint-view-definitions.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Rewrite the `aura graph` blueprint view (`render_blueprint`) to a main
graph (composites as opaque nodes) + a `where:` definitions section, on a flat
ascii-dag layout only, and re-point its tests.
**Architecture:** `render_blueprint` builds a flat main graph (one node per
top-level item — a composite labelled by `name()`, opaque) plus a definitions
block where each distinct composite type (deduped by `name()`, collected
recursively) renders its interior once with `[in:k]`/`[out]` port markers. The
old `ItemDisplay`/`producer_id`/`consumer_ids`/subgraph machinery and the
nested-composite `unimplemented!` are removed. `render_flat_graph` is untouched.
**Tech Stack:** `crates/aura-cli/src/graph.rs` (render), `crates/aura-cli/src/main.rs`
(tests), `ascii-dag` flat `RenderMode::Vertical`, the `aura_engine`
Blueprint/Composite API.
---
**Files this plan creates or modifies:**
- Modify: `crates/aura-cli/src/graph.rs:1-132` — module doc-comment (1-10), drop
`ItemDisplay`/`producer_id`/`consumer_ids` (16-46), rewrite `render_blueprint`
(48-132) incl. removing the `unimplemented!` (74-77); add `render_flat`,
`collect_distinct_composites`, `render_definition`; fix imports (12-14).
- Modify: `crates/aura-cli/src/main.rs:184` — stale "clustered blueprint view"
comment in the `graph` arm.
- Test: `crates/aura-cli/src/main.rs:215-227,256-286` — replace the two old
blueprint tests; add `blueprint_view_main_graph_shows_composite_as_opaque_node`,
`blueprint_view_defines_each_composite_once`,
`nested_composite_renders_without_panic`, `reused_composite_defined_once`, and a
recaptured `blueprint_view_golden`.
---
### Task 1: Rewrite `render_blueprint` + behavioural tests (RED → GREEN)
**Files:**
- Modify: `crates/aura-cli/src/graph.rs:1-132`
- Test: `crates/aura-cli/src/main.rs:215-227,256-286` (+ new tests)
- [ ] **Step 1: Replace the two old blueprint tests with the four behavioural tests**
In `crates/aura-cli/src/main.rs`, DELETE the test
`blueprint_view_shows_cluster_and_param_generic_labels` (currently lines 215-227)
and the test `blueprint_view_golden` (currently lines 256-286). In their place put
the four behavioural tests below (the recaptured golden is added in Task 2). Insert
them in the `#[cfg(test)] mod tests` block (e.g. where the old blueprint tests were):
```rust
#[test]
fn blueprint_view_main_graph_shows_composite_as_opaque_node() {
let out = graph::render_blueprint(&sample_blueprint());
// the composite is a single opaque main-graph node, not an expanded cluster
assert!(out.contains("[sma_cross]"), "missing opaque composite node:\n{out}");
// top-level leaves render as their bare-type nodes
for needle in ["[Exposure]", "[SimBroker]", "[Recorder]"] {
assert!(out.contains(needle), "missing {needle}:\n{out}");
}
// a definitions section is present
assert!(out.contains("where:"), "missing where: section:\n{out}");
// the flat layout draws no subgraph cluster box
assert!(!out.contains('╔'), "blueprint view must not draw a cluster box:\n{out}");
}
#[test]
fn blueprint_view_defines_each_composite_once() {
let out = graph::render_blueprint(&sample_blueprint());
// the sma_cross body is defined exactly once, with its interior + ports
assert_eq!(out.matches("sma_cross:").count(), 1, "definition not rendered once:\n{out}");
for needle in ["[SMA]", "[Sub]", "[in:0]", "[out]"] {
assert!(out.contains(needle), "missing {needle} in definition:\n{out}");
}
}
#[test]
fn nested_composite_renders_without_panic() {
// a composite whose interior contains another composite — render reads
// structure only (no compile/validate), so a minimal fixture suffices.
let inner = Composite::new(
"inner",
vec![Sma::factory().into()],
vec![],
vec![vec![Target { node: 0, slot: 0 }]],
OutPort { node: 0, field: 0 },
);
let outer = Composite::new(
"outer",
vec![BlueprintNode::Composite(inner), Sub::factory().into()],
vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
vec![vec![Target { node: 0, slot: 0 }]],
OutPort { node: 1, field: 0 },
);
let bp = Blueprint::new(
vec![BlueprintNode::Composite(outer)],
vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }],
vec![],
);
let out = graph::render_blueprint(&bp); // must not panic (no unimplemented!)
// outer shows the inner composite as an opaque node, and both get a definition
assert!(out.contains("[outer]"), "missing opaque outer node:\n{out}");
assert!(out.contains("[inner]"), "inner must be opaque inside outer's definition:\n{out}");
assert_eq!(out.matches("outer:").count(), 1, "outer defined once:\n{out}");
assert_eq!(out.matches("inner:").count(), 1, "inner defined once:\n{out}");
}
#[test]
fn reused_composite_defined_once() {
// the same composite type used twice: two opaque nodes, one definition.
let bp = Blueprint::new(
vec![
BlueprintNode::Composite(sma_cross("dup")),
BlueprintNode::Composite(sma_cross("dup")),
Exposure::factory().into(),
],
vec![SourceSpec {
kind: ScalarKind::F64,
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
}],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 0, from_field: 0 },
],
);
let out = graph::render_blueprint(&bp);
assert_eq!(out.matches("[dup]").count(), 2, "two opaque uses expected:\n{out}");
assert_eq!(out.matches("dup:").count(), 1, "body defined once:\n{out}");
}
```
These tests need `Composite`, `OutPort`, `BlueprintNode`, `Blueprint`, `Edge`,
`SourceSpec`, `Target`, `ScalarKind`, `Sma`, `Sub`, `Exposure` in scope. They are
already imported at the top of `main.rs` (lines 11-16: `aura_core::{... ScalarKind ...}`,
`aura_engine::{... Blueprint, BlueprintNode, Composite, Edge, ... OutPort, ...
SourceSpec, Target}`, `aura_std::{Exposure, Recorder, SimBroker, Sma, Sub}`). No
new imports needed in `main.rs`.
- [ ] **Step 2: Run the new tests to verify they fail (RED)**
Run: `cargo test -p aura-cli`
Expected: FAIL — the four new tests fail (the opaque-node / `where:` / definition
assertions fail against the current cluster render, and
`nested_composite_renders_without_panic` panics at the
`unimplemented!("...nested-composite cluster rendering...")`). The four
must-stay-green tests still pass; the build itself is clean (this is a behavioural
RED, not a compile error).
- [ ] **Step 3: Rewrite `graph.rs` — imports + module doc**
In `crates/aura-cli/src/graph.rs`, replace the module doc-comment (lines 1-10) and
imports (lines 12-14) with:
```rust
//! The `aura graph` ASCII-DAG adapter (#13, #38): turns the engine's
//! graph-as-data (C9) into an `ascii_dag::Graph` rendered to a `String`. Two
//! views. `render_blueprint` shows the authored structure — a flat main graph
//! wiring the harness with each composite as a single opaque node, plus a
//! `where:` section that defines each distinct composite type once (its interior
//! with `[in:k]`/`[out]` port markers). `render_flat_graph` shows the flat
//! post-inline graph (boundaries dissolved, C23). Rendering reads structure +
//! node `label()`s only — never `eval`.
//!
//! ascii-dag borrows its node labels as `&'a str`, so each function first
//! materializes the owned label `String`s (which outlive the `Graph`), then
//! borrows into them. `RenderMode::Vertical` is mandatory: Horizontal collapses a
//! fan-out onto one path. Both views build flat graphs (no subgraphs): the
//! subgraph layout mis-centres wide sibling labels, the flat layout does not.
use ascii_dag::graph::{Graph, RenderMode};
use aura_core::Node;
use aura_engine::{Blueprint, BlueprintNode, Composite, Edge, SourceSpec};
```
(`Composite` is added — named by the new helpers; `Target` is dropped — no longer
named after the `ItemDisplay` collapse. `Edge`/`SourceSpec`/`Node` remain: they are
`render_flat_graph`'s parameter types.)
- [ ] **Step 4: Rewrite `graph.rs` — replace `ItemDisplay`/`producer_id`/`consumer_ids`/`render_blueprint` (old lines 16-132)**
Delete the `ItemDisplay` enum, `producer_id`, `consumer_ids`, and the old
`render_blueprint` body (old lines 16-132) and replace with:
```rust
/// Blueprint view: the authored structure (#38). A flat main graph wires the
/// harness with each composite shown as a single opaque node; a `where:` section
/// defines each distinct composite type once. Flat layout only (no subgraphs).
pub fn render_blueprint(bp: &Blueprint) -> String {
// pass 1: one main-graph node per top-level item (leaf -> bare-type label;
// composite -> its name, opaque) and one node per source.
let mut labels: Vec<String> = Vec::new();
let mut item_ids: Vec<usize> = Vec::with_capacity(bp.nodes().len());
for item in bp.nodes() {
let id = labels.len();
labels.push(match item {
BlueprintNode::Leaf(factory) => factory.label(),
BlueprintNode::Composite(c) => c.name().to_string(),
});
item_ids.push(id);
}
let mut source_ids: Vec<usize> = Vec::with_capacity(bp.sources().len());
for src in bp.sources() {
let id = labels.len();
labels.push(format!("source:{:?}", src.kind));
source_ids.push(id);
}
// pass 2: edges — every endpoint is a single opaque node, so the slot that
// mattered for cluster fan-in is irrelevant here.
let mut edges: Vec<(usize, usize)> = Vec::new();
for e in bp.edges() {
edges.push((item_ids[e.from], item_ids[e.to]));
}
for (src, &sid) in bp.sources().iter().zip(&source_ids) {
for t in &src.targets {
edges.push((sid, item_ids[t.node]));
}
}
let main = render_flat(&labels, &edges);
// definitions: each distinct composite type, once, recursively.
let defs = collect_distinct_composites(bp);
if defs.is_empty() {
return main;
}
let body = defs.iter().map(|c| render_definition(c)).collect::<Vec<_>>().join("\n");
format!("{main}\nwhere:\n\n{body}")
}
/// Build and render a flat (no-subgraph) ascii-dag graph from owned labels and
/// edge pairs — the same idiom `render_flat_graph` uses.
fn render_flat(labels: &[String], edges: &[(usize, usize)]) -> String {
let mut g = Graph::with_mode(RenderMode::Vertical);
for (id, l) in labels.iter().enumerate() {
g.add_node(id, l);
}
for &(from, to) in edges {
g.add_edge(from, to, None);
}
g.render()
}
/// Every distinct composite type in the blueprint, in first-seen order, keyed by
/// `name()` (the authoring type identity — same name implies same structure).
/// Recurses into a composite's interior on first sight so nested composites get
/// their own definition; a later same-name occurrence is skipped (deduped).
fn collect_distinct_composites(bp: &Blueprint) -> Vec<&Composite> {
fn walk<'a>(items: &'a [BlueprintNode], seen: &mut Vec<&'a str>, out: &mut Vec<&'a Composite>) {
for item in items {
if let BlueprintNode::Composite(c) = item {
if !seen.contains(&c.name()) {
seen.push(c.name());
out.push(c);
walk(c.nodes(), seen, out);
}
}
}
}
let mut seen: Vec<&str> = Vec::new();
let mut out: Vec<&Composite> = Vec::new();
walk(bp.nodes(), &mut seen, &mut out);
out
}
/// Render one composite's interior as a flat graph: interior leaves as `[type]`,
/// nested composites as opaque `[name]`, plus an `[in:k]` entry marker per input
/// role (wired to its interior targets) and an `[out]` marker (wired from the
/// output port). Prefixed `"<name>:\n"`.
fn render_definition(c: &Composite) -> String {
let mut labels: Vec<String> = Vec::with_capacity(c.nodes().len());
for inner in c.nodes() {
labels.push(match inner {
BlueprintNode::Leaf(factory) => factory.label(),
BlueprintNode::Composite(inner_c) => inner_c.name().to_string(),
});
}
let mut edges: Vec<(usize, usize)> = Vec::new();
for e in c.edges() {
edges.push((e.from, e.to));
}
for (role, targets) in c.input_roles().iter().enumerate() {
let in_id = labels.len();
labels.push(format!("in:{role}"));
for t in targets {
edges.push((in_id, t.node));
}
}
let out_id = labels.len();
labels.push("out".to_string());
edges.push((c.output().node, out_id));
format!("{}:\n{}", c.name(), render_flat(&labels, &edges))
}
```
- [ ] **Step 5: Build (compile gate — `render_blueprint`'s signature is unchanged, so `main()` still compiles)**
Run: `cargo build -p aura-cli --all-targets`
Expected: compiles, 0 errors. (If clippy flags an unused `Target` import, it was
not dropped in Step 3 — drop it.)
- [ ] **Step 6: Run the package suite to verify the new tests pass (GREEN)**
Run: `cargo test -p aura-cli`
Expected: PASS — the four new tests now pass, and the four must-stay-green tests
still pass. (`blueprint_view_golden` is not present yet; it is added in Task 2.)
---
### Task 2: Recapture the blueprint golden + full-suite gate
**Files:**
- Test: `crates/aura-cli/src/main.rs` (re-add `blueprint_view_golden`); comment at
`main.rs:184`.
- [ ] **Step 1: Capture the exact rendered bytes**
Run: `cargo run -q -p aura-cli -- graph`
Expected: the new flat main-graph + `where: sma_cross: ...` render prints to
stdout (deterministic Sugiyama layout, no RNG). Copy the **verbatim** stdout
(including the trailing blank lines `render()` emits) for the golden below.
- [ ] **Step 2: Add the recaptured `blueprint_view_golden` test**
In `crates/aura-cli/src/main.rs`, in the `#[cfg(test)] mod tests` block, add:
```rust
#[test]
fn blueprint_view_golden() {
let out = graph::render_blueprint(&sample_blueprint());
// ascii-dag's Sugiyama layout is deterministic (no RNG); these are the
// exact bytes `aura graph` emits — main graph (composites opaque) + the
// `where:` definitions section. Re-capture via `aura graph` if intended.
let expected = r#"<PASTE VERBATIM STDOUT FROM STEP 1 HERE>"#;
assert_eq!(out, expected, "blueprint render drifted; re-capture if intended");
}
```
Replace `<PASTE VERBATIM STDOUT FROM STEP 1 HERE>` with the exact bytes captured in
Step 1 (raw string `r#"..."#`; preserve every space, box glyph, arrow, and the
trailing newlines). Do not hand-edit the captured bytes.
- [ ] **Step 3: Fix the stale comment in the `graph` arm**
In `crates/aura-cli/src/main.rs`, in `main()`'s `Some("graph")` arm (around line
184), update the comment that calls the default view the "clustered blueprint
view":
Replace:
```rust
// `--compiled` selects the flat post-inline view; default is the
// clustered blueprint view. Strictness beyond this stays minimal (#16).
```
with:
```rust
// `--compiled` selects the flat post-inline view; default is the
// blueprint view (main graph + composite definitions). Strictness
// beyond this stays minimal (#16).
```
- [ ] **Step 4: Run the package suite to verify the golden passes**
Run: `cargo test -p aura-cli`
Expected: PASS — all aura-cli tests including the new `blueprint_view_golden`. If
the golden fails, the pasted bytes do not match — re-capture via Step 1 and
re-paste; do not hand-edit.
- [ ] **Step 5: Full workspace gate — tests + lint**
Run: `cargo test --workspace`
Expected: PASS, 0 failed. In particular the four must-stay-green tests
(`compiled_view_dissolves_the_composite_boundary`, `compiled_view_golden`,
`swapped_param_vector_changes_the_compiled_render`,
`run_sample_is_deterministic_and_non_trivial`) remain green — `render_flat_graph` is
untouched, so `compiled_view_golden` is byte-identical.
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: 0 warnings (no unused `Target` import, no dead `ItemDisplay`/`producer_id`/
`consumer_ids`).
@@ -1,623 +0,0 @@
# Composite multi-output record — Implementation Plan
> **Parent spec:** `docs/specs/0018-composite-multi-output-record.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Make a composite's output a named, ordered, multi-field record
(`output: Vec<OutField { node, field, name }>`) so multi-line indicators
(MACD/Bollinger/Stochastic/Ichimoku) can be authored as a composition and
re-exported as a unit, selected downstream by `Edge::from_field`.
**Architecture:** The substrate is already multi-field everywhere except the
composite boundary (`NodeSchema.output: Vec<FieldSpec>`, `Edge::from_field`
selects a column, leaf multi-output already works). This cycle replaces the
single `OutPort` with a `Vec<OutField>` and lifts the three `field == 0` caps at
the boundary (`inline_composite` nested arm, `rewrite_edge` composite arm). Names
live at the blueprint boundary only and are dropped in the flat graph (C23); C8/C7/C4
are untouched — one port, one row, K columns.
**Tech Stack:** `crates/aura-engine/src/blueprint.rs` (type + compile logic),
`crates/aura-engine/src/lib.rs` (re-export), `crates/aura-cli/src/graph.rs`
(render), `crates/aura-cli/src/main.rs` (author sites + render tests),
`fieldtests/milestone-construction-layer/*.rs` (non-workspace stale-ref sweep).
**Files this plan creates or modifies:**
- Modify: `crates/aura-engine/src/blueprint.rs``OutPort``OutField`, `Composite`
struct/`new`/`output()`, `ItemLowering::Composite.output`, `inline_composite`,
`rewrite_edge`, all `#[cfg(test)]` `OutPort` literals; new capability tests.
- Modify: `crates/aura-engine/src/lib.rs:38` — re-export `OutPort``OutField`.
- Modify: `crates/aura-cli/src/graph.rs:105129``render_definition` K `[out:<name>]` markers.
- Modify: `crates/aura-cli/src/main.rs` — import `:13`, `sma_cross` `:130`, `macd` `:206`,
nested-render-test literals `:391`/`:398`, needle-list test `:377`, `blueprint_view_golden`.
- Modify: `fieldtests/milestone-construction-layer/mc_1..mc_4*.rs``OutPort``OutField` (8 sites, non-workspace).
**Naming decisions (orchestrator, fixed for this plan):**
- `sma_cross` single-field output name → `"cross"` (renders as `[out:cross]` in the re-captured golden).
- Engine test-fixture single-field outputs → `"out"`. New multi-output test fields → `"a"`, `"b"` (and `"c"` where a third is needed).
- Fieldtest single-field outputs → `"out"`.
---
## Task 1: Engine — introduce `OutField`, widen the type (behaviour-preserving)
This task replaces `OutPort` with `OutField` and widens `Composite.output` /
`ItemLowering::Composite.output` to vectors, threading **every** call site in
`blueprint.rs` so the crate compiles and **all existing tests stay green**. The
three `field == 0` caps are **kept** here (single-field composites only re-export
field 0, so behaviour is preserved); Task 2 lifts them test-first.
**Files:**
- Modify: `crates/aura-engine/src/blueprint.rs`
- Modify: `crates/aura-engine/src/lib.rs:38`
- [ ] **Step 1: Rename `OutPort` → `OutField` and add `name` (blueprint.rs:1823)**
Replace:
```rust
/// Which interior `(node, output-field)` is a composite's single output port (C8).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct OutPort {
pub node: usize,
pub field: usize,
}
```
with:
```rust
/// One re-exported field of a composite's output record: an interior
/// `(node, output-field)` surfaced at the boundary under `name`. `name` is a
/// non-load-bearing render/debug symbol (C23) — like `FieldSpec.name` and
/// `Composite.name`, it does not reach the flat graph.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OutField {
pub node: usize,
pub field: usize,
pub name: String,
}
```
(`Copy` is dropped — `String` is not `Copy`. Callers that relied on `output()`
returning by Copy are updated in Step 3 and Step 6.)
- [ ] **Step 2: Widen `Composite.output` to a record (blueprint.rs:4349)**
In `struct Composite`, change the field:
```rust
input_roles: Vec<Vec<Target>>,
output: Vec<OutField>,
```
(was `output: OutPort,` at `:48`.)
- [ ] **Step 3: Update `Composite::new` and `output()` (blueprint.rs:5685)**
In `Composite::new`, change the param type:
```rust
input_roles: Vec<Vec<Target>>,
output: Vec<OutField>,
) -> Self {
Self { name: name.into(), nodes, edges, input_roles, output }
}
```
(was `output: OutPort,` at `:61`.) Change the accessor (`:8385`):
```rust
/// The exposed output record: each entry re-exports one interior
/// `(node, output-field)` under a boundary name (C8 — one port, K columns).
pub fn output(&self) -> &[OutField] {
&self.output
}
```
(was `pub fn output(&self) -> OutPort { self.output }`.)
- [ ] **Step 4: Widen the lowering variant (blueprint.rs:243249)**
In `enum ItemLowering`, change the `Composite` variant:
```rust
/// A composite lowered to its interior: its output record is these flat
/// `(node, field)` producers (one per re-exported field, declared order), and
/// input role `r` fans into `roles[r]` (flat targets). Names dropped (C23).
Composite { output: Vec<(usize, usize)>, roles: Vec<Vec<Target>> },
```
(was `output: (usize, usize)`.)
- [ ] **Step 5: Make `inline_composite` build the output record, caps kept (blueprint.rs:292355)**
Remove the single pre-check at `:306308`:
```rust
if output.node >= item_count {
return Err(CompileError::OutputPortOutOfRange);
}
```
Replace the single-output resolution block (`:318333`, the `let out = match … ;`)
with a loop that builds the Vec. **The `!= 0` caps stay** (behaviour-preserving):
```rust
// resolve each re-exported field to a flat (node, field), in declared order
let mut out: Vec<(usize, usize)> = Vec::with_capacity(output.len());
for of in &output {
if of.node >= item_count {
return Err(CompileError::OutputPortOutOfRange);
}
let resolved = match &interior[of.node] {
ItemLowering::Leaf { index } => {
if of.field >= flat_nodes[*index].schema().output.len() {
return Err(CompileError::OutputPortOutOfRange);
}
(*index, of.field)
}
ItemLowering::Composite { output: nested, .. } => {
// cap kept in Task 1; Task 2 lifts this to nested.get(of.field)
if of.field != 0 {
return Err(CompileError::OutputPortOutOfRange);
}
nested[0]
}
};
out.push(resolved);
}
```
The terminal `Ok(ItemLowering::Composite { output: out, roles })` at `:354` is
unchanged (`out` is now a `Vec`).
- [ ] **Step 6: Thread `rewrite_edge`, cap kept (blueprint.rs:375381)**
Replace the composite arm:
```rust
ItemLowering::Composite { output, .. } => {
// cap kept in Task 1; Task 2 lifts this to output.get(e.from_field)
if e.from_field != 0 {
return Err(CompileError::BadInteriorIndex);
}
output[0]
}
```
(was `*output` after the `!= 0` guard; `output` is now a `Vec`, so `output[0]`.)
- [ ] **Step 7: Update `lib.rs` re-export (lib.rs:38)**
```rust
pub use blueprint::{Blueprint, BlueprintNode, CompileError, Composite, OutField};
```
(was `… Composite, OutPort};`.)
- [ ] **Step 8: Migrate all existing `OutPort` test literals (blueprint.rs `#[cfg(test)]`)**
Every existing test composite is single-field. Rewrite each literal at
`:532, :578, :611, :626, :641, :736, :916, :924, :967, :975` from
`OutPort { node: N, field: F }` to a one-element record:
```rust
vec![OutField { node: N, field: F, name: "out".into() }]
```
preserving each site's existing `node`/`field` values. (The site at `:641` is
`OutPort { node: 0, field: 5 }` — the OHLCV-field re-export — becomes
`vec![OutField { node: 0, field: 5, name: "out".into() }]`.) The comment mention
at `:549` is prose; update its wording from "OutPort" to "OutField" if it names
the type, otherwise leave.
- [ ] **Step 9: Build and test the engine crate**
Run: `cargo test -p aura-engine`
Expected: PASS — the crate compiles and every existing test is green
(single-field records are behaviour-preserving; the caps still hold).
- [ ] **Step 10: Clippy the engine crate**
Run: `cargo clippy -p aura-engine --all-targets -- -D warnings`
Expected: PASS — no warnings (watch for a needless `Vec::with_capacity` or
`clone` lint; none expected).
---
## Task 2: Engine — lift the caps, multi-output capability (RED-first)
The type is in place (Task 1); now lift the two `field == 0` caps, each gated by
a failing test written first. Tests reuse the existing `#[cfg(test)]` helpers
`pass1()` (1-in/1-out f64 leaf), `sink_f64()` (f64 sink), `Blueprint::new`,
`SourceSpec`, and the `bp.compile()` / `bp.compile().err()` assertion idiom — no
new helper is introduced. Spec test 3 (out-of-range re-export) is already covered
by the migrated `output_port_out_of_range_rejected` (Task 1 Step 8), so it is not
re-added here.
**Files:**
- Modify: `crates/aura-engine/src/blueprint.rs` (the two caps + three new `#[cfg(test)]` tests)
- [ ] **Step 1: Write the multi-output happy-path test (RED)**
Add to the `#[cfg(test)]` module, next to `single_composite_inlines_with_offset_fan_and_output`:
```rust
#[test]
fn composite_reexports_two_fields_to_distinct_consumers() {
// composite: two independent Pass1 leaves; role 0 -> leaf 0, role 1 -> leaf 1;
// output record re-exports leaf 0 as "a", leaf 1 as "b".
let c = Composite::new(
"two_out",
vec![pass1(), pass1()],
vec![],
vec![
vec![Target { node: 0, slot: 0 }],
vec![Target { node: 1, slot: 0 }],
],
vec![
OutField { node: 0, field: 0, name: "a".into() },
OutField { node: 1, field: 0, name: "b".into() },
],
);
// composite is item 0; two sinks (items 1, 2) read its two output fields by
// from_field; one source fans into both roles.
let bp = Blueprint::new(
vec![BlueprintNode::Composite(c), sink_f64(), sink_f64()],
vec![SourceSpec {
kind: ScalarKind::F64,
targets: vec![Target { node: 0, slot: 0 }, Target { node: 0, slot: 1 }],
}],
vec![
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // field "a" -> sink 1
Edge { from: 0, to: 2, slot: 0, from_field: 1 }, // field "b" -> sink 2
],
);
let (nodes, sources, edges) = bp.compile().expect("valid multi-output composite");
// flat layout: Pass1(0), Pass1(1), SinkF64(2), SinkF64(3)
assert_eq!(nodes.len(), 4);
// from_field 0 resolves to leaf 0, from_field 1 to leaf 1 — distinct producers
assert_eq!(
edges,
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 3, slot: 0, from_field: 0 },
]
);
// the source fanned into both interior leaves
assert_eq!(
sources[0].targets,
vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }]
);
}
```
Run: `cargo test -p aura-engine composite_reexports_two_fields_to_distinct_consumers`
Expected: FAIL — the consumer reading `from_field: 1` hits the `rewrite_edge` cap;
`compile` returns `Err(BadInteriorIndex)`, so `.expect(...)` panics.
- [ ] **Step 2: Lift the `rewrite_edge` cap (blueprint.rs composite arm)**
Replace the composite arm (the Step-6 Task-1 body) with the range-checked index:
```rust
ItemLowering::Composite { output, .. } => {
*output.get(e.from_field).ok_or(CompileError::BadInteriorIndex)?
}
```
Run: `cargo test -p aura-engine composite_reexports_two_fields_to_distinct_consumers`
Expected: PASS.
- [ ] **Step 3: Write the nested multi-output test (RED)**
An **outer** composite re-exporting two fields of an **inner** multi-output
composite (exercises the nested arm). Add next to `nested_composite_inlines`:
```rust
#[test]
fn outer_reexports_two_fields_of_inner_composite() {
// inner re-exports two leaves as "a","b"; outer re-exposes both inner roles
// and re-exports inner field 0 and field 1 (the latter exercises the nested arm).
let inner = Composite::new(
"inner_two",
vec![pass1(), pass1()],
vec![],
vec![
vec![Target { node: 0, slot: 0 }],
vec![Target { node: 1, slot: 0 }],
],
vec![
OutField { node: 0, field: 0, name: "a".into() },
OutField { node: 1, field: 0, name: "b".into() },
],
);
let outer = Composite::new(
"outer_two",
vec![BlueprintNode::Composite(inner)],
vec![],
vec![
vec![Target { node: 0, slot: 0 }], // outer role 0 -> inner role 0
vec![Target { node: 0, slot: 1 }], // outer role 1 -> inner role 1
],
vec![
OutField { node: 0, field: 0, name: "x".into() }, // inner field 0
OutField { node: 0, field: 1, name: "y".into() }, // inner field 1 (nested arm)
],
);
let bp = Blueprint::new(
vec![BlueprintNode::Composite(outer), sink_f64(), sink_f64()],
vec![SourceSpec {
kind: ScalarKind::F64,
targets: vec![Target { node: 0, slot: 0 }, Target { node: 0, slot: 1 }],
}],
vec![
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // outer field x -> sink 1
Edge { from: 0, to: 2, slot: 0, from_field: 1 }, // outer field y -> sink 2
],
);
let (nodes, _sources, edges) = bp.compile().expect("valid nested multi-output");
assert_eq!(nodes.len(), 4); // Pass1, Pass1, SinkF64, SinkF64
assert_eq!(
edges,
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 3, slot: 0, from_field: 0 },
]
);
}
```
Run: `cargo test -p aura-engine outer_reexports_two_fields_of_inner_composite`
Expected: FAIL — `inline_composite`'s nested arm caps `of.field != 0` on the
outer's `field: 1` re-export; `compile` returns `Err(OutputPortOutOfRange)`.
- [ ] **Step 4: Lift the nested arm cap (blueprint.rs inline_composite)**
Replace the nested arm in the Step-5 Task-1 loop:
```rust
ItemLowering::Composite { output: nested, .. } => {
*nested.get(of.field).ok_or(CompileError::OutputPortOutOfRange)?
}
```
(was the `if of.field != 0 { … } nested[0]` cap.)
Run: `cargo test -p aura-engine outer_reexports_two_fields_of_inner_composite`
Expected: PASS.
- [ ] **Step 5: Write the out-of-range consume guard test**
```rust
#[test]
fn consume_of_missing_output_field_is_rejected() {
// a single-field composite; a consumer reads from_field 1 (past the 1-field
// record) -> the rewrite_edge range-check rejects it.
let c = Composite::new(
"c",
vec![pass1()],
vec![],
vec![vec![Target { node: 0, slot: 0 }]],
vec![OutField { node: 0, field: 0, name: "a".into() }],
);
let bp = Blueprint::new(
vec![BlueprintNode::Composite(c), sink_f64()],
vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }],
vec![Edge { from: 0, to: 1, slot: 0, from_field: 1 }], // only field 0 exists
);
assert_eq!(bp.compile().err(), Some(CompileError::BadInteriorIndex));
}
```
Run: `cargo test -p aura-engine consume_of_missing_output_field_is_rejected`
Expected: PASS — `output.get(1)` on a 1-field record is `None``BadInteriorIndex`.
(This is a guard, not a RED gate: it also errored under the Task-1 cap, but now via
the range-check that replaced it.)
- [ ] **Step 6: Full engine regression + clippy**
Run: `cargo test -p aura-engine`
Expected: PASS — all old tests (single-field, behaviour-preserving) plus the four
new capability tests are green.
Run: `cargo clippy -p aura-engine --all-targets -- -D warnings`
Expected: PASS.
---
## Task 3: CLI — multi-output render + author sites + golden re-capture
`aura-cli` depends on `aura-engine`; after Task 1 it no longer compiles (`OutPort`
removed from the import and four literals). This task threads every CLI site,
re-exports the MACD's three lines, renders K named output markers, and re-captures
the one drifting golden — one atomic compile-and-test unit.
**Files:**
- Modify: `crates/aura-cli/src/main.rs` (import `:13`, `sma_cross` `:130`, `macd` `:206`, nested-render-test literals `:391`/`:398`, needle test `:377`, `blueprint_view_golden`)
- Modify: `crates/aura-cli/src/graph.rs:124126` (`render_definition`)
- [ ] **Step 1: Update the import (main.rs:13)**
Change `OutPort` to `OutField` in the `use` list:
```rust
f64_field, summarize, Blueprint, BlueprintNode, Composite, Edge, Harness, OutField,
```
- [ ] **Step 2: Render K named output markers (graph.rs:124126)**
Replace the single `[out]` marker block:
```rust
for of in c.output() {
let out_id = labels.len();
labels.push(format!("out:{}", of.name));
edges.push((of.node, out_id));
}
```
(was the three lines pushing one `"out"` label wired from `c.output().node`.)
Update the function's doc-comment at `:101104` to say "an `[out:<name>]` marker
per re-exported output field" (was "an `[out]` marker (wired from the output
port)").
- [ ] **Step 3: Migrate `sma_cross` to a single-field record (main.rs:130)**
```rust
vec![OutField { node: 2, field: 0, name: "cross".into() }],
```
(was `OutPort { node: 2, field: 0 }`.) Behaviour-preserving: one re-exported
field; renders as `[out:cross]`.
- [ ] **Step 4: Re-export all three MACD lines (main.rs:206)**
Replace the `macd` composite's output (was `OutPort { node: 4, field: 0 }, // the histogram`):
```rust
vec![
OutField { node: 2, field: 0, name: "macd".into() }, // the MACD line
OutField { node: 3, field: 0, name: "signal".into() }, // the signal line
OutField { node: 4, field: 0, name: "histogram".into() }, // the histogram
],
```
Update the `macd` doc-comment (`:178184`): the composite now exposes the **three
MACD lines** as a record; the strategy trades the histogram by reading
`from_field: 2`.
**Then update the consumer edge** in `macd_strategy_blueprint` (`:233`). The
histogram is now field index **2** of the 3-field record (was the sole output at
field 0), so the edge feeding `Exposure` must select it:
```rust
Edge { from: 0, to: 1, slot: 0, from_field: 2 }, // histogram → Exposure
```
(was `from_field: 0`.) This keeps the strategy trading the **histogram** — leaving
it at `0` would silently feed the MACD line instead, changing the run and breaking
`run_macd_compiles_from_nested_composite_and_is_deterministic`. The other three
edges (`:234236`) are unchanged.
- [ ] **Step 5: Migrate the nested-render-test literals (main.rs:391, :398)**
In `nested_composite_renders_without_panic`, both single-field composites:
```rust
vec![OutField { node: 0, field: 0, name: "out".into() }], // :391 inner
```
```rust
vec![OutField { node: 1, field: 0, name: "out".into() }], // :398 outer
```
- [ ] **Step 6: Build the CLI crate**
Run: `cargo build -p aura-cli`
Expected: PASS — every `OutPort` site threaded; crate compiles. (Golden/needle
tests may still be red — handled next.)
- [ ] **Step 7: Add the `[out:<name>]` needle assertion (main.rs:377)**
In `blueprint_view_defines_each_composite_once`, extend the needle list so the
multi-output marker is asserted. `sma_cross` is the rendered composite, so its
marker is `[out:cross]`:
```rust
for needle in ["[SMA]", "[Sub]", "[in:0]", "[out:cross]"] {
```
(was `… "[out]"`.)
Run: `cargo test -p aura-cli blueprint_view_defines_each_composite_once`
Expected: PASS — the render now emits `[out:cross]` for `sma_cross`.
- [ ] **Step 8: Re-capture the `blueprint_view_golden` (main.rs:464502)**
The `[out]``[out:cross]` change drifts this golden. Follow the test's own
re-capture protocol (comment at `:466468`): run the golden test, observe the
actual rendered string in the failure diff, and paste it verbatim into the golden
literal (`:497` region). Do **not** hand-edit the expected string field-by-field —
copy the actual output wholesale.
Run: `cargo test -p aura-cli blueprint_view_golden`
Expected: FAIL first (golden drift: `[out]``[out:cross]`), then PASS after the
literal is re-captured.
- [ ] **Step 9: Confirm the flat graph golden is byte-identical (main.rs:505530)**
`compiled_view_golden` pins the flat graph render; per C23 names are dropped at
inline, so it must **not** change (acceptance criterion 6 — the regression guard).
Run: `cargo test -p aura-cli compiled_view_golden`
Expected: PASS with **no** edit to the golden literal. If it drifts, a name leaked
into the flat graph — a bug to fix, not a golden to re-capture.
- [ ] **Step 10: Full CLI test + clippy**
Run: `cargo test -p aura-cli`
Expected: PASS — including `run_macd_compiles_from_nested_composite_and_is_deterministic`
(the MACD run still produces the histogram, now via `from_field: 2`).
Run: `cargo clippy -p aura-cli --all-targets -- -D warnings`
Expected: PASS.
---
## Task 4: Non-workspace fieldtest sweep + full workspace gate
The construction-layer fieldtests are **not** workspace members, so
`cargo …--workspace` does not compile them — they hold stale `OutPort` references
to a now-deleted type. Sweep them for tree consistency (mechanical), then run the
full workspace gate.
**Files:**
- Modify: `fieldtests/milestone-construction-layer/mc_1_composite_build_run.rs:6,20,47`
- Modify: `fieldtests/milestone-construction-layer/mc_2_miswire_render.rs:15,47`
- Modify: `fieldtests/milestone-construction-layer/mc_3_nested_composite.rs:18,35,52`
- Modify: `fieldtests/milestone-construction-layer/mc_4_introspect_graph.rs:13,34`
- [ ] **Step 1: Sweep `OutPort` → `OutField` across the fieldtests**
In each file: update the `use … OutPort` import to `OutField`, and rewrite each
`OutPort { node: N, field: F }` literal to `vec![OutField { node: N, field: F, name: "out".into() }]`
**at the `Composite::new` output-arg position** (these are all single-field
composites). Update comment mentions of `OutPort` (e.g. mc_1 `:6`) to `OutField`.
- [ ] **Step 2: Verify no `OutPort` remains anywhere in the tree**
Run: `rg -n "OutPort" --type rust`
Expected: **no matches** — the type is fully retired (every reference is now
`OutField`).
- [ ] **Step 3: Full workspace build**
Run: `cargo build --workspace`
Expected: PASS.
- [ ] **Step 4: Full workspace test**
Run: `cargo test --workspace`
Expected: PASS — every crate green; single-field composites behaviour-preserving,
multi-output capability covered by Task 2, render by Task 3.
- [ ] **Step 5: Full workspace clippy**
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: PASS — zero warnings.
---
## Acceptance gate (whole plan)
- [ ] `cargo build --workspace` green.
- [ ] `cargo test --workspace` green (incl. the four new engine capability tests and the re-captured `blueprint_view_golden`).
- [ ] `cargo clippy --workspace --all-targets -- -D warnings` green.
- [ ] `rg "OutPort" --type rust` returns nothing (type fully retired).
- [ ] `compiled_view_golden` unchanged (flat graph byte-identical — C23 / acceptance criterion 6).
- [ ] The MACD PoC re-exports `macd`/`signal`/`histogram`; the run still trades the histogram (via `from_field: 2`).
The implementation commit closes #40.
-740
View File
@@ -1,740 +0,0 @@
# Name the Composite Boundary — Implementation Plan
> **Parent spec:** `docs/specs/0019-name-composite-boundary.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Make composite input roles and params named projections (`Role { name,
targets }`, `ParamAlias { name, node, slot }`) — the same shape #40 gave outputs —
surfacing the full named boundary signature in the blueprint render, with the
param-space sweep surface unchanged (pure naming overlay, C23).
**Architecture:** A type-shape change that ripples engine → CLI, sequenced like
cycle 0018. Task 1 migrates the engine types behaviour-preserving (the new `params`
field is dormant; gate is per-crate `cargo test -p aura-engine`, NOT `--workspace`
`aura-cli` will not compile until Task 3, which is expected). Task 2 wires param
aliasing into `param_space()` RED-first. Task 3 does the CLI render + author sites.
Task 4 sweeps the out-of-CI fixtures (separate workspace root) and runs the full
`--workspace` triple. Aliasing is demonstrated on the **CLI MACD site only** (the
spec's worked example); every other composite site gets the forced role-name +
`params: vec![]`, so the param-space C23 anchor goldens stay byte-identical.
**Tech Stack:** `crates/aura-engine/src/blueprint.rs` (types, `collect_params`,
`inline_composite`), `crates/aura-engine/src/lib.rs` (re-export),
`crates/aura-cli/src/graph.rs` (`render_definition`), `crates/aura-cli/src/main.rs`
(author sites + render goldens), `fieldtests/milestone-construction-layer/mc_*.rs`.
---
**Files this plan creates or modifies:**
- Modify: `crates/aura-engine/src/blueprint.rs` — new `Role`/`ParamAlias` types;
`Composite` struct + `new` + accessors; `collect_params` aliasing; `inline_composite`
role-walk + alias validation; test-site migration + 4 new unit tests.
- Modify: `crates/aura-engine/src/lib.rs:38` — re-export `Role`, `ParamAlias`.
- Modify: `crates/aura-cli/src/graph.rs:105-131``render_definition` named roles + param markers.
- Modify: `crates/aura-cli/src/main.rs``macd` (:186) + `sma_cross` (:121) author sites; test sites (:382, :391, :398); `blueprint_view_golden` (:469) re-capture; `compiled_view_golden` (:510) must stay byte-identical.
- Modify: `fieldtests/milestone-construction-layer/mc_1..mc_4*.rs` — role literals + `params` arg + `input_roles()` reads.
---
## Task 1: Engine type migration (behaviour-preserving)
**Files:**
- Modify: `crates/aura-engine/src/blueprint.rs:43-93` (types, struct, new, accessors), `:309,342-357` (inline_composite), `:525-1152` (test `Composite::new` sites)
- Modify: `crates/aura-engine/src/lib.rs:38`
- [ ] **Step 1: Add `Role` and `ParamAlias` struct defs**
Insert both, immediately before the `Composite` struct (currently `blueprint.rs:49`),
after the `BlueprintNode` impl that ends at `:42`:
```rust
/// One named input role: role `r` (by position) fans the source value into
/// `targets`. The `name` is a non-load-bearing render symbol (C23); identity is
/// the role index, which survives lowering — the name does not.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Role {
pub name: String,
pub targets: Vec<Target>,
}
/// A composite-level alias relabelling one interior leaf param slot's surface
/// name in `param_space()`. `node` is the interior item index, `slot` the param
/// slot within that leaf. Pure legibility: the alias relabels in place and never
/// reorders, adds, or removes a slot (C23 — identity stays the slot).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ParamAlias {
pub name: String,
pub node: usize,
pub slot: usize,
}
```
- [ ] **Step 2: Change the `Composite` struct fields**
`blueprint.rs:49-55`. Replace:
```rust
pub struct Composite {
name: String,
nodes: Vec<BlueprintNode>,
edges: Vec<Edge>,
input_roles: Vec<Vec<Target>>,
output: Vec<OutField>,
}
```
with:
```rust
pub struct Composite {
name: String,
nodes: Vec<BlueprintNode>,
edges: Vec<Edge>,
input_roles: Vec<Role>,
params: Vec<ParamAlias>,
output: Vec<OutField>,
}
```
- [ ] **Step 3: Change `Composite::new` signature + body**
`blueprint.rs:62-70`. Replace:
```rust
pub fn new(
name: impl Into<String>,
nodes: Vec<BlueprintNode>,
edges: Vec<Edge>,
input_roles: Vec<Vec<Target>>,
output: Vec<OutField>,
) -> Self {
Self { name: name.into(), nodes, edges, input_roles, output }
}
```
with (new `params` arg between `input_roles` and `output`):
```rust
pub fn new(
name: impl Into<String>,
nodes: Vec<BlueprintNode>,
edges: Vec<Edge>,
input_roles: Vec<Role>,
params: Vec<ParamAlias>,
output: Vec<OutField>,
) -> Self {
Self { name: name.into(), nodes, edges, input_roles, params, output }
}
```
- [ ] **Step 4: Update `input_roles()` accessor return type + add `params()`**
`blueprint.rs:84-87`. Replace:
```rust
/// The input roles: role `r` fans into `input_roles()[r]` interior targets.
pub fn input_roles(&self) -> &[Vec<Target>] {
&self.input_roles
}
```
with:
```rust
/// The input roles: role `r` fans into `input_roles()[r].targets` interior
/// targets, under the boundary name `input_roles()[r].name` (C23 — name is a
/// render symbol, identity is the role index).
pub fn input_roles(&self) -> &[Role] {
&self.input_roles
}
/// The param aliases: each relabels one interior leaf param slot's surface
/// name in `param_space()` (pure naming overlay; identity stays the slot, C23).
pub fn params(&self) -> &[ParamAlias] {
&self.params
}
```
- [ ] **Step 5: Re-export the new types from `lib.rs`**
`crates/aura-engine/src/lib.rs:38`. Replace:
```rust
pub use blueprint::{Blueprint, BlueprintNode, CompileError, Composite, OutField};
```
with:
```rust
pub use blueprint::{Blueprint, BlueprintNode, CompileError, Composite, OutField, ParamAlias, Role};
```
- [ ] **Step 6: Migrate `inline_composite` — destructure + role-walk**
`blueprint.rs:309`. Replace:
```rust
let Composite { name: _, nodes, edges, input_roles, output } = c;
```
with (params do NOT lower — bound `_` here in Task 1; Task 2 wires alias validation):
```rust
let Composite { name: _, nodes, edges, input_roles, params: _, output } = c;
```
Then `blueprint.rs:345` — the inner role-target loop. Replace:
```rust
for t in role {
```
with (`role` is now `&Role`, not `&Vec<Target>`):
```rust
for t in &role.targets {
```
(The accumulator `let mut roles: Vec<Vec<Target>>` at `:342`, the `for (r, role) in
input_roles.iter().enumerate()` at `:343`, and `ItemLowering::Composite { ..., roles }`
at `:256`/`:359` are the internal flat-roles type — names already dropped — and do
NOT change.)
- [ ] **Step 7: Migrate every engine-test `Composite::new` call site**
`Composite::new` gained an arg and `input_roles` changed element type, so all
`#[cfg(test)]` construction sites break. Apply this exact transform at each — (a)
wrap each bare role vec `vec![Target { .. }, ..]` as `Role { name: "price".into(),
targets: vec![Target { .. }, .. ] }`, and (b) insert `vec![]` (empty params) as the
new arg immediately before the `output` (final) arg. **No aliases on any engine test
site** — that keeps the param-space goldens byte-identical (these sites are the C23
anchors).
Worked example — the `sma_cross()` test helper at `blueprint.rs:847-858`. Replace:
```rust
Composite::new(
"sma_cross",
vec![Sma::factory().into(), Sma::factory().into(), Sub::factory().into()],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
],
vec![vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }]],
vec![OutField { node: 2, field: 0, name: "out".into() }],
)
```
with:
```rust
Composite::new(
"sma_cross",
vec![Sma::factory().into(), Sma::factory().into(), Sub::factory().into()],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
],
vec![Role {
name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
}],
vec![],
vec![OutField { node: 2, field: 0, name: "out".into() }],
)
```
Apply the identical transform at every other engine-test `Composite::new` (the
role-literal line is named for each): `:525` (role lit `:532`), `:571` (`:576-577`,
two roles), `:622` (`:626`), `:656` (`:661-662`), `:669` (`:674-675`), `:707`
(`:711`), `:722` (`:726`), `:737` (`:741`), `:753` (`:757`), `:938` (`:943-944`,
two roles), `:1086` (`:1093`), `:1097` (`:1101`), `:1137` (`:1144`), `:1148`
(`:1152`). A site with two roles wraps each as its own `Role { name, targets }`
give the second role a distinct name (e.g. `"price2"` / a name matching its
semantics); the role name is never asserted by these tests (they assert
`param_space()` names or run behaviour), so any non-empty name is safe. The Step-8
compiler gate enumerates any site missed.
- [ ] **Step 8: Build + test the engine crate (behaviour-preserving gate)**
Run: `cargo test -p aura-engine`
Expected: PASS — compiles and all existing tests green. The new `params` field is
dormant (not yet consulted), the role-walk is equivalent, no aliases anywhere, so
`param_space()` output is byte-identical: the anchors
`param_space_mirrors_compiled_flat_node_param_order`,
`param_space_mirrors_compiled_flat_node_param_order_under_nesting`, and
`param_space_is_flat_path_qualified_and_slot_disambiguated` stay green unchanged.
(`aura-cli` is NOT built here — `-p aura-engine` is per-crate; `--workspace` is
deferred to Task 4, exactly as cycle 0018 sequenced. An unused-arg warning on the
`params: _` / dormant field is acceptable at this gate — Task 2 consumes them, and
the `-D warnings` clippy gate is Task 4.)
---
## Task 2: Param aliasing in `param_space()` (RED-first)
**Files:**
- Modify: `crates/aura-engine/src/blueprint.rs:151` (`param_space` call), `:223-246` (`collect_params`), `:309-310` (`inline_composite` validation)
- Test: `crates/aura-engine/src/blueprint.rs` `#[cfg(test)]` — 4 new tests
- [ ] **Step 1: Write the RED test — alias relabels in place**
Add to the `#[cfg(test)]` module (near the other `param_space` tests, ~`:1083`):
```rust
#[test]
fn param_alias_relabels_param_space_name_in_place() {
// two Sma leaves (each one `length` param) under a composite that aliases
// slot 0 of node 0 -> "shortLen" and slot 0 of node 1 -> "longLen".
let c = Composite::new(
"cross",
vec![Sma::factory().into(), Sma::factory().into(), Sub::factory().into()],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
],
vec![Role {
name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
}],
vec![
ParamAlias { name: "shortLen".into(), node: 0, slot: 0 },
ParamAlias { name: "longLen".into(), node: 1, slot: 0 },
],
vec![OutField { node: 2, field: 0, name: "out".into() }],
);
let bp = Blueprint::new(vec![BlueprintNode::Composite(c)], vec![], vec![]);
let names: Vec<String> = bp.param_space().into_iter().map(|p| p.name).collect();
// aliased in place: names are the aliases, NOT two duplicate "cross.length".
assert_eq!(names, vec!["cross.shortLen".to_string(), "cross.longLen".to_string()]);
}
```
- [ ] **Step 2: Run the RED test**
Run: `cargo test -p aura-engine param_alias_relabels_param_space_name_in_place`
Expected: FAIL — `collect_params` ignores `params`, so names are
`["cross.length", "cross.length"]` (assertion left/right mismatch).
- [ ] **Step 3: Thread aliases through `collect_params` + seed `param_space`**
`blueprint.rs:149-153` — the `param_space` body. Replace:
```rust
pub fn param_space(&self) -> Vec<ParamSpec> {
let mut out = Vec::new();
collect_params(&self.nodes, "", &mut out);
out
}
```
with:
```rust
pub fn param_space(&self) -> Vec<ParamSpec> {
let mut out = Vec::new();
collect_params(&self.nodes, "", &[], &mut out);
out
}
```
Then `blueprint.rs:223-246` — replace the whole `collect_params` fn with:
```rust
fn collect_params(
items: &[BlueprintNode],
prefix: &str,
aliases: &[ParamAlias],
out: &mut Vec<ParamSpec>,
) {
for (i, item) in items.iter().enumerate() {
match item {
BlueprintNode::Leaf(factory) => {
for (s, p) in factory.params().iter().enumerate() {
// an alias for this exact (node, slot) relabels in place;
// otherwise the factory param name, as today.
let local = aliases
.iter()
.find(|a| a.node == i && a.slot == s)
.map(|a| a.name.as_str())
.unwrap_or(p.name.as_str());
let name = if prefix.is_empty() {
local.to_string()
} else {
format!("{prefix}.{local}")
};
out.push(ParamSpec { name, kind: p.kind });
}
}
BlueprintNode::Composite(c) => {
let child = if prefix.is_empty() {
c.name().to_string()
} else {
format!("{prefix}.{}", c.name())
};
collect_params(c.nodes(), &child, c.params(), out);
}
}
}
}
```
- [ ] **Step 4: Run the test to verify GREEN**
Run: `cargo test -p aura-engine param_alias_relabels_param_space_name_in_place`
Expected: PASS.
- [ ] **Step 5: Write the RED test — out-of-range alias rejected at compile**
Add to the `#[cfg(test)]` module:
```rust
#[test]
fn out_of_range_param_alias_rejected() {
// alias names node 9 (no such interior item) -> caught at compile.
let c = Composite::new(
"cross",
vec![Sma::factory().into(), Sma::factory().into(), Sub::factory().into()],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
],
vec![Role {
name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
}],
vec![ParamAlias { name: "bogus".into(), node: 9, slot: 0 }],
vec![OutField { node: 2, field: 0, name: "out".into() }],
);
let bp = Blueprint::new(
vec![BlueprintNode::Composite(c)],
vec![SourceSpec { kind: ScalarKind::F64, targets: vec![] }],
vec![],
);
// two Sma leaves => two i64 length slots; supply a matching vector so the
// ONLY error is the bad alias, not arity.
let err = bp.compile_with_params(&[Scalar::I64(2), Scalar::I64(4)]).unwrap_err();
assert_eq!(err, CompileError::BadInteriorIndex);
}
```
- [ ] **Step 6: Run the RED test**
Run: `cargo test -p aura-engine out_of_range_param_alias_rejected`
Expected: FAIL — no alias validation yet (compile succeeds or errors with a
different variant), so `unwrap_err()`/`assert_eq!` mismatches.
- [ ] **Step 7: Add alias range-validation in `inline_composite`**
`blueprint.rs:309` — bind `params` (was `params: _` from Task 1):
```rust
let Composite { name: _, nodes, edges, input_roles, params, output } = c;
```
Then immediately after `let item_count = nodes.len();` (`:310`), insert the
validation loop (before `nodes` is moved into `lower_items` at `:313`):
```rust
// an alias must name a real interior leaf param slot (C23 — names are cosmetic
// but a dangling handle is an author error). Mirrors the output range-check.
for a in &params {
let ok = a.node < item_count
&& matches!(&nodes[a.node], BlueprintNode::Leaf(f) if a.slot < f.params().len());
if !ok {
return Err(CompileError::BadInteriorIndex);
}
}
```
- [ ] **Step 8: Run the test to verify GREEN**
Run: `cargo test -p aura-engine out_of_range_param_alias_rejected`
Expected: PASS.
- [ ] **Step 9: Add the regression + partial-aliasing tests**
Add both to the `#[cfg(test)]` module:
```rust
#[test]
fn unaliased_params_keep_factory_names() {
// no aliases => param_space identical to the pre-#41 path-qualified names.
let c = Composite::new(
"cross",
vec![Sma::factory().into(), Sma::factory().into(), Sub::factory().into()],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
],
vec![Role {
name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
}],
vec![],
vec![OutField { node: 2, field: 0, name: "out".into() }],
);
let bp = Blueprint::new(vec![BlueprintNode::Composite(c)], vec![], vec![]);
let names: Vec<String> = bp.param_space().into_iter().map(|p| p.name).collect();
assert_eq!(names, vec!["cross.length".to_string(), "cross.length".to_string()]);
}
#[test]
fn partial_aliasing_relabels_only_the_named_slot() {
// alias node 0 only; node 1 keeps its factory name; order intact.
let c = Composite::new(
"cross",
vec![Sma::factory().into(), Sma::factory().into(), Sub::factory().into()],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
],
vec![Role {
name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
}],
vec![ParamAlias { name: "shortLen".into(), node: 0, slot: 0 }],
vec![OutField { node: 2, field: 0, name: "out".into() }],
);
let bp = Blueprint::new(vec![BlueprintNode::Composite(c)], vec![], vec![]);
let names: Vec<String> = bp.param_space().into_iter().map(|p| p.name).collect();
assert_eq!(names, vec!["cross.shortLen".to_string(), "cross.length".to_string()]);
}
```
- [ ] **Step 10: Run the full engine suite (anchors stay green)**
Run: `cargo test -p aura-engine`
Expected: PASS — the four new tests green; the C23 anchors
(`param_space_mirrors_compiled_flat_node_param_order` and the `_under_nesting` +
`_disambiguated` siblings) still green unchanged (no aliases on those fixtures, so
their name goldens are byte-identical).
---
## Task 3: CLI render + author sites
**Files:**
- Modify: `crates/aura-cli/src/graph.rs:105-131`
- Modify: `crates/aura-cli/src/main.rs:121-132` (sma_cross), `:186-213` (macd), `:382`, `:391-404`, `:469-507` (blueprint golden), `:561-569` (macd render test)
- [ ] **Step 1: Render named roles + param markers in `render_definition`**
`graph.rs:117-128`. Replace the input-role loop + (keep) output loop:
```rust
for (role, targets) in c.input_roles().iter().enumerate() {
let in_id = labels.len();
labels.push(format!("in:{role}"));
for t in targets {
edges.push((in_id, t.node));
}
}
for of in c.output() {
let out_id = labels.len();
labels.push(format!("out:{}", of.name));
edges.push((of.node, out_id));
}
```
with (roles read `.name`/`.targets`; new `[param:<name>]` marker loop wired
marker → configured leaf, like `[in:]`):
```rust
for role in c.input_roles() {
let in_id = labels.len();
labels.push(format!("in:{}", role.name));
for t in &role.targets {
edges.push((in_id, t.node));
}
}
for a in c.params() {
let p_id = labels.len();
labels.push(format!("param:{}", a.name));
edges.push((p_id, a.node));
}
for of in c.output() {
let out_id = labels.len();
labels.push(format!("out:{}", of.name));
edges.push((of.node, out_id));
}
```
Also update the doc comment at `graph.rs:101-104` to mention the `[in:<name>]` and
`[param:<name>]` markers (replace `[in:k]` with `[in:<name>]`; add the param marker
clause). Cosmetic; keep it accurate.
- [ ] **Step 2: MACD author site — named role + aliases (the worked example)**
`main.rs:203-211` — the role + output args of `macd(...)`. Replace:
```rust
vec![vec![
Target { node: 0, slot: 0 }, // price → fast EMA
Target { node: 1, slot: 0 }, // price → slow EMA
]],
vec![
OutField { node: 2, field: 0, name: "macd".into() }, // the MACD line
OutField { node: 3, field: 0, name: "signal".into() }, // the signal line
OutField { node: 4, field: 0, name: "histogram".into() }, // the histogram
],
```
with:
```rust
vec![Role {
name: "price".into(),
targets: vec![
Target { node: 0, slot: 0 }, // price → fast EMA
Target { node: 1, slot: 0 }, // price → slow EMA
],
}],
vec![
ParamAlias { name: "fast".into(), node: 0, slot: 0 }, // fast EMA length
ParamAlias { name: "slow".into(), node: 1, slot: 0 }, // slow EMA length
ParamAlias { name: "signal".into(), node: 3, slot: 0 }, // signal EMA length
],
vec![
OutField { node: 2, field: 0, name: "macd".into() }, // the MACD line
OutField { node: 3, field: 0, name: "signal".into() }, // the signal line
OutField { node: 4, field: 0, name: "histogram".into() }, // the histogram
],
```
Ensure `ParamAlias` and `Role` are imported in `main.rs` (extend the existing
`use aura_engine::{... OutField ...}` line to include `ParamAlias, Role`).
- [ ] **Step 3: sma_cross author site — named role, no aliases**
`main.rs:129-130`. Replace:
```rust
vec![vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }]],
vec![OutField { node: 2, field: 0, name: "cross".into() }],
```
with (forced role-rename only; sma_cross stays unaliased — keeps the sample minimal):
```rust
vec![Role {
name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
}],
vec![],
vec![OutField { node: 2, field: 0, name: "cross".into() }],
```
- [ ] **Step 4: Migrate the two `Composite::new` sites in the render test**
`main.rs:391-397` and `:398-404` (inside `nested_composite_renders_without_panic`).
Apply the Task-1 transform to each: wrap the role literal (`:395`, `:402`) as
`Role { name: "price".into(), targets: vec![..] }`, and insert `vec![]` before the
`output` arg. No aliases.
- [ ] **Step 5: Update the render needle for the renamed role**
`main.rs:382` — the needle array in `blueprint_view_defines_each_composite_once`.
Replace the `"[in:0]"` element with `"[in:price]"` (the sma_cross role is now named
`price`). Keep `"[out:cross]"` and the type needles unchanged.
- [ ] **Step 6: Assert the MACD definition render shows the named signature**
`main.rs:561-569``macd_blueprint_renders_a_nested_composite_definition`. After the
existing assertions, add (the rendered MACD definition must now carry the named
inputs/params):
```rust
assert!(rendered.contains("[in:price]"), "named MACD input role: {rendered}");
assert!(rendered.contains("[param:fast]"), "aliased fast length: {rendered}");
assert!(rendered.contains("[param:slow]"), "aliased slow length: {rendered}");
assert!(rendered.contains("[param:signal]"), "aliased signal length: {rendered}");
```
(Use the same binding name the test already gives the rendered string; if it is not
`rendered`, match the local name at that site.)
- [ ] **Step 7: Re-capture the `blueprint_view_golden`**
Run: `cargo test -p aura-cli blueprint_view_golden`
Expected: FAIL — the golden at `main.rs:469-507` still pins `[in:0]`; the render now
emits `[in:price]` (and the sample sma_cross block re-layouts). Read the assertion
failure's "actual" rendered block and paste it **wholesale** into the golden string
literal (do not hand-edit field-by-field). Re-run: PASS.
- [ ] **Step 8: Confirm `compiled_view_golden` is byte-identical (C23 guard)**
Run: `cargo test -p aura-cli compiled_view_golden`
Expected: PASS with NO edit to the golden (`main.rs:510-535`). The boundary names
never reach the flat graph (C23); if this golden drifts, STOP — that is a bug, not a
golden to re-capture.
- [ ] **Step 9: Confirm MACD run determinism is unchanged**
Run: `cargo test -p aura-cli run_macd_compiles_from_nested_composite_and_is_deterministic`
Expected: PASS — params only gained names; the injected point vector and the run are
unchanged.
- [ ] **Step 10: Build + test the CLI crate**
Run: `cargo test -p aura-cli`
Expected: PASS — `aura-cli` compiles against the migrated engine, all render/run
tests green.
---
## Task 4: Fieldtests sweep + full workspace gate
**Files:**
- Modify: `fieldtests/milestone-construction-layer/mc_1_composite_build_run.rs:31,44`, `mc_2_miswire_render.rs:38,46`, `mc_3_nested_composite.rs:23,34,44,51`, `mc_4_introspect_graph.rs:22,60,66,109,110`
- [ ] **Step 1: Port the four fixture author sites + `input_roles()` reads**
These are a **separate workspace root** (`fieldtests/milestone-construction-layer/`),
not reached by `cargo build --workspace`. Apply the Task-1 transform to every
`Composite::new` in `mc_1..mc_4` — wrap each role literal as `Role { name:
"price".into(), targets: vec![..] }` and insert `vec![]` before the `output` arg
(no aliases). Sites: `mc_1:31` (role lit `:44`), `mc_2:38` (role lit `:46`),
`mc_3:23` (`:34`) and `mc_3:44` (`:51`), `mc_4:22`.
Then fix the `input_roles()` read sites in `mc_4_introspect_graph.rs`, whose element
type changed from `Vec<Target>` to `Role`:
- `:60`, `:66` — wherever the role is iterated/printed, read `role.targets` (and the
role's `.name` is now available to print). Match the current access shape at each
line and route it through `.targets`.
- `:109`, `:110``:110` does `input_roles()[0].len()`; `Role` has no `.len()`
becomes `input_roles()[0].targets.len()`. Adjust `:109` to whatever it reads on the
role accordingly. Keep the fixture's introspection axis intact (do not gut the
assertion — port it; the fixture demonstrates graph introspection via public
accessors).
Add `Role` (and `ParamAlias` if referenced) to each fixture's
`use aura_engine::{...}` imports as needed.
- [ ] **Step 2: Build the construction-layer fixture crate (separate-workspace gate)**
Run: `cd fieldtests/milestone-construction-layer && cargo build`
Expected: exit 0 — all four bins compile. (This gate exists because `--workspace`
does not reach this tree; it is the guard against the 0018 latent-drift recurrence
tracked in #42.)
- [ ] **Step 3: Full workspace triple (-D warnings)**
Run: `cargo build --workspace && cargo test --workspace && cargo clippy --workspace --all-targets -- -D warnings`
Expected: all PASS — engine + CLI compile, every test green, no clippy warnings (the
Task-1 `params`/role threading is now fully consumed, so no dead-code/unused warnings
remain).
- [ ] **Step 4: Confirm the named MACD param surface (acceptance evidence)**
Run: `cargo test -p aura-engine && cargo test -p aura-cli`
Expected: PASS. Then sanity-check the boundary type is fully migrated:
Run: `git grep -n "Vec<Vec<Target>>" crates/`
Expected: matches ONLY the internal flat-roles accumulator/`ItemLowering` in
`blueprint.rs` (`let mut roles: Vec<Vec<Target>>` and the `ItemLowering::Composite`
field) — NOT any `Composite` boundary field or `Composite::new` signature. (The
boundary now uses `Vec<Role>`; the internal post-resolution flat type legitimately
stays `Vec<Vec<Target>>`.)
@@ -1,380 +0,0 @@
# Composite Signature Render — Implementation Plan
> **Parent spec:** `docs/specs/0020-composite-signature-render.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Render a composite's blueprint definition as a typed signature title line
(`macd(fast:i64, slow:i64, signal:i64) -> (macd, signal, histogram)`) with param
names folded into leaf labels (`[EMA(fast)]`), ordered input slots as stubs
(`[Sub(#A,#B)]`), de-prefixed outputs (`[macd]`), and the `[param:*]` marker nodes
removed.
**Architecture:** Render-only. Two new helpers (`signature`, `leaf_label`) in
`graph.rs` + edits to `render_definition`; a `ScalarKind`→lowercase match (none
exists today); test/golden updates in `main.rs`; two stale doc-comment fixes. No
engine / `ParamAlias` / `param_space()` change. Task 1 changes only render code and
gates on `cargo build -p aura-cli` (the goldens go red and are re-captured in Task
2 — `cargo test` is NOT a Task-1 gate). Task 2 fixes the tests and runs the full
workspace triple.
**Tech Stack:** `crates/aura-cli/src/graph.rs`, `crates/aura-cli/src/main.rs`,
`crates/aura-core/src/node.rs` (doc comment).
---
**Files this plan creates or modifies:**
- Modify: `crates/aura-cli/src/graph.rs:16-18` (imports), `:101-137` (`render_definition` + doc), plus two new helpers
- Modify: `crates/aura-core/src/node.rs:93-101` (doc comment only)
- Modify: `crates/aura-cli/src/main.rs:393,394,428,429,452,581` (pins/needles), `:580-587` (MACD render test), `:483-521` (`blueprint_view_golden` re-capture)
---
## Task 1: graph.rs render change + doc fixes
**Files:**
- Modify: `crates/aura-cli/src/graph.rs:16-18,101-137`
- Modify: `crates/aura-core/src/node.rs:93-101`
- [ ] **Step 1: Add `ScalarKind` and `LeafFactory` to graph.rs imports**
`graph.rs:17`. Replace:
```rust
use aura_core::Node;
```
with:
```rust
use aura_core::{LeafFactory, Node, ScalarKind};
```
- [ ] **Step 2: Add the `signature` + `kind_str` helpers**
Insert immediately before `render_definition` (currently `graph.rs:106`, after the
doc comment is rewritten in Step 5 — insert these fns just above that doc comment at
`:101`):
```rust
/// `ScalarKind` as a lowercase type string for a signature (`i64`/`f64`/`bool`/
/// `timestamp`). The derived `Debug` gives PascalCase (`I64`), so this is explicit.
fn kind_str(kind: ScalarKind) -> &'static str {
match kind {
ScalarKind::I64 => "i64",
ScalarKind::F64 => "f64",
ScalarKind::Bool => "bool",
ScalarKind::Timestamp => "timestamp",
}
}
/// The composite's typed signature for the definition title:
/// `name(p1:kind, …) -> (o1, …)`. Param kinds come from the aliased interior leaf's
/// declared params; output **names only** (kinds need a pre-build factory interface,
/// #43). An empty alias list renders `name()`. Total: a malformed alias falls back
/// to `?` rather than panicking (compile is the validator, #41).
fn signature(c: &Composite) -> String {
let params: Vec<String> = c
.params()
.iter()
.map(|a| {
let kind = c
.nodes()
.get(a.node)
.and_then(|n| match n {
BlueprintNode::Leaf(f) => f.params().get(a.slot).map(|p| kind_str(p.kind)),
BlueprintNode::Composite(_) => None,
})
.unwrap_or("?");
format!("{}:{}", a.name, kind)
})
.collect();
let outs: Vec<String> = c.output().iter().map(|of| of.name.clone()).collect();
format!("{}({}) -> ({})", c.name(), params.join(", "), outs.join(", "))
}
```
- [ ] **Step 3: Add the `leaf_label` helper**
Insert directly after `signature` (above the `render_definition` doc comment):
```rust
/// A leaf item's render label: `factory.label()` plus an optional `(...)` listing
/// its aliased param names, then its input-slot stubs (`#A`, `#B`, … one per wired
/// input slot, slot index → letter) when the leaf has more than one wired input
/// slot. Params and stubs are `; `-separated when both present; either alone has no
/// separator; neither yields the bare label. A node's wired slots are the distinct
/// `.slot` values targeting it across interior edges (`Edge.to == index`) and input
/// roles (`Role.targets` with `node == index`).
fn leaf_label(c: &Composite, index: usize, factory: &LeafFactory) -> String {
let params: Vec<&str> = c
.params()
.iter()
.filter(|a| a.node == index)
.map(|a| a.name.as_str())
.collect();
let mut slots: Vec<usize> = Vec::new();
for e in c.edges() {
if e.to == index && !slots.contains(&e.slot) {
slots.push(e.slot);
}
}
for role in c.input_roles() {
for t in &role.targets {
if t.node == index && !slots.contains(&t.slot) {
slots.push(t.slot);
}
}
}
slots.sort_unstable();
let stubs: Vec<String> = if slots.len() > 1 {
slots.iter().map(|s| format!("#{}", (b'A' + *s as u8) as char)).collect()
} else {
Vec::new()
};
let parts: Vec<String> = match (params.is_empty(), stubs.is_empty()) {
(true, true) => return factory.label(),
(false, true) => vec![params.join(", ")],
(true, false) => vec![stubs.join(", ")],
(false, false) => vec![params.join(", "), stubs.join(", ")],
};
format!("{}({})", factory.label(), parts.join("; "))
}
```
- [ ] **Step 4: Edit `render_definition` — title, leaf labels, drop param markers, de-prefix outputs**
`graph.rs:107-136`. Replace the leaf-label loop, the `[param:*]` loop, the output
loop, and the title.
Replace `:108-113` (the leaf loop):
```rust
for inner in c.nodes() {
labels.push(match inner {
BlueprintNode::Leaf(factory) => factory.label(),
BlueprintNode::Composite(inner_c) => inner_c.name().to_string(),
});
}
```
with (enumerate, fold via `leaf_label`):
```rust
for (i, inner) in c.nodes().iter().enumerate() {
labels.push(match inner {
BlueprintNode::Leaf(factory) => leaf_label(c, i, factory),
BlueprintNode::Composite(inner_c) => inner_c.name().to_string(),
});
}
```
Delete the `[param:*]` marker loop `:125-129` entirely:
```rust
for a in c.params() {
let p_id = labels.len();
labels.push(format!("param:{}", a.name));
edges.push((p_id, a.node));
}
```
Replace the output marker label `:132`:
```rust
labels.push(format!("out:{}", of.name));
```
with (drop the `out:` prefix):
```rust
labels.push(of.name.clone());
```
Replace the title `:136`:
```rust
format!("{}:\n{}", c.name(), render_flat(&labels, &edges))
```
with:
```rust
format!("{}:\n{}", signature(c), render_flat(&labels, &edges))
```
- [ ] **Step 5: Fix the stale `render_definition` doc comment**
`graph.rs:101-105`. Replace:
```rust
/// Render one composite's interior as a flat graph: interior leaves as `[type]`,
/// nested composites as opaque `[name]`, plus an `[in:<name>]` entry marker per
/// input role (wired to its interior targets), a `[param:<name>]` marker per param
/// alias (wired to the leaf it relabels), and an `[out:<name>]` marker per
/// re-exported output field (wired from its producer). Prefixed `"<name>:\n"`.
```
with:
```rust
/// Render one composite's interior as a flat graph: interior leaves as
/// `[type(param…; #slot…)]` (aliased param names + ordered input-slot stubs folded
/// in via `leaf_label`), nested composites as opaque `[name]`, an `[in:<name>]`
/// entry marker per input role (wired to its interior targets), and an `[<name>]`
/// node per re-exported output field (wired from its producer). The title line is
/// the composite's typed `signature` (`name(p:kind, …) -> (out, …)`); params live
/// in the signature, not as marker nodes.
```
- [ ] **Step 6: Fix the stale `LeafFactory::label` doc comment**
`crates/aura-core/src/node.rs:93-101`. Replace:
```rust
/// The param-generic render label for the blueprint view (C22 "structure
/// before"): just the node type, e.g. `SMA`. A value-empty recipe has no
/// values to show; the tunable knobs are surfaced by `Blueprint::param_space`,
/// not in the graph. The label stays the bare type because the `ascii-dag`
/// renderer writes a label verbatim on one line (no wrapping) and overlaps two
/// wide sibling boxes inside a cluster subgraph — appending the knob names
/// (`SMA(length)`) would garble the blueprint view, and domain labels grow
/// unboundedly wide. The compiled view labels the built node valued (`SMA(2)`)
/// via `Node::label`, unaffected.
```
with:
```rust
/// The param-generic render label for the blueprint view (C22 "structure
/// before"): just the node type, e.g. `SMA`. A value-empty recipe has no values
/// to show; the tunable knobs are surfaced by `Blueprint::param_space`. The
/// factory label stays the bare type because alias / handle names are a
/// *composite-level* concept — the renderer folds them into the leaf label at
/// the composite boundary (`render_definition`'s `leaf_label`), where the
/// `(node, slot)` → name mapping lives, not on the standalone factory. (Wide
/// labels are safe: both `aura graph` views are flat since cycle 0017 — no
/// cluster subgraph, so no sibling-overlap garble.) The compiled view labels the
/// built node valued (`SMA(2)`) via `Node::label`, unaffected.
```
- [ ] **Step 7: Build the CLI crate (compile gate; tests deferred to Task 2)**
Run: `cargo build -p aura-cli`
Expected: PASS — the render code compiles with the two new helpers, the imports, and
the edited `render_definition`. (`cargo test` is NOT run here: the goldens/needle
assertions are now stale and go red until Task 2 re-captures them. `cargo build`
does not compile the `#[cfg(test)]` module, so this gate is a clean compile check of
the render path.)
---
## Task 2: main.rs tests + goldens + workspace gate
**Files:**
- Modify: `crates/aura-cli/src/main.rs:393,394,428,429,452` (pins/needles), `:580-587` (MACD render test), `:483-521` (`blueprint_view_golden`)
- [ ] **Step 1: Update the four non-MACD title pins (`name:` → `name(`)**
The title format changed from `name:` to `name(…) -> (…):`, so every
`.matches("<name>:")` count assert must repin to `"<name>("` (the new title is the
only place `name(` occurs; opaque main nodes are `[name]`, no paren).
`main.rs:393` — replace:
```rust
assert_eq!(out.matches("sma_cross:").count(), 1, "definition not rendered once:\n{out}");
```
with:
```rust
assert_eq!(out.matches("sma_cross(").count(), 1, "definition not rendered once:\n{out}");
```
`main.rs:428` — replace `out.matches("outer:")` with `out.matches("outer(")` (keep
the rest of the line identical). `main.rs:429` — replace `out.matches("inner:")`
with `out.matches("inner(")`. `main.rs:452` — replace `out.matches("dup:")` with
`out.matches("dup(")`.
- [ ] **Step 2: Update the definition needle array**
`main.rs:394`. Replace:
```rust
for needle in ["[SMA]", "[Sub]", "[in:price]", "[out:cross]"] {
```
with (the Sub now shows its two ordered inputs; the output marker is de-prefixed):
```rust
for needle in ["[SMA]", "[Sub(#A,#B)]", "[in:price]", "[cross]"] {
```
- [ ] **Step 3: Rewrite the MACD definition render assertions**
`main.rs:580-587`. Replace:
```rust
assert!(out.contains("[macd]"), "missing opaque macd node:\n{out}");
assert_eq!(out.matches("macd:").count(), 1, "macd defined once:\n{out}");
assert!(out.contains("[EMA]"), "macd interior must show EMA leaves:\n{out}");
assert!(out.contains("[in:price]"), "named MACD input role: {out}");
assert!(out.contains("[param:fast]"), "aliased fast length: {out}");
assert!(out.contains("[param:slow]"), "aliased slow length: {out}");
assert!(out.contains("[param:signal]"), "aliased signal length: {out}");
```
with:
```rust
assert!(out.contains("[macd]"), "missing opaque macd node:\n{out}");
assert_eq!(out.matches("macd(").count(), 1, "macd defined once:\n{out}");
assert!(
out.contains("macd(fast:i64, slow:i64, signal:i64) -> (macd, signal, histogram)"),
"typed signature line: {out}"
);
assert!(out.contains("[EMA(fast)]"), "fast EMA folds its param: {out}");
assert!(out.contains("[EMA(slow)]"), "slow EMA folds its param: {out}");
assert!(out.contains("[EMA(signal)]"), "signal EMA folds its param: {out}");
assert!(out.contains("[Sub(#A,#B)]"), "Sub shows its two ordered inputs: {out}");
assert!(out.contains("[in:price]"), "named MACD input role: {out}");
assert!(!out.contains("[param:"), "param marker nodes removed: {out}");
assert!(!out.contains("[out:"), "output prefix dropped: {out}");
```
- [ ] **Step 4: Re-capture `blueprint_view_golden` wholesale**
Run: `cargo test -p aura-cli blueprint_view_golden`
Expected: FAIL — the `where:` definition section drifted (`sma_cross:`
`sma_cross() -> (cross):`, `[Sub]``[Sub(#A,#B)]`, `[out:cross]``[cross]`).
Read the assertion's "left" (actual) rendered string and replace the entire
`expected` raw-string literal (`main.rs:488-519`) with the actual bytes verbatim —
do NOT hand-edit field-by-field. Re-run: PASS.
- [ ] **Step 5: Confirm `compiled_view_golden` byte-identical (C23 guard)**
Run: `cargo test -p aura-cli compiled_view_golden`
Expected: PASS with NO edit to the golden (`main.rs:529-547`). The compiled view does
not render a composite definition, so it must be untouched. If it drifts, STOP — that
is a bug, not a re-capture.
- [ ] **Step 6: Full workspace triple (-D warnings)**
Run: `cargo build --workspace && cargo test --workspace && cargo clippy --workspace --all-targets -- -D warnings`
Expected: all PASS — every test green (the five repins, the needle update, the MACD
assertions, both goldens), no clippy warnings. Spot-check the acceptance evidence:
Run: `cargo run -q -p aura-cli -- graph --macd`
Expected: the definition title reads
`macd(fast:i64, slow:i64, signal:i64) -> (macd, signal, histogram):`, the EMAs read
`[EMA(fast)]`/`[EMA(slow)]`/`[EMA(signal)]`, the Subs read `[Sub(#A,#B)]`, outputs
read `[macd]`/`[signal]`/`[histogram]`, and no `[param:*]` marker appears.
Run: `cargo run -q -p aura-cli -- run --macd`
Expected: deterministic JSON, `total_pips` and `exposure_sign_flips` unchanged from
before (render-only change does not touch the run).
@@ -1,647 +0,0 @@
# Fan-in input distinguishability — Implementation Plan
> **Parent spec:** `docs/specs/0021-fan-in-distinguishability.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Make a fan-in node whose colliding sources hide an unnamed
configuration axis illegal at construction, and render fan-in inputs as
source-derived recursive-signature identifiers instead of positional `#A/#B`.
**Architecture:** A shared `signature_of(&Composite, node)` helper in
aura-engine is the single source of truth for a node's recursive authoring
identity. The CLI render (`leaf_label`) abbreviates it to the shortest
sibling-unique prefix; the engine construction check (`inline_composite`)
rejects a fan-in where two sources share a signature and at least one has an
unaliased param. Task order keeps the whole workspace green at every boundary:
helper → CLI render+fixture → engine constraint+fixtures → ledger.
**Tech Stack:** aura-engine (`blueprint.rs`), aura-cli (`graph.rs`, `main.rs`),
aura-core (`LeafFactory`), `docs/design/INDEX.md`.
**Files this plan creates or modifies:**
- Modify: `crates/aura-engine/src/blueprint.rs``signature_of` helper (Task 1);
`CompileError::IndistinguishableFanIn` + `inline_composite` check + engine
fixture aliases (Task 3).
- Modify: `crates/aura-engine/src/lib.rs` — export `signature_of`.
- Modify: `crates/aura-cli/src/graph.rs:160-195``leaf_label` / new
`fan_in_identifiers` (Task 2).
- Modify: `crates/aura-cli/src/main.rs:122-137``sma_cross` aliases; render
test/golden/needle updates (Task 2).
- Modify: `docs/design/INDEX.md` — C9 contract refinement (Task 4).
- Test: engine signature unit tests, constraint accept/reject tests; CLI render
tests + recaptured goldens.
---
## Task 1: Engine — shared recursive `signature_of` helper
**Files:**
- Modify: `crates/aura-engine/src/blueprint.rs`
- Modify: `crates/aura-engine/src/lib.rs`
- Test: `crates/aura-engine/src/blueprint.rs` (test module)
Pure addition — no behaviour change, nothing breaks. Establishes the single
source of truth for a node's signature used by both later tasks.
- [ ] **Step 1: Write the failing test**
In the `blueprint.rs` `#[cfg(test)]` module, add (the fixtures `sma_cross()` at
`:919` and `fan_composite()` at `:581` already exist; `Ema`/`Sma`/`Sub`
factories are imported there):
```rust
#[test]
fn signature_of_is_type_initial_plus_aliases_plus_recursive_inputs() {
// EMA(fast) fed by role price -> "E" + "f"(alias) + "p"(role, no descent)
let c = macd_like_signature_fixture(); // built below
let sig = |n| signature_of(c.nodes(), c.edges(), c.input_roles(), c.params(), n);
// node 0 = Ema aliased "fast", fed by role "price"
assert_eq!(sig(0), "Efp");
// node 2 = Sub(node0, node1) where node1 = Ema aliased "slow" -> "S"+inputs
assert_eq!(sig(2), "SEfpEsp");
}
/// A composite: two aliased EMAs (fast, slow) on role `price`, into a Sub.
fn macd_like_signature_fixture() -> Composite {
Composite::new(
"sig",
vec![Ema::factory().into(), Ema::factory().into(), Sub::factory().into()],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
],
vec![Role {
name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
}],
vec![
ParamAlias { name: "fast".into(), node: 0, slot: 0 },
ParamAlias { name: "slow".into(), node: 1, slot: 0 },
],
vec![OutField { node: 2, field: 0, name: "x".into() }],
)
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cargo test -p aura-engine signature_of_is_type_initial`
Expected: FAIL — `cannot find function signature_of in this scope`.
- [ ] **Step 3: Write the helper**
In `blueprint.rs` (module-level `pub fn`, near `param_space`/`collect_params`):
Operate on the destructured pieces (not `&Composite`), so the engine check can
run after `inline_composite`'s destructure and the CLI can call it via the
composite accessors. Add near `param_space`/`collect_params`:
```rust
/// The recursive authoring signature of interior node `node`: the type initial,
/// then one initial per declared param alias (declared order), then each wired
/// input's signature in slot order — recursing into interior-leaf sources,
/// stopping at a named source (role name / nested-composite name). Single source
/// of truth for the fan-in distinguishability check (collision = equal
/// signatures) and the CLI render (shortest sibling-unique prefix). Terminates:
/// the dataflow is a DAG (C5) and the descent stops at named ports.
pub fn signature_of(
nodes: &[BlueprintNode],
edges: &[Edge],
roles: &[Role],
aliases: &[ParamAlias],
node: usize,
) -> String {
let mut s = String::new();
match &nodes[node] {
BlueprintNode::Leaf(f) => {
if let Some(ch) = f.label().chars().next() {
s.push(ch);
}
for a in aliases.iter().filter(|a| a.node == node) {
if let Some(ch) = a.name.chars().next() {
s.push(ch);
}
}
// wired input slots in slot order; per slot, the source's signature
// (interior edge -> recurse; role -> the role name, no descent)
let mut slotted: Vec<(usize, String)> = Vec::new();
for e in edges.iter().filter(|e| e.to == node) {
slotted.push((e.slot, signature_of(nodes, edges, roles, aliases, e.from)));
}
for r in roles {
for t in r.targets.iter().filter(|t| t.node == node) {
slotted.push((t.slot, r.name.clone()));
}
}
slotted.sort_by_key(|(slot, _)| *slot);
for (_, sig) in slotted {
s.push_str(&sig);
}
}
BlueprintNode::Composite(inner) => {
if let Some(ch) = inner.name().chars().next() {
s.push(ch);
}
}
}
s
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cargo test -p aura-engine signature_of_is_type_initial`
Expected: PASS.
- [ ] **Step 5: Export the helper**
In `crates/aura-engine/src/lib.rs`, add `signature_of` to the `pub use
blueprint::{...}` list (alongside `CompileError`, `Composite`).
- [ ] **Step 6: Workspace still green**
Run: `cargo test --workspace`
Expected: PASS (pure addition; no existing behaviour changed).
---
## Task 2: CLI — source-derived render identifiers + `sma_cross` aliases
**Files:**
- Modify: `crates/aura-cli/src/graph.rs:160-195`
- Modify: `crates/aura-cli/src/main.rs:122-137` and render tests
- Test: `crates/aura-cli/src/main.rs`
The engine has no constraint yet, so an aliased CLI `sma_cross` renders fine and
the goldens move once. After this task the whole CLI suite is green and the
workspace stays green.
- [ ] **Step 1: Add `fast`/`slow` aliases to the CLI `sma_cross` fixture**
In `main.rs:122` `fn sma_cross`, the empty params vec (`:134`) becomes:
```rust
vec![
ParamAlias { name: "fast".into(), node: 0, slot: 0 }, // fast SMA length
ParamAlias { name: "slow".into(), node: 1, slot: 0 }, // slow SMA length
],
```
- [ ] **Step 2: Replace the positional stub block in `leaf_label`**
In `graph.rs`, replace the stub-construction block (`:182-186`):
```rust
let stubs: Vec<String> = if slots.len() > 1 {
slots.iter().map(|s| format!("#{}", (b'A' + *s as u8) as char)).collect()
} else {
Vec::new()
};
```
with a call to a new helper, and add the helper below `leaf_label`:
```rust
let stubs: Vec<String> = if slots.len() > 1 {
fan_in_identifiers(c, index, &slots)
} else {
Vec::new()
};
```
```rust
/// One `#…` identifier per wired slot of a fan-in leaf, in slot order.
/// - A role-fed slot uses the role name verbatim (`#price`), never shortened.
/// - An interior-fed slot uses its source signature, never shorter than the
/// source's **base** (type initial + alias initials), extended into the
/// recursive tail only as far as needed to be unique among the siblings.
/// - Two siblings with equal full signatures (interchangeable inputs) cannot be
/// separated — those slots fall back to the positional letter `#A`. The engine
/// constraint guarantees no configuration-distinct pair fully collides, so a
/// valid blueprint reaches the fallback only for genuinely-interchangeable
/// inputs.
fn fan_in_identifiers(c: &Composite, index: usize, slots: &[usize]) -> Vec<String> {
// per slot: (slot, signature, base_len, is_role)
let srcs: Vec<(usize, String, usize, bool)> = slots
.iter()
.map(|&slot| {
let (sig, base, is_role) = slot_source(c, index, slot);
(slot, sig, base, is_role)
})
.collect();
srcs.iter()
.map(|(slot, sig, base, is_role)| {
if *is_role {
return format!("#{sig}"); // role name verbatim
}
let others: Vec<&String> =
srcs.iter().filter(|(s, _, _, _)| s != slot).map(|(_, x, _, _)| x).collect();
match unique_prefix_from(sig, *base, &others) {
Some(p) => format!("#{p}"),
None => format!("#{}", (b'A' + *slot as u8) as char), // interchangeable fallback
}
})
.collect()
}
/// The source feeding `(index, slot)`: `(signature, base_len, is_role)`. A role
/// returns its name verbatim with `is_role = true` (base_len unused); an interior
/// producer returns its `signature_of` and its base length (type initial + alias
/// initials).
fn slot_source(c: &Composite, index: usize, slot: usize) -> (String, usize, bool) {
for e in c.edges() {
if e.to == index && e.slot == slot {
let sig = signature_of(c.nodes(), c.edges(), c.input_roles(), c.params(), e.from);
return (sig, signature_base_len(c, e.from), false);
}
}
for r in c.input_roles() {
if r.targets.iter().any(|t| t.node == index && t.slot == slot) {
return (r.name.clone(), 0, true);
}
}
(String::new(), 0, false)
}
/// The base length of an interior node's signature: 1 (type / composite-name
/// initial) plus one per declared param alias on that node — the minimum the
/// rendered identifier never goes below.
fn signature_base_len(c: &Composite, node: usize) -> usize {
match &c.nodes()[node] {
BlueprintNode::Leaf(_) => 1 + c.params().iter().filter(|a| a.node == node).count(),
BlueprintNode::Composite(_) => 1,
}
}
/// The shortest prefix of `sig` of length ≥ `base` that no `other` starts with —
/// i.e. distinguishes `sig` from all siblings while never dropping below the
/// base. `None` when some `other` equals `sig` in full (inseparable —
/// interchangeable, caller uses the positional fallback).
fn unique_prefix_from(sig: &str, base: usize, others: &[&String]) -> Option<String> {
if others.iter().any(|o| o.as_str() == sig) {
return None;
}
let chars: Vec<char> = sig.chars().collect();
if chars.is_empty() {
return Some(String::new()); // degenerate (no producer); not reached for a wired slot
}
let start = base.max(1).min(chars.len());
for len in start..=chars.len() {
let prefix: String = chars[..len].iter().collect();
if others.iter().all(|o| !o.starts_with(&prefix)) {
return Some(prefix);
}
}
Some(sig.to_string())
}
```
Add `use aura_engine::signature_of;` to the imports at the top of `graph.rs`
(the existing `use aura_engine::{...}` line).
- [ ] **Step 3: Update the MACD render asserts**
In `main.rs` `macd_blueprint_renders_a_nested_composite_definition` (`:585`),
replace the single assert at `:598`:
```rust
assert!(out.contains("[Sub(#A,#B)]"), "Sub shows its two ordered inputs: {out}");
```
with two distinct-label asserts:
```rust
assert!(out.contains("[Sub(#Ef,#Es)]"), "MACD line Sub is fast-EMA minus slow-EMA: {out}");
assert!(out.contains("[Sub(#S,#Es)]"), "histogram Sub is the line minus signal-EMA: {out}");
```
- [ ] **Step 4: Update the sample needle test**
In `main.rs` `blueprint_view_defines_each_composite_once` (`:397`), the needle
array (`:402`) changes `[SMA]` and `[Sub(#A,#B)]`:
```rust
for needle in ["[SMA(fast)]", "[SMA(slow)]", "[Sub(#Sf,#Ss)]", "[price]", "[cross]"] {
```
- [ ] **Step 5: Re-capture `blueprint_view_golden`**
The golden string in `blueprint_view_golden` (`:491-530`) is layout-exact and
must be regenerated, not hand-edited.
Run: `cargo run -q --bin aura -- graph`
Copy the emitted `where: … sma_cross(fast:i64, slow:i64) -> (cross): …` block
verbatim into the golden literal, replacing the old `sma_cross()` / `[SMA]` /
`[Sub(#A,#B)]` lines.
Expected after edit: the golden shows `sma_cross(fast:i64, slow:i64) -> (cross)`,
`[SMA(fast)]`, `[SMA(slow)]`, `[Sub(#Sf,#Ss)]`.
- [ ] **Step 6: Add a focused render unit test**
In `main.rs` test module, add a test on hand-built composites covering the cases
the goldens don't isolate:
```rust
#[test]
fn fan_in_identifiers_are_source_derived_and_scoped_per_node_call() {
use aura_engine::{Blueprint, BlueprintNode};
// role passthrough: a Sub fed by role `price` + an EMA(slow) ->
// #price (role name) and #Es
let c = Composite::new(
"roles",
vec![Ema::factory().into(), Sub::factory().into()],
vec![Edge { from: 0, to: 1, slot: 1, from_field: 0 }],
vec![Role { name: "price".into(), targets: vec![Target { node: 1, slot: 0 }] }],
vec![ParamAlias { name: "slow".into(), node: 0, slot: 0 }],
vec![OutField { node: 1, field: 0, name: "o".into() }],
);
let bp = Blueprint::new(vec![BlueprintNode::Composite(c)], vec![], vec![]);
let out = graph::render_blueprint(&bp, graph::Color::Plain);
assert!(out.contains("[Sub(#price,#Es)]"), "role name verbatim + source-derived: {out}");
}
#[test]
fn fan_in_identifiers_descend_into_bare_combinators() {
use aura_engine::{Blueprint, BlueprintNode};
// Sub( Sub(EMA fast, EMA slow), Sub(EMA up, EMA down) ): the two inner Subs
// are param-less but have distinct recursive signatures (SEf… vs SEu…), so
// the outer Sub descends just far enough -> [Sub(#SEf,#SEu)].
let c = Composite::new(
"nest",
vec![
Ema::factory().into(), // 0 fast
Ema::factory().into(), // 1 slow
Ema::factory().into(), // 2 up
Ema::factory().into(), // 3 down
Sub::factory().into(), // 4 = Sub(0,1)
Sub::factory().into(), // 5 = Sub(2,3)
Sub::factory().into(), // 6 = Sub(4,5) (the outer fan-in)
],
vec![
Edge { from: 0, to: 4, slot: 0, from_field: 0 },
Edge { from: 1, to: 4, slot: 1, from_field: 0 },
Edge { from: 2, to: 5, slot: 0, from_field: 0 },
Edge { from: 3, to: 5, slot: 1, from_field: 0 },
Edge { from: 4, to: 6, slot: 0, from_field: 0 },
Edge { from: 5, to: 6, slot: 1, from_field: 0 },
],
vec![Role {
name: "price".into(),
targets: vec![
Target { node: 0, slot: 0 },
Target { node: 1, slot: 0 },
Target { node: 2, slot: 0 },
Target { node: 3, slot: 0 },
],
}],
vec![
ParamAlias { name: "fast".into(), node: 0, slot: 0 },
ParamAlias { name: "slow".into(), node: 1, slot: 0 },
ParamAlias { name: "up".into(), node: 2, slot: 0 },
ParamAlias { name: "down".into(), node: 3, slot: 0 },
],
vec![OutField { node: 6, field: 0, name: "o".into() }],
);
let bp = Blueprint::new(vec![BlueprintNode::Composite(c)], vec![], vec![]);
let out = graph::render_blueprint(&bp, graph::Color::Plain);
assert!(out.contains("[Sub(#SEf,#SEu)]"), "outer Sub descends into inner Subs: {out}");
assert!(out.contains("[Sub(#Ef,#Es)]"), "inner Sub uses EMA aliases: {out}");
}
```
(The interchangeable `[Join(#A,#B)]` positional fallback is pinned engine-side
by `interchangeable_fan_in_allowed` plus a render check is unnecessary — the
fallback path is the unchanged positional branch.)
- [ ] **Step 7: Confirm `compiled_view_golden` is byte-stable**
Run: `cargo test -p aura-cli compiled_view_golden`
Expected: PASS unchanged — the flat graph carries no aliases / `#` identifiers
(C23 guard). If it fails, the render change leaked into the compiled view — stop
and inspect; do not re-capture this golden.
- [ ] **Step 8: Full CLI + workspace green**
Run: `cargo test --workspace`
Expected: PASS. (Engine untouched this task; CLI self-consistent with aliased
`sma_cross` + source-derived render.)
---
## Task 3: Engine — `IndistinguishableFanIn` constraint + fixture aliases
**Files:**
- Modify: `crates/aura-engine/src/blueprint.rs` (`CompileError`, `inline_composite`, engine fixtures)
- Test: `crates/aura-engine/src/blueprint.rs`
The CLI `sma_cross` is already aliased (Task 2), so adding the constraint does
not break the CLI. RED-first: the reject test before the check.
- [ ] **Step 1: Write the failing constraint test**
In the `blueprint.rs` test module, beside `bad_interior_index_rejected`
(`~:782`):
```rust
#[test]
fn indistinguishable_fan_in_rejected() {
// two alias-less Sma (each an unaliased `length`) on role price into a Sub:
// signatures collide ("Sp"=="Sp") and a param is unaliased -> fault.
let c = Composite::new(
"ambig",
vec![Sma::factory().into(), Sma::factory().into(), Sub::factory().into()],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
],
vec![Role {
name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
}],
vec![],
vec![OutField { node: 2, field: 0, name: "x".into() }],
);
let bp = Blueprint::new(
vec![BlueprintNode::Composite(c)],
vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }],
vec![],
);
assert_eq!(bp.compile().err(), Some(CompileError::IndistinguishableFanIn { node: 2 }));
}
#[test]
fn interchangeable_fan_in_allowed() {
// fan_composite: two param-less Pass into a Join, equal signatures but no
// unaliased param -> interchangeable -> Ok.
let bp = Blueprint::new(
vec![BlueprintNode::Composite(fan_composite()), sink_f64()],
vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }],
vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
);
assert!(bp.compile().is_ok(), "param-less interchangeable fan-in must compile");
}
```
- [ ] **Step 2: Run to verify the reject test fails**
Run: `cargo test -p aura-engine indistinguishable_fan_in_rejected`
Expected: FAIL — `no variant named IndistinguishableFanIn` (does not compile yet)
or, once the variant exists but no check, the composite compiles `Ok` so the
assert fails.
- [ ] **Step 3: Add the `CompileError` variant**
In `blueprint.rs:127` `enum CompileError`, add:
```rust
/// A fan-in node (>1 input) at interior index `node` has two input slots fed by
/// sources with identical signatures where at least one source carries an
/// unaliased param slot — the inputs differ in configuration but share a
/// rendered identity. Name the distinguishing param (e.g. fast/slow).
IndistinguishableFanIn { node: usize },
```
- [ ] **Step 4: Add the check in `inline_composite`, after the alias-validity loop**
The check must run **after** the existing alias-validity loop (`:361-367`, which
raises `BadInteriorIndex` for a bogus alias index — `out_of_range_param_alias_rejected`
depends on that ordering) and **before** `lower_items` (`:370`). It operates on
the already-destructured pieces (`nodes`, `edges`, `input_roles`, `param_aliases`
— bound at `:356`). Insert directly after the alias loop closes at `:367`:
```rust
// Fan-in distinguishability: for each node with >1 wired input slot, a
// collision (equal source signatures) is a fault only when at least one
// colliding source has an unaliased param slot — the unnamed configuration
// axis. Runs after alias-validity (so a bad alias is BadInteriorIndex first)
// and before lowering. signature_of/leaf_has_unaliased_param take the pieces.
for node in 0..nodes.len() {
let mut sources: Vec<(usize, String, bool)> = Vec::new(); // (slot, sig, has_unaliased_param)
for e in edges.iter().filter(|e| e.to == node) {
sources.push((
e.slot,
signature_of(&nodes, &edges, &input_roles, &param_aliases, e.from),
leaf_has_unaliased_param(&nodes, &param_aliases, e.from),
));
}
for r in &input_roles {
for t in r.targets.iter().filter(|t| t.node == node) {
sources.push((t.slot, r.name.clone(), false)); // a role has no param of its own
}
}
if sources.len() < 2 {
continue;
}
for i in 0..sources.len() {
for j in (i + 1)..sources.len() {
if sources[i].1 == sources[j].1 && (sources[i].2 || sources[j].2) {
return Err(CompileError::IndistinguishableFanIn { node });
}
}
}
}
```
and a helper near `signature_of`:
```rust
/// Whether interior leaf `node` has at least one param slot with no alias in
/// `aliases` — the unnamed configuration axis the fan-in rule keys on. A non-leaf
/// (nested composite) reports `false`.
fn leaf_has_unaliased_param(nodes: &[BlueprintNode], aliases: &[ParamAlias], node: usize) -> bool {
match &nodes[node] {
BlueprintNode::Leaf(f) => {
let n_params = f.params().len();
let aliased = aliases.iter().filter(|a| a.node == node).count();
n_params > aliased
}
BlueprintNode::Composite(_) => false,
}
}
```
(Note: `param_alias_relabels_param_space_name_in_place` and
`param_space_is_flat_path_qualified_and_slot_disambiguated` call only
`param_space()`, never `compile()`, so the check does not reach them — they stay
green unchanged, including the deliberate duplicate `*.length` names.)
- [ ] **Step 5: Run the constraint tests**
Run: `cargo test -p aura-engine indistinguishable_fan_in_rejected interchangeable_fan_in_allowed`
Expected: both PASS.
- [ ] **Step 6: Correct the engine-local param-bearing fixtures**
Two fixtures actually reach `compile()` with the param-bearing alias-less shape
and now fail; correct exactly those:
1. **Engine-local `sma_cross()` (`:919`)** — used by `composite_sma_cross_harness`
(eight tests). Its empty params vec (`:931`) gains:
```rust
vec![
ParamAlias { name: "fast".into(), node: 0, slot: 0 },
ParamAlias { name: "slow".into(), node: 1, slot: 0 },
],
```
Then re-pin the name assert in `param_space_mirrors_compiled_flat_node_param_order`
(`:1149-1152`): `["sma_cross.length", "sma_cross.length", "scale"]`
`["sma_cross.fast", "sma_cross.slow", "scale"]`.
2. **The `fast_slow` fixture inside `param_space_mirrors_compiled_flat_node_param_order_under_nesting`
(`:1321`)** — this test calls `compile_with_params` (`:1352`). Its empty params
vec (`:1332`) gains the same `fast`/`slow` aliases. This test asserts only
per-slot *kinds* and *order* (`:1355+`), not names, so it stays green after the
alias (aliases rename, don't reorder).
Do **not** touch the separate `fast_slow` in `param_space_is_flat_path_qualified_and_slot_disambiguated`
(`:1265`): it calls only `param_space()`, never `compile()`, so the constraint
never reaches it; it deliberately demonstrates two same-type slots sharing the
`fast_slow.length` name disambiguated by slot, and its name assert (`:1292-1300`)
must stay as-is.
- [ ] **Step 7: Full engine + workspace green**
Run: `cargo test --workspace`
Expected: PASS. `fan_composite`-based tests (`:599`, `:678`) stay green
(interchangeable); the hand-wired flat fixtures (`main.rs:46`, `blueprint.rs:877`)
bypass `inline_composite` and stay green.
- [ ] **Step 8: Lint clean**
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: no warnings.
---
## Task 4: Design-ledger contract refinement
**Files:**
- Modify: `docs/design/INDEX.md`
- [ ] **Step 1: Add the refinement note**
Under the C9 contract (composite well-formedness / authoring-level identity),
add a dated refinement paragraph mirroring the existing `**Refinement (…).**`
shape:
```markdown
**Refinement (fan-in distinguishability, 2026-06-08).** A fan-in node (>1 input)
is well-formed only if its colliding sources — sources with equal recursive
signatures (type initial + alias initials + recursive input signatures) — do not
hide an unnamed configuration axis: a collision is a `CompileError`
(`IndistinguishableFanIn`) when at least one colliding source carries an
unaliased param slot. Genuinely-interchangeable sources (equal signatures, no
param) stay legal. Construction-phase only; the flat graph stays name-free (C23).
The graph view renders each fan-in input as the shortest sibling-unique prefix
of its source signature.
```
- [ ] **Step 2: Confirm no doc build breakage**
Run: `cargo doc --workspace --no-deps 2>&1`
Expected: no new warnings (markdown-only change).
@@ -1,357 +0,0 @@
# Composite Output Re-exports as Producer Bindings — Implementation Plan
> **Parent spec:** `docs/specs/0022-composite-output-binding-render.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** In the blueprint view's `where:` section, render a composite's output
re-exports as bindings on their producing node's label (`name := <producer-label>`)
instead of standalone terminal nodes.
**Architecture:** A single render-layer change in `crates/aura-cli/src/graph.rs`:
`render_definition` drops its per-`OutField` node+edge loop and instead prefixes
each interior node's label with an `output_binding(c, i)` (single `name := ` or
tuple `(a, b) := `). RED-first: re-pin every render test that asserts a producer
label which carries an `OutField` (the blast radius is wider than the spec's
Components table named — see Scope note), then implement.
**Tech Stack:** Rust; `aura-cli` (`graph.rs` renderer + in-crate tests +
`tests/cli_run.rs` integration test); `aura-engine::OutField` (read-only).
---
## Scope note — test blast radius (wider than spec §Components)
The spec's Components table named only `macd_blueprint_renders_a_nested_composite_definition`.
Recon + a workspace grep found **four** more render assertions that mechanically
break, because their asserted producer node carries an `OutField` and so gains a
`:=` prefix (the leading `[` in `contains("[Sub(…)]")` stops matching once the
label becomes `[name := Sub(…)]`). All are forced by the design — zero design
judgement. Full list (the only render assertions that change):
| Site | Test | Producer / OutField | Old assertion | New assertion |
|------|------|---------------------|---------------|---------------|
| `main.rs:405` | `blueprint_view_defines_each_composite_once` | `sma_cross` node 2 = `OutField "cross"` | `[Sub(#Sf,#Ss)]` + standalone `[cross]` | `[cross := Sub(#Sf,#Ss)]` (drop standalone `[cross]`) |
| `main.rs:600-602` | `macd_blueprint_renders_a_nested_composite_definition` | macd nodes 2/3/4 = `macd`/`signal`/`histogram` | `[EMA(signal)]`, `[Sub(#Ef,#Es)]`, `[Sub(#S,#Es)]` | `[signal := EMA(signal)]`, `[macd := Sub(#Ef,#Es)]`, `[histogram := Sub(#S,#Es)]` + negatives on `[signal]`/`[histogram]` |
| `main.rs:622` | `fan_in_identifiers_are_source_derived_and_scoped_per_node_call` | `roles` node 1 = `OutField "o"` | `[Sub(#price,#Es)]` | `[o := Sub(#price,#Es)]` |
| `main.rs:668` | `fan_in_identifiers_descend_into_bare_combinators` | `nest` node 6 = `OutField "o"` | `[Sub(#SEf,#SEu)]` | `[o := Sub(#SEf,#SEu)]` |
| `cli_run.rs:177` | `graph_renders_source_derived_fan_in_identifiers` | `sma_cross` node 2 = `OutField "cross"` | `[Sub(#Sf,#Ss)]` | `[cross := Sub(#Sf,#Ss)]` |
**Verified to survive unchanged** (asserted producer carries NO OutField, or the
assertion targets a main-graph opaque node): `main.rs:598/599` (`[EMA(fast)]`,
`[EMA(slow)]`), `main.rs:405` (`[SMA(fast)]`, `[SMA(slow)]`, `[price]`),
`main.rs:592` (`[macd]` opaque main node), `main.rs:669` (`[Sub(#Ef,#Es)]``nest`
node 4 carries no OutField), `main.rs:681` (`[macd]` opaque), `main.rs:437-440`
(`nested_composite_renders_without_panic` asserts only `[outer]`/`[inner]` opaque
nodes + `(`-counts; the re-exported producers there are not asserted), `cli_run.rs:182`
(negative `!contains("[Sub(#A,#B)]")`). The compiled-view golden and the MACD
determinism test are render-independent regression guards.
---
**Files this plan creates or modifies:**
- Modify: `crates/aura-cli/src/main.rs` — 4 render-assertion sites re-pinned (Task 1)
- Modify: `crates/aura-cli/tests/cli_run.rs:177` — 1 render-assertion site re-pinned (Task 1)
- Modify: `crates/aura-cli/src/graph.rs:19` — add `OutField` to the `aura_engine` use group (Task 2)
- Modify: `crates/aura-cli/src/graph.rs:282-315` — rewrite `render_definition` doc comment + output handling; add `output_binding` helper (Task 2)
No new files; no engine/blueprint/bootstrap change; the `fn macd` / `fn sma_cross`
fixtures are unchanged.
---
## Task 1: RED — re-pin render assertions to the `:=` binding form
**Files:**
- Modify: `crates/aura-cli/src/main.rs` (lines 405, 600-602, 622, 668)
- Modify: `crates/aura-cli/tests/cli_run.rs` (line 177)
- [ ] **Step 1: Re-pin `blueprint_view_defines_each_composite_once` (main.rs:405)**
Replace this exact line:
```rust
for needle in ["[SMA(fast)]", "[SMA(slow)]", "[Sub(#Sf,#Ss)]", "[price]", "[cross]"] {
```
with:
```rust
for needle in ["[SMA(fast)]", "[SMA(slow)]", "[cross := Sub(#Sf,#Ss)]", "[price]"] {
```
(The `cross` output is now folded onto its producer Sub, so `[Sub(#Sf,#Ss)]` becomes
`[cross := Sub(#Sf,#Ss)]` and the standalone `[cross]` stub is gone.)
- [ ] **Step 2: Re-pin the MACD producer assertions + add negatives (main.rs:600-602)**
Replace these exact three lines:
```rust
assert!(out.contains("[EMA(signal)]"), "signal EMA folds its param: {out}");
assert!(out.contains("[Sub(#Ef,#Es)]"), "MACD line Sub is fast-EMA minus slow-EMA: {out}");
assert!(out.contains("[Sub(#S,#Es)]"), "histogram Sub is the line minus signal-EMA: {out}");
```
with:
```rust
assert!(out.contains("[macd := Sub(#Ef,#Es)]"), "macd line Sub bound as output `macd`: {out}");
assert!(out.contains("[signal := EMA(signal)]"), "signal EMA bound as output `signal` (name/param pun is intended): {out}");
assert!(out.contains("[histogram := Sub(#S,#Es)]"), "histogram Sub bound as output `histogram`: {out}");
// output re-exports are folded onto their producers — no standalone stubs.
// `[macd]` is NOT a valid negative here: it still appears as the opaque
// composite node in the MAIN graph. Discriminate on signal/histogram.
assert!(!out.contains("[signal]"), "no standalone signal output stub: {out}");
assert!(!out.contains("[histogram]"), "no standalone histogram output stub: {out}");
```
(`[EMA(fast)]`/`[EMA(slow)]` on the lines just above stay unchanged — nodes 0/1
carry no OutField.)
- [ ] **Step 3: Re-pin `fan_in_identifiers_are_source_derived_and_scoped_per_node_call` (main.rs:622)**
Replace this exact line:
```rust
assert!(out.contains("[Sub(#price,#Es)]"), "role name verbatim + source-derived: {out}");
```
with:
```rust
assert!(out.contains("[o := Sub(#price,#Es)]"), "role name verbatim + source-derived, bound as output `o`: {out}");
```
- [ ] **Step 4: Re-pin `fan_in_identifiers_descend_into_bare_combinators` (main.rs:668)**
Replace this exact line:
```rust
assert!(out.contains("[Sub(#SEf,#SEu)]"), "outer Sub descends into inner Subs: {out}");
```
with:
```rust
assert!(out.contains("[o := Sub(#SEf,#SEu)]"), "outer Sub descends into inner Subs, bound as output `o`: {out}");
```
(The next line, `assert!(out.contains("[Sub(#Ef,#Es)]"), …)`, stays unchanged —
`nest` node 4 carries no OutField.)
- [ ] **Step 5: Re-pin the CLI integration test (cli_run.rs:177)**
Replace this exact block:
```rust
assert!(
stdout.contains("[Sub(#Sf,#Ss)]"),
"fan-in inputs must be source-derived (#Sf/#Ss), not positional: {stdout}"
);
```
with:
```rust
assert!(
stdout.contains("[cross := Sub(#Sf,#Ss)]"),
"fan-in inputs source-derived (#Sf/#Ss), bound as output `cross`: {stdout}"
);
```
(The negative assertion below, `!stdout.contains("[Sub(#A,#B)]")`, stays unchanged.)
- [ ] **Step 6: Run the re-pinned tests to verify they FAIL (RED)**
Run: `cargo test -p aura-cli 2>&1 | tail -40`
Expected: FAIL. The current renderer still emits standalone output nodes and bare
producer labels, so the new `contains("[… := …]")` assertions fail (and the MACD
negatives `!contains("[signal]")` fail because the standalone `[signal]`/`[histogram]`
stubs still exist). Failing tests include `blueprint_view_defines_each_composite_once`,
`macd_blueprint_renders_a_nested_composite_definition`,
`fan_in_identifiers_are_source_derived_and_scoped_per_node_call`,
`fan_in_identifiers_descend_into_bare_combinators`, and (integration)
`graph_renders_source_derived_fan_in_identifiers`.
## Task 2: GREEN — fold output re-exports onto their producers in `render_definition`
**Files:**
- Modify: `crates/aura-cli/src/graph.rs:19` (use group)
- Modify: `crates/aura-cli/src/graph.rs:282-315` (`render_definition` + new `output_binding`)
- [ ] **Step 1: Import `OutField` (graph.rs:19)**
Replace this exact line:
```rust
use aura_engine::{aliases_on, signature_of, Blueprint, BlueprintNode, Composite, Edge, SourceSpec};
```
with:
```rust
use aura_engine::{
aliases_on, signature_of, Blueprint, BlueprintNode, Composite, Edge, OutField, SourceSpec,
};
```
- [ ] **Step 2: Rewrite `render_definition`'s doc comment + body and add `output_binding`**
Replace the exact current doc comment + function (graph.rs:282-315):
```rust
/// Render one composite's interior as a flat graph: interior leaves as
/// `[type(param…; #slot…)]` (aliased param names + ordered input-slot stubs folded
/// in via `leaf_label`), nested composites as opaque `[name]`, an `[<name>]`
/// entry marker per input role (wired to its interior targets), and an `[<name>]`
/// node per re-exported output field (wired from its producer). The title line is
/// the composite's typed `signature` (`name(p:kind, …) -> (out, …)`); params live
/// in the signature, not as marker nodes.
fn render_definition(c: &Composite, color: Color) -> String {
let mut labels: Vec<String> = Vec::with_capacity(c.nodes().len());
for (i, inner) in c.nodes().iter().enumerate() {
labels.push(match inner {
BlueprintNode::Leaf(factory) => leaf_label(c, i, factory),
BlueprintNode::Composite(inner_c) => inner_c.name().to_string(),
});
}
let mut edges: Vec<(usize, usize)> = Vec::new();
for e in c.edges() {
edges.push((e.from, e.to));
}
for role in c.input_roles() {
let in_id = labels.len();
labels.push(role.name.clone());
for t in &role.targets {
edges.push((in_id, t.node));
}
}
for of in c.output() {
let out_id = labels.len();
labels.push(of.name.clone());
edges.push((of.node, out_id));
}
format!("{}:\n\n{}", signature(c), render_flat(&labels, &edges, color))
}
```
with:
```rust
/// Render one composite's interior as a flat graph: interior leaves as
/// `[type(param…; #slot…)]` (aliased param names + ordered input-slot stubs folded
/// in via `leaf_label`), nested composites as opaque `[name]`, and an `[<name>]`
/// entry marker per input role (wired to its interior targets). A re-exported
/// output is **not** a standalone node: its name is folded onto its producing
/// node's label as a binding (`[macd := Sub(…)]`, tuple `(a, b) := …` for several
/// outputs on one node) via [`output_binding`] — so the drawn graph is exactly the
/// computation DAG, every node a real step (C23: the name is a render symbol on the
/// producer, never a wired terminal). The title line is the composite's typed
/// `signature` (`name(p:kind, …) -> (out, …)`); params live in the signature, not
/// as marker nodes.
fn render_definition(c: &Composite, color: Color) -> String {
let mut labels: Vec<String> = Vec::with_capacity(c.nodes().len());
for (i, inner) in c.nodes().iter().enumerate() {
let base = match inner {
BlueprintNode::Leaf(factory) => leaf_label(c, i, factory),
BlueprintNode::Composite(inner_c) => inner_c.name().to_string(),
};
labels.push(match output_binding(c, i) {
Some(prefix) => format!("{prefix}{base}"),
None => base,
});
}
let mut edges: Vec<(usize, usize)> = Vec::new();
for e in c.edges() {
edges.push((e.from, e.to));
}
for role in c.input_roles() {
let in_id = labels.len();
labels.push(role.name.clone());
for t in &role.targets {
edges.push((in_id, t.node));
}
}
// outputs are folded onto their producers by output_binding above — no
// standalone output node and no producer→output edge (C23).
format!("{}:\n\n{}", signature(c), render_flat(&labels, &edges, color))
}
/// The `name := ` (single) or `(n1, n2) := ` (tuple) binding prefix for interior
/// node `i`, if any `OutField` re-exports it; `None` for a non-output node. Names
/// are ordered by re-exported `field` index (ties keep author order). The producer
/// label follows the prefix; no standalone output node or producer→output edge is
/// emitted (the output *is* the producer, surfaced by name — C23).
fn output_binding(c: &Composite, i: usize) -> Option<String> {
let mut outs: Vec<&OutField> = c.output().iter().filter(|of| of.node == i).collect();
if outs.is_empty() {
return None;
}
outs.sort_by_key(|of| of.field);
let names: Vec<&str> = outs.iter().map(|of| of.name.as_str()).collect();
Some(match names.as_slice() {
[one] => format!("{one} := "),
many => format!("({}) := ", many.join(", ")),
})
}
```
- [ ] **Step 3: Run the named render tests to verify they PASS (GREEN)**
Run: `cargo test -p aura-cli 2>&1 | tail -40`
Expected: PASS. All five re-pinned assertions now hold; the MACD negatives pass
(no standalone `[signal]`/`[histogram]`); the survivors (`[EMA(fast)]`, `[price]`,
`[macd]` opaque, `[Sub(#Ef,#Es)]` on `nest` node 4, `compiled_view_golden`,
`run_macd_compiles_from_nested_composite_and_is_deterministic`) stay green.
- [ ] **Step 4: Full workspace gate**
Run: `cargo test --workspace 2>&1 | tail -20`
Expected: PASS (0 failed). In particular `compiled_view_golden` is byte-identical
(C23 — the compiled view never carried output names) and the MACD run is
deterministic and unchanged in metrics.
- [ ] **Step 5: Lint + build gate**
Run: `cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -20`
Expected: PASS, no warnings (the `output_binding` `match names.as_slice()` arms and
the `OutField` import are all used).
---
## Self-review (planner, inline)
1. **Spec coverage:** spec's render change → Task 2; spec's "re-pin the pinning
test" → Task 1 Step 2; the four incidental breaks the spec under-counted →
Task 1 Steps 1/3/4/5 (Scope note documents why each is forced by the design).
Regression guards (golden, determinism) → Task 2 Steps 3-4. Covered.
2. **Placeholder scan:** no TBD/TODO/"similar to"/"implement later". Clean.
3. **Type consistency:** `output_binding`, `OutField`, `render_definition`,
`leaf_label`, `render_flat` spelled identically across tasks and matching the
tree. `OutField` fields (`node`, `field`, `name`) match `blueprint.rs:24`.
4. **Step granularity:** each step is one edit or one command (2-5 min).
5. **No commit steps:** none — orchestrator commits at iter close.
6. **Pin/replacement substring contiguity:** every asserted substring
(`[cross := Sub(#Sf,#Ss)]`, `[macd := Sub(#Ef,#Es)]`, `[signal := EMA(signal)]`,
`[histogram := Sub(#S,#Es)]`, `[o := Sub(#price,#Es)]`, `[o := Sub(#SEf,#SEu)]`)
appears contiguously as a single string literal in its assertion, and is
produced contiguously by the renderer as `format!("{prefix}{base}")` (e.g.
prefix `"macd := "` + base `"Sub(#Ef,#Es)"`; ascii-dag adds the `[…]`). No
soft-wrap split.
7. **Compile-gate vs deferred-caller:** no signature change. `render_definition`'s
signature is unchanged; `output_binding` is new and self-contained. Task 1
leaves the tree compiling (test-body edits only); Task 2's build gate is
satisfiable.
8. **Verification-command filters resolve:** Run steps use `cargo test -p aura-cli`
(whole crate suite — all five named tests exist in it today; no filter
substring that could match zero) and `cargo test --workspace` with an explicit
"0 failed" expectation, so "nothing ran" cannot masquerade as success.
9. **Parse-the-bytes:** all inlined bodies are Rust source-language (test +
implementation), not surface-language snippets; the profile declares no
`spec_validation` parser → parse gate is a no-op. The `implement` Rust compile
gate (Task 2 Steps 4-5) is the real check.
-51
View File
@@ -1,51 +0,0 @@
# Unify the Blueprint Main-Graph Render — Implementation Record
> **Parent spec:** `docs/specs/0023-unify-blueprint-render.md`
>
> **This is NOT a task-by-task plan.** Cycle 0023 was implemented through an
> exploratory spike→refactor path under direct user steering, not the standard
> `planner → implement` decomposition. This file exists to keep the
> spec/plan counter pairing intact (`naming.counter_dirs: [docs/specs, docs/plans]`)
> and to record *why* the standard plan artefact is absent, so the next cycle
> takes slot 0024 and a future reader does not mistake the gap for a lost file.
**Goal:** one shared render path for the blueprint main graph and the composite
`where:` interior — the blueprint is the root composite.
## Why no task-by-task plan
The spec named the direction but left one detail to "the planner/implementer call
against ascii-dag's actual capability": *where* a multi-output producer's selected
field surfaces. Resolving it needed an **empirical** answer, not a paper decision:
1. The obvious default (an ascii-dag per-edge label) turned out to be unreliable —
ascii-dag 0.9.1's `can_place_label` silently drops a label it cannot place
without collision (verified by throwaway probe against the real macd topology).
The Plain path drops it with no legend; the colored path shifts it to a legend.
2. So the field had to move to the **consumer node label**
(`[histogram → Exposure(scale)]`), which also removed the `render_flat`
signature change entirely (no edge-label slot).
A placeholder-free plan could not be written before that probe, so the work ran as
a spike (validate the notation visually with the user) → refactor (collapse the
duplication into one shared `render_graph` over borrowed slices — `LeafFactory`
is not `Clone`, so an owned root-composite adapter is impossible).
## What shipped (commits)
- `5dd9503` — spec: 0023 unify blueprint main-graph render
- `481172a` — feat(aura-cli): unify blueprint main-graph render through one
shared core (`render_graph`; `ParamNames` {Aliases, Factory}; `Entry` unifying
`Role`/`SourceSpec`; deleted the duplicated `blueprint_leaf_label`)
Production change confined to `crates/aura-cli/src/graph.rs`; goldens re-captured
in `crates/aura-cli/src/main.rs`. No engine change. Build/test/clippy green.
## Deferred / debt (filed, not lost)
- **Issue #49** — top-level blueprint multi-input leaves render without `#`-slot
stubs (`stub_ctx: None`); `fan_in_identifiers`/`signature_of` stays
composite-coupled. User-signed deferral.
- **Latent, untested** — `multi_output_field_name`'s leaf-producer fallback
(`from_field > 0` → index string) is unexercised (no multi-output leaf in the
current corpus); carried as latent code, no anchoring test this cycle.
File diff suppressed because it is too large Load Diff
-407
View File
@@ -1,407 +0,0 @@
# Render the root composite like any other composite — Implementation Plan
> **Parent spec:** `docs/specs/0025-render-root-slot-stubs.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Close #49 by making a top-level blueprint leaf render its fan-in slot
stubs (`[SimBroker(#E,#price)]`) and a bound root role render by name (`[price]`),
byte-symmetric with how the same constructs render inside a `where:` definition —
removing the last two pre-0024 root render special-cases.
**Architecture:** Two coordinated edits in `crates/aura-cli/src/graph.rs`, both in
the CLI render layer: (A) thread the root composite as the fan-in stub context
(`stub_ctx` drops its `Option`; both `render_graph` callers pass `&Composite`),
and (B) build root entries from `role.name` instead of `format!("source:{kind}")`.
The existing `slot_source`/`fan_in_identifiers`/`signature_of` are reused verbatim
(no new stub path). `render_flat_graph` is untouched (the compiled view keeps
`[source:F64]` — names dissolve post-inline, C23). Behaviour-preserving for the
run path (C1); read-only render (C9); no engine change.
**Tech Stack:** `crates/aura-cli/src/graph.rs` (render core), golden + assertion
updates in `crates/aura-cli/src/main.rs`. Goldens are value-asserted regressions
captured from the live `aura graph` / `aura graph --macd` output.
---
## Files this plan creates or modifies
- Modify: `crates/aura-cli/src/graph.rs:37-46``Entry` doc-comment (drop the
`source:{kind}` vestige; both views name entries by role).
- Modify: `crates/aura-cli/src/graph.rs:62-89``render_blueprint` entry
construction (role-named, bound-only filter) + the `render_graph` call
(`None``bp`) + its leading comment.
- Modify: `crates/aura-cli/src/graph.rs:106-117``render_graph` doc-comment +
signature (`stub_ctx: Option<&Composite>``&Composite`).
- Modify: `crates/aura-cli/src/graph.rs:243-258``leaf_label` doc-comment item 3
+ signature (`stub_ctx: Option<&Composite>``&Composite`).
- Modify: `crates/aura-cli/src/graph.rs:283-286``leaf_label` stub gate
(`match``if slots.len() > 1`).
- Modify: `crates/aura-cli/src/graph.rs:461``render_definition` `render_graph`
call (`Some(c)``c`).
- Test: `crates/aura-cli/src/main.rs:417` — needle `[SimBroker]` → captured
`[SimBroker(#E,#price)]` + comment correction.
- Test: `crates/aura-cli/src/main.rs:526-562``blueprint_view_golden` full
re-capture.
- Test: `crates/aura-cli/src/main.rs:615-639``macd_blueprint_renders_a_nested_composite_definition`
add a root-`SimBroker`-stub assertion.
- Test: `crates/aura-cli/src/main.rs:474-497``reused_composite_defined_once`
add a `[src]` role-name-passthrough assertion.
**Explicitly OUT of scope (do not touch):**
- `crates/aura-cli/src/graph.rs:486-505` (`render_flat_graph`) — the negative-control
path; must stay byte-identical so `compiled_view_golden` does not move.
- `crates/aura-cli/src/main.rs:564-589` (`compiled_view_golden`) — its expected
block (`[source:F64]`, `[SimBroker(0.0001)]`) must NOT be edited.
- `fieldtests/milestone-construction-layer/render_clustered.txt` — a stale manual
fieldtest fixture (carries `[source:F64]` + a cluster box already stale since
0017); not referenced by any `cargo test`, not part of this cycle.
- Any file under `crates/aura-engine/``signature_of` is read, not modified.
---
## Task 1: graph.rs — thread the stub context + name root entries by role
**Files:**
- Modify: `crates/aura-cli/src/graph.rs`
This task is one compile unit: changing `stub_ctx`'s type from
`Option<&Composite>` to `&Composite` breaks compilation until BOTH `render_graph`
call sites (`render_blueprint` and `render_definition`) are updated, so all edits
land together and the gate is a **build**, not a test (the goldens stay red until
Task 2 re-captures them).
- [ ] **Step 1: Rewrite the `Entry` doc-comment (drop the `source:{kind}` vestige)**
Replace the doc-comment above `struct Entry` (`graph.rs:37-42`). Current:
```rust
/// A CLI-local, *borrowed* render notion unifying a composite **input role** and a
/// blueprint **source** for the shared graph core (#48): both are an external entry
/// drawn as a marker node wired into its interior `targets`. The composite builds
/// these from `Role` (name = role name), the blueprint from `SourceSpec` (name =
/// `source:{kind}`) — no engine change, the list is assembled CLI-side and borrows
/// the engine's `Target` slices.
```
New:
```rust
/// A CLI-local, *borrowed* render notion unifying a composite **input role** and a
/// blueprint **bound source role** for the shared graph core (#48): both are an
/// external entry drawn as a marker node wired into its interior `targets`. Both
/// views build these from `Role` (name = role name); the blueprint root filters to
/// bound roles (`source.is_some()`), the only remaining root-vs-interior
/// distinction (C3) — no engine change, the list is assembled CLI-side and borrows
/// the engine's `Target` slices.
```
- [ ] **Step 2: Rewrite `render_blueprint`'s entry construction (role-named, bound-only filter)**
Replace the entry construction (`graph.rs:70-79`). Current:
```rust
let entries: Vec<Entry> = bp
.input_roles()
.iter()
.filter_map(|role| {
role.source.map(|kind| Entry {
name: format!("source:{kind:?}"),
targets: &role.targets,
})
})
.collect();
```
New:
```rust
let entries: Vec<Entry> = bp
.input_roles()
.iter()
.filter(|role| role.source.is_some())
.map(|role| Entry { name: role.name.clone(), targets: &role.targets })
.collect();
```
- [ ] **Step 3: Update `render_blueprint`'s leading comment and pass `bp` as the stub context**
Replace the comment block + `render_graph` call (`graph.rs:62-89`). Current
(comment `62-69`, call `80-89`):
```rust
// the main graph is the shared graph core (`render_graph`) over the root
// composite's top-level (nodes, edges): leaves enriched exactly as `where:`
// interior leaves are (param names folded in, a `<field> →` prefix for any input
// fed by a multi-output producer — the headline: macd's `histogram` driving
// Exposure), composites opaque. The deltas vs a composite definition: param names
// come from the builder (no alias overlay at the root); the entries are the
// root's source-bound roles, not interior roles; there is no output record
// (terminals are sinks) and no title.
let main = render_graph(
bp.nodes(),
bp.edges(),
&entries,
&[], // no output bindings: a blueprint's terminals are sinks
ParamNames::Factory,
None, // no fan-in stub context at the root (deterministic no-op, #48)
None, // no title
color,
);
```
New:
```rust
// the main graph is the shared graph core (`render_graph`) over the root
// composite's top-level (nodes, edges), rendered exactly as a `where:` interior:
// leaves enriched (param names, a `<field> →` prefix for a multi-output producer
// — macd's `histogram` driving Exposure — and, now threaded at the root too,
// fan-in slot stubs), composites opaque, entries named by their role. The only
// deltas vs a composite definition: param names come from the builder (no alias
// overlay at the root); entries are filtered to bound source roles (C3); there
// is no output record (terminals are sinks) and no title.
let main = render_graph(
bp.nodes(),
bp.edges(),
&entries,
&[], // no output bindings: a blueprint's terminals are sinks
ParamNames::Factory,
bp, // the root IS the fan-in stub context (it carries roles + edges)
None, // no title
color,
);
```
- [ ] **Step 4: Update `render_graph`'s doc-comment and signature**
Replace the tail of `render_graph`'s doc-comment + the signature
(`graph.rs:106-120`). Current (doc tail `106-109`, signature `110-120`):
```rust
/// `Some`. The two render-borders that differ — param-name source and fan-in stub
/// availability — are passed as `param_names` / `stub_ctx`.
#[allow(clippy::too_many_arguments)]
fn render_graph(
nodes: &[BlueprintNode],
edges: &[Edge],
entries: &[Entry],
output: &[OutField],
param_names: ParamNames,
stub_ctx: Option<&Composite>,
title: Option<&str>,
color: Color,
) -> String {
```
New:
```rust
/// `Some`. The render-border that differs — the param-name source — is passed as
/// `param_names`; `stub_ctx` is the borrowed composite (root or interior) both
/// views thread for fan-in slot resolution.
#[allow(clippy::too_many_arguments)]
fn render_graph(
nodes: &[BlueprintNode],
edges: &[Edge],
entries: &[Entry],
output: &[OutField],
param_names: ParamNames,
stub_ctx: &Composite,
title: Option<&str>,
color: Color,
) -> String {
```
- [ ] **Step 5: Rewrite `leaf_label`'s doc-comment item 3 and update its signature**
Replace item 3 of `leaf_label`'s doc-comment (`graph.rs:243-246`). Current:
```rust
/// 3. The leaf's input-slot stubs (`#Sf`, …) when it is a multi-input fan-in **and** a
/// composite context is available (`stub_ctx = Some`). At the blueprint root no
/// such context is threaded, so a top-level fan-in renders without stubs (a
/// deterministic no-op, #48) rather than duplicating the signature machinery.
```
New:
```rust
/// 3. The leaf's input-slot stubs (`#Sf`, …) when it is a multi-input fan-in. The
/// `stub_ctx` is the borrowed composite (root or interior) the wired slots
/// resolve against; both views thread it, so a top-level fan-in stubs exactly as
/// an interior one does — one shared path, no root carve-out (#49).
```
Then change the `leaf_label` signature parameter (`graph.rs:258`). Current:
```rust
stub_ctx: Option<&Composite>,
```
New:
```rust
stub_ctx: &Composite,
```
- [ ] **Step 6: Simplify the stub gate**
Replace the stub gate in `leaf_label` (`graph.rs:283-286`). Current:
```rust
let stubs: Vec<String> = match stub_ctx {
Some(c) if slots.len() > 1 => fan_in_identifiers(c, index, &slots),
_ => Vec::new(),
};
```
New:
```rust
let stubs: Vec<String> = if slots.len() > 1 {
fan_in_identifiers(stub_ctx, index, &slots)
} else {
Vec::new()
};
```
- [ ] **Step 7: Update `render_definition`'s `render_graph` call**
In `render_definition`, change the `stub_ctx` argument (`graph.rs:461`). Current:
```rust
Some(c),
```
New:
```rust
c,
```
- [ ] **Step 8: Build gate — graph.rs compiles**
Run: `cargo build -p aura-cli`
Expected: finishes with `0 errors` (a `warning:`-free build; the
`#[allow(clippy::too_many_arguments)]` stays, so no new clippy noise here).
- [ ] **Step 9: Observe the expected golden drift (RED confirmation, non-gating)**
Run: `cargo test -p aura-cli`
Expected: `blueprint_view_golden` FAILS ("blueprint render drifted") and
`blueprint_view_main_graph_shows_composite_as_opaque_node` FAILS (the
`[SimBroker]` needle no longer matches — the leaf now reads
`[SimBroker(#E,#price)]`). `compiled_view_golden`,
`macd_blueprint_renders_a_nested_composite_definition`,
`nested_composite_renders_without_panic`, and `reused_composite_defined_once`
still PASS. This drift is expected and is fixed in Task 2 — do NOT hand-edit the
goldens here.
---
## Task 2: main.rs — re-capture goldens and add the new assertions
**Files:**
- Test: `crates/aura-cli/src/main.rs`
The render bytes (stub text, ascii-dag re-flow) are produced by the live binary,
not hand-written. Capture them, paste them, and reuse the exact captured
`SimBroker` substring in the two `contains` assertions so the pin and the rendered
output are byte-consistent.
- [ ] **Step 1: Capture the exact sample `aura graph` output**
Run: `cargo run -p aura-cli -- graph`
Expected: the structural blueprint view prints to stdout. Two facts to read off
it: (a) the full output bytes (for Step 2), and (b) the exact `SimBroker`
bracket-substring on the `[Recorder] [SimBroker(…)]` line. The predicted value
is `[SimBroker(#E,#price)]` (`#E` = the `Exposure` producer's sibling-unique
`signature_of` prefix; `#price` = the `price` role name). If the capture differs,
use the **captured** substring everywhere below — the golden is value-asserted,
the prediction is not load-bearing.
- [ ] **Step 2: Replace the `blueprint_view_golden` expected block with the captured bytes**
In `blueprint_view_golden` (`main.rs:526`), replace the entire `expected`
raw-string (`main.rs:531-560`, between `let expected = r#"` and `"#;`) with the
exact stdout from Step 1. Keep the `// … Re-capture via aura graph if intended.`
comment (`main.rs:528-530`) and the assert (`main.rs:561`).
Verification of intent (not a code edit): the `where:` portion of the new block
(the `sma_cross(fast:i64, slow:i64) -> (cross):` definition through
`[cross := Sub(#Sf,#Ss)]`) must be **byte-identical** to the old block — only the
main-graph portion (the `[source:F64]``[price]` marker and the `SimBroker` leaf)
and its layout re-flow may differ. If a `where:` line changed, STOP — that means
the interior render moved, which this cycle must not do.
- [ ] **Step 3: Run the golden test green**
Run: `cargo test -p aura-cli blueprint_view_golden`
Expected: PASS (1 test). (Filter substring `blueprint_view_golden` matches exactly
this named test in the current tree.)
- [ ] **Step 4: Update the needle and comment in the opaque-node test**
In `blueprint_view_main_graph_shows_composite_as_opaque_node`, replace the needle
array (`main.rs:417`) and its comment (`main.rs:414-416`). Current:
```rust
// top-level leaves render enriched (param names folded in, #48), exactly as
// the `where:` interior leaves are — `Exposure` folds its `scale` param;
// paramless leaves (SimBroker, Recorder) stay bare.
for needle in ["[Exposure(scale)]", "[SimBroker]", "[Recorder]"] {
```
New (use the exact `SimBroker` substring captured in Step 1 — shown here as the
predicted `[SimBroker(#E,#price)]`):
```rust
// top-level leaves render enriched exactly as the `where:` interior leaves:
// `Exposure` folds its `scale` param; a paramless SINGLE-input leaf
// (`Recorder`) stays bare, but a paramless MULTI-input fan-in (`SimBroker`)
// now shows its slot stubs (#49).
for needle in ["[Exposure(scale)]", "[SimBroker(#E,#price)]", "[Recorder]"] {
```
- [ ] **Step 5: Add the root-`SimBroker`-stub assertion to the macd test**
First capture the macd view: `cargo run -p aura-cli -- graph --macd` and read its
`SimBroker` bracket-substring (predicted `[SimBroker(#E,#price)]`; use the captured
value if different). Then, in
`macd_blueprint_renders_a_nested_composite_definition`, add one assertion after the
`[histogram := Sub(#S,#Es)]` assertion (`main.rs:630`):
```rust
// the root SimBroker is a top-level multi-input fan-in: its two slots now
// render as #… stubs, mirroring the where: interior (#49).
assert!(out.contains("[SimBroker(#E,#price)]"), "root SimBroker shows its two slot stubs: {out}");
```
- [ ] **Step 6: Add the `[src]` role-name-passthrough assertion**
In `reused_composite_defined_once`, add an assertion after the `dup(` count
assertion (`main.rs:496`):
```rust
// the bound root role named "src" carries its name into the entry marker
// (general non-"price" role-name passthrough, not just the sample's price).
assert!(out.contains("[src]"), "root role name renders as the entry marker: {out}");
```
- [ ] **Step 7: Test gate — the whole CLI crate is green**
Run: `cargo test -p aura-cli`
Expected: all tests PASS (0 failed). In particular `compiled_view_golden` passes
**unchanged** (its `[source:F64]` / `[SimBroker(0.0001)]` block was not edited —
the negative control), and the four edited tests pass.
- [ ] **Step 8: Workspace gate — full suite + lint**
Run: `cargo test --workspace`
Expected: all tests PASS (0 failed) — no run-path test moved (C1).
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: finishes clean (no warnings; exit 0).
---
-645
View File
@@ -1,645 +0,0 @@
# Iteration 1 — graph model serializer — Implementation Plan
> **Parent spec:** `docs/specs/0026-graph-render-redesign.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run this
> plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Add a read-only Rust serializer `model_to_json(&Composite) -> String` that
turns the harness root composite + every distinct composite type into the canonical,
deterministic JSON graph model the viewer (iteration 2) will consume.
**Architecture:** A new `crates/aura-engine/src/graph_model.rs`, hand-rolled
deterministic JSON in the `RunReport::to_json` house style (no serde). It walks the
blueprint via the existing read-only accessors and emits a model with two top-level
keys: `root` (the harness scope) and `composites` (each distinct composite type once).
Read-only (C9): the function takes `&Composite`, returns `String`, and never calls
`eval`/`compile`/`bootstrap`.
**Tech Stack:** aura-engine (`blueprint.rs` accessors, `harness::Edge`,
`report.rs` JSON idiom), aura-core (`NodeSchema`/`PortSpec`/`FieldSpec`/`ParamSpec`/
`Firing`/`ScalarKind`).
---
## Model shape (the contract this iteration produces)
Resolved against the **types** (recon) and spec acceptance criterion 3, where the
spec's abridged example was prototype-flavoured:
```
{
"root": <scope>,
"composites": { "<composite-name>": <composite-def>, ... } // first-seen order
}
```
- **scope** = `{ "nodes": { "<key>": <node>, ... }, "edges": [ <edge>, ... ] }`
- **composite-def** = `{ "inputs": [ <port>, ... ], "outputs": [ [name,kind], ... ],
"nodes": {...}, "edges": [...] }`
- **node** (one of):
- primitive: `{ "prim": { "type": <label>, "role": <role>, "params": [[name,kind],...],
"ins": [<port>,...], "outs": [[name,kind],...] } }`
- composite: `{ "comp": "<name>" }`
- **port** (an input) = `[kind, firing]` — `firing` is `"any"` or `"barrier <N>"`.
Input ports carry **no name** (`PortSpec` has none); kind + firing only (criterion 3,
"omit nothing").
- **role** = `"source"` | `"sink"` | `"node"`:
- `sink` = primitive whose `schema().output` is empty (C8 pure consumer).
- `source` = a **synthetic** node minted from a bound root input-role
(`Role.source.is_some()`); see below.
- `node` = any other primitive.
- **kind** = `"i64"` | `"f64"` | `"bool"` | `"timestamp"` (the `kind_str` lowercase
mapping; C4 palette keys off these exact strings).
- **key** = the node's **index** in `nodes()` as a string (`"0"`, `"1"`, …). The
blueprint has no unique node names (C23: identity is positional); the type label is
display-only and lives in `"type"`. Synthetic source nodes are keyed `"src_<role>"`.
- **edge** = `[ <from-endpoint>, <to-endpoint> ]`:
- `"<key>.o<field>"` — node `key` output field (from `Edge.from`/`from_field`).
- `"<key>.i<slot>"` — node `key` input slot (from `Edge.to`/`slot`, or a role/source
target's `slot`).
- `"@<rolename>"` — an interior composite **input role** (composite scopes only).
- `"#<N>"` — a composite **output binding**, N = index into `output()` (composite
scopes only).
- **Synthetic source nodes (root scope only).** Each bound root input-role
(`Role.source.is_some()`) becomes a node `"src_<role.name>"` =
`{ "prim": { "type": <role.name>, "role": "source", "params": [],
"ins": [], "outs": [[<role.name>, <kind>]] } }`, plus one edge
`["src_<role>.o0", "<target.node>.i<target.slot>"]` per target. Interior composite
input-roles stay as `"@<role>"` endpoints (rendered as boundary inputs when drilled).
Deterministic ordering: nodes in index order, synthetic sources appended in
`input_roles()` order; `composites` in first-seen order; every JSON object's keys in
the fixed literal order shown above (the `RunReport::to_json` discipline).
**Files this plan creates or modifies:**
- Create: `crates/aura-engine/src/graph_model.rs` — the serializer + its `#[cfg(test)]`
module (golden / determinism / mis-wire-differs / structural / read-only).
- Modify: `crates/aura-engine/src/lib.rs:40-47` — add `mod graph_model;` and
`pub use graph_model::model_to_json;`.
- Test: (in `graph_model.rs`) a local fixture builder + the six test cases.
---
## Task 1: Module skeleton, lib wiring, JSON + kind helpers
**Files:**
- Create: `crates/aura-engine/src/graph_model.rs`
- Modify: `crates/aura-engine/src/lib.rs:40-47`
- [ ] **Step 1: Create the module with the JSON-escape + kind helpers**
Create `crates/aura-engine/src/graph_model.rs`:
```rust
//! The `aura graph` model serializer (#13, cycle 0026): turns the engine's
//! authored blueprint (graph-as-data, C9) into the canonical, deterministic JSON
//! model the browser viewer consumes. Read-only — walks structure + declared
//! `NodeSchema`s only, never `eval`/`compile`/`bootstrap`. Hand-rolled JSON in the
//! `RunReport::to_json` house style (no serde, C14).
use crate::{BlueprintNode, Composite};
use aura_core::{Firing, ScalarKind};
/// A scalar kind as the lowercase type string the model + C4 colour palette use.
/// Mirrors `aura-cli`'s `kind_str`; the `Debug` form is PascalCase, so explicit.
fn kind_str(k: ScalarKind) -> &'static str {
match k {
ScalarKind::I64 => "i64",
ScalarKind::F64 => "f64",
ScalarKind::Bool => "bool",
ScalarKind::Timestamp => "timestamp",
}
}
/// One input port's firing policy as a model string: `"any"` or `"barrier <N>"`.
fn firing_str(f: Firing) -> String {
match f {
Firing::Any => "any".to_string(),
Firing::Barrier(n) => format!("barrier {n}"),
}
}
/// A JSON string literal: wrap in quotes, escape `"` and `\` (the minimal set
/// `RunReport::json_str` uses — model names are author-controlled identifiers).
fn json_str(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
_ => out.push(c),
}
}
out.push('"');
out
}
```
- [ ] **Step 2: Wire the module into the crate root**
In `crates/aura-engine/src/lib.rs`, next to the existing `mod blueprint; mod harness;
mod report;` declarations (around lines 40-42), add:
```rust
mod graph_model;
```
and in the `pub use` surface (next to the `pub use blueprint::{…}` block around lines
45-47) add:
```rust
pub use graph_model::model_to_json;
```
- [ ] **Step 3: Add a placeholder `model_to_json` so the crate compiles**
Append to `graph_model.rs`:
```rust
/// Serialize the harness root composite + every distinct composite type into the
/// canonical graph model (`{ "root": …, "composites": … }`). Read-only (C9).
pub fn model_to_json(root: &Composite) -> String {
let _ = root;
String::new() // filled in Task 5
}
```
- [ ] **Step 4: Build to verify the skeleton compiles**
Run: `cargo build -p aura-engine`
Expected: PASS (0 errors; `dead_code` warnings on the unused helpers are acceptable
until Task 2-5 consume them).
---
## Task 2: Serialize one primitive node (type, role, params, ins, outs)
**Files:**
- Modify: `crates/aura-engine/src/graph_model.rs`
- [ ] **Step 1: Write the failing test for a primitive record**
Append to a `#[cfg(test)] mod tests` block in `graph_model.rs`:
```rust
#[cfg(test)]
mod tests {
use super::*;
use aura_core::{NodeSchema, ParamSpec, PortSpec, FieldSpec, PrimitiveBuilder, Node, Scalar};
/// A bare node whose only purpose is to back a PrimitiveBuilder in a test.
struct Bare;
impl Node for Bare {
fn lookbacks(&self) -> Vec<usize> { vec![] }
fn eval(&mut self, _c: aura_core::Ctx<'_>) -> Option<&[Scalar]> { None }
}
fn sub_builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"Sub",
NodeSchema {
inputs: vec![
PortSpec { kind: ScalarKind::F64, firing: Firing::Any },
PortSpec { kind: ScalarKind::F64, firing: Firing::Barrier(0) },
],
output: vec![FieldSpec { name: "diff", kind: ScalarKind::F64 }],
params: vec![],
},
|_| Box::new(Bare),
)
}
#[test]
fn primitive_record_carries_type_role_ins_outs() {
let got = prim_record(&sub_builder());
assert_eq!(
got,
r#"{"prim":{"type":"Sub","role":"node","params":[],"ins":[["f64","any"],["f64","barrier 0"]],"outs":[["diff","f64"]]}}"#
);
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `cargo test -p aura-engine graph_model::tests::primitive_record_carries_type_role_ins_outs`
Expected: FAIL — `prim_record` does not exist (compile error).
- [ ] **Step 3: Implement `prim_record` + the role derivation**
Append to `graph_model.rs` (before the tests module):
```rust
/// A `[name, kind]` JSON pair.
fn named_kind(name: &str, kind: ScalarKind) -> String {
format!("[{},{}]", json_str(name), json_str(kind_str(kind)))
}
/// An input port `[kind, firing]` (no name — `PortSpec` carries none).
fn port_json(p: &aura_core::PortSpec) -> String {
format!("[{},{}]", json_str(kind_str(p.kind)), json_str(&firing_str(p.firing)))
}
/// Comma-join with no surrounding brackets.
fn join(items: impl IntoIterator<Item = String>) -> String {
items.into_iter().collect::<Vec<_>>().join(",")
}
/// The `{ "prim": { … } }` record for a primitive node. `role` defaults to
/// `"node"`; a `vec![]` output is the C8 sink. (Source is minted separately for
/// bound root roles, see `scope_json`.)
fn prim_record(b: &crate::PrimitiveBuilder) -> String {
let s = b.schema();
let role = if s.output.is_empty() { "sink" } else { "node" };
let params = join(s.params.iter().map(|p| named_kind(&p.name, p.kind)));
let ins = join(s.inputs.iter().map(port_json));
let outs = join(s.output.iter().map(|f| named_kind(f.name, f.kind)));
format!(
r#"{{"prim":{{"type":{},"role":{},"params":[{params}],"ins":[{ins}],"outs":[{outs}]}}}}"#,
json_str(&b.label()),
json_str(role),
)
}
```
(`PrimitiveBuilder` is re-exported from the crate root; reference it as
`crate::PrimitiveBuilder`. Confirm the import path against `lib.rs` — if it is only
`aura_core::PrimitiveBuilder`, use that.)
- [ ] **Step 4: Run the test to verify it passes**
Run: `cargo test -p aura-engine graph_model::tests::primitive_record_carries_type_role_ins_outs`
Expected: PASS.
---
## Task 3: Serialize a scope (nodes map + edges) with synthetic source nodes
**Files:**
- Modify: `crates/aura-engine/src/graph_model.rs`
- [ ] **Step 1: Add a local harness fixture + the failing scope test**
Add to the `tests` module a fixture that builds a small **root** harness exercising a
bound source role, a composite reference, a plain node, and a sink. Use the blueprint
builder API as `build_sample()` (`aura-cli/src/main.rs:161`) and the engine's own
blueprint test fixtures (`blueprint.rs`) do — the implementer mirrors that builder
surface. The fixture must produce a `Composite` whose:
- `input_roles()` has one bound role `price` (`source: Some(ScalarKind::F64)`)
targeting node 0 slot 0,
- `nodes()` = `[ Composite("sma_cross"), Primitive(Exposure), Primitive(Recorder-sink) ]`,
- `edges()` wire them.
```rust
fn sample_root() -> Composite { /* build via the blueprint builder, mirroring
build_sample() in aura-cli/src/main.rs:161-190 and the fixtures in blueprint.rs */ }
#[test]
fn scope_emits_index_keyed_nodes_synthetic_sources_and_edges() {
let root = sample_root();
let got = scope_json(&root, /* is_root = */ true);
// a bound source role is minted as a synthetic source node…
assert!(got.contains(r#""src_price":{"prim":{"type":"price","role":"source""#), "{got}");
// …wired to its target by an index-keyed edge:
assert!(got.contains(r#"["src_price.o0","0.i0"]"#), "{got}");
// real nodes are index-keyed; the Recorder is a sink:
assert!(got.contains(r#""role":"sink""#), "{got}");
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `cargo test -p aura-engine graph_model::tests::scope_emits_index_keyed_nodes_synthetic_sources_and_edges`
Expected: FAIL — `scope_json` does not exist.
- [ ] **Step 3: Implement `scope_json`**
Append to `graph_model.rs`:
```rust
/// The pieces of one scope: the `"key":node,…` entries of the nodes object and the
/// edge-JSON list, returned separately so a composite scope can extend `edges` with
/// its `@role`/`#N` boundary endpoints before formatting (Task 4). `is_root` mints a
/// synthetic source node (+ its edges) per bound input-role (`source.is_some()`).
fn scope_parts(c: &Composite, is_root: bool) -> (Vec<String>, Vec<String>) {
let mut nodes: Vec<String> = Vec::new();
for (i, n) in c.nodes().iter().enumerate() {
let body = match n {
BlueprintNode::Primitive(b) => prim_record(b),
BlueprintNode::Composite(inner) => format!(r#"{{"comp":{}}}"#, json_str(inner.name())),
};
nodes.push(format!("{}:{}", json_str(&i.to_string()), body));
}
let mut edges: Vec<String> = c
.edges()
.iter()
.map(|e| {
format!(
"[{},{}]",
json_str(&format!("{}.o{}", e.from, e.from_field)),
json_str(&format!("{}.i{}", e.to, e.slot)),
)
})
.collect();
if is_root {
for r in c.input_roles().iter().filter(|r| r.source.is_some()) {
let kind = r.source.unwrap();
let key = format!("src_{}", r.name);
nodes.push(format!(
r#"{}:{{"prim":{{"type":{},"role":"source","params":[],"ins":[],"outs":[{}]}}}}"#,
json_str(&key),
json_str(&r.name),
named_kind(&r.name, kind),
));
for t in &r.targets {
edges.push(format!(
"[{},{}]",
json_str(&format!("{key}.o0")),
json_str(&format!("{}.i{}", t.node, t.slot)),
));
}
}
}
(nodes, edges)
}
/// One scope as `{ "nodes": {…}, "edges": [...] }`.
fn scope_json(c: &Composite, is_root: bool) -> String {
let (nodes, edges) = scope_parts(c, is_root);
format!(r#"{{"nodes":{{{}}},"edges":[{}]}}"#, nodes.join(","), edges.join(","))
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `cargo test -p aura-engine graph_model::tests::scope_emits_index_keyed_nodes_synthetic_sources_and_edges`
Expected: PASS.
---
## Task 4: Composite collection + composite-def with @role / #N endpoints
**Files:**
- Modify: `crates/aura-engine/src/graph_model.rs`
- [ ] **Step 1: Write the failing test for a composite definition**
Add to the `tests` module:
```rust
#[test]
fn composite_def_carries_inputs_outputs_and_role_output_endpoints() {
let root = sample_root();
// the sma_cross interior composite is node 0:
let inner = match &root.nodes()[0] { BlueprintNode::Composite(c) => c, _ => panic!() };
let got = composite_def_json(inner);
assert!(got.contains(r#""inputs":[["f64","any"]]"#) || got.contains(r#""inputs":[["f64""#), "{got}");
// an interior input role becomes an @role endpoint:
assert!(got.contains(r#""@price""#) || got.contains("@"), "{got}");
// an output binding becomes a #N endpoint:
assert!(got.contains(r#""#0""#), "{got}");
}
#[test]
fn distinct_composites_collected_once() {
let root = sample_root();
let model = model_to_json(&root);
// sma_cross appears once as a key under "composites":
assert_eq!(model.matches(r#""sma_cross":{"inputs""#).count(), 1, "{model}");
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `cargo test -p aura-engine graph_model::tests::composite_def_carries_inputs_outputs_and_role_output_endpoints`
Expected: FAIL — `composite_def_json` does not exist.
- [ ] **Step 3: Implement composite-def serialization + the distinct-composite walk**
Append to `graph_model.rs`:
```rust
/// A composite's interior scope plus its boundary: `inputs` (each role's declared
/// kind+firing — the interior port it feeds), `outputs` (`[name, kind]` per
/// re-exported field), and the interior `nodes`/`edges` with `@role` / `#N`
/// endpoints folded onto the edge list.
fn composite_def_json(c: &Composite) -> String {
// inputs: one port per input role; kind from role.source or the fed port.
let inputs = join(c.input_roles().iter().map(|r| {
let kind = r.source.unwrap_or(ScalarKind::F64); // interior roles: kind via the fed slot; fallback f64
format!("[{},{}]", json_str(kind_str(kind)), json_str("any"))
}));
// outputs: [name, kind] per OutField. Kind from the producing node's field.
let outputs = join(c.output().iter().map(|of| {
let kind = field_kind(c, of.node, of.field).unwrap_or(ScalarKind::F64);
named_kind(&of.name, kind)
}));
// interior scope (non-root: no synthetic sources), then extend the edge list
// with the boundary endpoints: @role per role-target, #N per output binding.
let (nodes, mut edges) = scope_parts(c, false);
for r in c.input_roles() {
for t in &r.targets {
edges.push(format!(
"[{},{}]",
json_str(&format!("@{}", r.name)),
json_str(&format!("{}.i{}", t.node, t.slot)),
));
}
}
for (n, of) in c.output().iter().enumerate() {
edges.push(format!(
"[{},{}]",
json_str(&format!("{}.o{}", of.node, of.field)),
json_str(&format!("#{n}")),
));
}
format!(
r#"{{"inputs":[{inputs}],"outputs":[{outputs}],"nodes":{{{}}},"edges":[{}]}}"#,
nodes.join(","),
edges.join(","),
)
}
/// The scalar kind of node `node`'s output field `field`, read from its schema.
fn field_kind(c: &Composite, node: usize, field: usize) -> Option<ScalarKind> {
match c.nodes().get(node)? {
BlueprintNode::Primitive(b) => b.schema().output.get(field).map(|f| f.kind),
BlueprintNode::Composite(inner) => {
let of = inner.output().get(field)?;
field_kind(inner, of.node, of.field)
}
}
}
/// Every distinct composite type in the blueprint, first-seen order, keyed by
/// name (mirrors aura-cli's `collect_distinct_composites`, graph.rs:173).
fn distinct_composites(root: &Composite) -> Vec<&Composite> {
fn walk<'a>(items: &'a [BlueprintNode], seen: &mut Vec<&'a str>, out: &mut Vec<&'a Composite>) {
for it in items {
if let BlueprintNode::Composite(c) = it {
if !seen.contains(&c.name()) {
seen.push(c.name());
out.push(c);
walk(c.nodes(), seen, out);
}
}
}
}
let mut seen = Vec::new();
let mut out = Vec::new();
walk(root.nodes(), &mut seen, &mut out);
out
}
```
- [ ] **Step 4: Run the tests to verify they pass**
Run: `cargo test -p aura-engine graph_model::tests::composite_def_carries_inputs_outputs_and_role_output_endpoints graph_model::tests::distinct_composites_collected_once`
Expected: PASS.
---
## Task 5: Top-level `model_to_json` + byte golden + determinism
**Files:**
- Modify: `crates/aura-engine/src/graph_model.rs`
- [ ] **Step 1: Implement the top-level assembly**
Replace the placeholder `model_to_json` body with:
```rust
pub fn model_to_json(root: &Composite) -> String {
let composites = join(distinct_composites(root).iter().map(|c| {
format!("{}:{}", json_str(c.name()), composite_def_json(c))
}));
format!(
r#"{{"root":{},"composites":{{{}}}}}"#,
scope_json(root, true),
composites,
)
}
```
- [ ] **Step 2: Write the determinism test (fully specified)**
Add to `tests`:
```rust
#[test]
fn model_is_deterministic() {
let a = model_to_json(&sample_root());
let b = model_to_json(&sample_root());
assert_eq!(a, b);
}
```
Run: `cargo test -p aura-engine graph_model::tests::model_is_deterministic`
Expected: PASS.
- [ ] **Step 3: Capture the byte golden**
Run the serializer once and capture its exact output as the golden constant. Add a
temporary print test:
```rust
#[test]
fn print_model() { println!("GOLDEN<<<{}>>>", model_to_json(&sample_root())); }
```
Run: `cargo test -p aura-engine graph_model::tests::print_model -- --nocapture`
Expected: prints the model JSON between `GOLDEN<<<` and `>>>`. Copy that exact string.
- [ ] **Step 4: Write the byte-golden test with the captured string**
Replace `print_model` with the golden assertion, pasting the captured bytes verbatim:
```rust
#[test]
fn model_golden() {
// Re-capture via the print_model harness if an intended model-shape change lands.
let expected = r##"<PASTE THE EXACT CAPTURED JSON STRING HERE>"##;
assert_eq!(model_to_json(&sample_root()), expected);
}
```
Run: `cargo test -p aura-engine graph_model::tests::model_golden`
Expected: PASS (the pasted string equals the freshly serialized model).
---
## Task 6: Structural assertions + mis-wire-differs + read-only
**Files:**
- Modify: `crates/aura-engine/src/graph_model.rs`
- [ ] **Step 1: Write the structural + mis-wire + read-only tests**
Add to `tests`. The mis-wire fixture is a **structural** change (a different edge
target), not a param swap — the model is pre-compile and param-generic, so a param
swap is invisible to it (recon concern 3).
```rust
#[test]
fn sink_has_empty_outs() {
// the Recorder node in the root is a sink with no outputs.
assert!(model_to_json(&sample_root()).contains(r#""role":"sink","params":[],"ins":[["f64","any"]],"outs":[]"#),
"{}", model_to_json(&sample_root()));
}
#[test]
fn multi_input_composite_has_two_inputs() {
// a composite with two f64 input roles serialises exactly two ports in "inputs".
let c = two_input_composite(); // local fixture: a composite with 2 f64 input roles
let def = composite_def_json(&c);
assert!(def.starts_with(r#"{"inputs":[["f64","any"],["f64","any"]],"#), "{def}");
}
#[test]
fn structural_miswire_changes_the_model() {
let ok = model_to_json(&sample_root());
let bad = model_to_json(&miswired_root()); // local fixture: same nodes, one edge re-targeted
assert_ne!(ok, bad);
}
#[test]
fn serializer_is_read_only() {
// Compile-time witness: model_to_json takes &Composite and returns String —
// no &mut, no eval/compile/bootstrap on the path. This test documents the C9
// contract; it passes by construction.
fn assert_sig(f: fn(&Composite) -> String) { let _ = f; }
assert_sig(model_to_json);
}
```
> Implementer note: `multi_input_composite_has_two_inputs` should assert against the
> `"inputs":[...]` segment specifically (slice it out), not the whole def, so the
> count is unambiguous. `two_input_composite()` and `miswired_root()` are small local
> fixtures built with the same blueprint builder as `sample_root()`.
- [ ] **Step 2: Run the new tests to verify they fail (where not yet satisfied)**
Run: `cargo test -p aura-engine graph_model::tests::structural_miswire_changes_the_model graph_model::tests::multi_input_composite_has_two_inputs`
Expected: FAIL initially if the fixtures are stubbed — implement the fixtures, then PASS.
- [ ] **Step 3: Implement the fixtures and make all tests pass**
Build `two_input_composite()` and `miswired_root()` with the blueprint builder
(mirroring `sample_root`). `miswired_root` = `sample_root` with one `Edge`'s `to`/`slot`
changed to a different valid target.
Run: `cargo test -p aura-engine graph_model`
Expected: PASS — all `graph_model` tests green.
- [ ] **Step 4: Full workspace gate**
Run: `cargo test --workspace`
Expected: PASS — no regression in any crate (the old ascii-dag goldens are untouched;
this iteration only adds a module). Then:
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: PASS — clean (remove any `dead_code` left from Task 1 by ensuring every
helper is now used).
-847
View File
@@ -1,847 +0,0 @@
# Graph Render Viewer (cycle 0026, iteration 2) — Implementation Plan
> **Parent spec:** `docs/specs/0026-graph-render-redesign.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Replace `aura graph`'s ascii-dag output with a single self-contained
HTML page — the ported prototype viewer (pin-graph via Graphviz-WASM) driven by
the deterministic model serializer that shipped in iteration 1 — and retire
`ascii-dag` from the workspace. Closes Gitea #51.
**Architecture:** `aura graph` calls a new read-only `render_html(&Composite)`
(crate `aura-cli`, module `render`) that concatenates the prototype's `<head>`
shell, the inlined vendored Graphviz-WASM + pan-zoom blobs, the inlined model
JSON (`aura_engine::model_to_json`), and the inlined ported viewer JS into one
`.html` on stdout. The viewer JS is the prototype's `genDot`/chrome **verbatim**,
prefixed with a `normalizeModel` adapter that maps aura's real model tuple shape
(post-0027 named inputs: `ins = [kind, firing, name]`, index node keys, `comp`
references, `src_<role>` sources) into the viewer's native shape. The old
ascii-dag adapter (`graph.rs`), its dependency, the `--compiled`/`--macd` flag
plumbing, and all 12 ascii-dag-asserting tests are deleted in one
compile-coherent task. No `eval`/build on the render path (C9); no `serde` (C14);
the four-colour palette stays the four scalar base types (C4); the viewer carries
no authoring logic (C10).
**Tech Stack:** `aura-cli` (`render.rs`, `main.rs`, `Cargo.toml`, `assets/`,
`tests/cli_run.rs`), `aura-engine::model_to_json` (consumed, unchanged),
`aura-core::node.rs` (stale-prose tidy), `docs/design/INDEX.md` (ledger note).
Vendored JS/WASM assets are **checked in** (see Task 1 rationale).
---
## Files this plan creates or modifies
- Create: `crates/aura-cli/assets/graph-viewer.js` — ported viewer (normalizeModel + verbatim genDot/chrome)
- Create: `crates/aura-cli/assets/viz-standalone.js` — vendored `@viz-js/viz@3.7.0` (Graphviz-WASM, ~1.38 MB, checked in)
- Create: `crates/aura-cli/assets/svg-pan-zoom.min.js` — vendored `svg-pan-zoom@3.6.1` (~30 KB, checked in)
- Create: `crates/aura-cli/assets/refresh-assets.sh` — pinned-version refresh script (provenance; not run at build time)
- Create: `crates/aura-cli/src/render.rs``render_html(&Composite) -> String` + smoke test
- Modify: `crates/aura-cli/src/main.rs``mod render;`; flip `["graph"]` arm to `render_html`; delete `--compiled`/`--macd` graph arms, `render_compiled`, the `color` plumbing, `USAGE`; delete 12 ascii-dag tests + `swapped_point`/`sample_point` helpers; drop `mod graph;`
- Modify: `crates/aura-cli/Cargo.toml:16` — remove `ascii-dag = "0.9.1"`
- Delete: `crates/aura-cli/src/graph.rs` — the 504-line ascii-dag adapter (incl. the stale #43 comment at :324)
- Modify: `crates/aura-cli/tests/cli_run.rs:164-185` — replace the ascii-dag fan-in test with the HTML-contract test
- Modify: `crates/aura-core/src/node.rs:150-157,196-199` — re-justify two stale ascii-dag prose references
- Modify: `docs/design/INDEX.md:694` — add the cycle-0026 render-redesign realization note
---
## Task 1: Vendor the assets + port the viewer JS
**Vendoring decision (orchestrator call, deferred by the spec):** the blobs are
**checked into the repo**, not git-ignored + fetched at build time. Substance:
(a) `render_html` inlines the assets via `include_str!`, which requires them
present at **compile time** — a build-time `unpkg` fetch makes the build
non-hermetic and non-offline; (b) C8 freezes deploy artifacts and C1 demands
determinism — a build that pulls a remote URL drifts with the registry and the
network; (c) the shipped `.html` must be byte-deterministic for a given commit.
A checked-in blob is the only shape satisfying all three. `refresh-assets.sh`
records the pinned provenance for a future deliberate refresh; it is **not** part
of the build. The ~1.4 MB cost is the price of a hermetic, frozen render asset.
The validated blobs already exist on disk in the prototype dir — copy them (no
network fetch in this task).
**Files:**
- Create: `crates/aura-cli/assets/viz-standalone.js`
- Create: `crates/aura-cli/assets/svg-pan-zoom.min.js`
- Create: `crates/aura-cli/assets/refresh-assets.sh`
- Create: `crates/aura-cli/assets/graph-viewer.js`
- [ ] **Step 1: Create the assets dir and copy the two vendored blobs from the prototype**
Run:
```sh
mkdir -p crates/aura-cli/assets
cp docs/design/prototypes/graph-render/viz-standalone.js crates/aura-cli/assets/viz-standalone.js
cp docs/design/prototypes/graph-render/svg-pan-zoom.min.js crates/aura-cli/assets/svg-pan-zoom.min.js
wc -c crates/aura-cli/assets/viz-standalone.js crates/aura-cli/assets/svg-pan-zoom.min.js
```
Expected: `viz-standalone.js``1379750` bytes, `svg-pan-zoom.min.js``29768` bytes (both non-empty, copied).
If the prototype blobs are absent (a clean checkout that never ran the
prototype's `fetch-deps.sh`), fetch them instead with the pinned URLs from Step 3
below, then re-run the `wc -c` check.
- [ ] **Step 2: Verify the assets are not git-ignored (they are checked in)**
Run:
```sh
git check-ignore crates/aura-cli/assets/viz-standalone.js crates/aura-cli/assets/svg-pan-zoom.min.js; echo "exit=$?"
```
Expected: `exit=1` (no path printed) — neither file is ignored, so both will be
committed. If any path is printed, a parent `.gitignore` rule is catching them;
do **not** add a crate-level `.gitignore` for these (unlike the prototype, the
engine checks them in).
- [ ] **Step 3: Create the provenance refresh script**
Create `crates/aura-cli/assets/refresh-assets.sh`:
```sh
#!/usr/bin/env bash
# Refresh the vendored graph-viewer assets. NOT run at build time — the blobs are
# checked into the repo so `include_str!` and the build stay hermetic/offline
# (C1/C8). Run this only to deliberately bump a pinned version, then commit the
# updated blobs. Mirrors the prototype's fetch-deps.sh.
set -euo pipefail
cd "$(dirname "$0")"
curl -fsSL "https://unpkg.com/@viz-js/viz@3.7.0/lib/viz-standalone.js" -o viz-standalone.js
curl -fsSL "https://unpkg.com/svg-pan-zoom@3.6.1/dist/svg-pan-zoom.min.js" -o svg-pan-zoom.min.js
echo "refreshed: viz-standalone.js (@viz-js/viz@3.7.0), svg-pan-zoom.min.js (svg-pan-zoom@3.6.1)"
```
Run:
```sh
chmod +x crates/aura-cli/assets/refresh-assets.sh
head -1 crates/aura-cli/assets/refresh-assets.sh
```
Expected: `#!/usr/bin/env bash`.
- [ ] **Step 4: Create the ported viewer `crates/aura-cli/assets/graph-viewer.js`**
This is the prototype's `index.html` script (lines 43-256) **verbatim**, with the
hardcoded model (prototype lines 52-100, the `F`/`I`/`P`/`C`/`COMP`/`ROOT`
constants) replaced by `normalizeModel(window.AURA_MODEL)`. `genDot`, `cellLabel`,
`addInfo`, the tooltip/interaction chrome, and `chrome()`/`show()` are unchanged.
Create `crates/aura-cli/assets/graph-viewer.js`:
```js
// aura graph viewer — ported from docs/design/prototypes/graph-render/index.html.
// A render asset (C10): no node/strategy/experiment logic, no DSL. Reads aura's
// emitted model (window.AURA_MODEL, injected by render_html) and draws a
// homogeneous pin-graph via Graphviz-WASM. genDot/cellLabel/chrome are the
// prototype verbatim; normalizeModel adapts aura's real tuple shape (post-0027
// named inputs) to the viewer's native shape.
const TYPE_COLOR = { i64: "#f9e2af", f64: "#89b4fa", bool: "#a6e3a1", timestamp: "#cba6f7" };
const COLOR_TYPE = Object.fromEntries(Object.entries(TYPE_COLOR).map(([k, v]) => [v.toLowerCase(), k]));
const PURPLE = "#5b4b8a", TEAL = "#2c5a5a", PLUS = "#7c6ba8";
const LEAF = "#45475a", SOURCE = "#2e5a3a", SINK = "#5a2e3e";
const bodyBgFor = (role) => role === "source" ? SOURCE : role === "sink" ? SINK : LEAF;
const HEAD = `bgcolor="#16161a"; rankdir=TB; nodesep=0.5; ranksep=0.65;
node [shape=plaintext, fontname="Courier", fontsize=12];
edge [penwidth=1.7, arrowsize=0.8];`;
const col = (k) => TYPE_COLOR[k] || "#cdd6f4";
// === adapt aura's emitted model to the viewer's native shape =================
// aura input tuples are [kind, firing, name]; the viewer wants [name, kind, meta?]
// (non-"any" firing surfaces in the tooltip). outs/params are already [name, kind].
// aura composite inputs are [kind, firing, name]; the viewer wants [name, kind].
// node keys (indices / "src_<role>") and edge endpoints pass through unchanged.
function adaptIns(ins) {
return ins.map(([kind, firing, name]) =>
firing && firing !== "any" ? [name, kind, { firing }] : [name, kind]);
}
function adaptNodes(nodes) {
const out = {};
for (const key in nodes) {
const n = nodes[key];
if (n.prim) {
const p = n.prim;
out[key] = { prim: { type: p.type, role: p.role, params: p.params, ins: adaptIns(p.ins), outs: p.outs } };
} else {
out[key] = n; // composite reference { comp }
}
}
return out;
}
function normalizeModel(model) {
const composites = {};
for (const cname in (model.composites || {})) {
const c = model.composites[cname];
composites[cname] = {
inputs: c.inputs.map(([kind, firing, name]) => [name, kind]),
outputs: c.outputs,
nodes: adaptNodes(c.nodes),
edges: c.edges,
};
}
return {
root: { inputs: [], outputs: [], nodes: adaptNodes(model.root.nodes), edges: model.root.edges },
composites,
};
}
const MODEL = normalizeModel(window.AURA_MODEL);
const COMP = MODEL.composites;
const ROOT = MODEL.root;
// === ONE homogeneous, per-cell-addressable node template =====================
function cellLabel(id, o, showTypes) {
const cols = Math.max(o.ins.length, o.outs.length, 1);
const span = (n) => Math.max(1, Math.floor(cols / n));
const txt = (n, k) => (showTypes && k) ? `${n} : ${k}` : n;
const cell = (io, arr) => ([n, k], i) =>
`<td port="${io}${i}" id="P_${id}_${io}${i}" href="#" colspan="${span(arr.length)}" ` +
`bgcolor="${io === "i" ? "#222b33" : "#332b22"}"><font color="${col(k)}">${txt(n, k)}</font></td>`;
const sp = o.params.map(([n, k]) => `<font color="${col(k)}">${showTypes && k ? `${n}:${k}` : n}</font>`);
const sig = o.params.length ? `<font color="#9399b2">(</font>${sp.join('<font color="#9399b2">, </font>')}<font color="#9399b2">)</font>` : "";
const name = `<font color="#f5f5f5"><b>${o.type}</b></font>${sig}`;
let bodyRow;
if (o.composite) {
bodyRow = `<tr><td colspan="${cols}" bgcolor="${o.bodyBg}"><table border="0" cellborder="0" cellspacing="3"><tr>` +
`<td id="plus_${id}" href="#" align="center" valign="middle" fixedsize="true" width="24" height="24" border="1" color="#a594c8" bgcolor="${PLUS}"><font color="#f5f5f5"><b>+</b></font></td>` +
`<td id="B_${id}" href="#">${name}</td></tr></table></td></tr>`;
} else {
bodyRow = `<tr><td id="B_${id}" href="#" colspan="${cols}" bgcolor="${o.bodyBg}">${name}</td></tr>`;
}
let rows = "";
if (o.ins.length) rows += `<tr>${o.ins.map(cell("i", o.ins)).join("")}</tr>`;
rows += bodyRow;
if (o.outs.length) rows += `<tr>${o.outs.map(cell("o", o.outs)).join("")}</tr>`;
return `<<table border="0" cellborder="1" cellspacing="0" cellpadding="6">${rows}</table>>`;
}
function addInfo(INFO, id, o) {
const ps = o.params && o.params.length ? o.params.map(([n, k]) => `\`${n}:${k}\``).join(", ") : "<span class='dim'>none</span>";
INFO.B[id] = `**${o.type}** <span class='dim'>· ${o.role || "node"}</span>\nparams: ${ps}`;
(o.ins || []).forEach(([n, k, m], i) => INFO.P[`P_${id}_i${i}`] = `**${n}** · \`${k}\`` + (m && m.firing ? `\nfiring: \`${m.firing}\`` : ""));
(o.outs || []).forEach(([n, k], i) => INFO.P[`P_${id}_o${i}`] = `**${n}** · \`${k}\``);
}
// === recursive DOT generator (the mini-inliner) ==============================
function genDot(def, role, expandedSet, showTypes) {
const INFO = { B: {}, P: {}, C: {} }, drill = {}, expandable = [], clusters = [], edges = [], topRank = [], bottomRank = [];
function build(path, def, brole) {
const childRes = {}; let block = "";
for (const key in def.nodes) {
const inst = def.nodes[key], cpath = [...path, key], cid = cpath.join("__");
if (inst.prim) {
const s = inst.prim;
block += `${cid} [label=${cellLabel(cid, { type: s.type, params: s.params, ins: s.ins, outs: s.outs, bodyBg: bodyBgFor(s.role), composite: false }, showTypes)}];\n`;
addInfo(INFO, cid, s);
if (s.role === "source") topRank.push(cid); // sinks are placed freely (not rank-pinned)
childRes[key] = { type: "leaf", id: cid, spec: s };
} else {
const cname = inst.comp, cdef = COMP[cname];
if (expandedSet.has(cid)) {
const sub = build(cpath, cdef, "cluster"); clusters.push(cid);
INFO.C["clust_" + cid] = `**${cname}** <span class='dim'>· composite · click frame to collapse</span>`;
block += `subgraph cluster_${cid} { id="clust_${cid}"; style=filled; color="#5b4b8a"; fillcolor="#211c30"; penwidth=2;\n${sub.block}}\n`;
childRes[key] = { type: "exp", resolveIn: sub.resolveIn, resolveOut: sub.resolveOut };
} else {
block += `${cid} [label=${cellLabel(cid, { type: cname, params: [], ins: cdef.inputs, outs: cdef.outputs, bodyBg: PURPLE, composite: true }, showTypes)}];\n`;
addInfo(INFO, cid, { type: cname, role: "composite", params: [], ins: cdef.inputs, outs: cdef.outputs });
drill[cid] = cname; expandable.push(cid);
childRes[key] = { type: "opaque", id: cid, cdef };
}
}
}
const pk = (r, io, idx) => r.type === "leaf" ? (io === "i" ? r.spec.ins : r.spec.outs)[idx][1] : (io === "i" ? r.cdef.inputs : r.cdef.outputs)[idx][1];
const resTarget = (ep) => { const [k, p] = ep.split("."); const idx = +p.slice(1), r = childRes[k]; return r.type === "exp" ? r.resolveIn(idx) : [{ id: r.id, port: "i" + idx, kind: pk(r, "i", idx) }]; };
const resSource = (ep) => { const [k, p] = ep.split("."); const idx = +p.slice(1), r = childRes[k]; return r.type === "exp" ? r.resolveOut(idx) : { id: r.id, port: "o" + idx, kind: pk(r, "o", idx) }; };
const resolveIn = (i) => { const rn = "@" + def.inputs[i][0]; return def.edges.filter(e => e[0] === rn).flatMap(e => resTarget(e[1])); };
const resolveOut = (i) => { const e = def.edges.find(e => e[1] === "#" + i); return resSource(e[0]); };
for (const [f, t] of def.edges) { if (f[0] === "@" || t[0] === "#") continue; const s = resSource(f); for (const d of resTarget(t)) edges.push([s, d]); }
if (brole === "drill") {
def.inputs.forEach(([rn, rk], i) => { const bid = `bin_${i}`;
block += `${bid} [label=${cellLabel(bid, { type: rn, params: [], ins: [], outs: [[rn, rk]], bodyBg: SOURCE, composite: false }, showTypes)}];\n`;
addInfo(INFO, bid, { type: rn, role: "input", params: [], ins: [], outs: [[rn, rk]] });
topRank.push(bid);
resolveIn(i).forEach(d => edges.push([{ id: bid, port: "o0", kind: rk }, d])); });
def.outputs.forEach(([on, ok], i) => { const bid = `bout_${i}`;
block += `${bid} [label=${cellLabel(bid, { type: on, params: [], ins: [[on, ok]], outs: [], bodyBg: SINK, composite: false }, showTypes)}];\n`;
addInfo(INFO, bid, { type: on, role: "output", params: [], ins: [[on, ok]], outs: [] });
bottomRank.push(bid);
edges.push([resolveOut(i), { id: bid, port: "i0", kind: ok }]); });
}
return { block, resolveIn, resolveOut };
}
const top = build([], def, role);
let dot = `digraph g { ${HEAD}\n${top.block}`;
// annotation by position: sources/inputs share the top rank, outputs the bottom rank.
const rankRow = (ids, r, chain) => ids.length
? `{rank=${r};${ids.map(id => " " + id + ";").join("")}${chain && ids.length > 1 ? " " + ids.join(" -> ") + " [style=invis];" : ""}}\n`
: "";
dot += rankRow(topRank, "min", true);
dot += rankRow(bottomRank, "max", false);
for (const [s, d] of edges) dot += `${s.id}:${s.port}:s -> ${d.id}:${d.port}:n [color="${TYPE_COLOR[s.kind] || "#7f849c"}"];\n`;
return { dot: dot + "}", info: INFO, drill, expandable, clusters };
}
// === styled tooltip + interaction ============================================
const tip = document.getElementById("tip");
const md = (s) => s.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>").replace(/`(.+?)`/g, "<code>$1</code>").replace(/\n/g, "<br>");
function showTip(text, x, y) {
tip.innerHTML = md(text); tip.style.display = "block";
const w = tip.offsetWidth, h = tip.offsetHeight;
let l = x + 14, t = y + 16;
if (l + w > innerWidth - 8) l = x - w - 14;
if (t + h > innerHeight - 8) t = y - h - 16;
tip.style.left = l + "px"; tip.style.top = t + "px";
}
const hideTip = () => { tip.style.display = "none"; };
const stage = document.getElementById("stage"), crumbEl = document.getElementById("crumb");
let viz, viewStack = [{ name: "root", def: ROOT }], expanded = new Set(), SHOW_TYPES = false, INFO = { B: {}, P: {}, C: {} };
function chrome() {
crumbEl.innerHTML = "";
viewStack.forEach((v, i) => {
if (i) { const s = document.createElement("span"); s.className = "sep"; s.textContent = " "; crumbEl.appendChild(s); }
const a = document.createElement("a"); a.textContent = v.name;
a.onclick = () => { viewStack = viewStack.slice(0, i + 1); expanded = new Set(); show(); };
crumbEl.appendChild(a);
});
}
function show() {
const v = viewStack[viewStack.length - 1];
const G = genDot(v.def, v.name === "root" ? "root" : "drill", expanded, SHOW_TYPES);
INFO = G.info;
stage.innerHTML = ""; hideTip();
const svg = viz.renderSVGElement(G.dot);
stage.appendChild(svg);
const pz = svgPanZoom(svg, { zoomEnabled: true, controlIconsEnabled: true, fit: true, center: true, minZoom: 0.1, maxZoom: 12, dblClickZoomEnabled: false });
addEventListener("resize", () => { pz.resize(); pz.fit(); pz.center(); });
svg.querySelectorAll("title").forEach(t => t.remove());
svg.querySelectorAll("a").forEach(a => a.removeAttributeNS("http://www.w3.org/1999/xlink", "title"));
const idOf = (el) => { const a = el.closest && el.closest("a[id], g[id]"); return a ? a.id.replace(/^a_/, "") : ""; };
svg.addEventListener("mousemove", (e) => {
const id = idOf(e.target);
if (id.startsWith("P_") && INFO.P[id]) return showTip(INFO.P[id], e.clientX, e.clientY);
if (id.startsWith("B_")) { const k = id.slice(2); if (INFO.B[k]) return showTip(INFO.B[k], e.clientX, e.clientY); }
if (id.startsWith("plus_")) return showTip("**expand** inline", e.clientX, e.clientY);
if (id.startsWith("clust_") && INFO.C[id]) return showTip(INFO.C[id], e.clientX, e.clientY);
const eg = e.target.closest && e.target.closest("g.edge");
if (eg) { const p = eg.querySelector("path"); const c = p && p.getAttribute("stroke") ? p.getAttribute("stroke").toLowerCase() : ""; return showTip("`" + (COLOR_TYPE[c] || "?") + "`", e.clientX, e.clientY); }
hideTip();
});
svg.addEventListener("mouseleave", hideTip);
svg.addEventListener("click", (e) => { const a = e.target.closest && e.target.closest("a"); if (a) e.preventDefault(); });
const click = (id, fn) => { const el = svg.getElementById(id) || svg.getElementById("a_" + id); if (el) { el.style.cursor = "pointer"; el.addEventListener("click", e => { e.preventDefault(); e.stopPropagation(); fn(); }); } };
for (const id in G.drill) click("B_" + id, () => { viewStack.push({ name: G.drill[id], def: COMP[G.drill[id]] }); expanded = new Set(); show(); });
for (const id of G.expandable) click("plus_" + id, () => { expanded.add(id); show(); });
for (const id of G.clusters) click("clust_" + id, () => { expanded.delete(id); show(); });
chrome();
}
Viz.instance().then(v => { viz = v; show(); }).catch(e => { document.getElementById("err").textContent = String(e && e.stack || e); });
```
- [ ] **Step 5: Sanity-check the viewer asset for the load-bearing markers**
Run:
```sh
grep -c "Viz.instance" crates/aura-cli/assets/graph-viewer.js
grep -c "normalizeModel(window.AURA_MODEL)" crates/aura-cli/assets/graph-viewer.js
grep -F 'i64: "#f9e2af"' crates/aura-cli/assets/graph-viewer.js | head -1
```
Expected: first two print `1`; the third prints the palette line (C4 four-colour
table present). These substrings are asserted by the Task 2 / Task 3 tests, so
they must be present contiguously.
---
## Task 2: `render_html` module + smoke test (coexists with graph.rs)
`render.rs` is a new module that compiles **alongside** the still-present
`graph.rs` — no retirement yet, so the workspace stays green. `render_html` is
read-only (C9): it calls `aura_engine::model_to_json` (a pure `&Composite ->
String` walk) and string-concatenates the inlined assets. No `eval`, no build, no
`serde`.
**Files:**
- Create: `crates/aura-cli/src/render.rs`
- Modify: `crates/aura-cli/src/main.rs:9` (add `mod render;`)
- Test: `crates/aura-cli/src/render.rs` (the `#[cfg(test)]` smoke test)
- [ ] **Step 1: Create `crates/aura-cli/src/render.rs`**
Create `crates/aura-cli/src/render.rs`:
```rust
//! Self-contained HTML graph viewer assembly (C9: read-only render path).
//!
//! `render_html` walks the blueprint into the deterministic JSON model
//! (`aura_engine::model_to_json`) and inlines it, the vendored Graphviz-WASM, the
//! pan-zoom helper, and the ported viewer JS into one standalone `.html`. The
//! emitted page fetches nothing at view time. No `eval`/build is on this path
//! (C9); the model is hand-rolled JSON (C14, owned by `model_to_json`).
use aura_core::Composite;
/// The prototype's `<head>` shell + body skeleton (header, stage, tooltip, error
/// pane), verbatim from `docs/design/prototypes/graph-render/index.html` lines
/// 1-38 — minus the two `<script src="./…">` tags, since `render_html` inlines
/// the assets instead.
const SHELL_HEAD: &str = r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>aura graph</title>
<style>
html, body { margin: 0; height: 100%; background: #16161a; color: #cdd6f4;
font-family: ui-monospace, monospace; }
header { padding: 8px 14px; border-bottom: 1px solid #313244; font-size: 13px;
display: flex; gap: 16px; align-items: baseline; flex-wrap: nowrap;
white-space: nowrap; overflow: hidden; }
header b { color: #f5e0dc; } .sub { color: #6c7086; }
#crumb a { color: #89b4fa; cursor: pointer; text-decoration: none; }
#crumb a:hover { text-decoration: underline; }
#crumb .sep { color: #6c7086; }
#status { color: #6c7086; margin-left: auto; }
#stage { width: 100%; height: calc(100% - 44px); }
#stage svg { width: 100%; height: 100%; cursor: default; }
#stage svg text { cursor: inherit; user-select: none; -webkit-user-select: none; }
#stage svg a { cursor: inherit; }
#err { padding: 14px; color: #f38ba8; white-space: pre-wrap; }
#tip { position: fixed; display: none; pointer-events: none; z-index: 10;
background: #1e1e2e; border: 1px solid #585b70; border-radius: 6px; padding: 6px 9px;
font-family: ui-monospace, monospace; font-size: 12px; color: #cdd6f4;
max-width: 420px; box-shadow: 0 6px 18px rgba(0,0,0,.55); line-height: 1.5; }
#tip b { color: #f5e0dc; } #tip code { color: #89b4fa; } #tip .dim { color: #7f849c; }
</style>
</head>
<body>
<header>
<b>aura graph</b>
<span id="crumb"></span>
<span class="sub">hover · [+] expand · body drill</span>
</header>
<div id="stage"></div>
<div id="tip"></div>
<pre id="err"></pre>
"#;
const VIZ_JS: &str = include_str!("../assets/viz-standalone.js");
const PANZOOM_JS: &str = include_str!("../assets/svg-pan-zoom.min.js");
const VIEWER_JS: &str = include_str!("../assets/graph-viewer.js");
/// Assemble the self-contained interactive graph viewer page for `root`.
///
/// Order is load-bearing: the Graphviz-WASM and pan-zoom globals (`Viz`,
/// `svgPanZoom`) and the model (`window.AURA_MODEL`) must be defined before the
/// viewer script runs.
pub fn render_html(root: &Composite) -> String {
let model = aura_engine::model_to_json(root);
let mut html = String::with_capacity(
SHELL_HEAD.len() + VIZ_JS.len() + PANZOOM_JS.len() + VIEWER_JS.len() + model.len() + 256,
);
html.push_str(SHELL_HEAD);
html.push_str("<script>");
html.push_str(VIZ_JS);
html.push_str("</script>\n<script>");
html.push_str(PANZOOM_JS);
html.push_str("</script>\n<script>window.AURA_MODEL = ");
html.push_str(&model);
html.push_str(";</script>\n<script>");
html.push_str(VIEWER_JS);
html.push_str("</script>\n</body>\n</html>\n");
html
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sample_blueprint;
/// `aura graph`'s page is self-contained (no remote fetch), embeds the
/// deterministic model as the viewer's data source, carries the Graphviz-WASM
/// bootstrap, and keeps the C4 four-colour palette. Structural, not pixel:
/// the DOT/SVG are Graphviz-version-dependent and are exercised by hand
/// against the prototype (spec Testing strategy).
#[test]
fn render_html_is_self_contained_and_embeds_the_model() {
let html = render_html(&sample_blueprint());
// a complete HTML document
assert!(html.starts_with("<!doctype html>"), "not an HTML doc");
assert!(html.trim_end().ends_with("</html>"), "HTML doc not closed");
// self-contained: every asset is inlined, no remote <script src>
assert!(!html.contains("<script src"), "assets must be inlined, found a remote script tag");
// the deterministic model is injected as the viewer's data source
assert!(html.contains("window.AURA_MODEL = {\"root\":"), "model JSON not injected");
// a known node from the sample harness round-trips via the model
assert!(html.contains("\"type\":\"Exposure\""), "sample model content missing");
// the vendored Graphviz-WASM is present (its bootstrap global)
assert!(html.contains("Viz.instance"), "viewer bootstrap (Viz.instance) missing");
// C4: the four-scalar palette, no fifth colour
assert!(
html.contains("i64: \"#f9e2af\"") && html.contains("timestamp: \"#cba6f7\""),
"C4 four-colour palette missing"
);
}
}
```
- [ ] **Step 2: Declare the module**
In `crates/aura-cli/src/main.rs`, add `mod render;` immediately after the existing
`mod graph;` at line 9:
```rust
mod graph;
mod render;
```
(Both modules coexist this task; `graph` is retired in Task 3.)
- [ ] **Step 3: Build and run the smoke test**
Run:
```sh
cargo test -p aura-cli render_html_is_self_contained_and_embeds_the_model
```
Expected: PASS (`test result: ok. 1 passed`). The model golden contract is
unchanged, so `cargo build --workspace` stays green with `graph.rs` still present.
---
## Task 3: Retire ascii-dag (atomic — flip `aura graph`, delete graph.rs + dep + dead tests/helpers)
This is one compile-coherent unit: removing `graph.rs` and the `ascii-dag` dep
breaks every `graph::` reference (the production arms + 12 tests) at once, so the
"0 errors" build gate is only satisfiable when **all** of them are removed in the
same task (planner self-review item 7 — signature/removal-breaks-all-callers).
**Files:**
- Modify: `crates/aura-cli/src/main.rs` (graph arm, flags, `render_compiled`, `color`, `USAGE`, `mod graph;`, 12 tests, `swapped_point`/`sample_point` helpers)
- Modify: `crates/aura-cli/Cargo.toml:16` (remove `ascii-dag`)
- Delete: `crates/aura-cli/src/graph.rs`
- Modify: `crates/aura-cli/tests/cli_run.rs:164-185` (replace the ascii-dag fan-in test)
- [ ] **Step 1: Flip the `["graph"]` arm and delete the `--compiled`/`--macd` graph arms**
In `crates/aura-cli/src/main.rs`, the `match` block (lines 379-395) currently reads:
```rust
match args.iter().map(String::as_str).collect::<Vec<_>>().as_slice() {
["run"] => println!("{}", run_sample().to_json()),
["run", "--macd"] => println!("{}", run_macd().to_json()),
["graph"] => println!("{}", graph::render_blueprint(&sample_blueprint(), color)),
["graph", "--compiled"] => {
println!("{}", render_compiled(sample_blueprint(), &sample_point(), color));
}
["graph", "--macd"] => println!("{}", graph::render_blueprint(&macd_blueprint(), color)),
["graph", "--macd", "--compiled"] => {
println!("{}", render_compiled(macd_blueprint(), &macd_point(), color));
}
["--help"] | ["-h"] => println!("{USAGE}"),
_ => {
eprintln!("aura: {USAGE}");
std::process::exit(2);
}
}
```
Replace it with (only `run`/`run --macd`/`graph`/help survive; `graph` emits the
HTML viewer via `print!` since `render_html` ends with a newline):
```rust
match args.iter().map(String::as_str).collect::<Vec<_>>().as_slice() {
["run"] => println!("{}", run_sample().to_json()),
["run", "--macd"] => println!("{}", run_macd().to_json()),
["graph"] => print!("{}", render::render_html(&sample_blueprint())),
["--help"] | ["-h"] => println!("{USAGE}"),
_ => {
eprintln!("aura: {USAGE}");
std::process::exit(2);
}
}
```
- [ ] **Step 2: Delete the `color` plumbing and the `render_compiled` helper**
In `crates/aura-cli/src/main.rs`, delete the `color` binding (lines 372-378):
```rust
// Edge colouring only when stdout is an interactive terminal; a redirect to a
// file or pipe stays plain (no escape codes). `run` output is JSON, never coloured.
let color = if std::io::stdout().is_terminal() {
graph::Color::Ansi
} else {
graph::Color::Plain
};
```
And delete the `render_compiled` helper (lines 358-363):
```rust
/// Compile a blueprint under its point vector and render the flat post-inline
/// (C23) view — the shared body of the `--compiled` paths.
fn render_compiled(bp: Composite, point: &[Scalar], color: graph::Color) -> String {
let flat = bp.compile_with_params(point).expect("valid blueprint");
graph::render_flat_graph(&flat.nodes, &flat.sources, &flat.edges, color)
}
```
If `is_terminal`/`IsTerminal` was imported only for the `color` binding, its `use`
becomes unused — remove it (clippy will name it in Step 7; the import is near the
top of `main.rs`).
- [ ] **Step 3: Shrink the `USAGE` string (drop the retired graph flags)**
In `crates/aura-cli/src/main.rs:365`, replace:
```rust
const USAGE: &str = "usage: aura run [--macd] | aura graph [--compiled | --macd [--compiled]]";
```
with:
```rust
const USAGE: &str = "usage: aura run [--macd] | aura graph";
```
- [ ] **Step 4: Drop `mod graph;` and delete the file**
In `crates/aura-cli/src/main.rs:9`, remove the `mod graph;` line (leaving
`mod render;`). Then (plain `rm`, not `git rm` — the deletion stays unstaged with
the rest of the iter's work for the orchestrator to commit):
```sh
rm crates/aura-cli/src/graph.rs
```
Expected: `graph.rs` removed (this also retires the stale #43 comment at :324,
which dies with the file).
- [ ] **Step 5: Delete the 12 ascii-dag-asserting tests + the orphaned `swapped_point`/`sample_point` helpers**
In `crates/aura-cli/src/main.rs`'s `#[cfg(test)] mod tests`, delete these test
functions in full (each asserts retired ascii-dag output and references
`graph::render_blueprint` / `graph::render_flat_graph` / `graph::Color`):
```text
blueprint_view_main_graph_shows_composite_as_opaque_node (~409-425)
blueprint_view_defines_each_composite_once (~427-435)
nested_composite_renders_without_panic (~437-473)
reused_composite_defined_once (~475-501)
compiled_view_dissolves_the_composite_boundary (~503-512)
swapped_param_vector_changes_the_compiled_render (~514-527)
blueprint_view_golden (~529-566)
compiled_view_golden (~568-593)
macd_blueprint_renders_a_nested_composite_definition (~619-646)
fan_in_identifiers_are_source_derived_and_scoped_per_node_call (~648-670)
fan_in_identifiers_descend_into_bare_combinators (~672-723)
ansi_colour_emits_escapes_only_when_requested (~725-735)
```
**Keep** every test that does not reference `graph::`:
`run_macd_compiles_from_nested_composite_and_is_deterministic` (~595-617),
`macd_param_space_surfaces_the_three_named_aliases` (~743-759),
`run_sample_is_deterministic_and_non_trivial` (~761+).
Then delete the now-orphaned test helper `swapped_point` (~405-407, used only by
the deleted `swapped_param_vector_changes_the_compiled_render`).
Completeness check — no `graph::` reference may survive in the file:
```sh
grep -n "graph::" crates/aura-cli/src/main.rs; echo "exit=$?"
```
Expected: `exit=1` (no matches). Any surviving match is a test the deletion list
missed — remove it.
- [ ] **Step 6: Remove the `ascii-dag` dependency**
In `crates/aura-cli/Cargo.toml`, delete line 16:
```toml
ascii-dag = "0.9.1"
```
- [ ] **Step 7: Build + clippy; remove whatever dead-code/unused-import the gate names**
`sample_point()` (main.rs ~199) loses its last caller when the compiled-view
tests go (its only uses were the `--compiled` arm + the deleted tests). The
`dead_code` lint under `-D warnings` will name it and any import (e.g.
`graph`-only or `IsTerminal`-only `use`s, the fixture imports `Role`/`Target`/
`Edge`/`OutField`/`BlueprintNode`/`ParamAlias`/`Sub`/`Ema`/`Sma`/`Exposure`/
`sma_cross` used only by the deleted tests) that is now unused.
Run, then delete each item the lint names, and re-run until clean:
```sh
cargo build --workspace
cargo clippy --workspace --all-targets -- -D warnings
```
Expected (final): `cargo build` 0 errors; `clippy` 0 warnings. Remove `sample_point`
and every unused `use` clippy reports; do **not** remove `macd_blueprint`,
`macd_point`, `run_macd` (still used by the kept tests / `run --macd` arm) or
`sample_blueprint`/`build_sample` (used by the `graph` arm + `render` test).
- [ ] **Step 8: Replace the integration test that pinned the old ascii-dag output**
In `crates/aura-cli/tests/cli_run.rs`, the test
`graph_renders_source_derived_fan_in_identifiers` (lines 164-185) asserts the
retired ascii-dag notation (`[cross := Sub(#Sf,#Ss)]`) and now fails. Replace the
entire test (its doc-comment + function) with the HTML-contract test:
```rust
/// Property: `aura graph` emits a single self-contained HTML page — the
/// interactive pin-graph viewer with the deterministic model inlined and the
/// vendored Graphviz-WASM + pan-zoom inlined (no remote fetch). Driven through
/// the built binary so it pins the observable CLI contract. The DOT/SVG are
/// Graphviz-version-dependent and not asserted (the prototype is the by-hand
/// visual reference); this pins the page envelope + the retirement of the old
/// ascii-dag notation.
#[test]
fn graph_emits_self_contained_html_viewer() {
let out = Command::new(BIN).arg("graph").output().expect("spawn aura graph");
assert_eq!(out.status.code(), Some(0), "`aura graph` exit: {:?}", out.status);
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
// a self-contained HTML document...
assert!(
stdout.trim_start().starts_with("<!doctype html>"),
"not an HTML doc: {:?}",
&stdout[..stdout.len().min(80)]
);
// ...with the deterministic model inlined as the viewer's data source...
assert!(stdout.contains("window.AURA_MODEL = {\"root\":"), "model not inlined: {stdout:?}");
// ...the Graphviz-WASM bootstrap present, and no remote asset fetch.
assert!(stdout.contains("Viz.instance"), "viewer bootstrap missing");
assert!(!stdout.contains("<script src"), "assets must be inlined, found remote <script src>");
// the retired ascii-dag invented notation is gone.
assert!(!stdout.contains("Sub(#Sf,#Ss)"), "retired ascii-dag notation must be gone: {stdout}");
}
```
- [ ] **Step 9: Full gate — build, test, lint, dep-tree, file-gone**
Run:
```sh
cargo build --workspace
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo tree -p aura-cli 2>/dev/null | grep -i ascii-dag; echo "ascii-dag-grep-exit=$?"
test ! -e crates/aura-cli/src/graph.rs && echo "graph.rs gone"
```
Expected: build 0 errors; `cargo test --workspace` all green (every test that ran
passed, and the new `graph_emits_self_contained_html_viewer` +
`render_html_is_self_contained_and_embeds_the_model` are among them — confirm both
names appear in the test output, not "0 ran"); clippy 0 warnings;
`ascii-dag-grep-exit=1` (ascii-dag absent from the dep tree); `graph.rs gone`.
---
## Task 4: Tidy the two stale ascii-dag prose references in aura-core
The cycle retires ascii-dag; these two comments in `crates/aura-core/src/node.rs`
are its last prose footprint and carry now-false justifications (the second
already false since cycle 0017 dropped cluster boxes). The `label()` single-line
rule stays true (an HTML-table cell label is still one line) but is re-justified
generically; the param-generic rule is re-justified on C23, not on an ascii-dag
limitation. Methods and assertions are unchanged — prose only.
**Files:**
- Modify: `crates/aura-core/src/node.rs:154` and `:198-199`
- [ ] **Step 1: Re-justify the `Node::label()` single-line constraint (~line 150-157)**
Replace:
```rust
/// A one-line, **non-load-bearing** render label (C23): a debug symbol for
/// tracing / graph rendering (#13), never read by the run loop and never
/// part of wiring (which is by index). Overrides SHOULD carry the node's
/// identifying params so identical node types disambiguate (`SMA(2)` vs
/// `SMA(4)`). MUST be single-line (no `\n`): ascii-dag breaks box drawing on
/// a multiline label. The default is a placeholder; every shipped node
/// overrides it. Returns an owned `String` and takes `&self`, so `Node`
/// stays object-safe and `Box<dyn Node>::label()` dispatches.
```
with:
```rust
/// A one-line, **non-load-bearing** render label (C23): a debug symbol for
/// tracing / graph rendering (#13), never read by the run loop and never
/// part of wiring (which is by index). Overrides SHOULD carry the node's
/// identifying params so identical node types disambiguate (`SMA(2)` vs
/// `SMA(4)`). MUST be single-line (no `\n`): the graph render draws each
/// label in one cell. The default is a placeholder; every shipped node
/// overrides it. Returns an owned `String` and takes `&self`, so `Node`
/// stays object-safe and `Box<dyn Node>::label()` dispatches.
```
- [ ] **Step 2: Re-justify the param-generic builder-label test comment (~line 196-199)**
Replace:
```rust
fn primitive_builder_label_is_the_bare_type() {
// param-generic: the label is just the node type, no knob suffix (the
// ascii-dag blueprint view cannot render wide cluster-sibling labels).
```
with:
```rust
fn primitive_builder_label_is_the_bare_type() {
// param-generic: the label is just the node type, no knob suffix — the
// blueprint view is param-generic (C23), values appear only post-compile.
```
- [ ] **Step 3: Verify no ascii-dag prose remains in the workspace source**
Run:
```sh
grep -rni "ascii-dag\|ascii_dag" crates/ ; echo "exit=$?"
```
Expected: `exit=1` (no matches) — the dep, the adapter, and the prose are all
gone. (`Cargo.lock` may still list it transitively until regenerated; the build
in Task 3 Step 9 already regenerated it — if it appears there only, that is the
lockfile and not a source reference.)
- [ ] **Step 4: Build + test aura-core to confirm the prose edits compile**
Run:
```sh
cargo test -p aura-core
```
Expected: PASS (prose-only change; all aura-core tests green).
---
## Task 5: Ledger realization note (cycle 0026)
Add one consolidated realization note for cycle 0026 (both iterations: the model
serializer + the WASM-Graphviz viewer) to the C9 section of the design ledger,
right after the cycle-0024 render-lineage note.
**Files:**
- Modify: `docs/design/INDEX.md:694` (insert after the cycle-0024 realization note, before `### C20`)
- [ ] **Step 1: Insert the realization note**
In `docs/design/INDEX.md`, after the paragraph ending at line 694 (`…the C8 0024
realization).`) and before the blank line preceding `### C20 — Strategy ↔
harness` (line 696), insert:
```markdown
**Realization (cycle 0026 — graph render redesign: model + WASM-Graphviz viewer,
#51).** `aura graph` no longer renders ASCII. The render path is now two pieces:
a read-only **model serializer** (`aura_engine::model_to_json`, iteration 1) that
walks the root composite + every distinct composite type into a deterministic,
hand-rolled JSON model (C14, golden-tested like `RunReport::to_json`; the
swapped-param mis-wire property moved here from the old compiled-view test), and a
**self-contained HTML viewer** (`aura-cli::render::render_html`, iteration 2) that
inlines that model, the ported prototype viewer JS, and a vendored Graphviz-WASM
blob into one page emitted to stdout. Layout/SVG happen in the browser via
WebAssembly — aura ships no layout engine and stays a serializer (C9: graph-as-data,
no `eval`/build on the path). The viewer is a render asset (C10 — no node/strategy
logic, no DSL); it labels every input pin from the model's now-real names (C23
debug symbols, named in cycle 0027) and colours wires by the four scalar base
types (C4). This **retires `ascii-dag`** and its adapter (`graph.rs`), the
`--compiled`/`--macd` flag plumbing, and the invented `#Sf`/`:=`/`histogram →`
notation — superseding the cycle-0017 flat-ascii model and its renderer-defect
workaround. The DOT/SVG are Graphviz-version-dependent and not golden-tested; the
deterministic JSON model is the asserted contract.
```
- [ ] **Step 2: Confirm the note placed cleanly**
Run:
```sh
grep -n "Realization (cycle 0026" docs/design/INDEX.md
grep -n "### C20 — Strategy" docs/design/INDEX.md
```
Expected: the 0026 note line number is immediately above (a few lines before) the
`### C20` line — the insertion sits at the end of the C9 section, not inside C20.
---
## Iteration done-state
After Task 5, the working tree carries: the new assets + `render.rs`, the
retired `graph.rs` + dep + flags + tests, the prose tidy, and the ledger note —
all unstaged. The full gate (Task 3 Step 9) is green: `cargo build --workspace`
0 errors, `cargo test --workspace` all green (model golden unchanged; the two new
HTML tests present), `cargo clippy --workspace --all-targets -- -D warnings`
clean, `ascii-dag` absent from the dep tree. The orchestrator inspects the diff
and commits (suggested subject: `feat(aura-cli): render the graph as a
self-contained WASM-Graphviz viewer; retire ascii-dag`, body `closes #51`).
-370
View File
@@ -1,370 +0,0 @@
# Name Input Ports — Implementation Plan
> **Parent spec:** `docs/specs/0027-name-input-ports.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Give `PortSpec` a non-load-bearing `name: String`, name every input
slot across the aura-std nodes and fixtures, carry `Role.name` into the derived
composite signature, and make `model_to_json` emit the name — so the graph model
labels input pins faithfully (C23-clean; identity stays positional).
**Architecture:** One contract change in `aura-core` ripples to every `PortSpec`
construction site (compiler-enforced). Task 1 adds the field and threads all 16
sites in one shot (the compile gate), leaving `port_json` untouched so the byte
golden stays green. Task 2 flips `port_json` to emit the name and re-captures the
golden + its two substring twins. Task 3 locks the per-node names. Task 4 records
the realization in the ledger.
**Tech Stack:** Rust — `crates/aura-core` (the `PortSpec` contract),
`crates/aura-std` (8 nodes), `crates/aura-engine` (`derive_signature`,
`graph_model`, fixtures), `crates/aura-cli` + `crates/aura-ingest` (fixtures),
`docs/design/INDEX.md` (ledger). Hand-rolled JSON, no serde (C14).
**Files this plan creates or modifies:**
- Modify: `crates/aura-core/src/node.rs:29-37``PortSpec` gains `name`, drops `Copy`, doc updated; test at `:172-179`.
- Modify: `crates/aura-std/src/sma.rs:31`, `ema.rs:63`, `sub.rs:27-28`, `add.rs:36-37`, `exposure.rs:32`, `sim_broker.rs:66-67`, `lincomb.rs:47-49`, `recorder.rs:41` — name input slots.
- Modify: `crates/aura-engine/src/blueprint.rs:76` (derive_signature carries `role.name`), `:742`, `:842`, `:1938` (fixtures).
- Modify: `crates/aura-engine/src/graph_model.rs:50-53` (port_json), `:252-253` (fixture), `:422` (byte golden), `:433` + `:443` (substring pins — the leaf + composite model-name witnesses).
- Modify: `crates/aura-engine/src/harness.rs:362`, `:380` (fixtures); `crates/aura-engine/src/report.rs:210`; `crates/aura-cli/src/main.rs:54`; `crates/aura-ingest/tests/real_bars.rs:26` (fixtures).
- Test: per-node name pins in each `aura-std` node's `#[cfg(test)]` mod.
- Modify: `docs/design/INDEX.md:296` — C8 realization note (cross-link C23).
---
## Task 1: Add `name` to `PortSpec` and thread every construction site
This is the compile-gate task: adding the field breaks all 16 construction sites
workspace-wide, so they ALL land here. `port_json` is **not** touched (Task 2), so
the model byte golden stays green and the whole suite passes at the end of this task.
**Files:**
- Modify: `crates/aura-core/src/node.rs`
- Modify: `crates/aura-std/src/{sma,ema,sub,add,exposure,sim_broker,lincomb,recorder}.rs`
- Modify: `crates/aura-engine/src/blueprint.rs`, `harness.rs`, `report.rs`
- Modify: `crates/aura-cli/src/main.rs`, `crates/aura-ingest/tests/real_bars.rs`
- Modify: `crates/aura-engine/src/graph_model.rs` (the `sub_builder` test fixture only)
- [ ] **Step 1: Change the `PortSpec` struct — add `name`, drop `Copy`, update the doc**
In `crates/aura-core/src/node.rs`, replace the doc block + struct at `:29-37`:
```rust
/// One declared input **port** of a node: its scalar kind, firing policy (C6), and
/// a non-load-bearing name. The lookback depth is NOT here — it is a build-time
/// *sizing* concern answered by `Node::lookbacks()`, not part of the static
/// signature (a node's lookback can depend on an injected param, e.g. `Sma`'s
/// window = its `length`). The `name` is a **non-load-bearing** debug symbol (C23):
/// identity is the positional slot, the name is for tracing / graph rendering
/// (#13), never read by bootstrap or the run loop (which wire by index). A
/// `String`, not `&'static str` like `FieldSpec.name`: a variadic node generates
/// per-slot names (`term[0]`), exactly as `ParamSpec.name` does (`weights[0]`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PortSpec {
pub kind: ScalarKind,
pub firing: Firing,
pub name: String,
}
```
- [ ] **Step 2: Update the `port_spec_carries_firing` test to carry + assert the name**
In `crates/aura-core/src/node.rs`, replace the test body at `:172-179`:
```rust
#[test]
fn port_spec_carries_firing() {
let a = PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "lhs".into() };
let b = PortSpec { kind: ScalarKind::F64, firing: Firing::Barrier(0), name: "rhs".into() };
assert_eq!(a.firing, Firing::Any);
assert_eq!(b.firing, Firing::Barrier(0));
assert_ne!(a.firing, b.firing);
// the name is carried (non-load-bearing, but present)
assert_eq!(a.name, "lhs");
assert_eq!(b.name, "rhs");
}
```
- [ ] **Step 3: Name the fixed-arity aura-std node input slots**
Edit each construction expression, appending `name`:
`crates/aura-std/src/sma.rs:31` — the single input becomes:
```rust
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "series".into() }],
```
`crates/aura-std/src/ema.rs:63` — identical, `name: "series".into()`.
`crates/aura-std/src/sub.rs:27-28` — the two inputs:
```rust
inputs: vec![
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "lhs".into() },
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "rhs".into() },
],
```
`crates/aura-std/src/add.rs:36-37` — the two inputs, identical names `"lhs"` / `"rhs"`.
`crates/aura-std/src/exposure.rs:32` — the single input: `name: "signal".into()`.
`crates/aura-std/src/sim_broker.rs:66-67` — the two inputs (the line comments move into the data):
```rust
inputs: vec![
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "exposure".into() },
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "price".into() },
],
```
- [ ] **Step 4: Name the variadic aura-std node input slots (generated in the loop)**
`crates/aura-std/src/lincomb.rs:47-49` — change `|_|` to `|i|` and generate the name (mirrors the `weights[i]` param loop two lines below):
```rust
let inputs = (0..arity)
.map(|i| PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: format!("term[{i}]") })
.collect();
```
`crates/aura-std/src/recorder.rs:41` — add `.enumerate()` and generate the name:
```rust
let inputs = kinds
.iter()
.enumerate()
.map(|(i, &kind)| PortSpec { kind, firing, name: format!("col[{i}]") })
.collect();
```
- [ ] **Step 5: Carry `Role.name` into the derived composite input port**
`crates/aura-engine/src/blueprint.rs:76` — inside `derive_signature`'s `.map(|role| { ... })`, the input port stops dropping the boundary name (restores symmetry with the output side at `:84`, which already does `FieldSpec { name: leak_name(&of.name), kind }`):
```rust
PortSpec { kind, firing: Firing::Any, name: role.name.clone() }
```
- [ ] **Step 6: Name the engine/cli/ingest test fixtures (fixture names are fine)**
- `crates/aura-engine/src/blueprint.rs:742` (`f64_any`):
```rust
fn f64_any() -> PortSpec {
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "in".into() }
}
```
- `crates/aura-engine/src/blueprint.rs:842` (`sink_i64` input): `PortSpec { kind: ScalarKind::I64, firing: Firing::Any, name: "in".into() }`.
- `crates/aura-engine/src/blueprint.rs:1938` (the exploding-builder fixture): `PortSpec { kind: ScalarKind::I64, firing: Firing::Any, name: "in".into() }`.
- `crates/aura-engine/src/harness.rs:362` (`f64_port`): `PortSpec { kind: ScalarKind::F64, firing, name: "in".into() }` (keep its existing `firing` arg).
- `crates/aura-engine/src/harness.rs:380` (`recorder_sig` loop): mirror the real Recorder —
```rust
kinds.iter().enumerate().map(|(i, &kind)| PortSpec { kind, firing, name: format!("col[{i}]") }).collect()
```
- `crates/aura-engine/src/report.rs:210`: `PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "in".into() }`.
- `crates/aura-cli/src/main.rs:54`: `aura_engine::PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "in".into() }` (keep the `aura_engine::` qualifier).
- `crates/aura-ingest/tests/real_bars.rs:26`: `PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "in".into() }`.
- `crates/aura-engine/src/graph_model.rs:252-253` (the `sub_builder` test fixture, used by Task 2's leaf-name test) — name its two ports:
```rust
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "lhs".into() },
PortSpec { kind: ScalarKind::F64, firing: Firing::Barrier(0), name: "rhs".into() },
```
- [ ] **Step 7: Build the workspace — confirm every site is threaded**
Run: `cargo build --workspace`
Expected: finishes with 0 errors (no `missing field name in initializer of PortSpec` and no `Copy`-move errors remain). If the compiler names any `PortSpec` literal still lacking `name`, add `name: "in".into()` (fixture) and rebuild.
- [ ] **Step 8: Run the full suite — confirm still green (port_json unchanged → golden intact)**
Run: `cargo test --workspace`
Expected: PASS, 0 failures. The model byte golden is unaffected because `port_json` still emits two-element tuples; no existing test asserts full `PortSpec` equality (the derive test checks only `.len()` and `.kind`).
---
## Task 2: `model_to_json` emits the name — `port_json` + golden + twins
**Files:**
- Modify: `crates/aura-engine/src/graph_model.rs:50-53` (port_json), `:422` (golden), `:433` + `:443` (substring pins)
- [ ] **Step 1: Append the name to `port_json`**
`crates/aura-engine/src/graph_model.rs:50-53` — rewrite the doc + body:
```rust
/// One input port as a model tuple: `["<kind>","<firing>","<name>"]`. The name is
/// the port's non-load-bearing label (`PortSpec.name`); for a composite boundary
/// it is the `Role.name` carried through `derive_signature`.
fn port_json(p: &aura_core::PortSpec) -> String {
format!(
"[{},{},{}]",
json_str(kind_str(p.kind)),
json_str(&firing_str(p.firing)),
json_str(&p.name)
)
}
```
- [ ] **Step 2: Run the golden test to see it go RED**
Run: `cargo test -p aura-engine model_golden`
Expected: FAIL — the emitted `ins` tuples now carry a third element, mismatching the stored golden at `:422`.
- [ ] **Step 3: Re-capture the byte golden**
Follow the re-capture recipe already in the test header (`graph_model.rs:419-421`):
run the print harness, copy the exact emitted JSON, and paste it as the new
`r##"..."##` golden string at `:422`. Every `"ins":[...]` tuple gains its third
element (leaf ports from `PortSpec.name`, e.g. `["f64","any","series"]`; the
composite `"inputs":[["f64","any"]]` becomes `[["f64","any","price"]]` from
`Role.name`; the `src_price` source keeps its empty `ins`).
Run: `cargo test -p aura-engine model_golden`
Expected: PASS (golden now matches the re-captured bytes).
- [ ] **Step 4: Update the two substring-pin twins**
`crates/aura-engine/src/graph_model.rs:433` (`sink_has_empty_outs`) — the Recorder
sink's single input now carries `col[0]`. Replace the asserted substring:
```rust
r#""role":"sink","params":[],"ins":[["f64","any","col[0]"]],"outs":[]"#
```
`crates/aura-engine/src/graph_model.rs:443` (`multi_input_composite_has_two_inputs`)
— the `two_input_composite()` fixture's roles are named `a`/`b`, carried through
`derive_signature`. Replace the `starts_with` prefix:
```rust
r#"{"inputs":[["f64","any","a"],["f64","any","b"]],"#
```
This assertion now also witnesses the composite-boundary `Role.name` carry.
- [ ] **Step 5: Run the engine suite green**
Run: `cargo test -p aura-engine`
Expected: PASS, 0 failures (golden re-captured, both twins updated, determinism
test still green). The two updated twins are exactly the spec's two model-name
witnesses: `sink_has_empty_outs` pins a **leaf** `PortSpec.name` reaching the model
(`Recorder`'s `col[0]` — a leaf primitive), and `multi_input_composite_has_two_inputs`
pins a **composite** boundary `Role.name` carried through `derive_signature`
(`a`/`b`).
---
## Task 3: Lock the per-node input-slot names (aura-std)
Each test pins the declared names so a future rename is a visible test change. Each
goes in the node's existing `#[cfg(test)] mod tests`.
**Files:**
- Test: `crates/aura-std/src/{sma,ema,sub,add,exposure,sim_broker,lincomb,recorder}.rs`
- [ ] **Step 1: Fixed-arity node name pins**
Add to each node's test module:
`sma.rs`:
```rust
#[test]
fn input_slot_is_named_series() {
assert_eq!(Sma::builder().schema().inputs[0].name, "series");
}
```
`ema.rs`:
```rust
#[test]
fn input_slot_is_named_series() {
assert_eq!(Ema::builder().schema().inputs[0].name, "series");
}
```
`sub.rs`:
```rust
#[test]
fn input_slots_are_named_lhs_rhs() {
let s = Sub::builder();
let names: Vec<&str> = s.schema().inputs.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, ["lhs", "rhs"]);
}
```
`add.rs`:
```rust
#[test]
fn input_slots_are_named_lhs_rhs() {
let a = Add::builder();
let names: Vec<&str> = a.schema().inputs.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, ["lhs", "rhs"]);
}
```
`exposure.rs`:
```rust
#[test]
fn input_slot_is_named_signal() {
assert_eq!(Exposure::builder().schema().inputs[0].name, "signal");
}
```
`sim_broker.rs` (the #21 self-documentation win):
```rust
#[test]
fn input_slots_are_named_exposure_price() {
let b = SimBroker::builder(1.0);
let names: Vec<&str> = b.schema().inputs.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, ["exposure", "price"]);
}
```
- [ ] **Step 2: Variadic node name pins (generated sequence)**
`lincomb.rs`:
```rust
#[test]
fn input_slots_are_named_term_index() {
let lc = LinComb::builder(3);
let names: Vec<String> = lc.schema().inputs.iter().map(|p| p.name.clone()).collect();
assert_eq!(names, ["term[0]", "term[1]", "term[2]"]);
}
```
`recorder.rs` (build a throwaway channel as the existing recorder tests do):
```rust
#[test]
fn input_slots_are_named_col_index() {
let (tx, _rx) = std::sync::mpsc::channel();
let r = Recorder::builder(vec![ScalarKind::F64, ScalarKind::F64], Firing::Any, tx);
let names: Vec<String> = r.schema().inputs.iter().map(|p| p.name.clone()).collect();
assert_eq!(names, ["col[0]", "col[1]"]);
}
```
`ScalarKind` and `Firing` are already in scope in `recorder.rs`'s test module (the
existing test at `:91` constructs `Recorder::builder(vec![ScalarKind::F64], Firing::Any, …)`),
so no new import is needed.
- [ ] **Step 3: Run aura-std + aura-core green**
Run: `cargo test -p aura-std -p aura-core`
Expected: PASS, 0 failures — all 8 node name pins plus the `port_spec_carries_firing` name assertion (Task 1) green.
---
## Task 4: Record the realization in the design ledger
**Files:**
- Modify: `docs/design/INDEX.md:296` (C8 section, after the cycle-0024 note, before `### C9`)
- [ ] **Step 1: Insert the C8 realization note**
At `docs/design/INDEX.md:296` (the blank line after C8's last realization note,
before `### C9`), insert, matching the existing realization-note format (bold lead
+ prose, as the FieldSpec/0005 note at `:219` and the param/0015 note at `:253`):
```markdown
**Realization (cycle 0027 — name input ports, refs #21/#51).** `PortSpec` now
carries a `name: String` (it drops `Copy`, like `ParamSpec`), so an input port is
named just as `FieldSpec.name` (output) and `ParamSpec.name` (param) already are.
The name is **non-load-bearing** (C23): wiring stays positional by slot, bootstrap
and the run loop never read it; it exists for tracing / graph rendering (#13). Leaf
primitives declare their slot names (`SimBroker`'s `exposure`/`price`, etc.);
`derive_signature` carries a composite's `Role.name` into the derived input port,
so the graph model (`model_to_json`) is homogeneously named across inputs, outputs,
and params and across both graph levels. This does **not** close #21 (a swapped
same-kind wiring is still only kind-checked) — it makes those slots
self-documenting; a name-consuming validation is its own future cycle.
```
- [ ] **Step 2: Verify the ledger still reads cleanly**
Run: `cargo doc --workspace --no-deps 2>&1 | rg -i "warning|error" || echo "doc clean"`
Expected: `doc clean` (the ledger is Markdown, not rustdoc — this gate just
confirms the code changes introduced no doc-build regression). The ledger edit
itself is prose; no code gate applies.
-590
View File
@@ -1,590 +0,0 @@
# Param-sweep grid — Iteration 1 (engine primitive) — Implementation Plan
> **Parent spec:** `docs/specs/0028-param-sweep-grid.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Ship the engine-side param-sweep primitive — `GridSpace` (validated
cartesian enumeration), `sweep()` (disjoint `std::thread::scope` execution), and
`SweepFamily` (ordered collection) — in a new `crates/aura-engine/src/sweep.rs`.
**Architecture:** A new module with three parts: enumeration (`GridSpace::new`
validates one value-list per param slot against `param_space()`; `points()`
materializes the cartesian product in odometer order), execution (`sweep()`
derives the worker count from `available_parallelism()` and delegates to a
private `sweep_with_threads()` that runs points disjointly over a shared atomic
cursor, tagging results by index and sorting after join for enumeration-order
determinism), and collection (`SweepFamily`/`SweepPoint`). The per-point
run-to-metrics closure is the author's. Zero external dependency (C16) — `std`
only, no `rayon`.
**Tech Stack:** `crates/aura-engine` (`sweep.rs` new, `lib.rs` re-export);
reuses `Composite::param_space`/`bootstrap_with_params` (#30/#31),
`summarize`/`f64_field`/`RunMetrics` (report), `Scalar`/`ScalarKind`/`ParamSpec`
(aura-core), and a duplicated value-empty SMA-cross two-sink test fixture.
---
**Files this plan creates or modifies:**
- Create: `crates/aura-engine/src/sweep.rs` — the sweep module (`GridSpace`,
`SweepError`, `SweepPoint`, `SweepFamily`, `sweep`, private
`sweep_with_threads`) + its `#[cfg(test)]` suite.
- Modify: `crates/aura-engine/src/lib.rs:34-44` — add `mod sweep;` to the module
list and a `pub use sweep::{…}` re-export.
---
## Task 1: GridSpace — cartesian enumeration + the typed fault gate
**Files:**
- Create: `crates/aura-engine/src/sweep.rs`
- Modify: `crates/aura-engine/src/lib.rs:34-44`
- [ ] **Step 1: Create `sweep.rs` with the test module only (RED)**
Create `crates/aura-engine/src/sweep.rs` with exactly this content (the
production types referenced by the tests do not exist yet, so the crate will not
compile — that is the RED state):
```rust
//! Grid param-sweep (C12.1): enumerate a cartesian grid over a blueprint's
//! param-space and run each point disjointly (C1). This module owns enumeration
//! (`GridSpace`), execution (`sweep`), and collection (`SweepFamily`); the
//! per-point run-to-metrics closure is the author's (harness-specific sink glue
//! the engine cannot generically own — C8/C18).
#[cfg(test)]
mod tests {
use super::*;
use aura_core::{ParamSpec, Scalar, ScalarKind};
fn i64_space(n: usize) -> Vec<ParamSpec> {
(0..n)
.map(|i| ParamSpec { name: format!("p{i}"), kind: ScalarKind::I64 })
.collect()
}
#[test]
fn points_enumerate_in_odometer_order() {
let space = i64_space(2);
let grid = GridSpace::new(
&space,
vec![
vec![Scalar::I64(2), Scalar::I64(3)],
vec![Scalar::I64(4), Scalar::I64(5)],
],
)
.expect("valid grid");
assert_eq!(
grid.points(),
vec![
vec![Scalar::I64(2), Scalar::I64(4)],
vec![Scalar::I64(2), Scalar::I64(5)],
vec![Scalar::I64(3), Scalar::I64(4)],
vec![Scalar::I64(3), Scalar::I64(5)],
],
);
}
#[test]
fn len_is_product_of_axis_lengths() {
let space = vec![
ParamSpec { name: "a".into(), kind: ScalarKind::I64 },
ParamSpec { name: "b".into(), kind: ScalarKind::I64 },
ParamSpec { name: "c".into(), kind: ScalarKind::F64 },
];
let grid = GridSpace::new(
&space,
vec![
vec![Scalar::I64(2), Scalar::I64(3)],
vec![Scalar::I64(4), Scalar::I64(5)],
vec![Scalar::F64(0.5)],
],
)
.expect("valid grid");
assert_eq!(grid.len(), 4);
assert!(!grid.is_empty());
}
#[test]
fn arity_mismatch_is_an_error() {
let space = i64_space(2);
let err = GridSpace::new(&space, vec![vec![Scalar::I64(2)]]).unwrap_err();
assert_eq!(err, SweepError::Arity { expected: 2, got: 1 });
}
#[test]
fn wrong_kind_is_a_kind_mismatch() {
let space = i64_space(1);
let err = GridSpace::new(&space, vec![vec![Scalar::F64(1.0)]]).unwrap_err();
assert_eq!(
err,
SweepError::KindMismatch {
slot: 0,
value_index: 0,
expected: ScalarKind::I64,
got: ScalarKind::F64,
},
);
}
#[test]
fn empty_axis_is_an_error() {
let space = i64_space(1);
let err = GridSpace::new(&space, vec![vec![]]).unwrap_err();
assert_eq!(err, SweepError::EmptyAxis { slot: 0 });
}
}
```
- [ ] **Step 2: Wire the module + partial re-export into `lib.rs`**
In `crates/aura-engine/src/lib.rs`, add `mod sweep;` after line 37 (`mod
report;`), so the module list reads:
```rust
mod blueprint;
mod graph_model;
mod harness;
mod report;
mod sweep;
```
And add the partial re-export after line 44 (`pub use report::{…}`):
```rust
pub use sweep::{GridSpace, SweepError};
```
(The full re-export — adding `sweep`, `SweepFamily`, `SweepPoint` — lands in
Task 2 once those symbols exist.)
- [ ] **Step 3: Run the test to verify it fails (RED)**
Run: `cargo test -p aura-engine --lib sweep::`
Expected: FAIL — compile error, `cannot find type GridSpace`/`SweepError` in this
scope (the production types are not written yet).
- [ ] **Step 4: Write the production `GridSpace` + `SweepError` (GREEN)**
Insert this block at the **top** of `crates/aura-engine/src/sweep.rs`, directly
after the `//!` module doc-comment and **before** the `#[cfg(test)]` line:
```rust
use aura_core::{ParamSpec, Scalar, ScalarKind};
/// A validated cartesian grid over a blueprint's param-space: one discrete
/// value-list per param slot, in `param_space()` order. Enumerates a family of
/// points (C12.1 grid axis).
pub struct GridSpace {
axes: Vec<Vec<Scalar>>,
}
impl GridSpace {
/// Validate `axes` against `space` (the blueprint's `param_space()`): one
/// axis per slot (`Arity`), every value the slot's declared kind
/// (`KindMismatch`), no empty axis (`EmptyAxis`, which would yield zero
/// points). On success the grid enumerates `∏ |axis_i|` points.
pub fn new(space: &[ParamSpec], axes: Vec<Vec<Scalar>>) -> Result<Self, SweepError> {
if axes.len() != space.len() {
return Err(SweepError::Arity { expected: space.len(), got: axes.len() });
}
for (slot, (axis, ps)) in axes.iter().zip(space).enumerate() {
if axis.is_empty() {
return Err(SweepError::EmptyAxis { slot });
}
for (value_index, v) in axis.iter().enumerate() {
if v.kind() != ps.kind {
return Err(SweepError::KindMismatch {
slot,
value_index,
expected: ps.kind,
got: v.kind(),
});
}
}
}
Ok(Self { axes })
}
/// The number of grid points (`∏ |axis_i|`), always `>= 1` (a valid grid has
/// no empty axis; a zero-param grid is the single empty point).
pub fn len(&self) -> usize {
self.axes.iter().map(|a| a.len()).product()
}
/// Always `false` — a valid `GridSpace` rejects empty axes, so it has at
/// least one point. Present to satisfy clippy's `len_without_is_empty`.
pub fn is_empty(&self) -> bool {
false
}
/// The cartesian product, in odometer order: the **last** axis varies
/// fastest. Deterministic — the same grid yields the same point sequence.
pub fn points(&self) -> Vec<Vec<Scalar>> {
let mut out = Vec::with_capacity(self.len());
let mut idx = vec![0usize; self.axes.len()];
loop {
out.push(self.axes.iter().zip(&idx).map(|(a, &i)| a[i]).collect());
// odometer increment from the last axis
let mut k = self.axes.len();
loop {
if k == 0 {
return out;
}
k -= 1;
idx[k] += 1;
if idx[k] < self.axes[k].len() {
break;
}
idx[k] = 0;
}
}
}
}
/// A structural fault constructing a `GridSpace` — the typed gate before any run.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SweepError {
/// The number of axes does not equal the param-space slot count.
Arity { expected: usize, got: usize },
/// A grid value's kind does not match its slot's declared kind. `slot` is the
/// flat param-space index; `value_index` is the position within that axis.
KindMismatch { slot: usize, value_index: usize, expected: ScalarKind, got: ScalarKind },
/// A slot was given no values (would collapse the product to zero points).
EmptyAxis { slot: usize },
}
```
- [ ] **Step 5: Run the test to verify it passes (GREEN)**
Run: `cargo test -p aura-engine --lib sweep::`
Expected: PASS — 5 tests (`points_enumerate_in_odometer_order`,
`len_is_product_of_axis_lengths`, `arity_mismatch_is_an_error`,
`wrong_kind_is_a_kind_mismatch`, `empty_axis_is_an_error`).
- [ ] **Step 6: Lint gate for this task**
Run: `cargo clippy -p aura-engine --all-targets -- -D warnings`
Expected: clean (no warnings). `GridSpace`/`SweepError` are reachable via the
Step-2 re-export, so no dead-code lint fires.
---
## Task 2: sweep() — disjoint parallel execution + the ordered family
**Files:**
- Modify: `crates/aura-engine/src/sweep.rs`
- Modify: `crates/aura-engine/src/lib.rs:44`
- [ ] **Step 1: Replace the test-module imports + add the fixtures and run-tests (RED)**
In `crates/aura-engine/src/sweep.rs`, replace the Task-1 test-module import lines
```rust
use super::*;
use aura_core::{ParamSpec, Scalar, ScalarKind};
```
with the full import block:
```rust
use super::*;
use crate::{
f64_field, summarize, BlueprintNode, Composite, Edge, OutField, ParamAlias, Role,
RunMetrics, Target,
};
use aura_core::{Firing, ParamSpec, Scalar, ScalarKind, Timestamp};
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
use std::sync::mpsc;
```
Then, inside the `mod tests { … }` block, **after** the existing
`empty_axis_is_an_error` test and **before** the module's closing `}`, append the
fixtures and the three execution tests:
```rust
fn synthetic_prices() -> Vec<(Timestamp, Scalar)> {
[
(1_i64, 1.0000_f64),
(2, 1.0010),
(3, 1.0030),
(4, 1.0060),
(5, 1.0040),
(6, 1.0010),
(7, 0.9990),
]
.iter()
.map(|&(t, p)| (Timestamp(t), Scalar::F64(p)))
.collect()
}
/// The SMA-cross signal as a reusable value-empty composite (duplicated from
/// `blueprint.rs`'s test module — test fixtures stay module-local, the same
/// way `report.rs` copies its harness helper).
fn sma_cross() -> Composite {
Composite::new(
"sma_cross",
vec![Sma::builder().into(), Sma::builder().into(), Sub::builder().into()],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
],
vec![Role {
name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
source: None,
}],
vec![
ParamAlias { name: "fast".into(), node: 0, slot: 0 },
ParamAlias { name: "slow".into(), node: 1, slot: 0 },
],
vec![OutField { node: 2, field: 0, name: "out".into() }],
)
}
/// The signal-quality harness as a value-empty composite blueprint with two
/// recording sinks (duplicated from `blueprint.rs`'s test module).
#[allow(clippy::type_complexity)]
fn composite_sma_cross_harness() -> (
Composite,
mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
) {
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
let bp = Composite::new(
"root",
vec![
BlueprintNode::Composite(sma_cross()),
Exposure::builder().into(),
SimBroker::builder(0.0001).into(),
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq).into(),
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex).into(),
],
vec![
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // composite out -> Exposure
Edge { from: 1, to: 2, slot: 0, from_field: 0 }, // exposure -> broker slot 0
Edge { from: 2, to: 3, slot: 0, from_field: 0 }, // equity -> sink
Edge { from: 1, to: 4, slot: 0, from_field: 0 }, // exposure -> sink
],
vec![Role {
name: "src".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 2, slot: 1 }],
source: Some(ScalarKind::F64),
}],
vec![], // params: the interior sma_cross + Exposure carry the knobs
vec![], // output: the root ends in sinks
);
(bp, rx_eq, rx_ex)
}
/// Build + bootstrap + run + drain + summarize one grid point. A free `fn`
/// (Copy + Sync) so it serves as the `sweep` closure AND a direct reference
/// for the "sweep == N independent runs" comparison.
fn run_point(point: &[Scalar]) -> RunMetrics {
let (bp, rx_eq, rx_ex) = composite_sma_cross_harness();
let mut h = bp
.bootstrap_with_params(point.to_vec())
.expect("grid points are kind-checked against param_space");
h.run(vec![synthetic_prices()]);
let equity = f64_field(&rx_eq.try_iter().collect::<Vec<_>>(), 0);
let exposure = f64_field(&rx_ex.try_iter().collect::<Vec<_>>(), 0);
summarize(&equity, &exposure)
}
fn sma_cross_grid() -> GridSpace {
let space = composite_sma_cross_harness().0.param_space();
GridSpace::new(
&space,
vec![
vec![Scalar::I64(2), Scalar::I64(3)], // fast ∈ {2, 3}
vec![Scalar::I64(4), Scalar::I64(5)], // slow ∈ {4, 5}
vec![Scalar::F64(0.5)], // scale ∈ {0.5}
],
)
.expect("grid matches the sample param-space")
}
#[test]
fn sweep_equals_n_independent_runs() {
let grid = sma_cross_grid();
let family = sweep(&grid, run_point);
assert_eq!(family.points.len(), 4);
// params carried in enumeration (odometer) order, self-describing
assert_eq!(
family.points[0].params,
vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)],
);
assert_eq!(
family.points[3].params,
vec![Scalar::I64(3), Scalar::I64(5), Scalar::F64(0.5)],
);
// each point's metrics equal a direct, independent run of the same point:
// the sweep adds enumeration + execution, never a metrics change (C1).
for pt in &family.points {
assert_eq!(pt.metrics, run_point(&pt.params));
assert!(pt.metrics.total_pips.is_finite());
}
}
#[test]
fn family_is_deterministic_across_thread_counts() {
let grid = sma_cross_grid();
let one = sweep_with_threads(&grid, 1, run_point);
let many = sweep_with_threads(&grid, 8, run_point);
// same family at 1 worker and at N (C1: order = enumeration, not completion)
assert_eq!(one, many);
// and identical to the public `sweep`, which derives the worker count
assert_eq!(one, sweep(&grid, run_point));
}
#[test]
fn distinct_points_produce_distinct_metrics() {
let family = sweep(&sma_cross_grid(), run_point);
// not a constant family: differing SMA lengths produce differing equity
let first = family.points[0].metrics.total_pips;
assert!(
family.points.iter().any(|p| p.metrics.total_pips != first),
"a 4-point SMA-length grid must not collapse to one metric",
);
}
```
- [ ] **Step 2: Run the tests to verify they fail (RED)**
Run: `cargo test -p aura-engine --lib sweep::`
Expected: FAIL — compile error, `cannot find function sweep` /
`cannot find function sweep_with_threads` / `cannot find type SweepPoint` /
`SweepFamily` (the execution layer is not written yet).
- [ ] **Step 3: Add the execution imports to `sweep.rs`**
In `crates/aura-engine/src/sweep.rs`, change the top production import line
```rust
use aura_core::{ParamSpec, Scalar, ScalarKind};
```
to add the two execution-layer imports immediately after it:
```rust
use aura_core::{ParamSpec, Scalar, ScalarKind};
use crate::RunMetrics;
use std::sync::atomic::{AtomicUsize, Ordering};
```
- [ ] **Step 4: Write `SweepPoint`, `SweepFamily`, `sweep`, `sweep_with_threads` (GREEN)**
In `crates/aura-engine/src/sweep.rs`, insert this block **after** the
`SweepError` enum and **before** the `#[cfg(test)]` line:
```rust
/// One enumerated point and the metrics its run produced. Self-describing: the
/// `params` vector is the point's coordinate in `param_space()` order.
#[derive(Clone, Debug, PartialEq)]
pub struct SweepPoint {
pub params: Vec<Scalar>,
pub metrics: RunMetrics,
}
/// The ordered result family of a sweep — one `SweepPoint` per grid point, in
/// enumeration (odometer) order, independent of thread completion order.
#[derive(Clone, Debug, PartialEq)]
pub struct SweepFamily {
pub points: Vec<SweepPoint>,
}
/// Run `run_one` over every grid point, disjointly in parallel (C1), and collect
/// the family in enumeration order. `run_one` builds + bootstraps + runs +
/// summarizes one point; it shares nothing mutable, so it is `Sync` and the runs
/// are lock-free. Parallelism is `available_parallelism()` workers — `std` only,
/// no external dependency (C16).
pub fn sweep<F>(space: &GridSpace, run_one: F) -> SweepFamily
where
F: Fn(&[Scalar]) -> RunMetrics + Sync,
{
let nthreads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
sweep_with_threads(space, nthreads, run_one)
}
/// The thread-count-explicit core of [`sweep`]. Module-private: the public
/// `sweep` derives the count, while the tests drive it at 1 and at N to pin
/// determinism under concurrency (C1). Workers pull point indices from a shared
/// atomic cursor (work-stealing load-balances uneven per-point cost); each tags
/// its result with the point index, and the family is assembled by sorting on
/// that index after the scope joins — so the order is the enumeration order, not
/// the completion order. Only the cursor is shared; the results side is lock-free.
fn sweep_with_threads<F>(space: &GridSpace, nthreads: usize, run_one: F) -> SweepFamily
where
F: Fn(&[Scalar]) -> RunMetrics + Sync,
{
let points = space.points();
// `points.len() >= 1` always (GridSpace rejects empty axes), so the clamp
// lower bound never exceeds the upper; this also coerces a 0 to 1 worker.
let nthreads = nthreads.clamp(1, points.len());
let cursor = AtomicUsize::new(0);
let mut results: Vec<(usize, RunMetrics)> = std::thread::scope(|scope| {
let handles: Vec<_> = (0..nthreads)
.map(|_| {
scope.spawn(|| {
let mut local: Vec<(usize, RunMetrics)> = Vec::new();
loop {
let i = cursor.fetch_add(1, Ordering::Relaxed);
if i >= points.len() {
break;
}
local.push((i, run_one(&points[i])));
}
local
})
})
.collect();
handles.into_iter().flat_map(|h| h.join().unwrap()).collect()
});
results.sort_by_key(|&(i, _)| i);
SweepFamily {
points: results
.into_iter()
.map(|(i, metrics)| SweepPoint { params: points[i].clone(), metrics })
.collect(),
}
}
```
- [ ] **Step 5: Expand the `lib.rs` re-export**
In `crates/aura-engine/src/lib.rs`, replace the Task-1 partial re-export line
```rust
pub use sweep::{GridSpace, SweepError};
```
with the full re-export:
```rust
pub use sweep::{sweep, GridSpace, SweepError, SweepFamily, SweepPoint};
```
- [ ] **Step 6: Run the tests to verify they pass (GREEN)**
Run: `cargo test -p aura-engine --lib sweep::`
Expected: PASS — 8 tests total (the 5 enumeration/fault tests from Task 1 plus
`sweep_equals_n_independent_runs`, `family_is_deterministic_across_thread_counts`,
`distinct_points_produce_distinct_metrics`).
- [ ] **Step 7: Full workspace build + test + lint gate**
Run: `cargo build --workspace`
Expected: success.
Run: `cargo test --workspace`
Expected: PASS — the whole suite green, including the new `sweep::` tests and all
pre-existing tests (no regression).
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: clean (no warnings).
-432
View File
@@ -1,432 +0,0 @@
# Param-sweep grid — Iteration 2 (`aura sweep` CLI demonstrator) — Implementation Plan
> **Parent spec:** `docs/specs/0028-param-sweep-grid.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Make the iteration-1 sweep primitive visible without writing Rust — an
`aura sweep` subcommand that runs the built-in sample over a small built-in grid
and prints one JSON line per point.
**Architecture:** All edits live in `crates/aura-cli/src/main.rs` plus a golden in
`crates/aura-cli/tests/cli_run.rs`. A `sample_blueprint_with_sinks()` factory
holds the sample topology and returns the two Recorder receivers (today
`build_sample` drops them); `build_sample` is re-expressed on top of it so the
`aura graph` path is unchanged. A pure `sweep_report() -> String` declares the
grid, calls the engine `sweep()` with a per-point build→run→drain→summarize
closure, and renders each `SweepPoint` via a hand-rolled `sweep_point_to_json`
(C14, no serde — mirrors `RunReport::to_json`'s token rules). The `main()`
dispatch grows a `["sweep"]` arm.
**Tech Stack:** `crates/aura-cli` (`main.rs` subcommand + helpers, `tests/cli_run.rs`
golden); consumes the iter-1 engine exports `GridSpace`/`sweep`/`SweepPoint` and
the existing `f64_field`/`summarize`/`synthetic_prices`/`sma_cross` surface.
---
**Files this plan creates or modifies:**
- Modify: `crates/aura-cli/src/main.rs:11-17` — extend the `use` blocks
(`ParamSpec` from aura-core; `GridSpace`, `sweep`, `SweepPoint` from
aura-engine).
- Modify: `crates/aura-cli/src/main.rs:160-194` — add `sample_blueprint_with_sinks()`;
re-express `build_sample()` on top of it.
- Modify: `crates/aura-cli/src/main.rs:352` — extend `USAGE`.
- Modify: `crates/aura-cli/src/main.rs:359-368` — add the `["sweep"]` dispatch arm.
- Modify: `crates/aura-cli/src/main.rs` (new fns `sweep_report`,
`sweep_point_to_json`, `json_string`, `scalar_token`) + its `#[cfg(test)]` module.
- Modify: `crates/aura-cli/tests/cli_run.rs` — add the `aura sweep` process golden.
---
## Task 1: `sample_blueprint_with_sinks` factory (sinks reachable for draining)
**Files:**
- Modify: `crates/aura-cli/src/main.rs:160-194`
- [ ] **Step 1: Write the failing test (RED)**
In `crates/aura-cli/src/main.rs`, inside the existing `#[cfg(test)] mod tests {
… }` block (around `main.rs:371-459`), add this test:
```rust
#[test]
fn sample_blueprint_with_sinks_bootstraps_runs_and_drains() {
// the factory returns the two Recorder receivers (build_sample drops them),
// so a caller can bootstrap one point, run it, and drain both sinks.
let (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks();
let mut h = bp
.bootstrap_with_params(vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)])
.expect("sample blueprint compiles under a valid point");
h.run(vec![synthetic_prices()]);
assert!(!rx_eq.try_iter().collect::<Vec<_>>().is_empty(), "equity sink drained empty");
assert!(!rx_ex.try_iter().collect::<Vec<_>>().is_empty(), "exposure sink drained empty");
}
```
- [ ] **Step 2: Run the test to verify it fails (RED)**
Run: `cargo test -p aura-cli --bin aura sample_blueprint_with_sinks`
Expected: FAIL — compile error, `cannot find function sample_blueprint_with_sinks`
in this scope.
- [ ] **Step 3: Add the factory and re-express `build_sample` (GREEN)**
In `crates/aura-cli/src/main.rs`, replace the current `build_sample` function
(`main.rs:160-189`, the one that constructs two channels and drops the receivers
at `let (tx_eq, _rx_eq) = …` / `let (tx_ex, _rx_ex) = …`) with the factory plus a
thin `build_sample` that discards the receivers:
```rust
/// The sample signal-quality blueprint (value-empty) **with its two recording
/// sinks reachable**: returns the equity + exposure receivers a per-point sweep
/// run drains (the SMA lengths + exposure scale are injected at compile via the
/// point vector). The single source of the sample topology — `build_sample`
/// (the `aura graph` entry) is expressed on top of it.
#[allow(clippy::type_complexity)]
fn sample_blueprint_with_sinks() -> (
Composite,
Receiver<(Timestamp, Vec<Scalar>)>,
Receiver<(Timestamp, Vec<Scalar>)>,
) {
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
let bp = Composite::new(
"sample",
vec![
BlueprintNode::Composite(sma_cross("sma_cross")),
Exposure::builder().into(),
SimBroker::builder(0.0001).into(),
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq).into(),
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex).into(),
],
vec![
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // spread -> Exposure
Edge { from: 1, to: 2, slot: 0, from_field: 0 }, // exposure -> broker slot 0
Edge { from: 2, to: 3, slot: 0, from_field: 0 }, // equity -> sink
Edge { from: 1, to: 4, slot: 0, from_field: 0 }, // exposure -> sink
],
vec![Role {
name: "price".into(),
targets: vec![
Target { node: 0, slot: 0 }, // price -> sma_cross role 0
Target { node: 2, slot: 1 }, // price -> SimBroker price slot
],
source: Some(ScalarKind::F64),
}],
vec![], // params: the interior sma_cross carries the aliases
vec![], // output: the root ends in sinks, no re-export
);
(bp, rx_eq, rx_ex)
}
/// The sample blueprint without its sink receivers — the `aura graph` render
/// entry, which never runs the graph (so the receivers are dropped).
fn build_sample() -> Composite {
sample_blueprint_with_sinks().0
}
```
(The `sample_blueprint()` wrapper at `main.rs:192-194``fn sample_blueprint()
-> Composite { build_sample() }` — is unchanged.)
- [ ] **Step 4: Run the test to verify it passes (GREEN)**
Run: `cargo test -p aura-cli --bin aura sample_blueprint_with_sinks`
Expected: PASS — 1 test (`sample_blueprint_with_sinks_bootstraps_runs_and_drains`).
- [ ] **Step 5: Graph non-regression + lint gate**
Run: `cargo test -p aura-cli`
Expected: PASS — the whole aura-cli suite green, **including** the existing graph
golden `graph_emits_self_contained_html_viewer` in `tests/cli_run.rs` (proves the
`build_sample` re-expression did not change the rendered sample topology).
Run: `cargo clippy -p aura-cli --all-targets -- -D warnings`
Expected: clean.
---
## Task 2: `sweep` subcommand — grid, render, and the JSON goldens
**Files:**
- Modify: `crates/aura-cli/src/main.rs:11-17` (imports), `:352` (USAGE),
`:359-368` (dispatch), plus new fns + tests
- Modify: `crates/aura-cli/tests/cli_run.rs`
- [ ] **Step 1: Write the `sweep_point_to_json` byte-pin test (RED)**
In `crates/aura-cli/src/main.rs`, inside the `#[cfg(test)] mod tests { … }` block,
add this test (a hand-constructed point, so the exact bytes — including the
`12.0 -> 12` / `0.5` token rules — are pinned):
```rust
#[test]
fn sweep_point_to_json_renders_canonical_form() {
let space = vec![
ParamSpec { name: "sma_cross.fast".into(), kind: ScalarKind::I64 },
ParamSpec { name: "sma_cross.slow".into(), kind: ScalarKind::I64 },
ParamSpec { name: "scale".into(), kind: ScalarKind::F64 },
];
let pt = SweepPoint {
params: vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)],
// fully-qualified: RunMetrics is needed only by this test, so it is not
// imported into production (where it would be an unused import — the
// production code reads `pt.metrics` by field, never naming the type).
metrics: aura_engine::RunMetrics { total_pips: 12.0, max_drawdown: 1.0, exposure_sign_flips: 1 },
};
assert_eq!(
sweep_point_to_json(&pt, &space),
r#"{"params":{"sma_cross.fast":2,"sma_cross.slow":4,"scale":0.5},"metrics":{"total_pips":12,"max_drawdown":1,"exposure_sign_flips":1}}"#,
);
}
```
`SweepPoint` and `ParamSpec` reach this test via the existing `use super::*`
(both are added to production imports in Step 3); `Scalar`/`ScalarKind` are
already in `super` (`main.rs:11`). `RunMetrics` is fully-qualified above, so it
needs no import.
- [ ] **Step 2: Run the test to verify it fails (RED)**
Run: `cargo test -p aura-cli --bin aura sweep_point_to_json`
Expected: FAIL — compile error, `cannot find function sweep_point_to_json` /
`cannot find type SweepPoint` / `cannot find value ParamSpec` (the helpers and
imports do not exist yet).
- [ ] **Step 3: Extend the imports + write the JSON helpers (GREEN for Step 1)**
In `crates/aura-cli/src/main.rs`, change the aura-core import (`main.rs:11`) to add
`ParamSpec`:
```rust
use aura_core::{Firing, ParamSpec, Scalar, ScalarKind, Timestamp};
```
and the aura-engine import (`main.rs:12-15`) to add `GridSpace`, `sweep`, and
`SweepPoint` (NOT `RunMetrics` — it is used only by the Step-1 test, which
fully-qualifies it, so importing it here would be an unused import in the
production build):
```rust
use aura_engine::{
f64_field, summarize, sweep, BlueprintNode, Composite, Edge, FlatGraph, GridSpace, Harness,
OutField, ParamAlias, Role, RunManifest, RunReport, SourceSpec, SweepPoint, Target,
};
```
Then add the three render helpers (place them next to the other sample helpers,
e.g. directly after `sample_blueprint()` near `main.rs:194`):
```rust
/// Render one swept point as one canonical JSON object (C14, hand-rolled — the
/// engine's `json_str` is private, so the rules of `RunReport::to_json` are
/// mirrored here, not called): `{"params":{name:value,…},"metrics":{…}}`. Param
/// names come from `param_space()` zipped onto the point vector; `f64` fields use
/// the round-trippable shortest form, so a whole-valued float renders without a
/// fractional part (`12.0` -> `12`).
fn sweep_point_to_json(pt: &SweepPoint, space: &[ParamSpec]) -> String {
let mut params = String::from("{");
for (i, (ps, v)) in space.iter().zip(&pt.params).enumerate() {
if i > 0 {
params.push(',');
}
params.push_str(&json_string(&ps.name));
params.push(':');
params.push_str(&scalar_token(*v));
}
params.push('}');
format!(
"{{\"params\":{params},\"metrics\":{{\"total_pips\":{tp},\"max_drawdown\":{dd},\"exposure_sign_flips\":{fl}}}}}",
tp = pt.metrics.total_pips,
dd = pt.metrics.max_drawdown,
fl = pt.metrics.exposure_sign_flips,
)
}
/// Minimal JSON string rendering: wrap in quotes, escape `"` and `\` (mirrors the
/// engine's private `json_str`; our param names contain neither, but the escape
/// keeps the helper honest).
fn json_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
_ => out.push(c),
}
}
out.push('"');
out
}
/// One scalar as its JSON value token: an integer for `I64`, the shortest
/// round-trippable form for `F64`, `true`/`false` for `Bool`, the epoch-ns
/// integer for a `Timestamp`.
fn scalar_token(v: Scalar) -> String {
match v {
Scalar::I64(n) => n.to_string(),
Scalar::F64(f) => f.to_string(),
Scalar::Bool(b) => b.to_string(),
Scalar::Ts(t) => t.0.to_string(),
}
}
```
- [ ] **Step 4: Run the byte-pin test to verify it passes (GREEN)**
Run: `cargo test -p aura-cli --bin aura sweep_point_to_json`
Expected: PASS — 1 test (`sweep_point_to_json_renders_canonical_form`).
- [ ] **Step 5: Add `sweep_report`, the dispatch arm, and the usage string**
In `crates/aura-cli/src/main.rs`, add the `sweep_report` function (e.g. directly
after `scalar_token`):
```rust
/// Run the built-in sample over a small built-in grid (fast ∈ {2,3},
/// slow ∈ {4,5}, scale ∈ {0.5} — 4 points) and render one JSON line per point in
/// enumeration (odometer) order. Pure + deterministic (C1): the same build yields
/// the same report. Each point builds a fresh blueprint (fresh sink channels),
/// bootstraps it under the point vector, runs it, and folds the drained sinks to
/// metrics — the per-point closure the engine `sweep` drives disjointly.
fn sweep_report() -> String {
let space = sample_blueprint_with_sinks().0.param_space();
let grid = GridSpace::new(
&space,
vec![
vec![Scalar::I64(2), Scalar::I64(3)], // fast ∈ {2, 3}
vec![Scalar::I64(4), Scalar::I64(5)], // slow ∈ {4, 5}
vec![Scalar::F64(0.5)], // scale ∈ {0.5}
],
)
.expect("the built-in grid matches the sample param-space");
let family = sweep(&grid, |point| {
let (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks();
let mut h = bp
.bootstrap_with_params(point.to_vec())
.expect("grid points are kind-checked against param_space");
h.run(vec![synthetic_prices()]);
let equity = f64_field(&rx_eq.try_iter().collect::<Vec<_>>(), 0);
let exposure = f64_field(&rx_ex.try_iter().collect::<Vec<_>>(), 0);
summarize(&equity, &exposure)
});
let mut out = String::new();
for pt in &family.points {
out.push_str(&sweep_point_to_json(pt, &space));
out.push('\n');
}
out
}
```
Change the `USAGE` const (`main.rs:352`) to advertise the new form:
```rust
const USAGE: &str = "usage: aura run [--macd] | aura graph | aura sweep";
```
Add the `["sweep"]` arm to the `match` in `main()` (`main.rs:359-368`), beside the
`["graph"]` arm:
```rust
["sweep"] => print!("{}", sweep_report()),
```
(The strict-trailing-token contract is inherited for free: `aura sweep extra`
does not match `["sweep"]` and falls through to the `_` usage-error arm.)
- [ ] **Step 6: Write the `sweep_report` structure goldens (RED then GREEN)**
In `crates/aura-cli/src/main.rs`, inside `#[cfg(test)] mod tests { … }`, add:
```rust
#[test]
fn sweep_report_renders_four_points_in_odometer_order() {
let out = sweep_report();
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines.len(), 4, "one JSON line per grid point; got: {out:?}");
// params byte-pinned per line: odometer order (last axis fastest) + the
// param-name and value-token rules. Metric *values* are left to the
// determinism check below (they are computed f64s, not predictable here).
assert!(lines[0].starts_with(
r#"{"params":{"sma_cross.fast":2,"sma_cross.slow":4,"scale":0.5},"metrics":{"#
));
assert!(lines[1].starts_with(
r#"{"params":{"sma_cross.fast":2,"sma_cross.slow":5,"scale":0.5},"metrics":{"#
));
assert!(lines[2].starts_with(
r#"{"params":{"sma_cross.fast":3,"sma_cross.slow":4,"scale":0.5},"metrics":{"#
));
assert!(lines[3].starts_with(
r#"{"params":{"sma_cross.fast":3,"sma_cross.slow":5,"scale":0.5},"metrics":{"#
));
for line in &lines {
assert!(line.contains(r#""total_pips":"#), "missing total_pips: {line}");
assert!(line.contains(r#""max_drawdown":"#), "missing max_drawdown: {line}");
assert!(line.contains(r#""exposure_sign_flips":"#), "missing flips: {line}");
assert!(line.ends_with('}'), "line not closed: {line}");
}
}
#[test]
fn sweep_report_is_deterministic() {
// C1 at the CLI edge: the same build yields a bit-identical report.
assert_eq!(sweep_report(), sweep_report());
}
```
Run: `cargo test -p aura-cli --bin aura sweep_report`
Expected: PASS — 2 tests (`sweep_report_renders_four_points_in_odometer_order`,
`sweep_report_is_deterministic`). (If run before Step 5's `sweep_report` exists,
this fails to compile — that is the RED state.)
- [ ] **Step 7: Write the `aura sweep` process golden (RED then GREEN)**
In `crates/aura-cli/tests/cli_run.rs`, append:
```rust
#[test]
fn sweep_prints_four_json_lines_and_exits_zero() {
let out = Command::new(BIN).arg("sweep").output().expect("spawn aura sweep");
assert!(out.status.success(), "exit status: {:?}", out.status);
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
// one JSON line per grid point (4-point built-in grid), each terminated by a
// newline from `sweep_report`.
assert_eq!(stdout.lines().count(), 4, "stdout was: {stdout:?}");
// the first line is the first odometer point (fast=2, slow=4), params pinned.
assert!(
stdout.lines().next().unwrap().starts_with(
"{\"params\":{\"sma_cross.fast\":2,\"sma_cross.slow\":4,\"scale\":0.5},\"metrics\":{"
),
"got: {stdout}"
);
}
#[test]
fn sweep_with_trailing_token_is_strict_and_exits_two() {
// strict-arg reading (#16): an unexpected trailing token is a usage error.
let out = Command::new(BIN).args(["sweep", "extra"]).output().expect("spawn aura");
assert_eq!(out.status.code(), Some(2), "exit status: {:?}", out.status);
}
```
Run: `cargo test -p aura-cli --test cli_run sweep`
Expected: PASS — 2 tests (`sweep_prints_four_json_lines_and_exits_zero`,
`sweep_with_trailing_token_is_strict_and_exits_two`).
- [ ] **Step 8: Full workspace build + test + lint gate**
Run: `cargo build --workspace`
Expected: success.
Run: `cargo test --workspace`
Expected: PASS — the whole suite green, including the new aura-cli `sweep` tests
and all pre-existing tests (no regression — the engine sweep tests from iter 1,
the `aura run`/`aura graph` goldens, the MACD tests).
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: clean (no warnings).
-433
View File
@@ -1,433 +0,0 @@
# Run Registry — Iteration 3 (CLI persistence + read surface) — Implementation Plan
> **Parent spec:** `docs/specs/0029-run-registry-sweep-family.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Wire `aura-registry` into the CLI: `aura sweep` persists each point's
`RunReport` to `runs/runs.jsonl`; `aura runs list` prints every stored record;
`aura runs rank <metric>` prints them best-first; with process-level goldens.
**Architecture:** `sweep_report` is split — a production `sweep_family() ->
SweepFamily` (the pure run) and a `#[cfg(test)] sweep_report() -> String`
renderer kept for the in-bin tests. The `["sweep"]` arm becomes `run_sweep`,
which persists each point via `default_registry()` and prints it. Two new arms,
`["runs","list"]` and `["runs","rank", metric]`, read the registry back (typed,
via serde) and print via `RunReport::to_json`. Process tests run the binary in a
temp cwd so persistence never dirties the repo; `/runs/` is git-ignored as a
backstop.
**Tech Stack:** `aura-cli`, `aura-registry` (Registry, rank_by).
---
**Files this plan creates or modifies:**
- Modify: `.gitignore` — ignore `/runs/`
- Modify: `crates/aura-cli/Cargo.toml:12-15` — add the `aura-registry` dependency
- Modify: `crates/aura-cli/src/main.rs:12-15` — import `SweepFamily` + `aura_registry::{rank_by, Registry}`
- Modify: `crates/aura-cli/src/main.rs:225-271` — split `sweep_report` into `sweep_family` + `#[cfg(test)] sweep_report`; add `default_registry`/`run_sweep`/`runs_list`/`runs_rank`/`load_runs_or_exit`
- Modify: `crates/aura-cli/src/main.rs:429` — extend `USAGE`
- Modify: `crates/aura-cli/src/main.rs:440` — replace the `["sweep"]` arm + add `runs` arms
- Test: `crates/aura-cli/tests/cli_run.rs` — temp-cwd helper, update the sweep golden, add the runs-flow / empty / strictness tests
---
### Task 1: Prep — git-ignore `/runs/` and add the registry dependency
**Files:**
- Modify: `.gitignore`
- Modify: `crates/aura-cli/Cargo.toml:12-15`
- [ ] **Step 1: Ignore the default registry path**
In `.gitignore`, append:
```
# `aura sweep` persists run records to ./runs/runs.jsonl by default; that store
# is local run telemetry, not a repo artifact.
/runs/
```
- [ ] **Step 2: Add the aura-registry dependency**
In `crates/aura-cli/Cargo.toml`, replace the `[dependencies]` block (lines
12-15):
```toml
[dependencies]
aura-core = { path = "../aura-core" }
aura-engine = { path = "../aura-engine" }
aura-std = { path = "../aura-std" }
```
with:
```toml
[dependencies]
aura-core = { path = "../aura-core" }
aura-engine = { path = "../aura-engine" }
aura-registry = { path = "../aura-registry" }
aura-std = { path = "../aura-std" }
```
- [ ] **Step 3: Verify the workspace still builds**
Run: `cargo build --workspace`
Expected: builds green, exit 0 (the new dependency is not yet used — that is
fine; `unused_crate_dependencies` is allow-by-default).
---
### Task 2: CLI wiring — persist on sweep, list + rank subcommands (RED-first)
**Files:**
- Modify: `crates/aura-cli/tests/cli_run.rs`
- Modify: `crates/aura-cli/src/main.rs`
- [ ] **Step 1: Write the failing process tests**
In `crates/aura-cli/tests/cli_run.rs`, add the temp-cwd helper just after the
`BIN` constant (line 8):
```rust
/// A fresh, unique working directory for a process test that persists run
/// records (so `aura sweep`'s `./runs/runs.jsonl` never dirties the repo).
/// Unique per test + per process; no external tempfile dependency.
fn temp_cwd(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("aura-cli-{}-{}", std::process::id(), name));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp cwd");
dir
}
```
Then replace `sweep_prints_four_json_lines_and_exits_zero` (the body up to and
including its closing `}`) — it must now run in a temp cwd, since `aura sweep`
persists:
```rust
#[test]
fn sweep_prints_four_json_lines_and_exits_zero() {
let cwd = temp_cwd("sweep4");
let out = Command::new(BIN).arg("sweep").current_dir(&cwd).output().expect("spawn aura sweep");
assert!(out.status.success(), "exit status: {:?}", out.status);
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
assert_eq!(stdout.lines().count(), 4, "stdout was: {stdout:?}");
let first = stdout.lines().next().unwrap();
// each line is a full RunReport; commit is the real git HEAD (volatile), so
// pin the first odometer point's manifest params, not the commit value.
assert!(first.starts_with("{\"manifest\":{\"commit\":\""), "got: {stdout}");
assert!(
first.contains("\"params\":{\"sma_cross.fast\":2,\"sma_cross.slow\":4,\"scale\":0.5}"),
"got: {stdout}"
);
let _ = std::fs::remove_dir_all(&cwd);
}
```
Then add the new tests at the end of the file:
```rust
#[test]
fn runs_list_and_rank_across_invocations() {
let cwd = temp_cwd("runs-flow");
// two sweeps accumulate 8 records over time
for _ in 0..2 {
let out = Command::new(BIN).arg("sweep").current_dir(&cwd).output().expect("spawn sweep");
assert!(out.status.success(), "sweep exit: {:?}", out.status);
}
// `runs list` shows all 8 stored records
let list = Command::new(BIN).args(["runs", "list"]).current_dir(&cwd).output().expect("spawn list");
assert!(list.status.success(), "list exit: {:?}", list.status);
let list_out = String::from_utf8(list.stdout).expect("utf-8");
assert_eq!(list_out.lines().count(), 8, "list: {list_out:?}");
assert!(list_out.lines().all(|l| l.starts_with("{\"manifest\":{")), "list shape: {list_out:?}");
// `runs rank total_pips`: 8 lines, highest total_pips first
let rank = Command::new(BIN).args(["runs", "rank", "total_pips"]).current_dir(&cwd).output().expect("spawn rank");
assert!(rank.status.success(), "rank exit: {:?}", rank.status);
let rank_out = String::from_utf8(rank.stdout).expect("utf-8");
assert_eq!(rank_out.lines().count(), 8, "rank: {rank_out:?}");
let pips: Vec<f64> = rank_out
.lines()
.map(|l| {
let key = "\"total_pips\":";
let start = l.find(key).expect("line has total_pips") + key.len();
let tail = &l[start..];
let end = tail.find([',', '}']).expect("token end");
tail[..end].parse().expect("total_pips is an f64")
})
.collect();
assert!(pips.windows(2).all(|w| w[0] >= w[1]), "rank not descending: {pips:?}");
// unknown metric is a usage error
let bogus = Command::new(BIN).args(["runs", "rank", "bogus"]).current_dir(&cwd).output().expect("spawn bogus");
assert_eq!(bogus.status.code(), Some(2), "bogus exit: {:?}", bogus.status);
let _ = std::fs::remove_dir_all(&cwd);
}
#[test]
fn runs_list_on_empty_registry_is_zero_lines_exit_zero() {
let cwd = temp_cwd("runs-empty");
let out = Command::new(BIN).args(["runs", "list"]).current_dir(&cwd).output().expect("spawn list");
assert!(out.status.success(), "list exit: {:?}", out.status);
assert_eq!(out.stdout.len(), 0, "expected no output, got: {:?}", out.stdout);
let _ = std::fs::remove_dir_all(&cwd);
}
#[test]
fn runs_bare_subcommand_is_strict_and_exits_two() {
// `aura runs` with no/unknown sub-subcommand is a usage error (#16 strict).
let out = Command::new(BIN).arg("runs").output().expect("spawn aura runs");
assert_eq!(out.status.code(), Some(2), "exit status: {:?}", out.status);
}
```
- [ ] **Step 2: Run the new tests to verify they fail**
Run: `cargo test -p aura-cli --test cli_run runs_`
Expected: FAIL — `runs_list_and_rank_across_invocations` and
`runs_list_on_empty_registry_is_zero_lines_exit_zero` fail because the current
binary has no `runs` arm, so `aura runs list` falls through to the usage-error
path and exits 2 (the success/line-count assertions fail).
(`runs_bare_subcommand_is_strict_and_exits_two` already passes — `aura runs` is
already a usage error today.)
- [ ] **Step 3: Add the imports**
In `crates/aura-cli/src/main.rs`, replace the import block (lines 12-15):
```rust
use aura_engine::{
f64_field, summarize, sweep, BlueprintNode, Composite, Edge, FlatGraph, GridSpace, Harness,
OutField, ParamAlias, Role, RunManifest, RunReport, SourceSpec, Target,
};
use aura_std::{Ema, Exposure, Recorder, SimBroker, Sma, Sub};
```
with (add `SweepFamily` to the engine list; add the registry import):
```rust
use aura_engine::{
f64_field, summarize, sweep, BlueprintNode, Composite, Edge, FlatGraph, GridSpace, Harness,
OutField, ParamAlias, Role, RunManifest, RunReport, SourceSpec, SweepFamily, Target,
};
use aura_registry::{rank_by, Registry};
use aura_std::{Ema, Exposure, Recorder, SimBroker, Sma, Sub};
```
- [ ] **Step 4: Split `sweep_report` into `sweep_family` + a test renderer**
In `crates/aura-cli/src/main.rs`, replace the whole `sweep_report` function
(from `fn sweep_report() -> String {` at line 225 through its closing `}` at
line 271 — keep the doc comment at line 224 above it):
```rust
fn sweep_report() -> String {
let space = sample_blueprint_with_sinks().0.param_space();
let grid = GridSpace::new(
&space,
vec![
vec![Scalar::I64(2), Scalar::I64(3)], // fast ∈ {2, 3}
vec![Scalar::I64(4), Scalar::I64(5)], // slow ∈ {4, 5}
vec![Scalar::F64(0.5)], // scale ∈ {0.5}
],
)
.expect("the built-in grid matches the sample param-space");
let family = sweep(&grid, |point| {
let (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks();
let mut h = bp
.bootstrap_with_params(point.to_vec())
.expect("grid points are kind-checked against param_space");
let prices = synthetic_prices();
let window = (
prices.first().expect("non-empty stream").0,
prices.last().expect("non-empty stream").0,
);
h.run(vec![prices]);
let equity = f64_field(&rx_eq.try_iter().collect::<Vec<_>>(), 0);
let exposure = f64_field(&rx_ex.try_iter().collect::<Vec<_>>(), 0);
let params = space
.iter()
.zip(point)
.map(|(ps, v)| (ps.name.clone(), scalar_as_param_f64(v)))
.collect();
RunReport {
manifest: RunManifest {
commit: option_env!("AURA_COMMIT").unwrap_or("unknown").to_string(),
params,
window,
seed: 0,
broker: "sim-optimal(pip_size=0.0001)".to_string(),
},
metrics: summarize(&equity, &exposure),
}
});
let mut out = String::new();
for pt in &family.points {
out.push_str(&pt.report.to_json());
out.push('\n');
}
out
}
```
with (the run becomes the production `sweep_family`; the renderer stays as a
`#[cfg(test)]` helper so it is not dead code in a normal build):
```rust
fn sweep_family() -> SweepFamily {
let space = sample_blueprint_with_sinks().0.param_space();
let grid = GridSpace::new(
&space,
vec![
vec![Scalar::I64(2), Scalar::I64(3)], // fast ∈ {2, 3}
vec![Scalar::I64(4), Scalar::I64(5)], // slow ∈ {4, 5}
vec![Scalar::F64(0.5)], // scale ∈ {0.5}
],
)
.expect("the built-in grid matches the sample param-space");
sweep(&grid, |point| {
let (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks();
let mut h = bp
.bootstrap_with_params(point.to_vec())
.expect("grid points are kind-checked against param_space");
let prices = synthetic_prices();
let window = (
prices.first().expect("non-empty stream").0,
prices.last().expect("non-empty stream").0,
);
h.run(vec![prices]);
let equity = f64_field(&rx_eq.try_iter().collect::<Vec<_>>(), 0);
let exposure = f64_field(&rx_ex.try_iter().collect::<Vec<_>>(), 0);
let params = space
.iter()
.zip(point)
.map(|(ps, v)| (ps.name.clone(), scalar_as_param_f64(v)))
.collect();
RunReport {
manifest: RunManifest {
commit: option_env!("AURA_COMMIT").unwrap_or("unknown").to_string(),
params,
window,
seed: 0,
broker: "sim-optimal(pip_size=0.0001)".to_string(),
},
metrics: summarize(&equity, &exposure),
}
})
}
/// Render a sweep family as one `RunReport` JSON line per point. Test helper:
/// production (`run_sweep`) renders *and* persists per point.
#[cfg(test)]
fn sweep_report() -> String {
let mut out = String::new();
for pt in &sweep_family().points {
out.push_str(&pt.report.to_json());
out.push('\n');
}
out
}
/// The default run registry: an append-only JSONL store under the current
/// working directory. (A project-configured runs-dir via `Aura.toml` is a later
/// refinement.)
fn default_registry() -> Registry {
Registry::open("runs/runs.jsonl")
}
/// `aura sweep`: run the built-in sweep, persist each point's `RunReport` to the
/// registry (the run record, queryable over time — C18), and print each as one
/// JSON line.
fn run_sweep() {
let reg = default_registry();
for pt in &sweep_family().points {
if let Err(e) = reg.append(&pt.report) {
eprintln!("aura: {e}");
std::process::exit(2);
}
println!("{}", pt.report.to_json());
}
}
/// `aura runs list`: print every stored run record, in store (over-time) order.
fn runs_list() {
for report in &load_runs_or_exit() {
println!("{}", report.to_json());
}
}
/// `aura runs rank <metric>`: print the stored runs best-first by `metric`.
fn runs_rank(metric: &str) {
match rank_by(load_runs_or_exit(), metric) {
Ok(ranked) => {
for report in &ranked {
println!("{}", report.to_json());
}
}
Err(e) => {
eprintln!("aura: {e}");
std::process::exit(2);
}
}
}
/// Load the registry or exit 2 with the error. A missing registry loads empty.
fn load_runs_or_exit() -> Vec<RunReport> {
default_registry().load().unwrap_or_else(|e| {
eprintln!("aura: {e}");
std::process::exit(2);
})
}
```
- [ ] **Step 5: Extend USAGE and the dispatch arms**
In `crates/aura-cli/src/main.rs`, replace the `USAGE` constant (line 429):
```rust
const USAGE: &str = "usage: aura run [--macd] | aura graph | aura sweep";
```
with:
```rust
const USAGE: &str =
"usage: aura run [--macd] | aura graph | aura sweep | aura runs list | aura runs rank <metric>";
```
Then replace the `["sweep"]` dispatch arm (line 440):
```rust
["sweep"] => print!("{}", sweep_report()),
```
with:
```rust
["sweep"] => run_sweep(),
["runs", "list"] => runs_list(),
["runs", "rank", metric] => runs_rank(metric),
```
- [ ] **Step 6: Run the new tests to verify they pass**
Run: `cargo test -p aura-cli --test cli_run runs_`
Expected: PASS — `runs_list_and_rank_across_invocations`,
`runs_list_on_empty_registry_is_zero_lines_exit_zero`, and
`runs_bare_subcommand_is_strict_and_exits_two` all green.
- [ ] **Step 7: Full workspace gate**
Run: `cargo test --workspace`
Expected: PASS — all crates green, including the updated `aura sweep` golden and
the in-bin `sweep_report_*` tests (still calling the `#[cfg(test)] sweep_report`).
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: no warnings (no dead `sweep_report`; no unused imports).
@@ -1,686 +0,0 @@
# Run Registry — Iteration 2 (engine + registry + CLI rethread) — Implementation Plan
> **Parent spec:** `docs/specs/0029-run-registry-sweep-family.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Make the sweep family self-describing — `SweepPoint` carries a full
`RunReport` — rethreading every caller in lockstep so the workspace returns
green, and add the new `aura-registry` crate (`open`/`append`/`load`/`rank_by`),
built and unit-tested but not yet CLI-wired.
**Architecture:** `SweepPoint.metrics: RunMetrics` becomes `SweepPoint.report:
RunReport`; the `sweep` closure bound changes to `Fn(&[Scalar]) -> RunReport`.
Task 1 threads all engine-internal + engine-test callers (gate scoped to
`-p aura-engine`; the workspace is intentionally red until Task 2). Task 2
rewrites the CLI's `sweep_report` closure to build a per-point `RunReport`,
retires the hand-rolled `sweep_point_to_json`/`json_string`/`scalar_token`, drops
the now-orphaned `ParamSpec`/`SweepPoint` imports, and updates both `aura sweep`
goldens to the RunReport shape (pinning the per-point manifest **params**, not
the volatile git-HEAD `commit`). Task 3 adds the `aura-registry` crate.
**Tech Stack:** `aura-engine` (sweep, report types), `aura-cli`, new
`aura-registry` (serde_json read-path).
---
**Files this plan creates or modifies:**
- Modify: `crates/aura-engine/src/sweep.rs:8,94-100,113,116,131,139,143,162``SweepPoint` field + sweep bounds + internals + doc
- Modify: `crates/aura-engine/src/sweep.rs:170-173,330-343,374-375,394,396` — engine test module (use, `run_point`, the three tests)
- Modify: `crates/aura-cli/src/main.rs:11,14` — drop orphaned `ParamSpec`/`SweepPoint` imports
- Modify: `crates/aura-cli/src/main.rs:209-261` — retire `sweep_point_to_json`/`json_string`/`scalar_token`; add `scalar_as_param_f64`
- Modify: `crates/aura-cli/src/main.rs:263-296` — rewrite `sweep_report` closure to build per-point `RunReport`, print via `to_json`
- Modify: `crates/aura-cli/src/main.rs:489-537` — remove one in-bin test, update the odometer golden to RunReport shape
- Modify: `crates/aura-cli/tests/cli_run.rs:191-207` — update the process golden to RunReport shape
- Create: `crates/aura-registry/Cargo.toml` — new member crate
- Create: `crates/aura-registry/src/lib.rs``Registry`/`rank_by`/`RegistryError` + tests
- Modify: `Cargo.toml` (root) — add `"crates/aura-registry"` to `members`
---
### Task 1: Engine — `SweepPoint` carries `RunReport` (+ all engine callers/tests)
**Files:**
- Modify: `crates/aura-engine/src/sweep.rs`
> Gate is scoped to `-p aura-engine`. The downstream `aura-cli` crate will not
> compile until Task 2 rethreads its closure — that is expected; do NOT run
> `cargo test --workspace` at this task's gate.
- [ ] **Step 1: Production import + struct field**
In `crates/aura-engine/src/sweep.rs`, replace line 8:
```rust
use crate::RunMetrics;
```
with:
```rust
use crate::RunReport;
```
Then replace the `SweepPoint` doc + struct (lines 94-100):
```rust
/// One enumerated point and the metrics its run produced. Self-describing: the
/// `params` vector is the point's coordinate in `param_space()` order.
#[derive(Clone, Debug, PartialEq)]
pub struct SweepPoint {
pub params: Vec<Scalar>,
pub metrics: RunMetrics,
}
```
with:
```rust
/// One enumerated point and the full `RunReport` its run produced.
/// Self-describing: `params` is the point's coordinate in `param_space()` order,
/// and `report` carries the run's `(manifest, metrics)` — the unit the run
/// registry indexes (C18).
#[derive(Clone, Debug, PartialEq)]
pub struct SweepPoint {
pub params: Vec<Scalar>,
pub report: RunReport,
}
```
- [ ] **Step 2: Closure bounds + internal tuple types + family assembly**
In the same file, make these five replacements:
- line 113 (the `sweep` doc tail) — replace:
```rust
/// no external dependency (C16).
```
with:
```rust
/// via `std::thread::scope`.
```
- line 116 (`sweep` where-bound) — replace `F: Fn(&[Scalar]) -> RunMetrics + Sync,` with `F: Fn(&[Scalar]) -> RunReport + Sync,`
- line 131 (`sweep_with_threads` where-bound) — replace `F: Fn(&[Scalar]) -> RunMetrics + Sync,` with `F: Fn(&[Scalar]) -> RunReport + Sync,`
- line 139 — replace `let mut results: Vec<(usize, RunMetrics)> = std::thread::scope(|scope| {` with `let mut results: Vec<(usize, RunReport)> = std::thread::scope(|scope| {`
- line 143 — replace `let mut local: Vec<(usize, RunMetrics)> = Vec::new();` with `let mut local: Vec<(usize, RunReport)> = Vec::new();`
- line 162 — replace:
```rust
.map(|(i, metrics)| SweepPoint { params: points[i].clone(), metrics })
```
with:
```rust
.map(|(i, report)| SweepPoint { params: points[i].clone(), report })
```
- [ ] **Step 3: Engine test module — use block + `run_point` returns `RunReport`**
In the `#[cfg(test)] mod tests` block, replace the `use crate::{…}` (lines 171-173):
```rust
use crate::{
f64_field, summarize, BlueprintNode, Composite, Edge, OutField, ParamAlias, Role,
RunMetrics, Target,
};
```
with (drop `RunMetrics`, add `RunManifest`; `RunReport` is in scope via `use super::*`):
```rust
use crate::{
f64_field, summarize, BlueprintNode, Composite, Edge, OutField, ParamAlias, Role,
RunManifest, Target,
};
```
Then replace `run_point` (lines 330-342):
```rust
/// Build + bootstrap + run + drain + summarize one grid point. A free `fn`
/// (Copy + Sync) so it serves as the `sweep` closure AND a direct reference
/// for the "sweep == N independent runs" comparison.
fn run_point(point: &[Scalar]) -> RunMetrics {
let (bp, rx_eq, rx_ex) = composite_sma_cross_harness();
let mut h = bp
.bootstrap_with_params(point.to_vec())
.expect("grid points are kind-checked against param_space");
h.run(vec![synthetic_prices()]);
let equity = f64_field(&rx_eq.try_iter().collect::<Vec<_>>(), 0);
let exposure = f64_field(&rx_ex.try_iter().collect::<Vec<_>>(), 0);
summarize(&equity, &exposure)
}
```
with (returns a `RunReport`; a minimal fixed manifest — the tests read only
`.report.metrics`, and `run_point` serving as the closure means
`pt.report == run_point(&pt.params)` holds by determinism):
```rust
/// Build + bootstrap + run + drain + summarize one grid point into a
/// `RunReport`. A free `fn` (Copy + Sync) so it serves as the `sweep` closure
/// AND a direct reference for the "sweep == N independent runs" comparison.
/// The manifest is a minimal fixed fixture — the metrics are the run's, and
/// determinism makes `run_point` reproduce a point's report exactly.
fn run_point(point: &[Scalar]) -> RunReport {
let (bp, rx_eq, rx_ex) = composite_sma_cross_harness();
let mut h = bp
.bootstrap_with_params(point.to_vec())
.expect("grid points are kind-checked against param_space");
h.run(vec![synthetic_prices()]);
let equity = f64_field(&rx_eq.try_iter().collect::<Vec<_>>(), 0);
let exposure = f64_field(&rx_ex.try_iter().collect::<Vec<_>>(), 0);
RunReport {
manifest: RunManifest {
commit: "test".to_string(),
params: Vec::new(),
window: (Timestamp(0), Timestamp(0)),
seed: 0,
broker: "test".to_string(),
},
metrics: summarize(&equity, &exposure),
}
}
```
- [ ] **Step 4: Engine tests — read `.report.metrics`**
In `sweep_equals_n_independent_runs`, replace the loop tail (lines 373-376):
```rust
for pt in &family.points {
assert_eq!(pt.metrics, run_point(&pt.params));
assert!(pt.metrics.total_pips.is_finite());
}
```
with:
```rust
for pt in &family.points {
assert_eq!(pt.report, run_point(&pt.params));
assert!(pt.report.metrics.total_pips.is_finite());
}
```
In `distinct_points_produce_distinct_metrics`, replace lines 394 and 396:
- line 394: `let first = family.points[0].metrics.total_pips;` → `let first = family.points[0].report.metrics.total_pips;`
- line 396: `family.points.iter().any(|p| p.metrics.total_pips != first),` → `family.points.iter().any(|p| p.report.metrics.total_pips != first),`
(`family_is_deterministic_across_thread_counts` needs no body change — it passes
`run_point` as the closure and compares whole families.)
- [ ] **Step 5: Engine gate (scoped)**
Run: `cargo test -p aura-engine`
Expected: PASS — all engine tests green, including the three sweep tests now
reading `.report`. (The workspace as a whole does not build yet — `aura-cli` is
rethreaded in Task 2.)
---
### Task 2: CLI — rethread the `sweep_report` closure to `RunReport`; retire the hand-rolled per-point JSON; update goldens
**Files:**
- Modify: `crates/aura-cli/src/main.rs`
- Modify: `crates/aura-cli/tests/cli_run.rs`
- [ ] **Step 1: Drop the orphaned imports**
In `crates/aura-cli/src/main.rs`, replace line 11:
```rust
use aura_core::{Firing, ParamSpec, Scalar, ScalarKind, Timestamp};
```
with (drop `ParamSpec` — used only by the retired `sweep_point_to_json` + its
removed test):
```rust
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
```
Then in the `use aura_engine::{…}` block, replace line 14:
```rust
OutField, ParamAlias, Role, RunManifest, RunReport, SourceSpec, SweepPoint, Target,
```
with (drop `SweepPoint` — used only by the retired fn + removed test;
`RunManifest`/`RunReport` stay, now also used by the new closure):
```rust
OutField, ParamAlias, Role, RunManifest, RunReport, SourceSpec, Target,
```
- [ ] **Step 2: Retire the hand-rolled per-point JSON helpers, add the coercion**
In `crates/aura-cli/src/main.rs`, delete the three functions
`sweep_point_to_json` (lines 209-232), `json_string` (lines 234-249), and
`scalar_token` (lines 251-261) in full, **replacing** that whole 209-261 span
with the single coercion helper:
```rust
/// Coerce a sweep point's `Scalar` value to the manifest's `f64` param type.
/// A tuning grid carries only numeric params (`I64` lengths, `F64` scales).
fn scalar_as_param_f64(s: &Scalar) -> f64 {
match s {
Scalar::I64(n) => *n as f64,
Scalar::F64(f) => *f,
other => unreachable!("non-numeric sweep param: {other:?}"),
}
}
```
- [ ] **Step 3: Rewrite `sweep_report` to build a per-point `RunReport`**
In `crates/aura-cli/src/main.rs`, replace the `sweep_report` body's closure +
print loop (the `let family = sweep(&grid, |point| { … });` at lines 280-289 and
the `for pt in &family.points { … }` loop at lines 290-294) with:
```rust
let family = sweep(&grid, |point| {
let (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks();
let mut h = bp
.bootstrap_with_params(point.to_vec())
.expect("grid points are kind-checked against param_space");
let prices = synthetic_prices();
let window = (
prices.first().expect("non-empty stream").0,
prices.last().expect("non-empty stream").0,
);
h.run(vec![prices]);
let equity = f64_field(&rx_eq.try_iter().collect::<Vec<_>>(), 0);
let exposure = f64_field(&rx_ex.try_iter().collect::<Vec<_>>(), 0);
let params = space
.iter()
.zip(point)
.map(|(ps, v)| (ps.name.clone(), scalar_as_param_f64(v)))
.collect();
RunReport {
manifest: RunManifest {
commit: option_env!("AURA_COMMIT").unwrap_or("unknown").to_string(),
params,
window,
seed: 0,
broker: "sim-optimal(pip_size=0.0001)".to_string(),
},
metrics: summarize(&equity, &exposure),
}
});
let mut out = String::new();
for pt in &family.points {
out.push_str(&pt.report.to_json());
out.push('\n');
}
out
```
(The `sweep_report` doc comment at lines 263-268 may keep its wording; the
closure now folds each point into a full `RunReport` rather than bare metrics —
optional one-word tidy, not required.)
- [ ] **Step 4: Remove the retired in-bin test, update the odometer golden**
In `crates/aura-cli/src/main.rs`, delete the test
`sweep_point_to_json_renders_canonical_form` in full (lines 491-509).
Then replace `sweep_report_renders_four_points_in_odometer_order` (lines
511-537) with (pins the per-point manifest **params** in odometer order; the
`commit` is the real git HEAD, volatile, so it is not pinned):
```rust
#[test]
fn sweep_report_renders_four_points_in_odometer_order() {
let out = sweep_report();
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines.len(), 4, "one JSON line per grid point; got: {out:?}");
// each line is a full RunReport; the commit is the real git HEAD
// (volatile), so pin the per-point manifest params (odometer order, last
// axis fastest) + the metric keys, not the commit value.
for line in &lines {
assert!(line.starts_with(r#"{"manifest":{"commit":""#), "not a RunReport: {line}");
}
assert!(lines[0].contains(r#""params":{"sma_cross.fast":2,"sma_cross.slow":4,"scale":0.5}"#), "line0: {}", lines[0]);
assert!(lines[1].contains(r#""params":{"sma_cross.fast":2,"sma_cross.slow":5,"scale":0.5}"#), "line1: {}", lines[1]);
assert!(lines[2].contains(r#""params":{"sma_cross.fast":3,"sma_cross.slow":4,"scale":0.5}"#), "line2: {}", lines[2]);
assert!(lines[3].contains(r#""params":{"sma_cross.fast":3,"sma_cross.slow":5,"scale":0.5}"#), "line3: {}", lines[3]);
for line in &lines {
assert!(line.contains(r#""total_pips":"#), "missing total_pips: {line}");
assert!(line.contains(r#""max_drawdown":"#), "missing max_drawdown: {line}");
assert!(line.contains(r#""exposure_sign_flips":"#), "missing flips: {line}");
assert!(line.ends_with('}'), "line not closed: {line}");
}
}
```
(`sweep_report_is_deterministic` at lines 539-543 is unchanged — still valid.)
- [ ] **Step 5: Update the process golden**
In `crates/aura-cli/tests/cli_run.rs`, replace the body of
`sweep_prints_four_json_lines_and_exits_zero` after the
`assert_eq!(stdout.lines().count(), 4, …)` line (lines 199-206):
```rust
assert_eq!(stdout.lines().count(), 4, "stdout was: {stdout:?}");
// the first line is the first odometer point (fast=2, slow=4), params pinned.
assert!(
stdout.lines().next().unwrap().starts_with(
"{\"params\":{\"sma_cross.fast\":2,\"sma_cross.slow\":4,\"scale\":0.5},\"metrics\":{"
),
"got: {stdout}"
);
```
with (each line is now a full RunReport leading with the volatile `commit`; pin
the manifest params, not the commit):
```rust
assert_eq!(stdout.lines().count(), 4, "stdout was: {stdout:?}");
let first = stdout.lines().next().unwrap();
// each line is a full RunReport; commit is the real git HEAD (volatile), so
// pin the first odometer point's manifest params, not the commit value.
assert!(first.starts_with("{\"manifest\":{\"commit\":\""), "got: {stdout}");
assert!(
first.contains("\"params\":{\"sma_cross.fast\":2,\"sma_cross.slow\":4,\"scale\":0.5}"),
"got: {stdout}"
);
```
(`sweep_with_trailing_token_is_strict_and_exits_two` is unchanged.)
- [ ] **Step 6: Workspace gate**
Run: `cargo test --workspace`
Expected: PASS — `aura-cli` compiles again; all tests green including the two
updated sweep goldens; `run`/`graph` goldens unchanged.
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: no warnings (no orphaned `ParamSpec`/`SweepPoint` imports remain).
---
### Task 3: New `aura-registry` crate
**Files:**
- Create: `crates/aura-registry/Cargo.toml`
- Create: `crates/aura-registry/src/lib.rs`
- Modify: `Cargo.toml` (root)
- [ ] **Step 1: Add the crate to the workspace members**
In the root `Cargo.toml`, replace the `members` list (lines 11-17):
```toml
members = [
"crates/aura-core",
"crates/aura-std",
"crates/aura-engine",
"crates/aura-cli",
"crates/aura-ingest",
]
```
with:
```toml
members = [
"crates/aura-core",
"crates/aura-std",
"crates/aura-engine",
"crates/aura-cli",
"crates/aura-ingest",
"crates/aura-registry",
]
```
- [ ] **Step 2: Create the crate manifest**
Create `crates/aura-registry/Cargo.toml`:
```toml
[package]
name = "aura-registry"
edition.workspace = true
version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
# the run registry's typed read-path: serde_json parses stored RunReport records
# back (admitted under the amended C16 per-case policy, INDEX.md). RunReport
# derives serde in aura-engine.
aura-engine = { path = "../aura-engine" }
serde_json = { workspace = true }
[dev-dependencies]
# fixtures construct RunReport/RunManifest literals, whose window needs Timestamp
aura-core = { path = "../aura-core" }
```
- [ ] **Step 3: Create the registry implementation**
Create `crates/aura-registry/src/lib.rs`:
```rust
//! The run registry (C18): an append-only store of run records — one
//! `(manifest, metrics)` `RunReport` per line in a JSONL file — with a typed
//! read-path (serde) for listing and ranking runs across invocations ("compare
//! experiments over time", which has no home in git or Gitea). Storage is
//! serde_json; display is the caller's concern (the CLI prints via
//! `RunReport::to_json`).
use std::fmt;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use aura_engine::RunReport;
/// An append-only run registry over a JSONL file: one serde_json line per
/// `RunReport`.
pub struct Registry {
path: PathBuf,
}
impl Registry {
/// Bind to a JSONL path. No I/O — the file is created lazily on first append.
pub fn open(path: impl AsRef<Path>) -> Registry {
Registry { path: path.as_ref().to_path_buf() }
}
/// Append one record as a single JSON line, creating the file (and its parent
/// directory) if absent.
pub fn append(&self, report: &RunReport) -> Result<(), RegistryError> {
if let Some(parent) = self.path.parent().filter(|p| !p.as_os_str().is_empty()) {
fs::create_dir_all(parent)?;
}
// a RunReport is finite by construction (its f64 fields are finite), so
// serialization is infallible here.
let line = serde_json::to_string(report).expect("a finite RunReport serializes");
let mut file = fs::OpenOptions::new().create(true).append(true).open(&self.path)?;
writeln!(file, "{line}")?;
Ok(())
}
/// Parse every non-empty line back into a typed `RunReport`, in file order. A
/// missing file is an empty registry (`Ok(vec![])`), not an error.
pub fn load(&self) -> Result<Vec<RunReport>, RegistryError> {
let text = match fs::read_to_string(&self.path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(RegistryError::Io(e)),
};
let mut reports = Vec::new();
for (i, raw) in text.lines().enumerate() {
if raw.trim().is_empty() {
continue;
}
let report =
serde_json::from_str(raw).map_err(|source| RegistryError::Parse { line: i + 1, source })?;
reports.push(report);
}
Ok(reports)
}
}
/// Sort `reports` **best-first** by a named metric. "Best" is per-metric, fixed
/// by each metric's meaning: `total_pips` higher-is-better (descending);
/// `max_drawdown` and `exposure_sign_flips` lower-is-better (ascending). A stable
/// sort keeps file (insertion) order among ties; `f64` keys use `partial_cmp`
/// (total, since metrics are finite by construction). An unknown metric name is a
/// `RegistryError::UnknownMetric`.
pub fn rank_by(mut reports: Vec<RunReport>, metric: &str) -> Result<Vec<RunReport>, RegistryError> {
match metric {
"total_pips" => {
reports.sort_by(|a, b| b.metrics.total_pips.partial_cmp(&a.metrics.total_pips).unwrap())
}
"max_drawdown" => {
reports.sort_by(|a, b| a.metrics.max_drawdown.partial_cmp(&b.metrics.max_drawdown).unwrap())
}
"exposure_sign_flips" => {
reports.sort_by(|a, b| a.metrics.exposure_sign_flips.cmp(&b.metrics.exposure_sign_flips))
}
other => return Err(RegistryError::UnknownMetric(other.to_string())),
}
Ok(reports)
}
/// What can go wrong reading or ranking the registry.
#[derive(Debug)]
pub enum RegistryError {
/// An I/O error reading or writing the JSONL file.
Io(std::io::Error),
/// A stored line did not parse as a `RunReport` (1-based line number).
Parse { line: usize, source: serde_json::Error },
/// `rank_by` was given a metric name it does not know.
UnknownMetric(String),
}
impl fmt::Display for RegistryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RegistryError::Io(e) => write!(f, "registry i/o: {e}"),
RegistryError::Parse { line, source } => {
write!(f, "registry parse error at line {line}: {source}")
}
RegistryError::UnknownMetric(m) => write!(
f,
"unknown metric '{m}' (known: total_pips, max_drawdown, exposure_sign_flips)"
),
}
}
}
impl std::error::Error for RegistryError {}
impl From<std::io::Error> for RegistryError {
fn from(e: std::io::Error) -> Self {
RegistryError::Io(e)
}
}
#[cfg(test)]
mod tests {
use super::*;
use aura_core::Timestamp;
use aura_engine::{RunManifest, RunMetrics, RunReport};
fn report_with(total_pips: f64, max_drawdown: f64, flips: u64) -> RunReport {
RunReport {
manifest: RunManifest {
commit: "c".to_string(),
params: vec![("p".to_string(), 1.0)],
window: (Timestamp(1), Timestamp(2)),
seed: 0,
broker: "b".to_string(),
},
metrics: RunMetrics { total_pips, max_drawdown, exposure_sign_flips: flips },
}
}
fn temp_path(name: &str) -> PathBuf {
// unique per test + per process; no external tempfile dependency
std::env::temp_dir().join(format!("aura-registry-{}-{}.jsonl", std::process::id(), name))
}
#[test]
fn append_then_load_round_trips_in_order() {
let path = temp_path("roundtrip");
let _ = fs::remove_file(&path);
let reg = Registry::open(&path);
let a = report_with(1.0, 0.5, 1);
let b = report_with(2.0, 0.3, 2);
reg.append(&a).expect("append a");
reg.append(&b).expect("append b");
assert_eq!(reg.load().expect("load"), vec![a, b]);
let _ = fs::remove_file(&path);
}
#[test]
fn load_missing_file_is_empty() {
let path = temp_path("missing");
let _ = fs::remove_file(&path);
let reg = Registry::open(&path);
assert_eq!(reg.load().expect("load missing"), Vec::<RunReport>::new());
}
#[test]
fn corrupt_line_is_a_parse_error_with_line_number() {
let path = temp_path("corrupt");
let _ = fs::remove_file(&path);
let reg = Registry::open(&path);
reg.append(&report_with(1.0, 0.5, 1)).expect("append");
let mut f = fs::OpenOptions::new().append(true).open(&path).expect("open");
writeln!(f, "not json").expect("write corrupt line");
drop(f);
match reg.load() {
Err(RegistryError::Parse { line, .. }) => assert_eq!(line, 2),
other => panic!("expected Parse at line 2, got {other:?}"),
}
let _ = fs::remove_file(&path);
}
#[test]
fn rank_by_orders_best_first_per_metric() {
let reports = vec![
report_with(1.0, 0.9, 5),
report_with(3.0, 0.1, 1),
report_with(2.0, 0.5, 3),
];
let by_pips = rank_by(reports.clone(), "total_pips").expect("rank pips");
assert_eq!(by_pips[0].metrics.total_pips, 3.0); // higher-is-better -> desc
let by_dd = rank_by(reports.clone(), "max_drawdown").expect("rank dd");
assert_eq!(by_dd[0].metrics.max_drawdown, 0.1); // lower-is-better -> asc
let by_flips = rank_by(reports.clone(), "exposure_sign_flips").expect("rank flips");
assert_eq!(by_flips[0].metrics.exposure_sign_flips, 1); // lower-is-better -> asc
}
#[test]
fn rank_by_unknown_metric_is_an_error() {
let reports = vec![report_with(1.0, 0.5, 1)];
match rank_by(reports, "sharpe") {
Err(RegistryError::UnknownMetric(m)) => assert_eq!(m, "sharpe"),
other => panic!("expected UnknownMetric, got {other:?}"),
}
}
}
```
- [ ] **Step 4: Crate gate**
Run: `cargo test -p aura-registry`
Expected: PASS — 5 tests (`append_then_load_round_trips_in_order`,
`load_missing_file_is_empty`, `corrupt_line_is_a_parse_error_with_line_number`,
`rank_by_orders_best_first_per_metric`, `rank_by_unknown_metric_is_an_error`).
- [ ] **Step 5: Full workspace gate**
Run: `cargo test --workspace`
Expected: PASS — all crates including the new `aura-registry`.
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: no warnings, exit 0.
-353
View File
@@ -1,353 +0,0 @@
# Run Registry — Iteration 1 (foundation) — Implementation Plan
> **Parent spec:** `docs/specs/0029-run-registry-sweep-family.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Lay the dependency-policy + serde foundation the run registry builds
on: amend C16, add `serde`/`serde_json` to the workspace, derive
`Serialize`/`Deserialize` on `Timestamp` + the run-report types, with round-trip
tests — no CLI change, no new crate, no `SweepPoint` change yet.
**Architecture:** C16's blanket zero-dependency clause is struck for a per-case
policy (ledger + the five source comments that assert the old framing). `serde`
lands as a workspace dependency; `Timestamp` (aura-core) and
`RunMetrics`/`RunManifest`/`RunReport` (aura-engine) gain serde derives, proven
by serde_json round-trip tests. The hand-rolled `to_json` writers are untouched.
**Tech Stack:** `serde` (derive) + `serde_json` (test-only), `aura-core`,
`aura-engine`, the design ledger.
---
**Files this plan creates or modifies:**
- Modify: `docs/design/INDEX.md:535-565` — amend C16 (strike blanket zero-dep / firewall; add per-case policy)
- Modify: `crates/aura-ingest/Cargo.toml:10-12` — rewrite the firewall/zero-dep comment
- Modify: `crates/aura-ingest/src/lib.rs:10-12` — rewrite the firewall/zero-dep module doc
- Modify: `Cargo.toml` (root) — add `[workspace.dependencies]` (serde + serde_json)
- Modify: `crates/aura-core/Cargo.toml:8``serde` dep + `serde_json` dev-dep
- Modify: `crates/aura-core/src/scalar.rs:7` — add serde derives to `Timestamp`
- Test: `crates/aura-core/src/scalar.rs` (tests mod ~69-111) — `timestamp_serde_round_trips`
- Modify: `crates/aura-engine/Cargo.toml:8-18``serde` dep, rewrite comment, `serde_json` dev-dep
- Modify: `crates/aura-engine/src/report.rs:6-13,14,33,52` — serde derives on the three report types + doc-comment fixes
- Test: `crates/aura-engine/src/report.rs` (tests mod ~198-413) — `runreport_serde_round_trips`
---
### Task 1: C16 amendment + stale-comment sweep (contract/docs, no test)
**Files:**
- Modify: `docs/design/INDEX.md:549-562`
- Modify: `crates/aura-ingest/Cargo.toml:10-12`
- Modify: `crates/aura-ingest/src/lib.rs:10-12`
- [ ] **Step 1: Amend the C16 body in the ledger**
In `docs/design/INDEX.md`, replace this exact block (lines 551-557, from
"`aura-ingest` is the" through "firewalled there."):
```
data-source ingestion edge, cycle 0011). `aura-ingest` is the
**external-dependency firewall**: it alone links external crates (via
`data-server`), keeping the other engine crates and the frozen deploy artifact
(C13) dependency-pure (see External components). The engine workspace is
**zero-external-dependency** by commitment — the hand-rolled JSON of C14/C18 and
the from-scratch `aura-core` substrate exist precisely to honour it; the one
sanctioned external tree enters at the ingestion edge and is firewalled there.
```
with:
```
data-source ingestion edge, cycle 0011, where the `data-server` external tree
enters). **Dependency policy (amended 2026-06-10, cycle 0029).** Dependencies
are admitted by deliberate, **per-case review** — what a crate pulls in weighed
against what it buys — with **particular scrutiny for anything that enters the
frozen deploy artifact** (C13: this bot = this commit). Well-established
standard crates (`serde`, `rayon`, …) pass that review and are used wherever
they do the job, including in the bot. There is **no blanket zero-dependency
commitment** and **no blanket admission**; hand-rolling what a vetted standard
crate already does is the anti-pattern, not the dependency. This strikes the
original *"zero-external-dependency by commitment"* clause and the
*"`aura-ingest` is the sole external-dependency firewall"* framing; `aura-ingest`
remains the data-source ingestion edge, no longer a dependency wall.
```
- [ ] **Step 2: Strike the dependency clause from C16 Forbids**
In the same file, in the C16 `**Forbids.**` sentence (lines 558-562), replace:
```
package mechanism); an external (crates.io / git) dependency in any engine crate
other than the ingestion-edge crate `aura-ingest`.
```
with:
```
package mechanism). (Dependency admission is governed by the per-case policy
above, not a blanket ban.)
```
- [ ] **Step 3: Rewrite the aura-ingest Cargo.toml comment**
In `crates/aura-ingest/Cargo.toml`, replace lines 10-12:
```
# data-server: aura's first real data source AND its one external dependency
# (transitively chrono + regex + zip). Isolated in this crate — the firewall
# that keeps aura-core/std/engine zero-external-dep (spec §Architecture).
```
with:
```
# data-server: aura's first real data source. It enters the workspace at the
# ingestion edge (transitively chrono + regex + zip). Dependencies are admitted
# by the amended C16 per-case policy (INDEX.md), not a blanket zero-dep wall.
```
- [ ] **Step 4: Rewrite the aura-ingest module doc**
In `crates/aura-ingest/src/lib.rs`, replace lines 10-12:
```
//! This crate is the workspace's **external-dependency firewall**: it is the
//! only crate that links `data-server` (and its transitive `chrono`/`regex`/
//! `zip`), so `aura-core`/`aura-std`/`aura-engine` stay zero-external-dependency.
```
with:
```
//! This crate is the **data-source ingestion edge**: it links `data-server`
//! (and its transitive `chrono`/`regex`/`zip`) and normalizes it into aura's
//! columns here. Dependencies follow the amended C16 per-case policy (INDEX.md),
//! not a blanket zero-dependency wall.
```
- [ ] **Step 5: Verify no struck framing survives + build is intact**
Run: `rg -n "external-dependency firewall|zero-external-dependency by commitment" docs/ crates/`
Expected: no matches (the only remaining `zero-dependency` mentions are the
report.rs doc comments, fixed in Task 3).
Run: `cargo build --workspace`
Expected: builds (doc/comment edits do not affect compilation), exit 0.
---
### Task 2: workspace serde deps + `Timestamp` serde (RED-first)
**Files:**
- Modify: `Cargo.toml` (root)
- Modify: `crates/aura-core/Cargo.toml:8`
- Modify: `crates/aura-core/src/scalar.rs:7`
- Test: `crates/aura-core/src/scalar.rs` (tests mod)
- [ ] **Step 1: Declare the workspace dependencies**
In the root `Cargo.toml`, after the `[workspace.package]` block (after line 23,
`publish = false`), append:
```toml
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
```
- [ ] **Step 2: Add serde to aura-core**
In `crates/aura-core/Cargo.toml`, under `[dependencies]` (line 8), add the dep
and a dev-dep block:
```toml
[dependencies]
serde = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
```
- [ ] **Step 3: Write the failing round-trip test**
In `crates/aura-core/src/scalar.rs`, inside `#[cfg(test)] mod tests` (after the
`use super::*;` at line 71), add:
```rust
#[test]
fn timestamp_serde_round_trips() {
let ts = Timestamp(1_700_000_000_000_000_000);
let json = serde_json::to_string(&ts).expect("serialize Timestamp");
// a newtype struct serializes transparently as its inner i64 — this is
// what lets RunManifest's `window` render as a [from, to] integer array
assert_eq!(json, "1700000000000000000");
let back: Timestamp = serde_json::from_str(&json).expect("deserialize Timestamp");
assert_eq!(back, ts);
}
```
- [ ] **Step 4: Run the test to verify it fails**
Run: `cargo test -p aura-core timestamp_serde_round_trips`
Expected: FAIL — compile error `the trait bound `Timestamp: serde::Serialize` is not satisfied` (the derive is not there yet).
- [ ] **Step 5: Add the serde derives to `Timestamp`**
In `crates/aura-core/src/scalar.rs`, replace line 7:
```rust
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
```
with:
```rust
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize)]
```
- [ ] **Step 6: Run the test to verify it passes**
Run: `cargo test -p aura-core timestamp_serde_round_trips`
Expected: PASS (1 passed).
---
### Task 3: aura-engine serde + report types + doc fix (RED-first)
**Files:**
- Modify: `crates/aura-engine/Cargo.toml:8-18`
- Modify: `crates/aura-engine/src/report.rs:6-13,14,33,52`
- Test: `crates/aura-engine/src/report.rs` (tests mod)
- [ ] **Step 1: Add serde to aura-engine + rewrite the dependency-purity comment**
In `crates/aura-engine/Cargo.toml`, replace the `[dependencies]` body (lines
9-12):
```toml
aura-core = { path = "../aura-core" }
# No external dependencies: aura-engine is an engine crate and stays
# dependency-pure. The data-server ingestion edge lives in the aura-ingest crate
# (the external-dependency firewall — cycle 0011, INDEX.md C16), never here.
```
with:
```toml
aura-core = { path = "../aura-core" }
# serde is admitted under the amended C16 per-case dependency policy (INDEX.md):
# it gives the run-report types a typed (de)serialization path for the run
# registry (cycle 0029). serde's output is deterministic (C1-safe).
serde = { workspace = true }
```
Then in the same file's `[dev-dependencies]` block (after `aura-std` at line
15/18), add:
```toml
serde_json = { workspace = true }
```
- [ ] **Step 2: Write the failing round-trip test**
In `crates/aura-engine/src/report.rs`, inside `#[cfg(test)] mod tests` (anywhere
after the `use` lines at 200-204; place it right before the closing `}` of the
module), add:
```rust
#[test]
fn runreport_serde_round_trips() {
let report = RunReport {
manifest: RunManifest {
commit: "abc123".to_string(),
params: vec![
("sma_fast".to_string(), 2.0),
("sma_slow".to_string(), 4.0),
("exposure_scale".to_string(), 1.0),
],
window: (Timestamp(1), Timestamp(6)),
seed: 0,
broker: "sim-optimal(pip_size=1.0)".to_string(),
},
metrics: RunMetrics { total_pips: 12.0, max_drawdown: 1.0, exposure_sign_flips: 1 },
};
let json = serde_json::to_string(&report).expect("serialize RunReport");
// window is a 2-element [from, to] array (Timestamp newtype is transparent)
assert!(json.contains("\"window\":[1,6]"), "window shape: {json}");
let back: RunReport = serde_json::from_str(&json).expect("deserialize RunReport");
assert_eq!(back, report);
}
```
- [ ] **Step 3: Run the test to verify it fails**
Run: `cargo test -p aura-engine runreport_serde_round_trips`
Expected: FAIL — compile error `the trait bound `RunReport: serde::Serialize` is not satisfied` (derives not there yet).
- [ ] **Step 4: Add the serde derives to the three report types**
In `crates/aura-engine/src/report.rs`, replace each of these three derive lines:
- line 14 (above `pub struct RunMetrics`): `#[derive(Clone, Debug, PartialEq)]`
- line 33 (above `pub struct RunManifest`): `#[derive(Clone, Debug, PartialEq)]`
- line 52 (above `pub struct RunReport`): `#[derive(Clone, Debug, PartialEq)]`
each with:
```rust
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
```
- [ ] **Step 5: Run the test to verify it passes**
Run: `cargo test -p aura-engine runreport_serde_round_trips`
Expected: PASS (1 passed).
- [ ] **Step 6: Fix the now-false zero-dependency doc comments**
In `crates/aura-engine/src/report.rs`, replace the module-doc tail (lines 6-8):
```
//! and folds them here. Output is canonical, hand-rolled JSON (C14): the schema
//! is tiny, closed, and flat, so the deliberately zero-dependency workspace
//! stays zero-dependency.
```
with:
```
//! and folds them here. Output is canonical JSON (C14): the schema is tiny,
//! closed, and flat. `to_json` is a hand-rolled writer; the report types also
//! derive serde (cycle 0029) for the run registry's typed read-path.
```
and replace the `to_json` doc tail (lines 11-13):
```
/// Render canonical, machine-readable JSON (C14). Hand-rolled — the schema
/// is tiny, closed, and flat, so the deliberately zero-dependency workspace
/// stays so.
```
with:
```
/// Render canonical, machine-readable JSON (C14). Hand-rolled — the schema
/// is tiny, closed, and flat. (The same types also derive serde for the run
/// registry's typed read-path, cycle 0029; this writer is kept for stdout.)
```
- [ ] **Step 7: Full workspace gate**
Run: `cargo test --workspace`
Expected: PASS — all tests green, including `timestamp_serde_round_trips` and `runreport_serde_round_trips`.
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: no warnings, exit 0.
Run: `rg -n "zero-dependency|zero-external-dep|external-dependency firewall" docs/ crates/`
Expected: no matches — every assertion of the struck framing is gone.
@@ -1,385 +0,0 @@
# Named param binding — Iteration 1 (single run) — Implementation Plan
> **Parent spec:** `docs/specs/0030-named-param-binding.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Add the single-run named-binding authoring layer — `Composite::with`
`Binder``Binder::bootstrap`, backed by the shared `resolve` core and the
`BindError` vocabulary — and convert the CLI sample single-run to it.
**Architecture:** A pure authoring layer in `aura-engine` over the existing
`Composite::param_space` / `bootstrap_with_params` primitives (engine core
untouched, C1/C12/C19/C23 preserved). `resolve` maps named bindings to a
positional `Vec<Scalar>` against `param_space()` using a total error order;
`Binder` is the fluent accumulator. The sweep side (`axis`/`SweepBinder`/
`resolve_axes`/`EmptyAxis` construction) is iteration 2 and out of scope here.
**Tech Stack:** Rust; `crates/aura-engine/src/blueprint.rs` (new items + tests),
`crates/aura-engine/src/lib.rs` (re-export), `crates/aura-cli/src/main.rs` (sample
conversion).
---
**Files this plan creates or modifies:**
- Modify: `crates/aura-engine/src/blueprint.rs` — add `enum BindError`, `struct
Binder`, `impl Binder`, free `fn resolve`, and `Composite::with`; add unit +
equivalence tests in the `#[cfg(test)] mod tests`.
- Modify: `crates/aura-engine/src/lib.rs:41` — add `BindError, Binder` to the
`pub use blueprint::{…}` re-export.
- Test: `crates/aura-engine/src/blueprint.rs` (test module) — `resolve` round-trip,
one assertion per single-run `BindError` variant, two precedence tests, one C1
named≡positional equivalence test.
- Modify: `crates/aura-cli/src/main.rs:514-525` — convert the
`sample_blueprint_with_sinks_bootstraps_runs_and_drains` test to `.with(…).bootstrap()`.
---
## Task 1: Engine — `BindError`, `resolve`, `Composite::with`, `Binder`
**Files:**
- Modify: `crates/aura-engine/src/blueprint.rs` (new top-level items near
`CompileError` ~271; `Composite::with` in `impl Composite` ~144-267; tests in
`#[cfg(test)] mod tests`)
- Modify: `crates/aura-engine/src/lib.rs:41`
- [ ] **Step 1: Add the skeleton (enum, builder, stubbed `resolve`) + re-export**
In `crates/aura-engine/src/blueprint.rs`, add these top-level items (place them
just above the `CompileError` definition, ~line 270; `ScalarKind`, `Scalar`,
`ParamSpec` are already imported at the top, `Harness` via `crate::harness`):
```rust
/// A fault in resolving a named binding against `param_space()` — the authoring
/// layer over `bootstrap_with_params` / `sweep`. Name-qualified: each message
/// names the offending knob, not a slot index. The total error order is documented
/// on the resolution path; the first failing check wins.
#[derive(Debug, PartialEq)]
pub enum BindError {
/// A bound name matches no `param_space()` slot's exact name.
UnknownKnob(String),
/// A `param_space()` slot was left unbound.
MissingKnob(String),
/// A bound value's kind does not equal the slot's declared kind.
KindMismatch { knob: String, expected: ScalarKind, got: ScalarKind },
/// The same name was bound twice.
DuplicateBinding(String),
/// A name matches the exact name of more than one slot.
AmbiguousKnob(String),
/// (sweep, iteration 2) An axis was given zero values.
EmptyAxis(String),
/// The resolved point passed name resolution but failed downstream bootstrap.
Compile(CompileError),
}
/// A fluent accumulator of named knob bindings for a single run, terminated by
/// [`Binder::bootstrap`]. Bindings are resolved against `param_space()` once, at
/// the terminal.
pub struct Binder {
bp: Composite,
bound: Vec<(String, Scalar)>,
}
impl Binder {
/// Bind one more knob by name; raw literals lower via `Into<Scalar>`.
pub fn with(mut self, name: &str, v: impl Into<Scalar>) -> Binder {
self.bound.push((name.to_string(), v.into()));
self
}
/// Resolve the accumulated bindings against `param_space()` and bootstrap.
pub fn bootstrap(self) -> Result<Harness, BindError> {
let space = self.bp.param_space();
let point = resolve(&space, &self.bound)?;
self.bp.bootstrap_with_params(point).map_err(BindError::Compile)
}
}
/// Resolve named bindings to a positional `Vec<Scalar>` in `param_space()` slot
/// order. (Body filled in Step 3.)
fn resolve(space: &[ParamSpec], bound: &[(String, Scalar)]) -> Result<Vec<Scalar>, BindError> {
let _ = (space, bound);
todo!("resolve")
}
```
Then add `Composite::with` inside the existing `impl Composite` block (anywhere
among its methods, e.g. just after `bootstrap` ~line 267):
```rust
/// Begin binding this blueprint's knobs **by name** for a single run (the
/// fluent alternative to a positional `bootstrap_with_params` vector). The
/// bound name is the exact `param_space()` name — path-qualified for a knob
/// inside a composite (`sma_cross.fast`), bare for a root-level knob (`scale`).
pub fn with(self, name: &str, v: impl Into<Scalar>) -> Binder {
Binder { bp: self, bound: vec![(name.to_string(), v.into())] }
}
```
In `crates/aura-engine/src/lib.rs`, line 41 currently reads:
```rust
pub use blueprint::{aliases_on, signature_of, BlueprintNode, CompileError, Composite, OutField, ParamAlias, Role};
```
Replace it with (adds `BindError, Binder`):
```rust
pub use blueprint::{aliases_on, signature_of, BindError, Binder, BlueprintNode, CompileError, Composite, OutField, ParamAlias, Role};
```
- [ ] **Step 2: Add all Task-1 tests, run, verify RED**
In the `#[cfg(test)] mod tests` of `crates/aura-engine/src/blueprint.rs`, add
(the module already has `use super::*;`, and `synthetic_prices` +
`composite_sma_cross_harness` fixtures):
```rust
#[test]
fn resolve_named_equals_positional_vector() {
// order-independent: shuffled bindings resolve to slot order
let space = vec![
ParamSpec { name: "sma_cross.fast".into(), kind: ScalarKind::I64 },
ParamSpec { name: "sma_cross.slow".into(), kind: ScalarKind::I64 },
ParamSpec { name: "scale".into(), kind: ScalarKind::F64 },
];
let bound = vec![
("scale".to_string(), Scalar::F64(0.5)),
("sma_cross.fast".to_string(), Scalar::I64(2)),
("sma_cross.slow".to_string(), Scalar::I64(4)),
];
assert_eq!(
resolve(&space, &bound),
Ok(vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)]),
);
}
#[test]
fn resolve_unknown_knob() {
let space = vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }];
assert_eq!(
resolve(&space, &[("nope".to_string(), Scalar::F64(0.5))]),
Err(BindError::UnknownKnob("nope".to_string())),
);
}
#[test]
fn resolve_missing_knob() {
let space = vec![
ParamSpec { name: "a".into(), kind: ScalarKind::I64 },
ParamSpec { name: "b".into(), kind: ScalarKind::I64 },
];
assert_eq!(
resolve(&space, &[("a".to_string(), Scalar::I64(1))]),
Err(BindError::MissingKnob("b".to_string())),
);
}
#[test]
fn resolve_kind_mismatch() {
let space = vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }];
assert_eq!(
resolve(&space, &[("scale".to_string(), Scalar::I64(2))]),
Err(BindError::KindMismatch {
knob: "scale".to_string(),
expected: ScalarKind::F64,
got: ScalarKind::I64,
}),
);
}
#[test]
fn resolve_duplicate_binding() {
let space = vec![ParamSpec { name: "a".into(), kind: ScalarKind::I64 }];
assert_eq!(
resolve(
&space,
&[("a".to_string(), Scalar::I64(1)), ("a".to_string(), Scalar::I64(2))],
),
Err(BindError::DuplicateBinding("a".to_string())),
);
}
#[test]
fn resolve_ambiguous_knob() {
// two slots with the identical exact name
let space = vec![
ParamSpec { name: "dup".into(), kind: ScalarKind::I64 },
ParamSpec { name: "dup".into(), kind: ScalarKind::I64 },
];
assert_eq!(
resolve(&space, &[("dup".to_string(), Scalar::I64(1))]),
Err(BindError::AmbiguousKnob("dup".to_string())),
);
}
#[test]
fn resolve_precedence_unknown_before_kind_mismatch() {
// Phase-1 (unknown) wins over Phase-2 (kind mismatch)
let space = vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }];
let bound = vec![
("typo".to_string(), Scalar::I64(9)),
("scale".to_string(), Scalar::I64(2)),
];
assert_eq!(resolve(&space, &bound), Err(BindError::UnknownKnob("typo".to_string())));
}
#[test]
fn resolve_precedence_unknown_before_duplicate() {
// intra-binding: check a (unknown) precedes check d (duplicate)
let space = vec![ParamSpec { name: "a".into(), kind: ScalarKind::I64 }];
let bound = vec![
("typo".to_string(), Scalar::I64(1)),
("typo".to_string(), Scalar::I64(2)),
];
assert_eq!(resolve(&space, &bound), Err(BindError::UnknownKnob("typo".to_string())));
}
#[test]
fn named_binder_runs_bit_identical_to_positional() {
// C1 equivalence: the named builder bootstraps to a run bit-identical to
// the positional vector, over the sample composite harness.
let (bp, comp_eq, comp_ex) = composite_sma_cross_harness();
let mut named = bp
.with("sma_cross.fast", 2)
.with("sma_cross.slow", 4)
.with("scale", 0.5)
.bootstrap()
.expect("named binding resolves and bootstraps");
named.run(vec![synthetic_prices()]);
let named_eq = comp_eq.try_iter().collect::<Vec<_>>();
let named_ex = comp_ex.try_iter().collect::<Vec<_>>();
let (bp2, pos_eq, pos_ex) = composite_sma_cross_harness();
let mut positional = bp2
.bootstrap_with_params(vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)])
.expect("positional bootstrap");
positional.run(vec![synthetic_prices()]);
let pos_eq_v = pos_eq.try_iter().collect::<Vec<_>>();
let pos_ex_v = pos_ex.try_iter().collect::<Vec<_>>();
assert!(!named_eq.is_empty(), "named run drained empty");
assert_eq!(named_eq, pos_eq_v, "equity stream: named must equal positional");
assert_eq!(named_ex, pos_ex_v, "exposure stream: named must equal positional");
}
```
Run: `cargo test -p aura-engine resolve_ named_binder_runs_bit_identical_to_positional 2>&1 | tail -20`
(filters match the nine new tests by their `resolve_*` prefix + the equivalence
test name; all currently call `resolve` via the `todo!()` stub).
Expected: FAIL — every new test panics with `not yet implemented: resolve` (the
`todo!("resolve")` stub).
- [ ] **Step 3: Implement `resolve` (total error order), run, verify GREEN**
In `crates/aura-engine/src/blueprint.rs`, replace the `resolve` stub body from
Step 1 with:
```rust
fn resolve(space: &[ParamSpec], bound: &[(String, Scalar)]) -> Result<Vec<Scalar>, BindError> {
// Phase 1 — per binding in input order: claim slots by exact name (a, b, d).
let mut claimed: Vec<Option<Scalar>> = vec![None; space.len()];
for (name, value) in bound {
let matches: Vec<usize> = space
.iter()
.enumerate()
.filter(|(_, p)| p.name == *name)
.map(|(i, _)| i)
.collect();
match matches.as_slice() {
[] => return Err(BindError::UnknownKnob(name.clone())), // a
[idx] => {
if claimed[*idx].is_some() {
return Err(BindError::DuplicateBinding(name.clone())); // d
}
claimed[*idx] = Some(*value);
}
_ => return Err(BindError::AmbiguousKnob(name.clone())), // b
}
}
// Phase 2 — slot walk in param_space() order (e, f).
let mut point = Vec::with_capacity(space.len());
for (i, p) in space.iter().enumerate() {
match claimed[i] {
None => return Err(BindError::MissingKnob(p.name.clone())), // e
Some(v) => {
if v.kind() != p.kind {
return Err(BindError::KindMismatch {
knob: p.name.clone(),
expected: p.kind,
got: v.kind(),
}); // f
}
point.push(v);
}
}
}
Ok(point)
}
```
Run: `cargo test -p aura-engine resolve_ named_binder_runs_bit_identical_to_positional 2>&1 | tail -20`
Expected: PASS — all nine new tests green.
- [ ] **Step 4: Full workspace gate**
Run: `cargo build --workspace 2>&1 | tail -5`
Expected: success, no errors.
Run: `cargo test --workspace 2>&1 | tail -15`
Expected: all suites PASS (the nine new + all pre-existing).
Run: `cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -10`
Expected: clean — no warnings. (`resolve` is used by `Binder::bootstrap`;
`BindError`/`Binder`/`with` are `pub`-re-exported so the unconstructed `EmptyAxis`
variant is reachable API, not `dead_code`.)
## Task 2: CLI — convert the sample single-run to `.with(…).bootstrap()`
**Files:**
- Modify: `crates/aura-cli/src/main.rs:514-525`
(test `sample_blueprint_with_sinks_bootstraps_runs_and_drains`)
- [ ] **Step 1: Convert the bootstrap call**
In `crates/aura-cli/src/main.rs`, in the test
`sample_blueprint_with_sinks_bootstraps_runs_and_drains`, replace:
```rust
let mut h = bp
.bootstrap_with_params(vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)])
.expect("sample blueprint compiles under a valid point");
```
with (the exact `param_space()` names: composite-interior knobs path-qualified,
the root `scale` bare):
```rust
let mut h = bp
.with("sma_cross.fast", 2)
.with("sma_cross.slow", 4)
.with("scale", 0.5)
.bootstrap()
.expect("sample blueprint compiles under a valid point");
```
(`with`/`bootstrap` are methods on the already-imported `Composite` / the
re-exported `Binder`; no new `use` is required. If the compiler reports `Scalar`
is now unused in this test, leave it — it is still used elsewhere in the test
module; do not remove the import.)
- [ ] **Step 2: Run the converted test, verify GREEN (behaviour-preserving)**
Run: `cargo test -p aura-cli sample_blueprint_with_sinks_bootstraps_runs_and_drains 2>&1 | tail -10`
Expected: PASS — `1 passed` (the named form bootstraps and drains both sinks
exactly as the positional form did).
- [ ] **Step 3: Full workspace gate**
Run: `cargo test --workspace 2>&1 | tail -15`
Expected: all suites PASS.
Run: `cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -10`
Expected: clean.
@@ -1,425 +0,0 @@
# Named param binding — Iteration 2 (sweep axes) — Implementation Plan
> **Parent spec:** `docs/specs/0030-named-param-binding.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Add the sweep-axes named-binding layer — `Composite::axis`
`SweepBinder``SweepBinder::sweep`, backed by `resolve_axes` (per-element
kind-checked, a superset of `GridSpace::new`) — and convert the CLI sample sweep
to it.
**Architecture:** A pure authoring layer in `aura-engine` over the existing
`GridSpace::new` / `sweep` primitives (engine core untouched, C1/C12/C19/C23).
`resolve_axes` shares `resolve`'s name→slot mapping (iter 1), adds the `EmptyAxis`
check and a per-element axis kind-check; because its validation is a superset of
`GridSpace::new`'s, the downstream `GridSpace::new(...).expect(...)` is infallible.
The single-run side (iter 1) is done and committed (64b19ab).
**Tech Stack:** Rust; `crates/aura-engine/src/blueprint.rs` (new items + tests),
`crates/aura-engine/src/lib.rs` (re-export), `crates/aura-cli/src/main.rs` (sweep
conversion + drop the orphaned `GridSpace` import).
---
**Files this plan creates or modifies:**
- Modify: `crates/aura-engine/src/blueprint.rs` — add free `fn resolve_axes`
(adjacent to `fn resolve` ~:324-363), `Composite::axis`, `struct SweepBinder` +
`impl SweepBinder` (adjacent to `Binder` ~:302-320), a new `use` for the sweep
primitives, and iter-2 tests in `#[cfg(test)] mod tests`.
- Modify: `crates/aura-engine/src/lib.rs:40-43` — add `SweepBinder` to the
`pub use blueprint::{…}` re-export (`BindError, Binder` already present).
- Modify: `crates/aura-cli/src/main.rs:226-266` — convert `sweep_family()`'s grid
construction to `.axis(…).sweep(…)`; drop the now-unused `GridSpace` from the
import at `:13`.
- Test: `crates/aura-engine/src/blueprint.rs` (test module) — `resolve_axes`
round-trip/parity, `EmptyAxis`, `MissingKnob` (sweep), per-element
`KindMismatch` (resolve-direct, mixed axis), a builder no-panic wrong-kind test,
and a GridSpace `.points()` parity test.
- Unchanged (content-pin twins — stay green, no edit): `crates/aura-cli/src/main.rs:541-544`
and `crates/aura-cli/tests/cli_run.rs:214` (sweep JSON param goldens — the
conversion preserves names/values/order).
---
## Task 1: Engine — `resolve_axes`, `Composite::axis`, `SweepBinder`
**Files:**
- Modify: `crates/aura-engine/src/blueprint.rs` (new items near the iter-1
`Binder`/`resolve` block ~:300-363; new `use`; tests)
- Modify: `crates/aura-engine/src/lib.rs:40-43`
- [ ] **Step 1: Add the skeleton (axis, SweepBinder, stubbed `resolve_axes`) + imports + re-export**
In `crates/aura-engine/src/blueprint.rs`, add a `use` for the sweep primitives
near the existing `use crate::harness::{…};` (~line 21). Use the collision-free
forms (`sweep` is both a module and a re-exported fn at the crate root):
```rust
use crate::sweep::sweep;
use crate::{GridSpace, RunReport, SweepFamily};
```
Add `Composite::axis` inside an `impl Composite` block (e.g. just after
`Composite::with`, ~line 274):
```rust
/// Begin binding this blueprint's knobs **by name** as sweep axes (the fluent
/// authoring alternative to a positional `GridSpace`). The bound name is the
/// exact `param_space()` name (path-qualified for a composite-interior knob,
/// bare for a root-level knob).
pub fn axis(self, name: &str, vals: impl IntoIterator<Item = impl Into<Scalar>>) -> SweepBinder {
let axis = vals.into_iter().map(Into::into).collect();
SweepBinder { bp: self, axes: vec![(name.to_string(), axis)] }
}
```
Add the `SweepBinder` type + impl adjacent to `Binder` (~after line 320), with a
stubbed `resolve_axes` (filled in Step 3):
```rust
/// A fluent accumulator of named sweep axes, terminated by [`SweepBinder::sweep`].
/// Axes are resolved against `param_space()` once, at the terminal; resolution is a
/// superset of `GridSpace::new`'s checks, so the grid it builds cannot fail.
pub struct SweepBinder {
bp: Composite,
axes: Vec<(String, Vec<Scalar>)>,
}
impl SweepBinder {
/// Bind one more sweep axis by name; raw literals lower via `Into<Scalar>`.
pub fn axis(mut self, name: &str, vals: impl IntoIterator<Item = impl Into<Scalar>>) -> SweepBinder {
self.axes.push((name.to_string(), vals.into_iter().map(Into::into).collect()));
self
}
/// Resolve the named axes against `param_space()` into a positional grid and
/// run the disjoint sweep.
pub fn sweep<F>(self, run_one: F) -> Result<SweepFamily, BindError>
where
F: Fn(&[Scalar]) -> RunReport + Sync,
{
let space = self.bp.param_space();
let ordered = resolve_axes(&space, &self.axes)?;
let grid = GridSpace::new(&space, ordered)
.expect("named layer pre-validates arity/kind/non-empty");
Ok(sweep(&grid, run_one))
}
}
/// Resolve named sweep axes to a positional `Vec<Vec<Scalar>>` in `param_space()`
/// slot order. (Body filled in Step 3.)
fn resolve_axes(space: &[ParamSpec], axes: &[(String, Vec<Scalar>)]) -> Result<Vec<Vec<Scalar>>, BindError> {
let _ = (space, axes);
todo!("resolve_axes")
}
```
In `crates/aura-engine/src/lib.rs`, the re-export block at lines 40-43 currently
reads:
```rust
pub use blueprint::{
aliases_on, signature_of, BindError, Binder, BlueprintNode, CompileError, Composite, OutField,
ParamAlias, Role,
};
```
Add `SweepBinder` (alphabetical, after `Role`):
```rust
pub use blueprint::{
aliases_on, signature_of, BindError, Binder, BlueprintNode, CompileError, Composite, OutField,
ParamAlias, Role, SweepBinder,
};
```
- [ ] **Step 2: Add all Task-1 tests, run, verify RED**
In the `#[cfg(test)] mod tests` of `crates/aura-engine/src/blueprint.rs`, add an
explicit import for the grid primitives the parity test needs (the test module
does not import sweep symbols today), placed with the module's other `use`s
(~:831-834):
```rust
use crate::GridSpace;
```
Then add these tests:
```rust
#[test]
fn resolve_axes_named_equals_positional() {
// named axes resolve to the positional Vec<Vec<Scalar>> in slot order,
// order-independent on the binding side
let space = vec![
ParamSpec { name: "sma_cross.fast".into(), kind: ScalarKind::I64 },
ParamSpec { name: "sma_cross.slow".into(), kind: ScalarKind::I64 },
ParamSpec { name: "scale".into(), kind: ScalarKind::F64 },
];
let axes = vec![
("scale".to_string(), vec![Scalar::F64(0.5)]),
("sma_cross.fast".to_string(), vec![Scalar::I64(2), Scalar::I64(3)]),
("sma_cross.slow".to_string(), vec![Scalar::I64(4), Scalar::I64(5)]),
];
assert_eq!(
resolve_axes(&space, &axes),
Ok(vec![
vec![Scalar::I64(2), Scalar::I64(3)],
vec![Scalar::I64(4), Scalar::I64(5)],
vec![Scalar::F64(0.5)],
]),
);
}
#[test]
fn resolve_axes_empty_axis() {
let space = vec![ParamSpec { name: "a".into(), kind: ScalarKind::I64 }];
assert_eq!(
resolve_axes(&space, &[("a".to_string(), vec![])]),
Err(BindError::EmptyAxis("a".to_string())),
);
}
#[test]
fn resolve_axes_missing_knob() {
let space = vec![
ParamSpec { name: "a".into(), kind: ScalarKind::I64 },
ParamSpec { name: "b".into(), kind: ScalarKind::I64 },
];
assert_eq!(
resolve_axes(&space, &[("a".to_string(), vec![Scalar::I64(1)])]),
Err(BindError::MissingKnob("b".to_string())),
);
}
#[test]
fn resolve_axes_per_element_kind_mismatch() {
// a MIXED-kind axis: the second element mismatches; per-element check must
// catch it (a first-element-only check would pass it through to a panic).
let space = vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }];
let axes = vec![("scale".to_string(), vec![Scalar::F64(0.5), Scalar::I64(1)])];
assert_eq!(
resolve_axes(&space, &axes),
Err(BindError::KindMismatch {
knob: "scale".to_string(),
expected: ScalarKind::F64,
got: ScalarKind::I64,
}),
);
}
#[test]
fn named_sweep_rejects_wrong_kind_axis_without_panic() {
// builder path: an F64 axis bound to an I64 slot is rejected as a clean
// BindError, never reaching GridSpace::new's .expect() — the run closure
// must not be invoked.
let (bp, _eq, _ex) = composite_sma_cross_harness();
let result = bp
.axis("sma_cross.fast", [2.0, 3.0]) // F64 values for the I64 slot
.axis("sma_cross.slow", [4])
.axis("scale", [0.5])
.sweep(|_: &[Scalar]| -> RunReport { panic!("axis pre-validation must reject before running") });
assert_eq!(
result,
Err(BindError::KindMismatch {
knob: "sma_cross.fast".to_string(),
expected: ScalarKind::I64,
got: ScalarKind::F64,
}),
);
}
#[test]
fn named_axes_grid_parity_with_positional() {
// named axes enumerate the SAME GridSpace points (same order) as the
// positional grid over the sample param-space.
let space = composite_sma_cross_harness().0.param_space();
let named = resolve_axes(
&space,
&[
("sma_cross.fast".to_string(), vec![Scalar::I64(2), Scalar::I64(3)]),
("sma_cross.slow".to_string(), vec![Scalar::I64(4), Scalar::I64(5)]),
("scale".to_string(), vec![Scalar::F64(0.5)]),
],
)
.expect("named axes resolve");
let positional = vec![
vec![Scalar::I64(2), Scalar::I64(3)],
vec![Scalar::I64(4), Scalar::I64(5)],
vec![Scalar::F64(0.5)],
];
let named_pts = GridSpace::new(&space, named).expect("named grid").points();
let pos_pts = GridSpace::new(&space, positional).expect("positional grid").points();
assert_eq!(named_pts, pos_pts, "named and positional grids must enumerate identically");
}
```
Run: `cargo test -p aura-engine resolve_axes_ named_sweep_rejects_wrong_kind_axis_without_panic named_axes_grid_parity_with_positional 2>&1 | tail -25`
Expected: FAIL — every new test panics with `not yet implemented: resolve_axes`
(the `todo!("resolve_axes")` stub; the builder/parity tests reach it via
`SweepBinder::sweep` / direct call).
- [ ] **Step 3: Implement `resolve_axes`, run, verify GREEN**
In `crates/aura-engine/src/blueprint.rs`, replace the `resolve_axes` stub body
from Step 1 with:
```rust
fn resolve_axes(space: &[ParamSpec], axes: &[(String, Vec<Scalar>)]) -> Result<Vec<Vec<Scalar>>, BindError> {
// Phase 1 — per axis in input order: claim slots by exact name (a, b, c, d).
let mut claimed: Vec<Option<Vec<Scalar>>> = vec![None; space.len()];
for (name, values) in axes {
let matches: Vec<usize> = space
.iter()
.enumerate()
.filter(|(_, p)| p.name == *name)
.map(|(i, _)| i)
.collect();
match matches.as_slice() {
[] => return Err(BindError::UnknownKnob(name.clone())), // a
[idx] => {
if values.is_empty() {
return Err(BindError::EmptyAxis(name.clone())); // c
}
if claimed[*idx].is_some() {
return Err(BindError::DuplicateBinding(name.clone())); // d
}
claimed[*idx] = Some(values.clone());
}
_ => return Err(BindError::AmbiguousKnob(name.clone())), // b
}
}
// Phase 2 — slot walk in param_space() order: completeness (e) + per-element kind (f).
let mut ordered = Vec::with_capacity(space.len());
for (i, p) in space.iter().enumerate() {
match &claimed[i] {
None => return Err(BindError::MissingKnob(p.name.clone())), // e
Some(values) => {
for v in values {
if v.kind() != p.kind {
return Err(BindError::KindMismatch {
knob: p.name.clone(),
expected: p.kind,
got: v.kind(),
}); // f — first offending element in axis order
}
}
ordered.push(values.clone());
}
}
}
Ok(ordered)
}
```
Run: `cargo test -p aura-engine resolve_axes_ named_sweep_rejects_wrong_kind_axis_without_panic named_axes_grid_parity_with_positional 2>&1 | tail -25`
Expected: PASS — all six new tests green.
- [ ] **Step 4: Full workspace gate**
Run: `cargo build --workspace 2>&1 | tail -5`
Expected: success.
Run: `cargo test --workspace 2>&1 | tail -15`
Expected: all suites PASS (six new + all pre-existing).
Run: `cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -10`
Expected: clean. (`resolve_axes` is called by `SweepBinder::sweep`;
`axis`/`SweepBinder`/`sweep` are `pub`-re-exported; the previously-unconstructed
`BindError::EmptyAxis` is now constructed in `resolve_axes`, so no `dead_code`.)
## Task 2: CLI — convert the sample sweep to `.axis(…).sweep(…)`
**Files:**
- Modify: `crates/aura-cli/src/main.rs:226-266` (`sweep_family`) and `:13` (import)
- [ ] **Step 1: Convert `sweep_family()` grid construction to named axes**
In `crates/aura-cli/src/main.rs`, replace the body of `sweep_family()` (lines
227-265 — the `let space = …` + `GridSpace::new(…).expect(…)` + `sweep(&grid, …)`)
with the named form. Keep the per-point closure body **verbatim** (it still reads
`space` for the manifest params); only the grid construction and the `sweep(&grid,
…)` wrapper change:
```rust
fn sweep_family() -> SweepFamily {
let bp = sample_blueprint_with_sinks().0;
let space = bp.param_space();
bp.axis("sma_cross.fast", [2, 3])
.axis("sma_cross.slow", [4, 5])
.axis("scale", [0.5])
.sweep(|point| {
let (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks();
let mut h = bp
.bootstrap_with_params(point.to_vec())
.expect("grid points are kind-checked against param_space");
let prices = synthetic_prices();
let window = (
prices.first().expect("non-empty stream").0,
prices.last().expect("non-empty stream").0,
);
h.run(vec![prices]);
let equity = f64_field(&rx_eq.try_iter().collect::<Vec<_>>(), 0);
let exposure = f64_field(&rx_ex.try_iter().collect::<Vec<_>>(), 0);
let params = space
.iter()
.zip(point)
.map(|(ps, v)| (ps.name.clone(), scalar_as_param_f64(v)))
.collect();
RunReport {
manifest: RunManifest {
commit: option_env!("AURA_COMMIT").unwrap_or("unknown").to_string(),
params,
window,
seed: 0,
broker: "sim-optimal(pip_size=0.0001)".to_string(),
},
metrics: summarize(&equity, &exposure),
}
})
.expect("the built-in named grid matches the sample param-space")
}
```
Then remove the now-orphaned `GridSpace` from the import block at line 13. The
block currently reads:
```rust
use aura_engine::{
f64_field, summarize, sweep, BlueprintNode, Composite, Edge, FlatGraph, GridSpace, Harness,
OutField, ParamAlias, Role, RunManifest, RunReport, SourceSpec, SweepFamily, Target,
};
```
Remove `GridSpace, ` (it is the only symbol the conversion orphans — `sweep`,
`SweepFamily`, `RunReport`, `RunManifest`, `summarize`, `f64_field` all remain
used; `Scalar` is used elsewhere and is imported separately). Result:
```rust
use aura_engine::{
f64_field, summarize, sweep, BlueprintNode, Composite, Edge, FlatGraph, Harness,
OutField, ParamAlias, Role, RunManifest, RunReport, SourceSpec, SweepFamily, Target,
};
```
- [ ] **Step 2: Run the sweep goldens (both roots), verify GREEN**
Run: `cargo test -p aura-cli sweep_report_renders_four_points_in_odometer_order 2>&1 | tail -8`
Expected: PASS — the unit golden (`main.rs:541-544`) is unchanged; the named axes
produce the same `"params":{"sma_cross.fast":N,"sma_cross.slow":M,"scale":0.5}`
lines in odometer order.
Run: `cargo test -p aura-cli --test cli_run sweep_prints_four_json_lines_and_exits_zero 2>&1 | tail -8`
Expected: PASS — the integration golden (`tests/cli_run.rs:214`) is unchanged
(the binary's first line still `"params":{"sma_cross.fast":2,"sma_cross.slow":4,"scale":0.5}`).
- [ ] **Step 3: Full workspace gate**
Run: `cargo test --workspace 2>&1 | tail -15`
Expected: all suites PASS.
Run: `cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -10`
Expected: clean (the `GridSpace` import was dropped; no unused-import warning).
-496
View File
@@ -1,496 +0,0 @@
# Node-instance naming — Implementation Plan
> **Parent spec:** `docs/specs/0031-node-instance-naming.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Give every blueprint node a name (default = its lowercased type label),
make `param_space()` uniformly `<node>.<param>`, re-key fan-in distinguishability
onto the node name, and remove the index-addressed `ParamAlias` overlay.
**Architecture:** `PrimitiveBuilder` (aura-core) gains an instance-name slot
(`name: Option<String>`, `.named()`, `node_name()`). `blueprint.rs` (aura-engine)
inserts the node-name segment in `collect_params`, re-keys `signature_of` and the
fan-in predicate onto the node name, and drops `ParamAlias` / `Composite.params` /
the `Composite::new` 5th argument / `aliases_on` / `check_alias_indices`. Removing
the argument is a compile-coupled change, so every in-workspace `Composite::new`
caller and `ParamAlias` reference migrates in the same task; the `cargo build
--workspace` gate enumerates completeness.
**Tech Stack:** Rust. `crates/aura-core/src/node.rs`,
`crates/aura-engine/src/{blueprint.rs, lib.rs, graph_model.rs, sweep.rs}`,
`crates/aura-cli/src/main.rs`, `crates/aura-cli/tests/cli_run.rs`,
`docs/design/INDEX.md`.
---
**Files this plan creates or modifies:**
- Modify: `crates/aura-core/src/node.rs:77-116``PrimitiveBuilder` instance-name mechanism
- Test: `crates/aura-core/src/node.rs` (mod tests, ~:190) — `node_name()` default + override
- Modify: `crates/aura-engine/src/blueprint.rs``collect_params` (:688-730), `signature_of` (:546-590), `leaf_has_unaliased_param``leaf_has_param` (:674-686), `check_composite_fan_in` (:635-672), `check_fan_in_distinguishability` (:616-628), `Composite` struct (:137-144) + `Composite::new` (:151-160) + `params()` (:180-184) + `param_space()` call (:197); REMOVE `ParamAlias` (:121-130), `aliases_on` (:592-598), `check_alias_indices` (:600-614)
- Test: `crates/aura-engine/src/blueprint.rs` (mod tests) — new path-shape / default-name / forcing-function / paramless tests; update the mirror + `top_level` + `signature_of` tests; remove the alias-semantics tests
- Modify: `crates/aura-engine/src/lib.rs:41-42` — drop the `ParamAlias` / `aliases_on` / `signature_of` re-export of the retired overlay
- Modify: `crates/aura-engine/src/graph_model.rs``ParamAlias` import (:235) + `Composite::new` fixtures (:274,:302,:326,:344) + alias literals (:287-288): structural migration only
- Modify: `crates/aura-engine/src/sweep.rs``ParamAlias` import (:173) + `Composite::new` fixtures (:276,:306) + alias literals (:289-290): structural migration only
- Modify: `crates/aura-cli/src/main.rs``sma_cross` sample (:137-155), root harness (:170), EMA/MACD composite (:338,:382), sweep goldens (:537-540), `.axis()` block (:229-231), single-run `.with()` (:516-518), MACD param assertion (:597-599), `ParamAlias` import (:14)
- Modify: `crates/aura-cli/tests/cli_run.rs:214` — the sweep-golden twin (lockstep with main.rs:537-540)
- Modify: `docs/design/INDEX.md` — amend C9 (:367-375) and C23 (:359-366, :813-851)
**Out of scope (do NOT touch):** `crates/aura-cli/tests/fixtures/sample-model.json`
and the `model_to_json` golden at `graph_model.rs:430``model_to_json` emits the
**type** label (`b.label()``"SMA"`) and factory param names (`"length"`), which
are alias-independent and node-name-independent; this cycle does not change the JSON
model surface, so those goldens stay byte-identical. The `fieldtests/` crates are
frozen non-workspace records — not built or gated by `cargo --workspace`.
---
## Task 1: `PrimitiveBuilder` instance-name mechanism (aura-core)
Additive — no existing call site breaks (`PrimitiveBuilder::new` keeps its
signature; the new field defaults internally).
**Files:**
- Modify: `crates/aura-core/src/node.rs:77-116`
- Test: `crates/aura-core/src/node.rs` (mod tests)
- [ ] **Step 1: Write the failing test**
Add to the `mod tests` block in `crates/aura-core/src/node.rs` (near the existing
`primitive_builder_label_is_the_bare_type` at ~:196):
```rust
#[test]
fn node_name_defaults_to_lowercased_type_label() {
// unnamed → the type label, ASCII-lowercased verbatim (no snake_case)
let unnamed = PrimitiveBuilder::new(
"SimBroker",
NodeSchema::default(),
|_| panic!("not built in this test"),
);
assert_eq!(unnamed.node_name(), "simbroker");
// explicit name overrides
let named = PrimitiveBuilder::new(
"SMA",
NodeSchema::default(),
|_| panic!("not built in this test"),
)
.named("fast");
assert_eq!(named.node_name(), "fast");
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cargo test -p aura-core node_name_defaults_to_lowercased_type_label`
Expected: compile error / FAIL — no method `node_name` / `named` on `PrimitiveBuilder`.
- [ ] **Step 3: Write minimal implementation**
In `crates/aura-core/src/node.rs`, add a `name: Option<String>` field to
`PrimitiveBuilder` (struct at :77-84), initialise it in `new` (:90-96), and add
the two methods. Concretely:
Struct — add the field:
```rust
pub struct PrimitiveBuilder {
name: &'static str, // existing: the type label
instance_name: Option<String>, // NEW: the optional instance name
schema: NodeSchema,
build: Box<dyn Fn(&[Scalar]) -> Box<dyn Node>>,
}
```
`new` — initialise the new field (`instance_name: None`):
```rust
pub fn new(
name: &'static str,
schema: NodeSchema,
build: impl Fn(&[Scalar]) -> Box<dyn Node> + 'static,
) -> Self {
Self { name, instance_name: None, schema, build: Box::new(build) }
}
```
Add the two methods (near `label()` at :113):
```rust
/// Set this node instance's name. Must be non-empty.
pub fn named(mut self, name: &str) -> Self {
debug_assert!(!name.is_empty(), "node name must be non-empty");
self.instance_name = Some(name.to_string());
self
}
/// The resolved node name: the explicit instance name if set, else the
/// type label ASCII-lowercased verbatim (e.g. "SimBroker" -> "simbroker").
pub fn node_name(&self) -> String {
self.instance_name
.clone()
.unwrap_or_else(|| self.name.to_ascii_lowercase())
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cargo test -p aura-core node_name_defaults_to_lowercased_type_label`
Expected: PASS.
- [ ] **Step 5: Build the crate (additive, no caller breakage)**
Run: `cargo build -p aura-core`
Expected: 0 errors (the new field is internal; `new` still takes 3 args).
---
## Task 2: Engine mechanism, overlay removal, and the full compile-coupled migration (aura-engine + aura-cli)
This is one task by necessity: dropping the `Composite::new` 5th argument and the
`ParamAlias` struct breaks every caller at once, so all callers + the struct
removal + the dependent test-string updates land together under one
`cargo build/test --workspace` gate (planner self-review #7).
**Files:** `crates/aura-engine/src/blueprint.rs`, `lib.rs`, `graph_model.rs`,
`sweep.rs`; `crates/aura-cli/src/main.rs`, `crates/aura-cli/tests/cli_run.rs`.
- [ ] **Step 1: Write/replace the executable-spec tests (the RED side)**
In `crates/aura-engine/src/blueprint.rs` `mod tests`, add the new behaviour tests.
These assert the post-change strings, so they fail (or do not compile) until the
logic + signature land in Steps 2-7. Use named SMAs and the **new** 5-arg
`Composite::new` (no `params` arg):
```rust
#[test]
fn named_siblings_path_qualify_with_node_segment() {
use aura_std::{Sma, Sub};
// two SMAs, named fast/slow, feeding a Sub, under composite "sma_cross"
let cross = Composite::new(
"sma_cross",
vec![
Sma::builder().named("fast").into(),
Sma::builder().named("slow").into(),
Sub::builder().into(),
],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
],
vec![Role {
name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
source: None,
}],
vec![OutField { node: 2, field: 0, name: "cross".into() }],
);
let names: Vec<String> = cross.param_space().into_iter().map(|p| p.name).collect();
assert_eq!(names, ["sma_cross.fast.length", "sma_cross.slow.length"]);
}
#[test]
fn unnamed_single_primitive_uses_lowercased_type_label_segment() {
use aura_std::Sma;
let bp = Composite::new(
"root",
vec![Sma::builder().into()],
vec![],
vec![],
vec![],
);
assert_eq!(bp.param_space()[0].name, "sma.length");
}
#[test]
fn unnamed_same_type_param_bearing_fan_in_is_rejected() {
use aura_std::{Sma, Sub};
let bp = Composite::new(
"sma_cross",
vec![Sma::builder().into(), Sma::builder().into(), Sub::builder().into()],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
],
vec![Role {
name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
source: None,
}],
vec![OutField { node: 2, field: 0, name: "cross".into() }],
);
// both SMAs default to "sma" → collide → fan-in indistinguishable
assert_eq!(bp.compile().err(), Some(CompileError::IndistinguishableFanIn { node: 2 }));
}
#[test]
fn named_param_bearing_fan_in_bootstraps() {
use aura_std::{Sma, Sub};
let bp = Composite::new(
"sma_cross",
vec![
Sma::builder().named("fast").into(),
Sma::builder().named("slow").into(),
Sub::builder().into(),
],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
],
vec![Role {
name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
source: None,
}],
vec![OutField { node: 2, field: 0, name: "cross".into() }],
);
// distinct node names → fan-in distinguishable → compiles
assert!(bp.compile().is_ok());
}
```
Keep `interchangeable_fan_in_allowed` (:1561) — two **paramless** same-type
siblings stay legal; after migration its fixture's `Composite::new` loses the
5th arg but the assertion (compiles OK) is unchanged.
- [ ] **Step 2: Run the new tests to verify they fail**
Run: `cargo test -p aura-engine named_siblings_path_qualify_with_node_segment`
Expected: FAIL — either a compile error (5-arg `Composite::new` not yet in
effect) or an assertion mismatch (`sma_cross.length` vs `sma_cross.fast.length`).
- [ ] **Step 3: Rewrite `collect_params` to insert the node segment uniformly**
Replace `collect_params` (:688-730) with the node-segment form (drop the
`aliases` parameter; a primitive contributes `<prefix>.<node-name>.<param>`):
```rust
fn collect_params(items: &[BlueprintNode], prefix: &str, out: &mut Vec<ParamSpec>) {
for item in items {
match item {
BlueprintNode::Primitive(b) => {
let node = if prefix.is_empty() {
b.node_name()
} else {
format!("{prefix}.{}", b.node_name())
};
for p in b.params() {
out.push(ParamSpec { name: format!("{node}.{}", p.name), kind: p.kind });
}
}
BlueprintNode::Composite(c) => {
let child = if prefix.is_empty() {
c.name().to_string()
} else {
format!("{prefix}.{}", c.name())
};
collect_params(c.nodes(), &child, out);
}
}
}
}
```
Update the `param_space()` call site (:197) to drop `&self.params`:
```rust
collect_params(&self.nodes, "", &mut out);
```
- [ ] **Step 4: Re-key `signature_of` and the fan-in predicate onto the node name**
`signature_of` (:546-590): drop the `aliases: &[ParamAlias]` parameter and the
`aliases_on` loop (:559-563); the per-node component becomes the full node name
(so two `"sma"` collide, `"fast"`/`"slow"` differ). Keep the `-> String` return.
Replace the primitive arm's initial-building with the node name:
```rust
pub fn signature_of(nodes: &[BlueprintNode], edges: &[Edge], roles: &[Role], node: usize) -> String {
let mut sig = match &nodes[node] {
BlueprintNode::Primitive(b) => b.node_name(), // was: type initial + alias initials
BlueprintNode::Composite(c) => c.name().to_string(),
};
// recurse over inputs exactly as today (edges feeding `node`), appending each
// input's signature_of(...) in slot order:
for e in inputs_of(edges, node) { // same input-gathering as today
sig.push_str(&signature_of(nodes, edges, roles, e.from));
}
sig
}
```
(Match the existing recursion shape at :559-590; only the per-node head and the
removed `aliases` param change.)
`leaf_has_unaliased_param` (:674-686) → `leaf_has_param`: drop the `aliases` arg
and the `aliased` subtraction (:681-682); it is now true iff the leaf node has ≥1
param:
```rust
fn leaf_has_param(nodes: &[BlueprintNode], node: usize) -> bool {
matches!(&nodes[node], BlueprintNode::Primitive(b) if !b.params().is_empty())
}
```
`check_composite_fan_in` (:635-672): drop the `param_aliases` parameter; remove
the `check_alias_indices` call (:644); update the two helper calls (:651-652):
```rust
signature_of(nodes, edges, input_roles, e.from),
leaf_has_param(nodes, e.from),
```
`check_fan_in_distinguishability` (:616-628): drop `c.params()` from the
`check_composite_fan_in(...)` call (:623).
The `IndistinguishableFanIn { node }` construction (:666) is unchanged.
- [ ] **Step 5: Remove the overlay (struct, field, arg, helpers, alias tests)**
- Delete `pub struct ParamAlias { … }` (:121-130).
- Delete the `params: Vec<ParamAlias>` field from `Composite` (:142) and the
`pub fn params(&self) -> &[ParamAlias]` accessor (:180-184).
- Change `Composite::new` (:151-160) to a 5-arg signature (drop `params`):
```rust
pub fn new(
name: impl Into<String>,
nodes: Vec<BlueprintNode>,
edges: Vec<Edge>,
input_roles: Vec<Role>,
output: Vec<OutField>,
) -> Self {
Self { name: name.into(), nodes, edges, input_roles, output }
}
```
- Delete `aliases_on` (:592-598) and `check_alias_indices` (:600-614). Keep the
`CompileError::BadInteriorIndex` variant (:458) — it is still used by the other
index checks (:498-515, :872-914); only the dangling-alias path that produced it
is gone.
- Delete the alias-semantics tests: `param_alias_relabels_param_space_name_in_place`
(:1991-2022), `partial_aliasing_relabels_only_the_named_slot` (:2087-2113),
`out_of_range_param_alias_rejected` (:2024-2057), `unaliased_params_keep_factory_names`
(:2059-…). Their behaviour no longer exists.
- Amend the alias-overlay comment at :803-810 to describe node-name qualification.
- [ ] **Step 6: Update the engine `lib.rs` re-export**
In `crates/aura-engine/src/lib.rs:41-42`, remove `ParamAlias` (and `aliases_on`
if re-exported) from the `pub use blueprint::{…}` list. Keep `signature_of`,
`Composite`, `Role`, `OutField`, `BindError`, etc. (Quote: drop exactly the
`ParamAlias`/`aliases_on` identifiers from the brace list.)
- [ ] **Step 7: Mechanically migrate every in-workspace `Composite::new` call site + `ParamAlias` reference**
Drop the 5th (`params`) argument from **every** `Composite::new(...)` call, and
remove every `ParamAlias { … }` literal / import, across the workspace. The
recon hand-list (advisory; the compile gate is authoritative):
- `crates/aura-engine/src/blueprint.rs` tests: the ~46 `Composite::new` call
sites enumerated by recon (e.g. :1273, :1301, …, :2363) lose their 5th arg;
the alias literals at :1312-1313, :1759-1760, :2006-2007, :2037, :2100,
:2192-2193, :2319-2321 are removed (or replaced with `.named(...)` on the
relevant builder where a same-type param-bearing fan-in fixture would otherwise
become `IndistinguishableFanIn`).
- `crates/aura-engine/src/graph_model.rs`: import (:235), `Composite::new`
fixtures (:274,:302,:326,:344), alias literals (:287-288) — drop the arg; where
a fixture fans in two same-type param-bearing leaves, add `.named("a")`/`.named("b")`
so it still compiles. **Do not** change the expected JSON golden at :430 (it
pins `"type":"SMA"` + `["length","i64"]`, which `model_to_json` still emits).
- `crates/aura-engine/src/sweep.rs`: import (:173), fixtures (:276,:306), alias
literals (:289-290) — same treatment.
- `crates/aura-cli/src/main.rs`: import (:14) removed; `sma_cross` (:137-155) —
the two `ParamAlias{name:"fast"/"slow"}` (:150-151) become `.named("fast")` /
`.named("slow")` on the two `Sma::builder()` (:140-ish) and the `params` arg is
dropped; root harness (:170) drops its `vec![]` params arg; EMA/MACD composite
(:338,:382) — its EMA-leg aliases (:363-365) become `.named(...)` on the EMA
builders, params arg dropped.
- [ ] **Step 8: Update the path-string assertions in the engine tests**
- `param_space_mirrors_compiled_flat_node_param_order` (:1958, assertion
:1981-1984): the fixture's two SMAs become `.named("fast")`/`.named("slow")`,
the Exposure node stays default-named; the expected vector changes from
`["sma_cross.fast", "sma_cross.slow", "scale"]` to
`["sma_cross.fast.length", "sma_cross.slow.length", "exposure.scale"]`. The
**kind-by-slot** part of the assertion (the load-bearing arity/order invariant)
is unchanged.
- `param_space_mirrors_compiled_flat_node_param_order_under_nesting` (:2177,
assertion :2236-2239): name the fixture's SMAs; the kind-only assertion stays;
any old alias literals (:2192-2193) are removed.
- `param_space_is_flat_path_qualified_and_slot_disambiguated` (:2116, assertion
:2152-2160): name the two SMAs; the expected strings gain the node segment
(e.g. `strategy.fast_slow.fast.length` / `…slow.length` per the chosen fixture
names).
- `top_level_leaf_params_are_unqualified` (:2243-2256): **invert** the pin — the
assertion at :2255 becomes `assert_eq!(space[0].name, "sma.length")` (root node
now carries its name segment). Rename the test to
`top_level_leaf_params_carry_the_node_segment` to reflect the inverted intent.
- `signature_of_is_type_initial_plus_aliases_plus_recursive_inputs` (:1289-1297)
+ `macd_like_signature_fixture` (:1299-1317): rename to
`signature_of_is_node_name_plus_recursive_inputs`; the fixture's EMA builders
are default-named (`"ema"`) or `.named(...)`; the expected signature strings
(:1294, :1296) are recomputed from the node-name head (no longer `"Efp"` /
`"SEfpEsp"`). Compute the new expected strings from the rewritten `signature_of`
and pin them exactly.
- `indistinguishable_fan_in_rejected` (:1531-1559): stays green
(`IndistinguishableFanIn { node: 2 }`); update the fixture comment (:1533-1534)
from "unaliased" to "both default to `sma`". (This test overlaps
`unnamed_same_type_param_bearing_fan_in_is_rejected` from Step 1 — keep both or
fold into one; do not leave a duplicate name.)
- [ ] **Step 9: Migrate the CLI sweep goldens (both files, lockstep)**
- `crates/aura-cli/src/main.rs`: the `.axis(...)` block (:229-231) →
`.axis("sma_cross.fast.length", …).axis("sma_cross.slow.length", …).axis("exposure.scale", …)`;
the single-run `.with(...)` block (:516-518) → the same three names; the sweep
golden assertions (:537-540) → `"params":{"sma_cross.fast.length":N,"sma_cross.slow.length":N,"exposure.scale":0.5}`;
the MACD param assertion (:597-599) gains the node segments for its EMA knobs +
`exposure.scale`.
- `crates/aura-cli/tests/cli_run.rs:214`: the **twin** golden — replace
`"params":{"sma_cross.fast":2,"sma_cross.slow":4,"scale":0.5}` with
`"params":{"sma_cross.fast.length":2,"sma_cross.slow.length":4,"exposure.scale":0.5}`.
Keep the substring contiguous on one line (self-review #6).
- [ ] **Step 10: Workspace build + test + clippy gate (the completeness enumerator)**
Run: `cargo build --workspace`
Expected: 0 errors. (Any remaining un-migrated `Composite::new` caller or
`ParamAlias` reference surfaces here — fix it, re-run, until 0 errors. This gate
is the authoritative completeness check for the compile-coupled migration.)
Run: `cargo test --workspace`
Expected: PASS — all new tests green, the mirror/top_level/signature tests green
with their updated strings, the CLI goldens green with the migrated names, the
`model_to_json` goldens green **unchanged**.
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: 0 warnings.
---
## Task 3: Amend the design ledger (C9, C23)
Doc-only; no compile coupling.
**Files:** `docs/design/INDEX.md`
- [ ] **Step 1: Amend C9 (fan-in distinguishability)**
In `docs/design/INDEX.md`, the C9 fan-in refinement paragraph (:367-375): replace
the rationale that keys distinguishability on "equal recursive signatures (type
initial + alias initials + …)" and fires "when at least one colliding source
carries an **unaliased param slot**" with the node-name keying: colliding sources
are those with equal recursive **node-name** signatures; the collision is
`IndistinguishableFanIn` when at least one carries a **param** (a param-bearing
same-name collision), resolved by giving the colliding nodes distinct names.
Paramless interchangeable same-name sources stay legal.
- [ ] **Step 2: Amend C23 (named boundary projections)**
In the C23-related prose at :359-366 and :813-851: strike the `ParamAlias`
param-overlay from the named-projection set (the param projection is retired;
`Role.name` input roles and `OutField.name` outputs remain). Note that node names
join the same non-load-bearing debug-symbol class (construction-time identity,
dropped at lowering; the flat graph stays wired by raw index). Remove the sentence
referencing the dangling-alias `BadInteriorIndex` compile check (:362-364) — that
path is gone with the overlay.
- [ ] **Step 3: Verify the ledger references no retired symbol**
Run: `grep -nE "ParamAlias|unaliased|aliases_on|check_alias_indices" docs/design/INDEX.md`
Expected: no remaining references to the retired overlay (or only historical
"was retired in cycle 0031" notes if added deliberately).
@@ -1,488 +0,0 @@
# Param-Namespace Injectivity — Implementation Plan
> **Parent spec:** `docs/specs/0032-param-namespace-injectivity.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Make `param_space()` injectivity a single enforced compile invariant
(`check_param_namespace_injective``CompileError::DuplicateParamPath`), retire
the fan-in machinery and `BindError::AmbiguousKnob` it subsumes, and run the check
before name resolution so the by-name author sees the structural cure.
**Architecture:** One structural check over the `param_space()` names replaces
`check_fan_in_distinguishability` and is called from `compile_with_params` plus
both binders (before `resolve`/`resolve_axes`). The fan-in machinery
(`signature_of`, `leaf_has_param`, the two check fns) and `AmbiguousKnob` (now dead,
since an injective space can never multi-match) are removed wholesale. The
removal+rewire is enum/signature-coupled, so it lands as one `cargo build
--workspace`-gated task after the new RED tests are in place.
**Tech Stack:** `crates/aura-engine/src/blueprint.rs` (the whole change),
`crates/aura-engine/src/lib.rs` (one re-export line), `docs/design/INDEX.md`
(C9/C12/C19/C23 amendments). Engine core untouched.
---
**Files this plan creates or modifies:**
- Modify: `crates/aura-engine/src/blueprint.rs` — the check, the variant swap, the
machinery removal, the two resolver arms, the three call sites, the test
migrations/removals/additions.
- Modify: `crates/aura-engine/src/lib.rs:41` — drop `signature_of` from the
`pub use blueprint::{…}` re-export.
- Modify: `docs/design/INDEX.md` — C9 refinement (367-379), C12/C19 note (265-268),
C23 amendment.
- Test: `crates/aura-engine/src/blueprint.rs` — add tests 3/5/6 (RED-first),
migrate tests 1/2, remove tests 7/8 + the `macd_like_signature_fixture` helper.
---
### Task 1: RED — add the new variant and the three failing tests
Additive only (nothing removed, nothing wired): the new `DuplicateParamPath`
variant so the tests compile, and the three new tests asserting the post-change
behaviour. They must FAIL against current code.
**Files:**
- Modify: `crates/aura-engine/src/blueprint.rs:440-465` (CompileError enum — add one variant)
- Test: `crates/aura-engine/src/blueprint.rs` (tests module — add 3 tests)
- [ ] **Step 1: Add the `DuplicateParamPath` variant to `CompileError`**
In `crates/aura-engine/src/blueprint.rs`, inside `enum CompileError` (derives
`#[derive(Debug, PartialEq, Eq)]` at line 440), add this variant immediately after
the existing `IndistinguishableFanIn { node: usize },` (line 460). Do NOT remove
`IndistinguishableFanIn` yet (Task 2 removes it):
```rust
/// Two `param_space()` slots resolved to the same path-qualified name — the
/// by-name knob address space (C12/C19) is not injective, so no binding can
/// select one slot without the other. Carries the duplicated path. Cure: give
/// the colliding same-type sibling nodes distinct names with `.named(...)`.
DuplicateParamPath(String),
```
- [ ] **Step 2: Add test 3 (`non_fan_in_duplicate_path_is_rejected`)**
Append to the `#[cfg(test)] mod tests` block in `blueprint.rs`:
```rust
#[test]
fn non_fan_in_duplicate_path_is_rejected() {
use aura_std::Sma;
// two unnamed SMAs ("sma" each), each feeding its OWN sink — no shared
// fan-in node, so the old fan-in check never fired; but param_space() has
// the path "dup.sma.length" twice.
let dup = Composite::new(
"dup",
vec![
Sma::builder().into(), // node 0 -> dup.sma.length
Sma::builder().into(), // node 1 -> dup.sma.length (duplicate)
sink_f64(), // node 2
sink_f64(), // node 3
],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 3, slot: 0, from_field: 0 },
],
vec![Role {
name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
source: None,
}],
vec![],
);
let bp = Composite::new(
"root",
vec![BlueprintNode::Composite(dup)],
vec![],
vec![Role {
name: "src".into(),
targets: vec![Target { node: 0, slot: 0 }],
source: Some(ScalarKind::F64),
}],
vec![],
);
// two params declared (one per SMA); supply both so the only failure under
// test is the duplicate path (green today, DuplicateParamPath after).
assert_eq!(
bp.compile_with_params(&[Scalar::I64(2), Scalar::I64(3)]).err(),
Some(CompileError::DuplicateParamPath("dup.sma.length".to_string()))
);
}
```
- [ ] **Step 3: Add test 5 (`asymmetric_node_name_collision_compiles`)**
Append to the tests block:
```rust
#[test]
fn asymmetric_node_name_collision_compiles() {
use aura_std::{Sma, Sub};
// inner composite `asym`: a param-bearing Sma (default "sma") + a paramless
// Pass1 forced to "sma", both on role price fanning into a Sub. Equal
// node-name signatures + one param -> rejected today (IndistinguishableFanIn);
// param_space() is the single injective entry ["asym.sma.length"] (the
// paramless leg contributes no path) -> admitted after the change. Pass1 is
// built inline (the pass1() helper returns an already-.into()'d node, so it
// cannot take .named).
let paramless_sma = PrimitiveBuilder::new(
"Pass1",
NodeSchema { inputs: vec![f64_any()], output: out_v(), params: vec![] },
|_| Box::new(Pass1 { out: [Scalar::F64(0.0)] }),
)
.named("sma");
let asym = Composite::new(
"asym",
vec![Sma::builder().into(), paramless_sma.into(), Sub::builder().into()],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
],
vec![Role {
name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
source: None,
}],
vec![OutField { node: 2, field: 0, name: "out".into() }],
);
let bp = Composite::new(
"root",
vec![BlueprintNode::Composite(asym)],
vec![],
vec![Role {
name: "src".into(),
targets: vec![Target { node: 0, slot: 0 }],
source: Some(ScalarKind::F64),
}],
vec![],
);
assert_eq!(
bp.param_space().into_iter().map(|p| p.name).collect::<Vec<_>>(),
["asym.sma.length"]
);
assert!(bp.compile_with_params(&[Scalar::I64(2)]).is_ok());
}
```
- [ ] **Step 4: Add test 6 (`by_name_bootstrap_of_unnamed_cross_reports_duplicate_path`)**
Append to the tests block:
```rust
#[test]
fn by_name_bootstrap_of_unnamed_cross_reports_duplicate_path() {
// the canonical by-name flow on an unnamed cross: the binder runs the
// injectivity check before name resolution, so the author sees the
// structural DuplicateParamPath (carrying the .named(...) cure) instead of
// AmbiguousKnob.
let bp = sma_cross_under_root(false);
assert_eq!(
bp.with("sma_cross.sma.length", 2).bootstrap().err(),
Some(BindError::Compile(CompileError::DuplicateParamPath(
"sma_cross.sma.length".to_string()
)))
);
}
```
- [ ] **Step 5: Run the three new tests to verify they FAIL (RED)**
Run (one filter per invocation — multi-filter prints nothing here):
`cargo test --workspace non_fan_in_duplicate_path_is_rejected`
Expected: FAIL — `left: None` (compiles green today), `right: Some(DuplicateParamPath("dup.sma.length"))`.
`cargo test --workspace asymmetric_node_name_collision_compiles`
Expected: FAIL — `assertion failed: bp.compile_with_params(&[Scalar::I64(2)]).is_ok()` (the asym fan-in is rejected as `IndistinguishableFanIn` today).
`cargo test --workspace by_name_bootstrap_of_unnamed_cross_reports_duplicate_path`
Expected: FAIL — `left: Some(AmbiguousKnob("sma_cross.sma.length"))`, `right: Some(Compile(DuplicateParamPath("sma_cross.sma.length")))`.
(All three compile — `DuplicateParamPath` exists from Step 1; `IndistinguishableFanIn` and `AmbiguousKnob` still exist, removed in Task 2.)
---
### Task 2: GREEN — add the check, wire it, remove the fan-in machinery + AmbiguousKnob, migrate/remove tests
Enum/signature-coupled: every removal and rewire lands together under one
`cargo build --workspace` gate so the build never sees a half-removed enum.
**Files:**
- Modify: `crates/aura-engine/src/blueprint.rs` (check fn; head ~189; binders ~311/~335; resolvers ~370/~415; CompileError ~460; BindError ~287; remove fns 524-615; migrate tests 1258/1527; remove tests 1008-1019, 1269-1298)
- Modify: `crates/aura-engine/src/lib.rs:41`
- [ ] **Step 1: Add `check_param_namespace_injective`**
Add this free function in `blueprint.rs` near the other module-level `check_*` /
`fn resolve` helpers (e.g. just above `fn resolve`):
```rust
/// Structural validation (param-value-independent): the `param_space()` name
/// projection is the by-name knob address space (C12/C19) and must be injective —
/// a duplicated path is a knob no binding can select alone. The first duplicate in
/// `param_space()` order is reported (the order is deterministic). Single source of
/// duplicate detection; called from `compile_with_params` and from both binders
/// before name resolution.
fn check_param_namespace_injective(space: &[ParamSpec]) -> Result<(), CompileError> {
let mut seen = std::collections::HashSet::new();
for p in space {
if !seen.insert(p.name.as_str()) {
return Err(CompileError::DuplicateParamPath(p.name.clone()));
}
}
Ok(())
}
```
- [ ] **Step 2: Rewire the `compile_with_params` head**
In `blueprint.rs:189-202`, replace the head (the `check_fan_in_distinguishability`
call and the later separate `param_space()` arity computation) so `param_space()`
is computed once and the injectivity check replaces the fan-in check. Change:
```rust
// structural validation, all pre-build (no node constructed):
check_fan_in_distinguishability(&self.nodes)?;
validate_wiring(&self.nodes, &self.edges, &self.input_roles, &self.output)?;
for (r, role) in self.input_roles.iter().enumerate() {
if role.source.is_none() {
return Err(CompileError::UnboundRootRole { role: r });
}
}
let expected = self.param_space().len();
if params.len() != expected {
return Err(CompileError::ParamArity { expected, got: params.len() });
}
```
to:
```rust
// structural validation, all pre-build (no node constructed):
let space = self.param_space();
check_param_namespace_injective(&space)?;
validate_wiring(&self.nodes, &self.edges, &self.input_roles, &self.output)?;
for (r, role) in self.input_roles.iter().enumerate() {
if role.source.is_none() {
return Err(CompileError::UnboundRootRole { role: r });
}
}
if params.len() != space.len() {
return Err(CompileError::ParamArity { expected: space.len(), got: params.len() });
}
```
- [ ] **Step 3: Wire the check into `Binder::bootstrap`**
In `blueprint.rs:311-315`, insert the check after `let space`:
```rust
pub fn bootstrap(self) -> Result<Harness, BindError> {
let space = self.bp.param_space();
check_param_namespace_injective(&space).map_err(BindError::Compile)?;
let point = resolve(&space, &self.bound)?;
self.bp.bootstrap_with_params(point).map_err(BindError::Compile)
}
```
- [ ] **Step 4: Wire the check into `SweepBinder::sweep`**
In `blueprint.rs:335-344`, insert the check after `let space`:
```rust
pub fn sweep<F>(self, run_one: F) -> Result<SweepFamily, BindError>
where
F: Fn(&[Scalar]) -> RunReport + Sync,
{
let space = self.bp.param_space();
check_param_namespace_injective(&space).map_err(BindError::Compile)?;
let ordered = resolve_axes(&space, &self.axes)?;
let grid = GridSpace::new(&space, ordered)
.expect("named layer pre-validates arity/kind/non-empty");
Ok(sweep(&grid, run_one))
}
```
- [ ] **Step 5: Replace both `_ => AmbiguousKnob` resolver arms with `unreachable!`**
In `resolve_axes` (the arm at `blueprint.rs:370`) and `resolve` (the arm at
`blueprint.rs:415`), replace:
```rust
_ => return Err(BindError::AmbiguousKnob(name.clone())), // b
```
with (both arms, identical):
```rust
_ => unreachable!("param_space() is injective — checked before resolve"),
```
- [ ] **Step 6: Swap the `CompileError` variant**
In `blueprint.rs:456-460`, remove the `IndistinguishableFanIn` variant and its doc
comment:
```rust
/// A fan-in node (>1 input) at interior index `node` has two input slots fed by
/// sources with identical signatures where at least one source carries an
/// unaliased param slot — the inputs differ in configuration but share a
/// rendered identity. Name the distinguishing param (e.g. fast/slow).
IndistinguishableFanIn { node: usize },
```
(The `DuplicateParamPath(String)` variant added in Task 1 Step 1 stays.)
- [ ] **Step 7: Remove the `BindError::AmbiguousKnob` variant**
In `blueprint.rs:287-288`, remove:
```rust
/// A name matches the exact name of more than one slot.
AmbiguousKnob(String),
```
- [ ] **Step 8: Remove the fan-in machinery**
Delete these four functions wholesale (with their doc comments):
`signature_of` (`blueprint.rs:524-555`), `check_fan_in_distinguishability`
(`557-569`), `check_composite_fan_in` (`571-608`), `leaf_has_param` (`610-615`).
- [ ] **Step 9: Drop `signature_of` from the `lib.rs` re-export**
In `crates/aura-engine/src/lib.rs:41`, remove the leading `signature_of, ` so the
line:
```rust
signature_of, BindError, Binder, BlueprintNode, CompileError, Composite, OutField, Role,
```
becomes:
```rust
BindError, Binder, BlueprintNode, CompileError, Composite, OutField, Role,
```
- [ ] **Step 10: Migrate test 1 (`unnamed_same_type_param_bearing_fan_in_is_rejected`)**
In `blueprint.rs:1258`, replace:
```rust
assert_eq!(bp.compile().err(), Some(CompileError::IndistinguishableFanIn { node: 2 }));
```
with:
```rust
assert_eq!(
bp.compile().err(),
Some(CompileError::DuplicateParamPath("sma_cross.sma.length".to_string()))
);
```
- [ ] **Step 11: Migrate test 2 (`indistinguishable_fan_in_rejected`)**
In `blueprint.rs:1527`, replace:
```rust
assert_eq!(bp.compile().err(), Some(CompileError::IndistinguishableFanIn { node: 2 }));
```
with:
```rust
assert_eq!(
bp.compile().err(),
Some(CompileError::DuplicateParamPath("ambig.sma.length".to_string()))
);
```
- [ ] **Step 12: Remove the obsolete tests + helper**
Delete `resolve_ambiguous_knob` (`blueprint.rs:1008-1019`),
`signature_of_is_node_name_plus_recursive_inputs` (`1269-1278`), and
`macd_like_signature_fixture` (`1280-1298` — its only user was the removed test;
verified by recon).
- [ ] **Step 13: Build the workspace (the coupling gate)**
Run: `cargo build --workspace`
Expected: success, 0 errors. (Every reference to the removed `IndistinguishableFanIn`
/ `AmbiguousKnob` / `signature_of` / `check_*` / `leaf_has_param` is gone in the
same task; the build sees no half-removed enum.)
- [ ] **Step 14: Run the three Task-1 tests to verify they now PASS (GREEN)**
`cargo test --workspace non_fan_in_duplicate_path_is_rejected` → PASS
`cargo test --workspace asymmetric_node_name_collision_compiles` → PASS
`cargo test --workspace by_name_bootstrap_of_unnamed_cross_reports_duplicate_path` → PASS
- [ ] **Step 15: Run the migrated tests to verify they PASS**
`cargo test --workspace unnamed_same_type_param_bearing_fan_in_is_rejected` → PASS
`cargo test --workspace indistinguishable_fan_in_rejected` → PASS
`cargo test --workspace interchangeable_fan_in_allowed` → PASS (unchanged; the
paramless interchangeable fan-in stays legal — no param path, no duplicate)
- [ ] **Step 16: Full workspace gate**
Run: `cargo test --workspace`
Expected: PASS, 0 failed. (CLI sweep/run goldens stay green — the sample legs are
named fast/slow, so its `param_space()` is injective and the bound paths are
unchanged.)
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: clean, 0 warnings. (`check_param_namespace_injective` is now wired into
three call sites — no dead_code.)
---
### Task 3: Ledger amendments (`docs/design/INDEX.md`)
Prose-only; reframe the contracts the cycle touches per spec §Error handling /
Contract amendments. No compile gate.
**Files:**
- Modify: `docs/design/INDEX.md` (C9 refinement 367-379; C12/C19 note 265-268; C23)
- [ ] **Step 1: Amend the C9 fan-in refinement (367-379)**
Reframe the "Refinement (fan-in distinguishability …)" paragraph onto the
fundamental statement: a blueprint compiles only if its `param_space()` name
projection is **injective**; the param-bearing indistinguishable fan-in is one
instance of a duplicated path. Record that `signature_of`, `leaf_has_param`, and
the fan-in-specific check are retired (cycle 0032); the error is now
`DuplicateParamPath` (path-carrying, not a node index); paramless interchangeable
same-name sources stay legal (no path, no duplicate). State that the old
signature-collision predicate's extra breadth — rejecting an **asymmetric
param/paramless** collision and a **role-vs-leg** collision, neither a path
duplicate — guarded **render identity**, dead since the renderer was retired in
0026; both are intentionally dropped, and a future node-/wiring-name
distinguishability check, if ever wanted, is decoupled from param-space injectivity.
- [ ] **Step 2: Refine the C12/C19 positional-identity note (265-268)**
At the sentence "Identity is **positional** (the slot, C23 'by index, not name');
… same-type siblings in one composite share a name, uniqueness is at the slot",
add the refinement: identity in the **flat graph** stays positional (C23, unchanged),
but the `param_space()` **name projection** — the authoring / by-name address space
— must be **injective** for a blueprint to compile (cycle 0032). Two layers:
positional wiring below, injective name address space above.
- [ ] **Step 3: Note the C23 amendment**
Record that the injectivity check is part of bootstrap-as-compilation; node names
stay non-load-bearing (dropped at lowering, flat graph wired by raw index). The check
reads the boundary name projection; it does not make names load-bearing in the
flat graph.
- [ ] **Step 4: Verify the ledger still builds as docs (sanity)**
Run: `cargo doc --workspace --no-deps 2>&1`
Expected: success (no doctest breakage; the ledger is prose, but confirm the
workspace doc build is unaffected).
@@ -1,280 +0,0 @@
# Unify RunReport JSON (serde) — Implementation Plan
> **Parent spec:** `docs/specs/0033-unify-runreport-json-serde.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Render `RunReport` stdout JSON through serde (the same encoder the run
registry uses on disk), retiring the hand-rolled `to_json` writer, so a record's
stdout bytes equal its `runs.jsonl` bytes — pinned by a test.
**Architecture:** `RunReport` already derives serde; the registry already
serializes via `serde_json::to_string`. This cycle swaps `RunReport::to_json`'s
body to delegate to `serde_json::to_string`, deletes the now-unused hand-rolled
`json_str` helper, promotes `serde_json` from a dev-dep to a normal dep of
`aura-engine`, and flips the two RunReport JSON goldens (engine canonical-form +
CLI sweep) to the serde shape. RED-first: Task 1 moves the goldens + adds the pin
test (RED against the current hand-rolled writer); Task 2 swaps the body + dep
(GREEN).
**Tech Stack:** `aura-engine` (`report.rs`, `Cargo.toml`), `aura-cli` integration
goldens (`tests/cli_run.rs`), serde / serde_json.
**Parse-the-bytes gate (self-review #9):** the profile declares no
`spec_validation` → the gate is a no-op. All inlined bodies are Rust (caught by
the `implement` compile gate) or TOML; the JSON goldens live inside Rust string
literals. No surface-language snippet needs a parser.
---
**Files this plan creates or modifies:**
- Modify: `crates/aura-engine/src/report.rs:6-8` — module-header doc: drop the
now-false "`to_json` is a hand-rolled writer" claim.
- Modify: `crates/aura-engine/src/report.rs:59-103``RunReport::to_json` doc
comment + body swapped to serde.
- Modify: `crates/aura-engine/src/report.rs:159-175` — delete the `json_str`
free function (unused after the swap).
- Test: `crates/aura-engine/src/report.rs:389-412` — canonical-form golden
updated to the serde shape.
- Test: `crates/aura-engine/src/report.rs` (tests module, after line 412) — new
`to_json_equals_serde_disk_shape` pin test.
- Modify: `crates/aura-engine/Cargo.toml:8-17``serde_json` dev-dep → normal
dep.
- Test: `crates/aura-cli/tests/cli_run.rs:213-216` — sweep golden `params`
substring updated to the serde array-of-pairs shape.
**Out of scope (deferred to cycle-close audit, per the spec's INDEX.md:729
deferral precedent):** `crates/aura-engine/src/graph_model.rs:30` stale
cross-reference to `RunReport::json_str` (a different file, #58/#28 territory —
not widened into here); `docs/design/INDEX.md:729` ledger cross-ref.
---
### Task 1: Move the goldens + add the pin test (RED)
All edits here are test/golden code only. `serde_json` is already a dev-dep, so
the new pin test compiles without the Task-2 dep promotion. Against the current
hand-rolled `to_json` (params-object / whole-int floats), all three assertions
FAIL — the intended RED state proving the serde shape genuinely differs.
**Files:**
- Test: `crates/aura-engine/src/report.rs:389-412` (canonical-form golden)
- Test: `crates/aura-engine/src/report.rs` (new pin test after line 412)
- Test: `crates/aura-cli/tests/cli_run.rs:213-216` (sweep golden)
- [ ] **Step 1: Update the engine canonical-form golden to the serde shape**
In `crates/aura-engine/src/report.rs`, in `to_json_renders_the_canonical_form`,
replace the expected raw-string literal (currently line 410) — only the
`r#"..."#` argument changes, the `RunReport { ... }` fixture above it is
untouched:
Replace:
```rust
r#"{"manifest":{"commit":"abc123","params":{"sma_fast":2,"sma_slow":4,"exposure_scale":1},"window":[1,6],"seed":0,"broker":"sim-optimal(pip_size=1.0)"},"metrics":{"total_pips":12,"max_drawdown":1,"exposure_sign_flips":1}}"#,
```
with:
```rust
r#"{"manifest":{"commit":"abc123","params":[["sma_fast",2.0],["sma_slow",4.0],["exposure_scale",1.0]],"window":[1,6],"seed":0,"broker":"sim-optimal(pip_size=1.0)"},"metrics":{"total_pips":12.0,"max_drawdown":1.0,"exposure_sign_flips":1}}"#,
```
- [ ] **Step 2: Add the `to_json_equals_serde_disk_shape` pin test**
In `crates/aura-engine/src/report.rs`, insert this test immediately after
`to_json_renders_the_canonical_form` (after its closing `}` at line 412, before
`runreport_serde_round_trips`). `serde_json` is called fully-qualified (the
existing `runreport_serde_round_trips` does the same — no new `use` needed):
```rust
#[test]
fn to_json_equals_serde_disk_shape() {
// the same RunReport value the canonical-form test builds.
let report = RunReport {
manifest: RunManifest {
commit: "abc123".to_string(),
params: vec![
("sma_fast".to_string(), 2.0),
("sma_slow".to_string(), 4.0),
("exposure_scale".to_string(), 1.0),
],
window: (Timestamp(1), Timestamp(6)),
seed: 0,
broker: "sim-optimal(pip_size=1.0)".to_string(),
},
metrics: RunMetrics { total_pips: 12.0, max_drawdown: 1.0, exposure_sign_flips: 1 },
};
// stdout (to_json) and disk (serde_json::to_string) are now the same bytes.
assert_eq!(report.to_json(), serde_json::to_string(&report).unwrap());
}
```
- [ ] **Step 3: Update the CLI sweep golden to the serde shape**
In `crates/aura-cli/tests/cli_run.rs`, in
`sweep_prints_four_json_lines_and_exits_zero`, replace the `params` assert
(lines 213-216):
Replace:
```rust
assert!(
first.contains("\"params\":{\"sma_cross.fast.length\":2,\"sma_cross.slow.length\":4,\"exposure.scale\":0.5}"),
"got: {stdout}"
);
```
with:
```rust
assert!(
first.contains("\"params\":[[\"sma_cross.fast.length\",2.0],[\"sma_cross.slow.length\",4.0],[\"exposure.scale\",0.5]]"),
"got: {stdout}"
);
```
- [ ] **Step 4: Verify the engine canonical-form golden FAILS (RED)**
Run: `cargo test -p aura-engine to_json_renders_the_canonical_form`
Expected: FAIL — `assert_eq!` left/right mismatch (current hand-rolled output has
`"params":{...}` and whole-int floats; the new expected has `"params":[[...]]`
and `.0` floats).
- [ ] **Step 5: Verify the pin test FAILS (RED)**
Run: `cargo test -p aura-engine to_json_equals_serde_disk_shape`
Expected: FAIL — `report.to_json()` (hand-rolled, object/int) `!=`
`serde_json::to_string(&report)` (array-of-pairs / `.0`).
- [ ] **Step 6: Verify the CLI sweep golden FAILS (RED)**
Run: `cargo test -p aura-cli sweep_prints_four_json_lines_and_exits_zero`
Expected: FAIL — the spawned binary still renders the params-object form, so
`first.contains("\"params\":[[...]]")` is false (panic with `got: {stdout}`
showing the object form).
---
### Task 2: Swap the body + promote the dep (GREEN)
Promote `serde_json` to a normal dep, swap `to_json`'s body to serde, delete the
now-unused `json_str`, and refresh the two doc comments. These MUST land together:
the body swap references `serde_json` in non-test code (needs the normal dep), and
deleting `json_str` while the hand-rolled body still calls it would not compile —
so the dep move, the body swap, and the `json_str` deletion are one atomic task
with a single build+clippy gate. This flips every Task-1 assertion to GREEN.
**Files:**
- Modify: `crates/aura-engine/Cargo.toml:8-17`
- Modify: `crates/aura-engine/src/report.rs:6-8` (module header)
- Modify: `crates/aura-engine/src/report.rs:59-103` (`to_json` doc + body)
- Modify: `crates/aura-engine/src/report.rs:159-175` (delete `json_str`)
- [ ] **Step 1: Promote `serde_json` to a normal dependency**
In `crates/aura-engine/Cargo.toml`, move `serde_json` from `[dev-dependencies]`
to `[dependencies]`. The two blocks (lines 8-17) become:
```toml
[dependencies]
aura-core = { path = "../aura-core" }
# serde is admitted under the amended C16 per-case dependency policy (INDEX.md):
# it gives the run-report types a typed (de)serialization path for the run
# registry (cycle 0029). serde's output is deterministic (C1-safe).
serde = { workspace = true }
# serde_json renders RunReport JSON for both the registry (disk) and stdout
# (RunReport::to_json) — one shape, no hand-rolled writer (amended C16).
serde_json = { workspace = true }
[dev-dependencies]
aura-std = { path = "../aura-std" }
```
- [ ] **Step 2: Swap `to_json`'s doc comment + body to serde**
In `crates/aura-engine/src/report.rs`, replace the whole `to_json` doc comment +
method (lines 59-103 — from the `/// Render canonical…` doc block through the
method's closing `}`) with:
```rust
/// Render the canonical, machine-readable JSON (C14) via serde — the same
/// encoder the run registry uses on disk, so a record's stdout shape and its
/// `runs.jsonl` shape are byte-identical. `params` is an array of
/// `[name, value]` pairs; finite `f64` fields carry a fractional part
/// (`2.0`). Consumers must parse values as numbers, never key off the token
/// shape.
pub fn to_json(&self) -> String {
serde_json::to_string(self).expect("a finite RunReport always serializes")
}
```
- [ ] **Step 3: Delete the now-unused `json_str` helper**
In `crates/aura-engine/src/report.rs`, delete the entire `json_str` free function
(lines 159-175, the `/// Minimal JSON string rendering…` doc through the fn's
closing `}`). Its only callers were inside the `to_json` body just replaced, so
nothing references it now (leaving it would trip `clippy -D warnings` as
`dead_code`). The independent `json_str` in `graph_model.rs` is untouched.
- [ ] **Step 4: Refresh the module-header doc**
In `crates/aura-engine/src/report.rs`, replace the module-header lines 6-8:
Replace:
```rust
//! and folds them here. Output is canonical JSON (C14): the schema is tiny,
//! closed, and flat. `to_json` is a hand-rolled writer; the report types also
//! derive serde (cycle 0029) for the run registry's typed read-path.
```
with:
```rust
//! and folds them here. Output is canonical JSON (C14): the schema is tiny,
//! closed, and flat. `to_json` renders via serde (the report types derive it,
//! cycle 0029) — the same encoder the run registry uses, so a record's stdout
//! and on-disk shapes coincide.
```
- [ ] **Step 5: Build the workspace (compile gate)**
Run: `cargo build --workspace`
Expected: success — `aura-engine` compiles with `serde_json` as a normal dep and
the serde-backed `to_json`; no `unused`/`dead_code` from the removed `json_str`.
- [ ] **Step 6: Verify the engine canonical-form golden PASSES (GREEN)**
Run: `cargo test -p aura-engine to_json_renders_the_canonical_form`
Expected: PASS — `to_json()` now emits the serde shape matching the updated
expected string.
- [ ] **Step 7: Verify the pin test PASSES (GREEN)**
Run: `cargo test -p aura-engine to_json_equals_serde_disk_shape`
Expected: PASS — `to_json()` and `serde_json::to_string(&report)` are now the
same bytes.
- [ ] **Step 8: Verify the CLI sweep golden + structural golden PASS (GREEN)**
Run: `cargo test -p aura-cli sweep_prints_four_json_lines_and_exits_zero`
Expected: PASS — the binary now renders params as the array-of-pairs serde form.
Run: `cargo test -p aura-cli run_prints_json_and_exits_zero`
Expected: PASS — the structural asserts (commit prefix, `window:[1,7]`,
`total_pips:` key, `exposure_sign_flips:1}}`) are shape-agnostic and stay green.
- [ ] **Step 9: Full workspace gate**
Run: `cargo test --workspace`
Expected: PASS — all tests green, including the determinism asserts
(`report_is_deterministic_end_to_end`, `run_sample_is_deterministic_and_non_trivial`,
`run_macd_compiles_from_nested_composite_and_is_deterministic`,
`sweep_report_is_deterministic`) which compare two serde outputs and stay green.
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: clean — no `dead_code` (json_str gone), no warnings.
-385
View File
@@ -1,385 +0,0 @@
# Blueprint constant bind — `PrimitiveBuilder::bind` — Implementation Plan
> **Parent spec:** `docs/specs/0034-blueprint-constant-bind.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Add `PrimitiveBuilder::bind(slot, value)` so a param-bearing node can fix
one declared param to a structural constant, removed from `param_space` entirely.
**Architecture:** One new method on `PrimitiveBuilder` (`aura-core/src/node.rs`)
shrinks the builder's declared param surface (`schema.params`) and wraps its build
closure to re-splice the captured constant at its original positional slot. The
construction layer (`collect_params`, `lower_items`, `Composite::param_space`,
`compile_with_params` in `aura-engine/src/blueprint.rs`) is **unchanged** — both
dock sites already key off `builder.params()`, so the shrink propagates for free.
**Tech Stack:** Rust workspace — `aura-core` (the method + unit tests),
`aura-std` (node-vehicle coverage: `Sma`, `LinComb`), `aura-engine` (composite
`param_space` coverage + the zero-change guard).
---
**Files this plan creates or modifies:**
- Modify: `crates/aura-core/src/node.rs:87-130` — append `bind` into the
`impl PrimitiveBuilder` block (after `label()`, before the closing `}`).
- Test: `crates/aura-core/src/node.rs:177-` — append unit tests into the existing
`#[cfg(test)] mod tests` (after the last test, before its closing `}`).
- Test: `crates/aura-std/src/sma.rs:63-158` — append two tests into the existing
`mod tests` (after `input_slot_is_named_series`).
- Test: `crates/aura-std/src/lincomb.rs:86-151` — append one test into the
existing `mod tests` (after `input_slots_are_named_term_index`).
- Test: `crates/aura-engine/src/blueprint.rs:754-` — append one test into the
existing `mod tests` (among the `param_space_*` tests, ~line 1883).
**Construction layer that must stay UNCHANGED (guard, not edit):**
`crates/aura-engine/src/blueprint.rs``collect_params` (`:546-569`),
`lower_items` (`:585-628`), `Composite::param_space` (`:179-183`),
`compile_with_params` (`:189-`).
**Parse-the-bytes gate (self-review rule 9):** the project profile declares no
`spec_validation` slot → the inlined-body parse gate is a **no-op** (no-parser
skip). All inlined bodies are Rust source; the `implement` compile gate
(`cargo build`/`cargo test`) is their validation.
---
### Task 1: `PrimitiveBuilder::bind` + aura-core unit tests (RED→GREEN)
**Files:**
- Modify: `crates/aura-core/src/node.rs:87-130` (`impl PrimitiveBuilder`)
- Test: `crates/aura-core/src/node.rs:177-` (`mod tests`)
- [ ] **Step 1: Write the failing tests**
Append into `crates/aura-core/src/node.rs`'s `#[cfg(test)] mod tests` block (after
the last existing test, before the module's closing `}`). These use a hand-built
`PrimitiveBuilder::new` (the in-crate `Bare` node and a local `Probe` node) — no
dependency on `aura-std`:
```rust
/// A node whose label echoes the param vector its constructor received — lets a
/// test read back the positional vector `.build()` reconstructed after binds.
struct Probe(Vec<Scalar>);
impl Node for Probe {
fn lookbacks(&self) -> Vec<usize> {
vec![]
}
fn eval(&mut self, _ctx: Ctx<'_>) -> Option<&[Scalar]> {
None
}
fn label(&self) -> String {
format!("{:?}", self.0)
}
}
fn probe3() -> PrimitiveBuilder {
// params [a: I64, b: I64, c: F64]; build stores the received slice
PrimitiveBuilder::new(
"Probe",
NodeSchema {
inputs: vec![],
output: vec![],
params: vec![
ParamSpec { name: "a".into(), kind: ScalarKind::I64 },
ParamSpec { name: "b".into(), kind: ScalarKind::I64 },
ParamSpec { name: "c".into(), kind: ScalarKind::F64 },
],
},
|p| Box::new(Probe(p.to_vec())),
)
}
#[test]
fn bind_removes_slot_and_reconstructs_positionally() {
// chained bind of b then a (reverse slot order); c stays open
let bound = probe3().bind("b", Scalar::I64(8)).bind("a", Scalar::I64(7));
assert_eq!(
bound.params().iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
["c"], // only c remains open
);
let built = bound.build(&[Scalar::F64(9.0)]); // inject c
assert_eq!(
built.label(),
format!("{:?}", vec![Scalar::I64(7), Scalar::I64(8), Scalar::F64(9.0)]),
);
// partial: bind only b; a and c stay open and keep their positions
let built2 = probe3()
.bind("b", Scalar::I64(8))
.build(&[Scalar::I64(7), Scalar::F64(9.0)]); // a, c injected in order
assert_eq!(
built2.label(),
format!("{:?}", vec![Scalar::I64(7), Scalar::I64(8), Scalar::F64(9.0)]),
);
}
#[test]
#[should_panic(expected = "no open param named")]
fn bind_unknown_slot_panics() {
let b = PrimitiveBuilder::new(
"Probe",
NodeSchema {
inputs: vec![],
output: vec![],
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
},
|_| Box::new(Bare),
);
let _ = b.bind("width", Scalar::I64(2)); // "width" is not a declared param
}
#[test]
#[should_panic(expected = "ambiguous")]
fn bind_ambiguous_slot_panics() {
let b = PrimitiveBuilder::new(
"Probe",
NodeSchema {
inputs: vec![],
output: vec![],
params: vec![
ParamSpec { name: "dup".into(), kind: ScalarKind::I64 },
ParamSpec { name: "dup".into(), kind: ScalarKind::I64 },
],
},
|_| Box::new(Bare),
);
let _ = b.bind("dup", Scalar::I64(1)); // two slots named "dup"
}
#[test]
#[should_panic(expected = "kind mismatch")]
fn bind_kind_mismatch_panics() {
let b = PrimitiveBuilder::new(
"Probe",
NodeSchema {
inputs: vec![],
output: vec![],
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
},
|_| Box::new(Bare),
);
let _ = b.bind("length", Scalar::F64(2.0)); // F64 value for an I64 slot
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `cargo test -p aura-core`
Expected: FAIL — compile error `no method named \`bind\` found for struct \`PrimitiveBuilder\``.
- [ ] **Step 3: Write the `bind` method**
Append into `crates/aura-core/src/node.rs` inside `impl PrimitiveBuilder` (after
`label()` at line 127-129, before the block's closing `}` at line 130):
```rust
/// Bind a declared param `slot` to a structural constant, removing it from the
/// node's param surface (`params()` / `schema().params` / the aggregated
/// `param_space`). Authoring-time: `slot` names the param (the by-name authoring
/// address space, C23/0032) and must match **exactly one** still-open param —
/// bind panics on zero matches (unknown / already-bound name) and on more than
/// one (an ambiguous duplicate-named slot); `value`'s kind must match the slot's
/// declared `ParamSpec.kind`. Returns `Self` so binds chain (`.bind(..).bind(..)`).
pub fn bind(mut self, slot: &str, value: Scalar) -> Self {
// Enforce the "exactly one" precondition rather than assume it: a node's own
// `schema.params` is NOT covered by `check_param_namespace_injective` (which
// guards only the aggregated path-qualified space, after `.bind()`), so
// per-node name uniqueness is not pinned elsewhere. Mirror the collect-all-
// then-reject posture of the `resolve` binder (blueprint.rs).
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,
[] => panic!("bind: no open param named `{slot}`"),
_ => panic!("bind: ambiguous — multiple open params named `{slot}`"),
};
assert_eq!(
value.kind(),
self.schema.params[pos].kind,
"bind: kind mismatch for param `{slot}`",
);
self.schema.params.remove(pos); // [param_space side] shrink the declared surface
let inner = self.build; // [value side] wrap the build closure
self.build = Box::new(move |open: &[Scalar]| {
let mut full = open.to_vec();
full.insert(pos, value); // re-splice at the slot's original position
inner(&full)
});
self
}
```
- [ ] **Step 4: Run the tests to verify they pass**
Run: `cargo test -p aura-core`
Expected: PASS — all `aura-core` tests green, including
`bind_removes_slot_and_reconstructs_positionally`, `bind_unknown_slot_panics`,
`bind_ambiguous_slot_panics`, `bind_kind_mismatch_panics`.
---
### Task 2: node-vehicle coverage (aura-std — `Sma`, `LinComb`)
These exercise the Task-1 method from a consumer crate (real nodes). No new
production code: the method exists after Task 1, so the tests are written to PASS
(their RED — "method missing" — was shown in Task 1). They confirm `bind` composes
with real builders and that positional reconstruction is observable through a
node's own `eval` / `label`.
**Files:**
- Test: `crates/aura-std/src/sma.rs:63-158` (`mod tests`)
- Test: `crates/aura-std/src/lincomb.rs:86-151` (`mod tests`)
- [ ] **Step 1: Write the `Sma` coverage tests**
Append into `crates/aura-std/src/sma.rs`'s `mod tests` (after
`input_slot_is_named_series` at `:154-157`, before the module's closing `}` at
`:158`). `Scalar` is already in scope via `use super::*`:
```rust
#[test]
fn bind_removes_slot_from_param_space() {
// a bound param-bearing node reports an empty param surface — parity with the
// SimBroker precedent (nodes_declare_expected_params, this file)
let sma2 = Sma::builder().named("bias").bind("length", Scalar::I64(2));
assert!(sma2.schema().params.is_empty());
// contrast: the length-generic SMA keeps `length` open
assert_eq!(Sma::builder().named("bias").params().len(), 1);
}
#[test]
fn bound_node_builds_with_injected_value() {
// built with an empty open slice, the bound builder yields SMA(2)
let node = Sma::builder().bind("length", Scalar::I64(2)).build(&[]);
assert_eq!(node.label(), "SMA(2)");
}
```
- [ ] **Step 2: Write the `LinComb` chained-bind test**
Append into `crates/aura-std/src/lincomb.rs`'s `mod tests` (after
`input_slots_are_named_term_index` at `:146-150`, before the closing `}` at
`:151`). `Scalar`, `ScalarKind`, `Ctx` are in scope via `use super::*`;
`AnyColumn`, `Timestamp` via the test-mod `use aura_core::{AnyColumn, Timestamp}`:
```rust
#[test]
fn chained_bind_reconstructs_positional_vector() {
// bind BOTH weights, in reverse slot order, to DISTINCT values; build empty.
let builder = LinComb::builder(2)
.bind("weights[1]", Scalar::F64(2.0))
.bind("weights[0]", Scalar::F64(0.5));
assert!(builder.params().is_empty());
let mut lc = builder.build(&[]);
let mut inputs = vec![
AnyColumn::with_capacity(ScalarKind::F64, 1),
AnyColumn::with_capacity(ScalarKind::F64, 1),
];
inputs[0].push(Scalar::F64(10.0)).unwrap();
inputs[1].push(Scalar::F64(3.0)).unwrap();
// 0.5*10 + 2.0*3 = 11.0 — holds ONLY if each weight landed in its right slot
// (a swap would give 2.0*10 + 0.5*3 = 21.5)
assert_eq!(lc.eval(Ctx::new(&inputs, Timestamp(0))), Some([Scalar::F64(11.0)].as_slice()));
// partial: bind weights[0], leave weights[1] open → inject it at build
let partial = LinComb::builder(2).bind("weights[0]", Scalar::F64(0.5));
assert_eq!(
partial.params().iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
["weights[1]"],
);
let mut lc2 = partial.build(&[Scalar::F64(2.0)]); // weights[1] = 2.0 injected
let mut inputs2 = vec![
AnyColumn::with_capacity(ScalarKind::F64, 1),
AnyColumn::with_capacity(ScalarKind::F64, 1),
];
inputs2[0].push(Scalar::F64(10.0)).unwrap();
inputs2[1].push(Scalar::F64(3.0)).unwrap();
assert_eq!(lc2.eval(Ctx::new(&inputs2, Timestamp(0))), Some([Scalar::F64(11.0)].as_slice()));
}
```
- [ ] **Step 3: Run the aura-std tests to verify they pass**
Run: `cargo test -p aura-std`
Expected: PASS — all `aura-std` tests green, including
`bind_removes_slot_from_param_space`, `bound_node_builds_with_injected_value`,
`chained_bind_reconstructs_positional_vector`.
---
### Task 3: composite `param_space` coverage + zero-change guard + workspace gate
**Files:**
- Test: `crates/aura-engine/src/blueprint.rs:754-` (`mod tests`)
- [ ] **Step 1: Write the composite `param_space` test**
Append into `crates/aura-engine/src/blueprint.rs`'s `#[cfg(test)] mod tests`, among
the `param_space_*` tests (after
`param_space_is_flat_path_qualified_and_slot_disambiguated` ends at `:1883`).
`Composite`, `Scalar`, `ScalarKind` are in scope via `use super::*`; bring the
nodes in per-test as the neighbours do:
```rust
#[test]
fn param_space_reflects_only_open_knobs() {
use aura_std::{Exposure, Sma};
// "sma2_entry": Sma "bias" with length bound to 2 (a structural constant)
// plus Exposure "exp" whose `scale` stays open. The bound knob must be
// absent from param_space; only the open one remains.
let strat = Composite::new(
"sma2_entry",
vec![
Sma::builder().named("bias").bind("length", Scalar::I64(2)).into(),
Exposure::builder().named("exp").into(),
],
vec![], // edges — irrelevant to param_space()
vec![], // input_roles
vec![], // output
);
let names: Vec<&str> = strat.param_space().iter().map(|p| p.name.as_str()).collect();
// collect_params runs with an empty prefix, so a top-level leaf is qualified
// by its OWN node segment, not the root composite's name → "exp.scale".
// `bias.length` is GONE (bound), not present-but-fixed.
assert_eq!(names, ["exp.scale"]);
}
```
- [ ] **Step 2: Run the aura-engine test to verify it passes**
Run: `cargo test -p aura-engine param_space_reflects_only_open_knobs`
Expected: PASS (`test result: ok. 1 passed`). The filter substring
`param_space_reflects_only_open_knobs` is the exact new test name — it resolves to
this one test.
- [ ] **Step 3: Assert the construction layer is unchanged (zero-change guard)**
Run: `git diff -- crates/aura-engine/src/blueprint.rs`
Expected: every added/changed line is inside the `#[cfg(test)] mod tests` block
(after line ~1883). Confirm `collect_params` (`:546-569`), `lower_items`
(`:585-628`), `Composite::param_space` (`:179-183`), and `compile_with_params`
(`:189-`) bodies are byte-for-byte unchanged. Any production-line change here is a
plan violation — the spec's load-bearing claim is that these four are untouched.
- [ ] **Step 4: Full workspace gate (build, lint, test)**
Run: `cargo build --workspace`
Expected: PASS (`Finished`), no errors.
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: PASS, no warnings.
Run: `cargo test --workspace`
Expected: PASS — the whole suite green, including the new `bind_*`,
`bound_node_builds_with_injected_value`, `chained_bind_reconstructs_positional_vector`,
and `param_space_reflects_only_open_knobs` tests, with no regression in the
existing `param_space_*` / `nodes_declare_expected_params` / arity tests.
-475
View File
@@ -1,475 +0,0 @@
# Node instance name in the graph model — Implementation Plan
> **Parent spec:** `docs/specs/0035-node-name-in-graph-model.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Surface a node's explicit instance name in the `aura graph` viewer as
`fast: SMA[length]` — a conditional `"name"` field in the JSON graph model
(engine half) and a `cellLabel` `name: ` prefix in graph-viewer.js (viewer half),
for explicitly `.named()` nodes only.
**Architecture:** The name crosses two surfaces meeting at the JSON model. Engine:
a new raw `PrimitiveBuilder::instance_name() -> Option<&str>` (the explicit name,
default *not* resolved) feeds a conditional leading `"name"` field in
`prim_record`; both golden twins are re-captured. Viewer: `adaptNodes` carries
`name` onto the per-node object, the `genDot` leaf-emit forwards it into
`cellLabel`, and `cellLabel` prepends a `name: ` declaration prefix when present.
The name is render/model-only (dropped at lowering, C23); the model stays
deterministic (C14).
**Tech Stack:** `crates/aura-core` (PrimitiveBuilder), `crates/aura-engine`
(graph_model.rs emitter + goldens), `crates/aura-cli` (graph-viewer.js +
fixture + headless node guard).
---
**Files this plan creates or modifies:**
- Modify: `crates/aura-core/src/node.rs` — add `instance_name()` accessor after `node_name()` (~line 110); re-ground `named()` doc (~line 98); add accessor unit test in `mod tests` (after ~line 290)
- Modify: `crates/aura-engine/src/graph_model.rs``prim_record` conditional leading `"name"` (lines 72-83); new `prim_record` unit test (after line 373); re-capture inline `model_golden` (line 429)
- Modify: `crates/aura-cli/tests/fixtures/sample-model.json:1``+"name"` on the two SMA legs
- Modify: `crates/aura-cli/assets/graph-viewer.js``adaptNodes` (line 32), `genDot` leaf-emit (line 108), `cellLabel` head (line 74)
- Create: `crates/aura-cli/tests/viewer_name_prefix.mjs` — headless render guard (named leaf → prefix; unnamed leaf → bare)
- Create: `crates/aura-cli/tests/viewer_name_prefix.rs``.rs` wrapper shelling `node` (mirrors `viewer_dot.rs`)
---
### Task 1: aura-core — `instance_name()` accessor + re-grounded `named()` doc
**Files:**
- Modify: `crates/aura-core/src/node.rs` (`named()` doc ~98; new accessor after `node_name()` ~110; test in `mod tests` after ~290)
- [ ] **Step 1: Write the failing accessor test**
In `crates/aura-core/src/node.rs`, inside `mod tests`, directly after the
`node_name_defaults_to_lowercased_type_label` test (which ends at ~line 290),
add:
```rust
#[test]
fn instance_name_is_some_only_when_explicitly_named() {
// unnamed → None (instance_name() does NOT resolve node_name()'s
// lowercased-type default — that is exactly the value the graph model
// must not surface)
let unnamed = PrimitiveBuilder::new(
"SimBroker",
NodeSchema::default(),
|_| panic!("not built in this test"),
);
assert_eq!(unnamed.instance_name(), None);
// explicitly named → Some(the raw name)
let named = PrimitiveBuilder::new(
"SMA",
NodeSchema::default(),
|_| panic!("not built in this test"),
)
.named("fast");
assert_eq!(named.instance_name(), Some("fast"));
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `cargo test -p aura-core instance_name_is_some_only_when_explicitly_named`
Expected: FAIL — compile error `no method named `instance_name` found for struct `PrimitiveBuilder``.
- [ ] **Step 3: Re-ground the `named()` doc (code unchanged)**
In `crates/aura-core/src/node.rs`, replace the single-line doc above `named()`:
```rust
/// Set this node instance's name. Must be non-empty.
```
with:
```rust
/// Set this node instance's explicit name. Must be non-empty: the name forms
/// a knob-address segment (`<composite>.<name>.<param>`, via `node_name()` in
/// `collect_params`) and is the `Some`/`None` switch for the graph-model
/// `"name"` field and the viewer prefix. An empty name would yield a broken
/// address segment (`sma_cross..length`) and a degenerate `Some("")` —
/// serialised as `"name":""` yet rendering no prefix, i.e. two encodings for
/// "no visible name". The `debug_assert` guards that at source.
```
Leave the `named()` body (the `debug_assert!` + the two statements) unchanged.
- [ ] **Step 4: Add the `instance_name()` accessor**
In `crates/aura-core/src/node.rs`, immediately after `node_name()`'s closing
brace (the method ending `.unwrap_or_else(|| self.name.to_ascii_lowercase())` +
`}` at ~line 110), add:
```rust
/// The explicit instance name if one was set via `named()`, else `None`.
/// Unlike `node_name()`, this does not resolve the default — callers that must
/// distinguish "explicitly named" from "defaulted" (the graph model) read this.
pub fn instance_name(&self) -> Option<&str> {
self.instance_name.as_deref()
}
```
- [ ] **Step 5: Run the test to verify it passes**
Run: `cargo test -p aura-core instance_name_is_some_only_when_explicitly_named`
Expected: PASS (`test result: ok. 1 passed`).
---
### Task 2: aura-engine — `prim_record` conditional `"name"` field
**Files:**
- Modify: `crates/aura-engine/src/graph_model.rs` (`prim_record` 72-83; new unit test after line 373)
- [ ] **Step 1: Write the failing `prim_record` test**
In `crates/aura-engine/src/graph_model.rs`, directly after the
`primitive_record_carries_type_role_ins_outs` test (which ends at line 373),
add:
```rust
#[test]
fn prim_record_emits_name_for_explicit_only() {
// explicitly named → a leading "name" field, ahead of "type"
let named = prim_record(&sub_builder().named("fast"));
assert!(
named.starts_with(r#"{"prim":{"name":"fast","type":"Sub""#),
"{named}"
);
// unnamed → no "name" field at all
let unnamed = prim_record(&sub_builder());
assert!(!unnamed.contains(r#""name""#), "{unnamed}");
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `cargo test -p aura-engine prim_record_emits_name_for_explicit_only`
Expected: FAIL — the `named` record has no `"name"` field, so `starts_with(...)`
is false (panics with the printed record `{"prim":{"type":"Sub",...}}`).
- [ ] **Step 3: Add the conditional `"name"` fragment to `prim_record`**
In `crates/aura-engine/src/graph_model.rs`, replace the body of `prim_record`
(lines 72-83):
```rust
fn prim_record(b: &aura_core::PrimitiveBuilder) -> String {
let s = b.schema();
let role = if s.output.is_empty() { "sink" } else { "node" };
let params = join(s.params.iter().map(|p| named_kind(&p.name, p.kind)));
let ins = join(s.inputs.iter().map(port_json));
let outs = join(s.output.iter().map(|f| named_kind(f.name, f.kind)));
format!(
r#"{{"prim":{{"type":{},"role":{},"params":[{params}],"ins":[{ins}],"outs":[{outs}]}}}}"#,
json_str(&b.label()),
json_str(role),
)
}
```
with:
```rust
fn prim_record(b: &aura_core::PrimitiveBuilder) -> String {
let s = b.schema();
let role = if s.output.is_empty() { "sink" } else { "node" };
let params = join(s.params.iter().map(|p| named_kind(&p.name, p.kind)));
let ins = join(s.inputs.iter().map(port_json));
let outs = join(s.output.iter().map(|f| named_kind(f.name, f.kind)));
// explicit instance name only — an unnamed node carries no "name" field
// (node_name()'s lowercased-type default would be a redundant type-duplicate)
let name = match b.instance_name() {
Some(n) => format!(r#""name":{},"#, json_str(n)),
None => String::new(),
};
format!(
r#"{{"prim":{{{name}"type":{},"role":{},"params":[{params}],"ins":[{ins}],"outs":[{outs}]}}}}"#,
json_str(&b.label()),
json_str(role),
)
}
```
- [ ] **Step 4: Run the unit test to verify it passes**
Run: `cargo test -p aura-engine prim_record_emits_name_for_explicit_only`
Expected: PASS (`test result: ok. 1 passed`).
> **Note:** `model_golden` (inline) and the `sample-model.json` byte fixture are
> now stale — `model_to_json` emits the new `"name"` field, so the content-pin
> caught the intended change. Task 3 re-captures both. Do NOT run the full
> `aura-engine` suite at this step (it would show `model_golden` red, which is
> expected and resolved next).
---
### Task 3: Re-capture both golden twins
**Files:**
- Modify: `crates/aura-engine/src/graph_model.rs` (inline `model_golden`, line 429)
- Modify: `crates/aura-cli/tests/fixtures/sample-model.json:1`
The two SMA legs inside `sma_cross` are the only `.named()` nodes; each gains a
leading `"name"`. Both target substrings are unique (verified: 1 occurrence each)
and contiguous on a single line, so the replacements are exact.
- [ ] **Step 1: Re-capture the inline `model_golden`**
In `crates/aura-engine/src/graph_model.rs` (the `expected` string at line 429),
make these two exact replacements:
Replace `"0":{"prim":{"type":"SMA"` with `"0":{"prim":{"name":"fast","type":"SMA"`
Replace `"1":{"prim":{"type":"SMA"` with `"1":{"prim":{"name":"slow","type":"SMA"`
(Every other byte of the golden is unchanged: the root nodes — `comp:sma_cross`,
`Exposure`, `Recorder`, `src_price` — and the `Sub` leg keyed `"2"` are
byte-identical.)
- [ ] **Step 2: Re-capture the `sample-model.json` fixture**
In `crates/aura-cli/tests/fixtures/sample-model.json` (single line), make the
same two exact replacements:
Replace `"0":{"prim":{"type":"SMA"` with `"0":{"prim":{"name":"fast","type":"SMA"`
Replace `"1":{"prim":{"type":"SMA"` with `"1":{"prim":{"name":"slow","type":"SMA"`
(All other records — `Exposure`, `SimBroker`, the two `Recorder` sinks,
`src_price`, the `Sub` leg, every edge, the re-export name `"cross"` — are
byte-identical.)
- [ ] **Step 3: Verify the inline golden + determinism are green**
Run: `cargo test -p aura-engine model_golden`
Expected: PASS (`test result: ok. 1 passed`).
- [ ] **Step 4: Verify the fixture consumer is still green**
Run: `cargo test -p aura-cli --test viewer_dot`
Expected: PASS — `viewer_emits_valid_dot_node_identifiers` asserts DOT-identifier
validity, not model bytes, so the added `"name"` field does not perturb it
(`test result: ok. 1 passed`).
---
### Task 4: Viewer render guard (RED)
**Files:**
- Create: `crates/aura-cli/tests/viewer_name_prefix.mjs`
- Create: `crates/aura-cli/tests/viewer_name_prefix.rs`
- [ ] **Step 1: Write the headless render guard**
Create `crates/aura-cli/tests/viewer_name_prefix.mjs`:
```js
// Headless render guard for the `aura graph` viewer (graph-viewer.js).
//
// Property protected: a leaf primitive built with an explicit instance name
// renders a `name: ` declaration prefix immediately ahead of the bold type head
// (`fast: SMA…`); an UNNAMED leaf renders the bare type head, no prefix.
//
// It loads the *real* viewer module (so a fix or a regression there is observed
// here) and drives the exported pure `genDot` over a minimal root model with one
// named leaf (SMA, name "fast") and one unnamed leaf (Sub) — exercising the
// genDot leaf-emit + cellLabel prefix path directly, no composite expand needed.
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const require = createRequire(import.meta.url);
const here = dirname(fileURLToPath(import.meta.url));
const model = {
root: {
nodes: {
"0": { prim: { name: "fast", type: "SMA", role: "node", params: [["length", "i64"]], ins: [["f64", "any", "series"]], outs: [["value", "f64"]] } },
"1": { prim: { type: "Sub", role: "node", params: [], ins: [["f64", "any", "lhs"], ["f64", "any", "rhs"]], outs: [["value", "f64"]] } },
},
edges: [["0.o0", "1.i0"]],
},
composites: {},
};
// genDot closes over module-scoped ROOT/COMP derived from window.AURA_MODEL at
// load. Stub the global BEFORE requiring the viewer (mirrors the browser).
global.window = { AURA_MODEL: model };
const { normalizeModel, genDot } = require(join(here, "..", "assets", "graph-viewer.js"));
const ROOT = normalizeModel(model).root;
const { dot } = genDot(ROOT, "root", new Set(), false);
// The named leaf renders `fast: ` (colon + one trailing space, no leading space)
// contiguously ahead of the bold SMA type head.
const NAMED = '<font color="#cdd6f4">fast</font><font color="#9399b2">: </font><font color="#f5f5f5"><b>SMA</b></font>';
if (!dot.includes(NAMED)) {
console.error(
"named leaf missing the `fast: ` prefix before the SMA head.\n--- dot ---\n" + dot
);
process.exit(1);
}
// Exactly ONE name prefix in the whole graph: the named SMA leg. The `: `
// separator font (#9399b2 colon-space) is unique to the prefix at showTypes=false
// (sig uses `[`/`]`/`, `, never `: `), so it appears once — the unnamed Sub has
// no prefix.
const SEP = '<font color="#9399b2">: </font>';
const sepCount = dot.split(SEP).length - 1;
if (sepCount !== 1) {
console.error(
`expected exactly 1 name prefix (the named leaf), found ${sepCount} — ` +
"an unnamed leaf must render no prefix.\n--- dot ---\n" + dot
);
process.exit(1);
}
console.log("OK — named leaf renders `fast: ` prefix; unnamed leaf stays bare.");
process.exit(0);
```
- [ ] **Step 2: Write the `.rs` wrapper**
Create `crates/aura-cli/tests/viewer_name_prefix.rs`:
```rust
//! Integration guard: the `aura graph` viewer renders an explicit instance name
//! as a `name: ` prefix ahead of the type head, and leaves an unnamed leaf bare.
//!
//! Like `viewer_dot.rs`, the property lives in JavaScript (`cellLabel` in
//! assets/graph-viewer.js), so this shells out to `node` running the headless
//! guard `tests/viewer_name_prefix.mjs`, which loads the real viewer module and
//! drives the exported `genDot` over a minimal model (a named SMA + an unnamed
//! Sub).
//!
//! `node` is REQUIRED: if it is not on PATH this test FAILS (a skipped guard is
//! not a guard).
use std::path::PathBuf;
use std::process::Command;
#[test]
fn viewer_renders_name_prefix_for_named_leaf_only() {
let script = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("viewer_name_prefix.mjs");
assert!(
script.exists(),
"guard script missing at {}",
script.display()
);
let out = match Command::new("node").arg(&script).output() {
Ok(out) => out,
Err(e) => panic!(
"node is required for the viewer name-prefix guard but could not be run \
({e}); install Node.js or ensure `node` is on PATH"
),
};
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
out.status.success(),
"viewer name-prefix guard failed (exit {:?}).\n--- node stdout ---\n{stdout}\n--- node stderr ---\n{stderr}",
out.status.code()
);
}
```
- [ ] **Step 3: Run the guard to verify it fails**
Run: `cargo test -p aura-cli --test viewer_name_prefix`
Expected: FAIL — before the viewer change `adaptNodes` drops `name`, so
`cellLabel` renders the bare SMA head with no prefix; the guard prints
"named leaf missing the `fast: ` prefix before the SMA head" and exits 1, and the
`.rs` test fails with the captured node stderr.
---
### Task 5: Viewer implementation (GREEN)
**Files:**
- Modify: `crates/aura-cli/assets/graph-viewer.js` (`adaptNodes` line 32, `genDot` leaf-emit line 108, `cellLabel` head line 74)
- [ ] **Step 1: `adaptNodes` carries `name` onto the per-node object**
In `crates/aura-cli/assets/graph-viewer.js`, replace line 32:
```js
out[key] = { prim: { type: p.type, role: p.role, params: p.params, ins: adaptIns(p.ins), outs: p.outs } };
```
with:
```js
out[key] = { prim: { name: p.name, type: p.type, role: p.role, params: p.params, ins: adaptIns(p.ins), outs: p.outs } };
```
- [ ] **Step 2: `genDot` leaf-emit forwards `name` into `cellLabel`**
In `crates/aura-cli/assets/graph-viewer.js`, replace line 108 (the leaf-emit
`cellLabel` call — NOT the composite-emit call at line 120, which keeps
`type: cname` and gets no name):
```js
block += `${cid} [label=${cellLabel(cid, { type: s.type, params: s.params, ins: s.ins, outs: s.outs, bodyBg: bodyBgFor(s.role), composite: false }, showTypes)}];\n`;
```
with:
```js
block += `${cid} [label=${cellLabel(cid, { name: s.name, type: s.type, params: s.params, ins: s.ins, outs: s.outs, bodyBg: bodyBgFor(s.role), composite: false }, showTypes)}];\n`;
```
- [ ] **Step 3: `cellLabel` prepends the `name: ` prefix**
In `crates/aura-cli/assets/graph-viewer.js`, replace line 74:
```js
const name = `<font color="#f5f5f5"><b>${o.type}</b></font>${sig}`;
```
with:
```js
const prefix = o.name
? `<font color="#cdd6f4">${o.name}</font><font color="#9399b2">: </font>`
: "";
const name = `${prefix}<font color="#f5f5f5"><b>${o.type}</b></font>${sig}`;
```
- [ ] **Step 4: Run the render guard to verify it passes**
Run: `cargo test -p aura-cli --test viewer_name_prefix`
Expected: PASS — the named leaf now renders the `fast: ` prefix ahead of the SMA
head, the unnamed Sub stays bare (`test result: ok. 1 passed`).
---
### Task 6: Workspace verification gate
**Files:** none (verification only)
- [ ] **Step 1: Build the workspace**
Run: `cargo build --workspace`
Expected: `Finished` with 0 errors.
- [ ] **Step 2: Run the full test suite**
Run: `cargo test --workspace`
Expected: all test binaries report `test result: ok` (0 failed). In particular
`instance_name_is_some_only_when_explicitly_named`, `prim_record_emits_name_for_explicit_only`,
`model_golden`, `viewer_emits_valid_dot_node_identifiers`, and
`viewer_renders_name_prefix_for_named_leaf_only` all pass.
- [ ] **Step 3: Clippy clean**
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: `Finished` with 0 warnings.
@@ -1,497 +0,0 @@
# Sample Showcase Blueprint — Implementation Plan
> **Parent spec:** `docs/specs/0036-sample-showcase-blueprint.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Enrich the `aura graph` / `aura sweep` sample blueprint into a "trend +
momentum, weighted blend" strategy that demonstrates multiply-nested composites, a
multi-param node inside a composite, a multi-output (MACD-like) node, and `bind()`.
**Architecture:** A new `signals` composite (root → `signals` → {`trend` =
`sma_cross`, `momentum` = `macd`}, plus a `LinComb(3)` `blend`) replaces the bare
`sma_cross` at node 0 of `sample_blueprint_with_sinks`. Pure authoring over
already-shipped primitives — no engine/viewer change. The sweep re-paths its axes
to the new param-space; a longer warm-up stream lets the enriched signal run; the
render fixture is re-captured; a new headless guard protects depth-2 nesting.
**Tech Stack:** `crates/aura-cli/src/main.rs` (the sample + sweep + their in-crate
tests), `crates/aura-cli/tests/fixtures/sample-model.json` (render golden), a new
`crates/aura-cli/tests/` headless guard pair. Reuses `aura_std::LinComb` + the
existing `sma_cross`/`macd` builders. No change to `aura-engine` or
`graph-viewer.js`.
---
**Files this plan creates or modifies:**
- Modify: `crates/aura-cli/src/main.rs:17` — add `LinComb` to the `aura_std` import.
- Modify: `crates/aura-cli/src/main.rs` — add `fn signals(name: &str) -> Composite`
and `fn showcase_prices() -> Vec<(Timestamp, Scalar)>`; swap node 0 of
`sample_blueprint_with_sinks` (`:173`); re-path the `sweep_family` axes (`:228-230`)
and swap its closure stream (`:236`).
- Modify: `crates/aura-cli/src/main.rs:503-540` — re-path the two in-crate tests
(`sample_blueprint_with_sinks_bootstraps_runs_and_drains`,
`sweep_report_renders_four_points_in_odometer_order`).
- Modify: `crates/aura-cli/tests/fixtures/sample-model.json` — re-captured enriched model.
- Create: `crates/aura-cli/tests/viewer_nested_depth.mjs` — headless depth-2 DOT-id guard.
- Create: `crates/aura-cli/tests/viewer_nested_depth.rs``.rs` wrapper that runs the `.mjs` under `node`.
**Untouched (must stay green):** `run_sample`/`sample_harness`, `run_macd`/`macd()`/
`macd_point`, `crates/aura-engine/src/graph_model.rs` `model_golden` (own minimal
`sample_root()`), `crates/aura-cli/assets/graph-viewer.js`, `crates/aura-cli/src/render.rs`.
**Parse-the-bytes gate (planner self-review item 9):** the project declares no
spec-validation parsers in its CLAUDE.md project facts → documented no-op. The
inlined Rust is source-language (the `implement` compile gate catches it, not the
parse gate); the `.mjs` is JS with no declared parser; the fixture JSON is
machine-generated (Task 3), not hand-inlined. Gate fires empty.
---
### Task 1: Enriched blueprint source
**Files:**
- Modify: `crates/aura-cli/src/main.rs:17` (import), plus new fns + the
`sample_blueprint_with_sinks` node-0 swap (`:173`) + `sweep_family` axes (`:228-230`)
and stream (`:236`).
- [ ] **Step 1: Add `LinComb` to the `aura_std` import**
Replace `crates/aura-cli/src/main.rs:17`:
```rust
use aura_std::{Ema, Exposure, Recorder, SimBroker, Sma, Sub};
```
with:
```rust
use aura_std::{Ema, Exposure, LinComb, Recorder, SimBroker, Sma, Sub};
```
- [ ] **Step 2: Add the `signals` composite builder**
Insert this fn immediately after `fn sma_cross(...)` (which ends at `:155`), before
`fn sample_blueprint_with_sinks`:
```rust
/// The blended signal: a trend leg (SMA-cross) and a momentum leg (MACD), combined
/// by a weighted sum. A multiply-nested composite (root → signals → {trend,
/// momentum}); the blend is a multi-param node living inside it, with one weight
/// bound as a structural constant (so it drops out of the sweepable surface).
fn signals(name: &str) -> Composite {
Composite::new(
name,
vec![
BlueprintNode::Composite(sma_cross("trend")), // 0 trend leg (one f64 "cross")
BlueprintNode::Composite(macd("momentum")), // 1 momentum leg (3 outputs)
// 2 blend: Σ wᵢ·termᵢ over [trend.cross, momentum.histogram, momentum.signal].
// weights[2] is bound (a fixed signal-line weight) → removed from param_space;
// weights[0]/[1] stay tunable. `.named("blend")` makes the path signals.blend.*
// (and renders the `blend:` prefix).
LinComb::builder(3)
.named("blend")
.bind("weights[2]", Scalar::F64(0.5))
.into(),
],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 }, // trend.cross → blend.term[0]
Edge { from: 1, to: 2, slot: 1, from_field: 2 }, // momentum.histogram → blend.term[1]
Edge { from: 1, to: 2, slot: 2, from_field: 1 }, // momentum.signal → blend.term[2]
],
vec![Role {
name: "price".into(),
targets: vec![
Target { node: 0, slot: 0 }, // price → trend role 0
Target { node: 1, slot: 0 }, // price → momentum role 0
],
source: None,
}],
vec![OutField { node: 2, field: 0, name: "signal".into() }],
)
}
```
- [ ] **Step 3: Add the `showcase_prices` warm-up stream**
Insert this fn next to `signals` (anywhere in the non-test region; suggested: right
after `synthetic_prices`, which ends at `:36`):
```rust
/// A warm-up-adequate synthetic stream for the enriched sample/sweep: ~18 ticks
/// rising, falling, then rising again so the trend SMA spread and the MACD
/// EMA-of-EMA histogram both warm up and flip sign. It shares the proven warm-up
/// profile of `macd_prices` (the enriched sample embeds the same `macd` composite,
/// so it needs the same warm-up length); the flat `run_sample` keeps the shorter
/// `synthetic_prices`. Deterministic and fixed (C1).
fn showcase_prices() -> Vec<(Timestamp, Scalar)> {
[
1.0000_f64, 1.0008, 1.0021, 1.0039, 1.0062, 1.0090, 1.0083, 1.0061, 1.0034,
1.0012, 0.9998, 1.0006, 1.0024, 1.0047, 1.0069, 1.0086, 1.0097, 1.0092,
]
.iter()
.enumerate()
.map(|(i, &p)| (Timestamp(i as i64 + 1), Scalar::F64(p)))
.collect()
}
```
- [ ] **Step 4: Swap node 0 of `sample_blueprint_with_sinks`**
Replace `crates/aura-cli/src/main.rs:173`:
```rust
BlueprintNode::Composite(sma_cross("sma_cross")),
```
with:
```rust
BlueprintNode::Composite(signals("signals")),
```
(The other four root nodes, the root edges `:179-184`, and the `price` role `:185-192`
are unchanged: `signals` re-exports a single f64 `"signal"`, the same one-output shape
`sma_cross` re-exported as `"cross"`, so `signals.out0 → Exposure` keeps working.)
- [ ] **Step 5: Re-path the `sweep_family` axes**
Replace the axis chain at `crates/aura-cli/src/main.rs:228-230`:
```rust
bp.axis("sma_cross.fast.length", [2, 3])
.axis("sma_cross.slow.length", [4, 5])
.axis("exposure.scale", [0.5])
```
with the eight free-param axes (two real on the trend lengths, the rest singletons →
still 4 points; `signals.blend.weights[2]` is bound, so it is **not** an axis):
```rust
bp.axis("signals.trend.fast.length", [2, 3])
.axis("signals.trend.slow.length", [4, 5])
.axis("signals.momentum.fast.length", [2])
.axis("signals.momentum.slow.length", [4])
.axis("signals.momentum.signal.length", [3])
.axis("signals.blend.weights[0]", [1.0])
.axis("signals.blend.weights[1]", [1.0])
.axis("exposure.scale", [0.5])
```
- [ ] **Step 6: Swap the sweep closure's stream to `showcase_prices`**
In the same `sweep_family` closure, replace the single call at
`crates/aura-cli/src/main.rs:236`:
```rust
let prices = synthetic_prices();
```
with:
```rust
let prices = showcase_prices();
```
(The rest of the closure — window, run, drain, fold — is unchanged.)
- [ ] **Step 7: Build + clippy gate (source only)**
Run: `cargo build -p aura-cli`
Expected: compiles, `0 errors` (the in-crate tests still reference the old param
strings — they compile fine as data; they FAIL at test-time, which Task 2 fixes).
Run: `cargo clippy -p aura-cli --all-targets -- -D warnings`
Expected: clean — `signals` is used by the node-0 swap and `showcase_prices` by the
sweep closure, so no `dead_code` warning.
---
### Task 2: Re-path the in-crate tests
**Files:**
- Modify: `crates/aura-cli/src/main.rs:503-517` (`sample_blueprint_with_sinks_bootstraps_runs_and_drains`)
- Modify: `crates/aura-cli/src/main.rs:519-540` (`sweep_report_renders_four_points_in_odometer_order`)
- [ ] **Step 1: Re-path + re-stream the bootstrap-runs-drains test**
Replace the body of `sample_blueprint_with_sinks_bootstraps_runs_and_drains`
(`:507-514`) — set all eight free params via `.with()` (the bound `weights[2]` is
NOT set) and run the warm-up stream:
```rust
let (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks();
let mut h = bp
.with("signals.trend.fast.length", 2)
.with("signals.trend.slow.length", 4)
.with("signals.momentum.fast.length", 2)
.with("signals.momentum.slow.length", 4)
.with("signals.momentum.signal.length", 3)
.with("signals.blend.weights[0]", 1.0)
.with("signals.blend.weights[1]", 1.0)
.with("exposure.scale", 0.5)
.bootstrap()
.expect("sample blueprint compiles under a valid point");
h.run(vec![showcase_prices()]);
```
(The two `assert!(... drained empty)` lines at `:515-516` are unchanged — the
18-tick stream warms every leg, so both sinks drain non-empty.)
- [ ] **Step 2: Re-path the sweep-report odometer assertions**
Replace the four param-row assertions at `crates/aura-cli/src/main.rs:530-533` with
the re-pathed rows (param-space render order: trend.fast, trend.slow, momentum
fast/slow/signal, blend weights[0]/[1], exposure.scale — the two trend lengths vary,
trend.slow fastest, the rest constant). Each row is one contiguous string:
```rust
assert!(lines[0].contains(r#""params":[["signals.trend.fast.length",2.0],["signals.trend.slow.length",4.0],["signals.momentum.fast.length",2.0],["signals.momentum.slow.length",4.0],["signals.momentum.signal.length",3.0],["signals.blend.weights[0]",1.0],["signals.blend.weights[1]",1.0],["exposure.scale",0.5]]"#), "line0: {}", lines[0]);
assert!(lines[1].contains(r#""params":[["signals.trend.fast.length",2.0],["signals.trend.slow.length",5.0],["signals.momentum.fast.length",2.0],["signals.momentum.slow.length",4.0],["signals.momentum.signal.length",3.0],["signals.blend.weights[0]",1.0],["signals.blend.weights[1]",1.0],["exposure.scale",0.5]]"#), "line1: {}", lines[1]);
assert!(lines[2].contains(r#""params":[["signals.trend.fast.length",3.0],["signals.trend.slow.length",4.0],["signals.momentum.fast.length",2.0],["signals.momentum.slow.length",4.0],["signals.momentum.signal.length",3.0],["signals.blend.weights[0]",1.0],["signals.blend.weights[1]",1.0],["exposure.scale",0.5]]"#), "line2: {}", lines[2]);
assert!(lines[3].contains(r#""params":[["signals.trend.fast.length",3.0],["signals.trend.slow.length",5.0],["signals.momentum.fast.length",2.0],["signals.momentum.slow.length",4.0],["signals.momentum.signal.length",3.0],["signals.blend.weights[0]",1.0],["signals.blend.weights[1]",1.0],["exposure.scale",0.5]]"#), "line3: {}", lines[3]);
```
(Lines `:520-529` — the 4-line count, the `{"manifest":{"commit":"` prefix check —
and the metric-key block `:534-539` are unchanged: structurally still 4 points with
the same metric keys.)
- [ ] **Step 3: Run the in-crate tests**
Run: `cargo test -p aura-cli`
Expected: PASS — including `sample_blueprint_with_sinks_bootstraps_runs_and_drains`,
`sweep_report_renders_four_points_in_odometer_order`, `sweep_report_is_deterministic`
(re-runs the enriched sweep; deterministic), and the untouched `run_sample_*` /
`run_macd_*` / `macd_param_space_*` tests. (The integration guards still read the
not-yet-re-captured fixture — they stay green on the old fixture until Task 3.)
Note: if a `sweep_report` row mismatches at runtime, the param **names** are fixed
(param-space order above) — re-capture only the **values/order** by reading the
actual failing-assert output (`{}` prints the real line) and correcting that row;
do not change the param names.
---
### Task 3: Re-capture the `sample-model.json` fixture
**Files:**
- Modify: `crates/aura-cli/tests/fixtures/sample-model.json`
- [ ] **Step 1: Add a temporary capture test**
Insert into `crates/aura-cli/src/main.rs` `mod tests` (e.g. after
`sample_blueprint_with_sinks_bootstraps_runs_and_drains`):
```rust
#[test]
fn capture_sample_model_tmp() {
println!("AURA_MODEL_CAPTURE:{}", aura_engine::model_to_json(&sample_blueprint()));
}
```
- [ ] **Step 2: Capture the enriched model into the fixture**
Run (one positional filter; prefix-grep isolates the model line, strip it, write the file):
```
cargo test -p aura-cli capture_sample_model_tmp -- --nocapture 2>/dev/null \
| grep '^AURA_MODEL_CAPTURE:' | sed 's/^AURA_MODEL_CAPTURE://' \
> crates/aura-cli/tests/fixtures/sample-model.json
```
Expected: `crates/aura-cli/tests/fixtures/sample-model.json` is one line of valid
JSON whose root node `"0"` is now `{"comp":"signals"}` and whose `"composites"`
object carries `signals`, `trend`, `momentum` (and no longer a top-level
`sma_cross`). Verify it parses:
Run: `node -e "JSON.parse(require('fs').readFileSync('crates/aura-cli/tests/fixtures/sample-model.json','utf8')); console.log('OK valid JSON')"`
Expected: `OK valid JSON`
- [ ] **Step 3: Remove the temporary capture test**
Delete the `capture_sample_model_tmp` test added in Step 1.
- [ ] **Step 4: Confirm the fixture consumers stay green**
Run: `cargo test -p aura-cli --test viewer_dot`
Expected: PASS (the re-captured fixture still yields valid Graphviz ids at depth 1;
`viewer_dot.rs` is the `.rs` wrapper that runs `viewer_dot_ids.mjs`).
Run: `cargo test -p aura-cli render_html_is_self_contained`
Expected: PASS (the live render still contains `"type":"Exposure"` and starts at `{"root":`).
---
### Task 4: Depth-2 render guard
**Files:**
- Create: `crates/aura-cli/tests/viewer_nested_depth.mjs`
- Create: `crates/aura-cli/tests/viewer_nested_depth.rs`
- [ ] **Step 1: Write the headless depth-2 guard**
Create `crates/aura-cli/tests/viewer_nested_depth.mjs`:
```javascript
// Headless depth-2 nesting guard for the `aura graph` viewer (graph-viewer.js).
//
// Property protected: the viewer renders MULTIPLY-nested composites — a composite
// inside a composite inside the root — with valid Graphviz node identifiers at
// every depth. genDot builds ids as `n` + the node-key path joined by `__`
// (graph-viewer.js: `cid = "n" + cpath.join("__")`), so a depth-2 interior node
// gets a three-segment id like `n0__0__0`. A digit-led non-numeral id (`0__0__0`)
// would be misparsed by Graphviz and collapse the interior; this guard pins that
// the `n`-prefix rule holds at depth 2, beyond the depth-1 viewer_dot_ids.mjs guard.
//
// It loads the real viewer module and the re-captured sample fixture (which is now
// the enriched root → signals → {trend, momentum} graph), then EXPANDS RECURSIVELY:
// discover the top-level expandable (signals), expand, re-discover the newly
// revealed expandables (trend, momentum), expand those — until none remain — and
// asserts (a) every DOT id is valid and (b) at least one three-segment (depth-2) id
// was actually produced, so the guard cannot pass vacuously on a flat model.
import { createRequire } from "node:module";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const require = createRequire(import.meta.url);
const here = dirname(fileURLToPath(import.meta.url));
const model = JSON.parse(readFileSync(join(here, "fixtures", "sample-model.json"), "utf8"));
// Stub the global BEFORE requiring the viewer (mirrors the browser bootstrap).
global.window = { AURA_MODEL: model };
const { normalizeModel, genDot } = require(join(here, "..", "assets", "graph-viewer.js"));
const ROOT = normalizeModel(model).root;
// Expand every expandable composite, re-discovering deeper ones each pass, until
// the expandable set stops growing (reaches every nesting level).
let expanded = new Set();
let dot = "";
for (let pass = 0; pass < 16; pass++) {
const g = genDot(ROOT, "root", expanded, false);
dot = g.dot;
const fresh = g.expandable.filter((id) => !expanded.has(id));
if (!fresh.length) break;
fresh.forEach((id) => expanded.add(id));
}
// Collect every node identifier referenced in the final DOT (node-def lines + edge
// endpoints), mirroring viewer_dot_ids.mjs.
const ids = new Set();
for (const m of dot.matchAll(/^\s*([^\s\[]+)\s*\[label=/gm)) ids.add(m[1]);
for (const m of dot.matchAll(/(\S+?):[a-z]\w*:[a-z]\s*->\s*(\S+?):[a-z]\w*:[a-z]/g)) {
ids.add(m[1]);
ids.add(m[2]);
}
const QUOTED = /^".*"$/s;
const NUMERAL = /^-?(\.[0-9]+|[0-9]+(\.[0-9]*)?)$/;
const BARE = /^[A-Za-z_][A-Za-z0-9_]*$/;
const isValid = (id) => QUOTED.test(id) || NUMERAL.test(id) || BARE.test(id);
const bad = [...ids].filter((id) => !isValid(id));
if (bad.length) {
console.error(
"INVALID DOT node identifier(s) at nesting depth — Graphviz will misparse a\n" +
"digit-prefixed, non-numeral id and collapse the nested composite:\n " +
bad.map((id) => JSON.stringify(id)).join("\n ")
);
process.exit(1);
}
// Anti-vacuous: at least one three-segment (depth-2) id must exist, proving the
// guard actually expanded two composite levels (root → signals → trend/momentum).
const depth2 = [...ids].filter((id) => /^n\d+__\d+__\d+/.test(id));
if (!depth2.length) {
console.error(
"no depth-2 (three-segment `nA__B__C`) id found — the guard did not reach two\n" +
"nesting levels; the fixture is not multiply-nested or the expand loop stalled."
);
process.exit(1);
}
console.log(`OK — ${ids.size} DOT ids valid; ${depth2.length} reached nesting depth 2.`);
process.exit(0);
```
- [ ] **Step 2: Write the `.rs` wrapper**
Create `crates/aura-cli/tests/viewer_nested_depth.rs` (mirrors `viewer_name_prefix.rs`):
```rust
//! Integration guard: the `aura graph` viewer renders multiply-nested composites
//! (a composite inside a composite inside the root) with valid Graphviz node ids
//! at every depth.
//!
//! Like `viewer_dot_ids.rs`/`viewer_dot.rs`, the property lives in JavaScript
//! (`genDot` in assets/graph-viewer.js), so this shells out to `node` running the
//! headless guard `tests/viewer_nested_depth.mjs`, which loads the real viewer
//! module and the re-captured sample fixture and expands it recursively to depth 2.
//!
//! `node` is REQUIRED: if it is not on PATH this test FAILS (a skipped guard is
//! not a guard).
use std::path::PathBuf;
use std::process::Command;
#[test]
fn viewer_renders_nested_composites_to_depth_2_with_valid_ids() {
let script = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("viewer_nested_depth.mjs");
assert!(
script.exists(),
"guard script missing at {}",
script.display()
);
let out = match Command::new("node").arg(&script).output() {
Ok(out) => out,
Err(e) => panic!(
"node is required for the viewer depth-2 nesting guard but could not be run \
({e}); install Node.js or ensure `node` is on PATH"
),
};
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
out.status.success(),
"viewer depth-2 nesting guard failed (exit {:?}).\n--- node stdout ---\n{stdout}\n--- node stderr ---\n{stderr}",
out.status.code()
);
}
```
- [ ] **Step 3: Run the new guard**
Run: `cargo test -p aura-cli --test viewer_nested_depth`
Expected: PASS — `OK — N DOT ids valid; M reached nesting depth 2.` (M ≥ 1). This
confirms the depth-2 nesting renders with valid ids against the re-captured fixture.
---
### Task 5: Workspace gate
**Files:** none (verification only).
- [ ] **Step 1: Full build**
Run: `cargo build --workspace`
Expected: compiles, `0 errors`.
- [ ] **Step 2: Full test suite**
Run: `cargo test --workspace`
Expected: PASS — all crates, including the re-pathed in-crate tests, the re-captured
fixture consumers, the new `viewer_nested_depth` guard, and the untouched engine
`model_golden` / `nested_composite_inlines` / `param_space_*` tests.
- [ ] **Step 3: Clippy**
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: clean, no warnings.
@@ -1,649 +0,0 @@
# Bound params in the graph model + viewer signature — Implementation Plan
> **Parent spec:** `docs/specs/0037-bound-param-in-graph-model.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Surface a `bind`-bound param in the graph model and the `aura graph`
viewer signature — all slots shown, the bound one as `name=value`, dimmed — while
leaving the tunable surface (`param_space` / sweep axes) untouched.
**Architecture:** A conditional field threaded `engine → model → viewer`, the
twin of cycle 0035's instance-name thread. `PrimitiveBuilder::bind` records a
`BoundParam` (original slot position + name + kind + value); `prim_record` emits a
conditional `"bound"` field via a new `scalar_str` helper; `graph-viewer.js`
merges free + bound params into slot order by position and renders the bound slot
dimmed. Render/debug surface only — dropped at lowering (C23), model stays
deterministic (C14). Purely additive: a new struct, a `Vec::new()`-default field,
a new accessor, a conditional model field defaulting to empty, a viewer field
defaulting to `[]`. No call site of `bind`/`prim_record`/`adaptNodes`/`genDot`/
`cellLabel` changes arity.
**Tech Stack:** `crates/aura-core/src/node.rs` (Rust), `crates/aura-engine/src/
graph_model.rs` (Rust), `crates/aura-cli/assets/graph-viewer.js` (JS),
`crates/aura-cli/tests/` (Rust + headless `node` guards), the
`crates/aura-cli/tests/fixtures/sample-model.json` golden.
---
**Files this plan creates or modifies:**
- Modify: `crates/aura-core/src/node.rs``BoundParam` struct (after `ParamSpec`,
~:67), `PrimitiveBuilder.bound` field (struct :77-85), init in `new` (:96),
record in `bind` (between the kind-assert :172 and `remove(pos)` :173),
`bound_params()` accessor (after `instance_name()` :120-122), unit test (tests
mod :229+, beside `bind_removes_slot_and_reconstructs_positionally` :386).
- Modify: `crates/aura-engine/src/graph_model.rs``scalar_str` helper (beside
`kind_str` :14-21), conditional `"bound"` field in `prim_record` (:72-89; field
block after the `name` conditional :80-83, `{bound}` in the format string :85),
unit test (tests mod, beside `prim_record_emits_name_for_explicit_only` :383).
- Modify: `crates/aura-cli/assets/graph-viewer.js``adaptNodes` (:32), `genDot`
leaf-emit cellLabel call (:111), `cellLabel` `sp`/`sig` (:72-73).
- Modify: `crates/aura-cli/tests/fixtures/sample-model.json` — re-capture; the
`signals` composite's node `"2"` (`blend`) gains `"bound":[[2,"weights[2]","f64","0.5"]]`.
- Create: `crates/aura-cli/tests/viewer_bound_param.mjs` — headless render guard
(trailing + middle bind).
- Create: `crates/aura-cli/tests/viewer_bound_param.rs` — Rust shell that runs the
`.mjs` via `node`. Mirror of `viewer_name_prefix.rs`.
**Note on verification commands (project memory):** use ONE positional
`cargo test` filter per invocation (a multi-filter `cargo test X Y` silently
prints nothing here); for "everything still green" use an unfiltered per-crate run
and read the summary count. The viewer guards REQUIRE `node` on PATH
(`/usr/bin/node`, v26 confirmed) — a skipped guard is not a guard.
**Parse-the-bytes gate:** the project declares no `spec_validation` parser (see
CLAUDE.md project facts), so the inline-body parse gate is a documented no-op; the
Rust bodies are caught by the `implement` compile gate and the JS by the `node`
guard run.
---
### Task 1: aura-core — `BoundParam`, `bind` records original position, `bound_params()`
**Files:**
- Modify: `crates/aura-core/src/node.rs`
- Test: `crates/aura-core/src/node.rs` (tests mod)
- [ ] **Step 1: Write the failing test**
In `crates/aura-core/src/node.rs`, inside `#[cfg(test)] mod tests` (after
`bind_removes_slot_and_reconstructs_positionally`, ~:408), add:
```rust
#[test]
fn bind_records_bound_param_at_original_position() {
// middle-of-three: binding `b` records its ORIGINAL slot position (1).
let mid = probe3().bind("b", Scalar::I64(8));
assert_eq!(
mid.bound_params(),
[BoundParam { pos: 1, name: "b".into(), kind: ScalarKind::I64, value: Scalar::I64(8) }]
.as_slice(),
);
// chained reverse-order bind (b then a): each entry carries its ORIGINAL
// position regardless of the shrinking array — positions {1, 0} in call order.
let pair = probe3().bind("b", Scalar::I64(8)).bind("a", Scalar::I64(7));
assert_eq!(
pair.bound_params(),
[
BoundParam { pos: 1, name: "b".into(), kind: ScalarKind::I64, value: Scalar::I64(8) },
BoundParam { pos: 0, name: "a".into(), kind: ScalarKind::I64, value: Scalar::I64(7) },
]
.as_slice(),
);
// an unbound builder records nothing.
assert!(probe3().bound_params().is_empty());
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cargo test -p aura-core bind_records_bound_param_at_original_position`
Expected: FAIL — does not compile: `cannot find type BoundParam in this scope` /
`no method named bound_params found for struct PrimitiveBuilder`.
- [ ] **Step 3: Write the implementation**
(3a) Add the `BoundParam` struct immediately after `ParamSpec` (after its closing
`}` at ~:67, before the `PrimitiveBuilder` doc-comment at :69). `Scalar` /
`ScalarKind` are already imported at `node.rs:13` — no new import:
```rust
/// One param bound to a structural constant by [`PrimitiveBuilder::bind`] — a
/// render/debug surface only (C23), dropped at lowering. `pos` is the slot's
/// position in the node's ORIGINAL (pre-bind) param list, so the model serializer
/// and viewer can place it back into slot order regardless of bind sequence.
#[derive(Clone, Debug, PartialEq)]
pub struct BoundParam {
pub pos: usize,
pub name: String,
pub kind: ScalarKind,
pub value: Scalar,
}
```
(3b) Add the `bound` field to `PrimitiveBuilder` (struct at :77-85). Replace:
```rust
pub struct PrimitiveBuilder {
name: &'static str,
instance_name: Option<String>,
schema: NodeSchema,
// The build closure's type is exactly the recipe contract (a param slice in, a
// boxed node out); a type alias would not clarify it.
#[allow(clippy::type_complexity)]
build: Box<dyn Fn(&[Scalar]) -> Box<dyn Node>>,
}
```
with:
```rust
pub struct PrimitiveBuilder {
name: &'static str,
instance_name: Option<String>,
schema: NodeSchema,
bound: Vec<BoundParam>,
// The build closure's type is exactly the recipe contract (a param slice in, a
// boxed node out); a type alias would not clarify it.
#[allow(clippy::type_complexity)]
build: Box<dyn Fn(&[Scalar]) -> Box<dyn Node>>,
}
```
(3c) Initialize `bound` in `new` (:96). Replace:
```rust
Self { name, instance_name: None, schema, build: Box::new(build) }
```
with:
```rust
Self { name, instance_name: None, schema, bound: Vec::new(), build: Box::new(build) }
```
(3d) Add the `bound_params()` accessor immediately after `instance_name()`
(after its closing `}` at :122):
```rust
/// The params bound to structural constants by [`bind`](Self::bind), in
/// bind-call order, each carrying its ORIGINAL slot position. The render-
/// surface twin of [`instance_name`](Self::instance_name): the model
/// serializer reads it to show a bound slot's fixed value (the value captured
/// in the build closure is otherwise unreachable to it). Empty when no binds.
pub fn bound_params(&self) -> &[BoundParam] {
&self.bound
}
```
(3e) In `bind`, record the `BoundParam` at its original position. Insert between
the kind-assert (ends :172) and `self.schema.params.remove(pos);` (:173):
```rust
// [render side] record the bound slot for the model/viewer at its ORIGINAL
// pre-bind position. `pos` indexes the already-shrunk array (earlier binds
// removed), so map it back to the full-arity index by adding one for each
// earlier-bound slot at an original position <= it. Render/debug only (C23);
// the closure capture below is what bootstrap actually reads.
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 });
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cargo test -p aura-core bind_records_bound_param_at_original_position`
Expected: PASS (`test ... ok`, 1 passed).
- [ ] **Step 5: Verify aura-core suite stays green**
Run: `cargo test -p aura-core`
Expected: PASS — all tests ok, 0 failed (existing `bind_*` tests included).
---
### Task 2: aura-engine — `scalar_str` + conditional `"bound"` in `prim_record`
**Files:**
- Modify: `crates/aura-engine/src/graph_model.rs`
- Test: `crates/aura-engine/src/graph_model.rs` (tests mod)
- [ ] **Step 1: Write the failing test**
In `crates/aura-engine/src/graph_model.rs`, inside `#[cfg(test)] mod tests` (after
`prim_record_emits_name_for_explicit_only`, ~:394), add. The tests mod already
imports `Scalar`, `PrimitiveBuilder` (:244-245) and `aura_std::{Exposure, …, Sma}`
(:247):
```rust
#[test]
fn prim_record_emits_bound_only_when_bound() {
// a bound i64 param → a "bound" field carrying [pos,"name","kind","value"]
let bound = prim_record(&Sma::builder().bind("length", Scalar::I64(5)));
assert!(bound.contains(r#""bound":[[0,"length","i64","5"]]"#), "{bound}");
// the bound slot also leaves the tunable "params" surface (bind's tuning side)
assert!(bound.contains(r#""params":[]"#), "{bound}");
// a bound f64 value renders canonically (shortest round-trip), e.g. 0.5
let f = prim_record(&Exposure::builder().bind("scale", Scalar::F64(0.5)));
assert!(f.contains(r#""bound":[[0,"scale","f64","0.5"]]"#), "{f}");
// an unbound builder → no "bound" field at all
let unbound = prim_record(&Sma::builder());
assert!(!unbound.contains(r#""bound""#), "{unbound}");
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cargo test -p aura-engine prim_record_emits_bound_only_when_bound`
Expected: FAIL — does not compile: `no method named bound_params` (until Task 1
lands) OR, with Task 1 present, assertion failure (`"bound"` substring absent).
- [ ] **Step 3: Write the implementation**
(3a) Add the `scalar_str` helper immediately after `kind_str` (after its closing
`}` at :21):
```rust
/// A scalar VALUE as a canonical, deterministic string for the model (C14). `f64`
/// uses the shortest round-trippable debug form, which keeps a decimal point on
/// whole values (`1.0`, not `1`) so a bound f64 is never mistaken for an i64 in
/// the render. The value side of `kind_str`.
fn scalar_str(s: aura_core::Scalar) -> String {
use aura_core::Scalar;
match s {
Scalar::I64(n) => n.to_string(),
Scalar::F64(f) => format!("{f:?}"),
Scalar::Bool(b) => b.to_string(),
Scalar::Ts(t) => t.0.to_string(),
}
}
```
(3b) In `prim_record` (:72-89), add the `bound` field block after the `name`
conditional (after its closing line :83, before the `format!` at :84):
```rust
// bound params: present only when the node has binds (mirrors the conditional
// "name" field). each entry: [pos,"name","kind","value"].
let bound = if b.bound_params().is_empty() {
String::new()
} else {
let items = join(b.bound_params().iter().map(|bp| {
format!(
"[{},{},{},{}]",
bp.pos,
json_str(&bp.name),
json_str(kind_str(bp.kind)),
json_str(&scalar_str(bp.value)),
)
}));
format!(r#","bound":[{items}]"#)
};
```
(3c) Insert `{bound}` into the `format!` template (:85), after `"params":[{params}]`
and before `,"ins"`. Replace:
```rust
r#"{{"prim":{{{name}"type":{},"role":{},"params":[{params}],"ins":[{ins}],"outs":[{outs}]}}}}"#,
```
with:
```rust
r#"{{"prim":{{{name}"type":{},"role":{},"params":[{params}]{bound},"ins":[{ins}],"outs":[{outs}]}}}}"#,
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cargo test -p aura-engine prim_record_emits_bound_only_when_bound`
Expected: PASS (1 passed).
- [ ] **Step 5: Verify the no-bind golden stays byte-stable**
Run: `cargo test -p aura-engine model_golden`
Expected: PASS — the inline `model_golden` (`sample_root`, no binds) is unchanged:
the conditional field is inert for unbound nodes.
- [ ] **Step 6: Verify aura-engine suite stays green**
Run: `cargo test -p aura-engine`
Expected: PASS — all tests ok, 0 failed (`model_is_deterministic` included).
---
### Task 3: graph-viewer.js — carry `bound`, render the merged signature
**Files:**
- Modify: `crates/aura-cli/assets/graph-viewer.js`
- [ ] **Step 1: `adaptNodes` carries `bound`**
In `crates/aura-cli/assets/graph-viewer.js` (:32), replace:
```js
out[key] = { prim: { name: p.name, type: p.type, role: p.role, params: p.params, ins: adaptIns(p.ins), outs: p.outs } };
```
with:
```js
out[key] = { prim: { name: p.name, type: p.type, role: p.role, params: p.params, bound: p.bound || [], ins: adaptIns(p.ins), outs: p.outs } };
```
- [ ] **Step 2: `genDot` leaf-emit forwards `bound`**
In `genDot` (:111), replace:
```js
block += `${cid} [label=${cellLabel(cid, { name: s.name, type: s.type, params: s.params, ins: s.ins, outs: s.outs, bodyBg: bodyBgFor(s.role), composite: false }, showTypes)}];\n`;
```
with:
```js
block += `${cid} [label=${cellLabel(cid, { name: s.name, type: s.type, params: s.params, bound: s.bound, ins: s.ins, outs: s.outs, bodyBg: bodyBgFor(s.role), composite: false }, showTypes)}];\n`;
```
- [ ] **Step 3: `cellLabel` merges free + bound params into slot order**
In `cellLabel` (:72-73), replace:
```js
const sp = o.params.map(([n, k]) => `<font color="${col(k)}">${showTypes && k ? `${n}:${k}` : n}</font>`);
const sig = o.params.length ? `<font color="#9399b2">[</font>${sp.join('<font color="#9399b2">, </font>')}<font color="#9399b2">]</font>` : "";
```
with:
```js
// merge free params and bound params back into slot order by original position;
// a bound slot renders as a dimmed name=value (reuses the 0035 separator grey).
const bnd = o.bound || [];
const total = o.params.length + bnd.length;
const byPos = new Map(bnd.map(([pos, n, k, v]) => [pos, [n, k, v]]));
const slots = [];
let fi = 0;
for (let pos = 0; pos < total; pos++) {
if (byPos.has(pos)) {
const [n, , v] = byPos.get(pos);
slots.push(`<font color="#9399b2">${n}=${v}</font>`);
} else {
const [n, k] = o.params[fi++];
slots.push(`<font color="${col(k)}">${showTypes && k ? `${n}:${k}` : n}</font>`);
}
}
const sig = total ? `<font color="#9399b2">[</font>${slots.join('<font color="#9399b2">, </font>')}<font color="#9399b2">]</font>` : "";
```
(`o.bound || []` keeps the composite / boundary `cellLabel` calls — which pass no
`bound` — rendering exactly as before: `total` collapses to `params.length`.)
- [ ] **Step 4: Verify the existing viewer guards stay green**
The existing guards exercise `genDot`/`cellLabel` over models with no `bound`
field; the merge must render them identically. They read the OLD fixture (Task 4
re-captures it) — both states are backward-compatible.
Run: `cargo test -p aura-cli --test viewer_name_prefix`
Expected: PASS.
Run: `cargo test -p aura-cli --test viewer_dot`
Expected: PASS.
Run: `cargo test -p aura-cli --test viewer_nested_depth`
Expected: PASS.
---
### Task 4: Re-capture the `sample-model.json` fixture
**Files:**
- Modify: `crates/aura-cli/tests/fixtures/sample-model.json`
- Modify (temporary): `crates/aura-cli/src/main.rs` (a throwaway capture test, removed in Step 4)
- [ ] **Step 1: Add a temporary capture test**
In `crates/aura-cli/src/main.rs`, inside `#[cfg(test)] mod tests` (:560,
`use super::*` already present), add:
```rust
#[test]
fn capture_sample_model_tmp() {
println!("AURA_MODEL_CAPTURE:{}", aura_engine::model_to_json(&sample_blueprint()));
}
```
- [ ] **Step 2: Capture the model into the fixture**
Run (one positional filter, per project note):
```bash
cargo test -p aura-cli capture_sample_model_tmp -- --nocapture 2>/dev/null \
| grep '^AURA_MODEL_CAPTURE:' | sed 's/^AURA_MODEL_CAPTURE://' \
> crates/aura-cli/tests/fixtures/sample-model.json
```
Expected: the fixture is rewritten as a single JSON line. Verify the `blend`
node gained the bound field:
```bash
grep -c '"bound":\[\[2,"weights\[2\]","f64","0.5"\]\]' crates/aura-cli/tests/fixtures/sample-model.json
```
Expected: `1`.
- [ ] **Step 3: Validate the fixture is well-formed JSON**
Run:
```bash
node -e "JSON.parse(require('fs').readFileSync('crates/aura-cli/tests/fixtures/sample-model.json','utf8')); console.log('OK valid JSON')"
```
Expected: `OK valid JSON`.
- [ ] **Step 4: Remove the temporary capture test**
Delete the `capture_sample_model_tmp` test added in Step 1 from
`crates/aura-cli/src/main.rs`.
- [ ] **Step 5: Verify the fixture consumers stay green**
The two consumers assert DOT-id grammar / nesting depth, not params, and read the
re-captured fixture (now carrying `bound`, handled by Task 3's `p.bound || []`).
Run: `cargo test -p aura-cli --test viewer_dot`
Expected: PASS.
Run: `cargo test -p aura-cli --test viewer_nested_depth`
Expected: PASS.
---
### Task 5: New headless render guard — bound slot in true slot order
**Files:**
- Create: `crates/aura-cli/tests/viewer_bound_param.mjs`
- Create: `crates/aura-cli/tests/viewer_bound_param.rs`
- [ ] **Step 1: Write the headless guard script**
Create `crates/aura-cli/tests/viewer_bound_param.mjs`:
```js
// Headless render guard for the `aura graph` viewer (graph-viewer.js).
//
// Property protected: a bind-bound param slot renders in the node signature as a
// DIMMED `name=value`, in TRUE slot order (by original position), with the free
// slots in their type colour — never dropped. Covers a trailing bind (the
// cycle-0036 showcase shape) AND a non-trailing (middle) bind, which an
// append-after-free design would render in the wrong order.
//
// It loads the real viewer module and drives the exported pure `genDot`. `node`
// is REQUIRED: a skipped guard is not a guard.
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const require = createRequire(import.meta.url);
const here = dirname(fileURLToPath(import.meta.url));
// Load the viewer once with an empty model; normalizeModel/genDot are pure and
// take the model/def explicitly, so each case is rendered by passing its own.
global.window = { AURA_MODEL: { root: { nodes: {}, edges: [] }, composites: {} } };
const { normalizeModel, genDot } = require(join(here, "..", "assets", "graph-viewer.js"));
const dotFor = (model) => genDot(normalizeModel(model).root, "root", new Set(), false).dot;
const fail = (msg, dot) => {
console.error(msg + "\n--- dot ---\n" + dot);
process.exit(1);
};
// --- Model A: trailing bind (the cycle-0036 showcase shape) ---
const trailing = {
root: { nodes: { "0": { prim: {
name: "blend", type: "LinComb", role: "node",
params: [["weights[0]", "f64"], ["weights[1]", "f64"]],
bound: [[2, "weights[2]", "f64", "0.5"]],
ins: [["f64", "any", "term[0]"], ["f64", "any", "term[1]"], ["f64", "any", "term[2]"]],
outs: [["value", "f64"]],
} } }, edges: [] },
composites: {},
};
const dotA = dotFor(trailing);
const BOUND = '<font color="#9399b2">weights[2]=0.5</font>';
const FREE0 = '<font color="#89b4fa">weights[0]</font>';
const FREE1 = '<font color="#89b4fa">weights[1]</font>';
if (!dotA.includes(BOUND)) fail("trailing: bound slot weights[2]=0.5 not rendered dimmed", dotA);
if (!dotA.includes(FREE0) || !dotA.includes(FREE1)) fail("trailing: a free weight slot is missing or not type-coloured", dotA);
const a0 = dotA.indexOf(FREE0), a1 = dotA.indexOf(FREE1), ab = dotA.indexOf(BOUND);
if (!(a0 < a1 && a1 < ab)) fail("trailing: slot order is not weights[0] < weights[1] < weights[2]=0.5", dotA);
// --- Model B: NON-TRAILING (middle) bind — pins slot-order faithfulness ---
const middle = {
root: { nodes: { "0": { prim: {
name: "mid", type: "Probe", role: "node",
params: [["a", "f64"], ["c", "f64"]],
bound: [[1, "b", "f64", "9.0"]],
ins: [["f64", "any", "t0"], ["f64", "any", "t1"], ["f64", "any", "t2"]],
outs: [["value", "f64"]],
} } }, edges: [] },
composites: {},
};
const dotB = dotFor(middle);
const MA = '<font color="#89b4fa">a</font>';
const MB = '<font color="#9399b2">b=9.0</font>';
const MC = '<font color="#89b4fa">c</font>';
if (!dotB.includes(MA) || !dotB.includes(MB) || !dotB.includes(MC)) fail("middle: a free or bound slot is missing", dotB);
const ba = dotB.indexOf(MA), bb = dotB.indexOf(MB), bc = dotB.indexOf(MC);
if (!(ba < bb && bb < bc)) fail("middle: bound slot b=9.0 not placed between free a and c (append-only bug)", dotB);
console.log("OK — bound slot renders dimmed name=value in true slot order (trailing + middle bind).");
process.exit(0);
```
- [ ] **Step 2: Write the Rust shell**
Create `crates/aura-cli/tests/viewer_bound_param.rs`:
```rust
//! Integration guard: the `aura graph` viewer renders a bind-bound param slot as
//! a dimmed `name=value` in true slot order (trailing + non-trailing bind).
//!
//! The property lives in JavaScript (`cellLabel` in assets/graph-viewer.js), so
//! this shells out to `node` running the headless guard
//! `tests/viewer_bound_param.mjs`, which loads the real viewer module and drives
//! the exported `genDot` over two minimal models.
//!
//! `node` is REQUIRED: if it is not on PATH this test FAILS (a skipped guard is
//! not a guard).
use std::path::PathBuf;
use std::process::Command;
#[test]
fn viewer_renders_bound_param_in_slot_order() {
let script = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("viewer_bound_param.mjs");
assert!(script.exists(), "guard script missing at {}", script.display());
let out = match Command::new("node").arg(&script).output() {
Ok(out) => out,
Err(e) => panic!(
"node is required for the viewer bound-param guard but could not be run \
({e}); install Node.js or ensure `node` is on PATH"
),
};
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
out.status.success(),
"viewer bound-param guard failed (exit {:?}).\n--- node stdout ---\n{stdout}\n--- node stderr ---\n{stderr}",
out.status.code()
);
}
```
- [ ] **Step 3: Run the new guard to verify it passes**
Run: `cargo test -p aura-cli --test viewer_bound_param`
Expected: PASS — `viewer_renders_bound_param_in_slot_order ... ok`; the `.mjs`
prints `OK — bound slot renders dimmed name=value in true slot order …`.
---
### Task 6: Full-workspace gate
**Files:** none (verification only)
- [ ] **Step 1: Build**
Run: `cargo build --workspace`
Expected: finishes, 0 errors.
- [ ] **Step 2: Test**
Run: `cargo test --workspace`
Expected: all tests pass, 0 failed (the new aura-core + aura-engine unit tests,
the new `viewer_bound_param` guard, and every existing test — the sweep param
pins at `main.rs:595-598` and `cli_run.rs:214` stay byte-identical, since the
tunable surface is unchanged).
- [ ] **Step 3: Lint**
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: finishes, 0 warnings.
---
## Spec coverage map
| Spec section | Task |
|---|---|
| `BoundParam` + `bind` records original position + `bound_params()` | Task 1 |
| `scalar_str` + conditional `"bound"` in `prim_record` | Task 2 |
| `adaptNodes`/`genDot`/`cellLabel` merge + dimmed render | Task 3 |
| `sample-model.json` re-capture (blend gains `"bound"`) | Task 4 |
| Headless render guard (trailing + middle bind, slot order) | Task 5 |
| Goldens / sweep pins stay green; build/test/clippy clean | Tasks 2, 4, 6 |
| Acceptance criteria #1#4 | Tasks 16 (render: 3+5; model: 2+4; tunable surface untouched: 6; tests: 1,2,5) |
@@ -1,298 +0,0 @@
# Dedup the SMA-cross harness test fixtures — Implementation Plan
> **Parent spec:** `docs/specs/0038-dedup-sma-cross-test-fixtures.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Collapse the three verbatim-duplicated SMA-cross harness `#[cfg(test)]`
fixtures (`synthetic_prices`, `sma_cross`, `composite_sma_cross_harness`) in
`aura-engine`'s `blueprint.rs` and `sweep.rs` test modules into one shared
crate-root `#[cfg(test)] mod test_fixtures`.
**Architecture:** A new test-only module `crates/aura-engine/src/test_fixtures.rs`
holds the three fixtures as `pub(crate)` free fns; it is declared in `lib.rs`
with `#[cfg(test)] mod test_fixtures;` (sibling to the existing
`#[cfg(test)] mod reexport_tests`). The two consuming test modules delete their
local copies and import from `crate::test_fixtures`. Behaviour-preserving,
`#[cfg(test)]`-only — no production code, no public API, no runtime path changes.
**Tech Stack:** Rust, `aura-engine` crate; verified against `cargo test` and
`cargo clippy -D warnings`.
---
## Files this plan creates or modifies
- Create: `crates/aura-engine/src/test_fixtures.rs` — the shared `#[cfg(test)]`
fixture module (three `pub(crate)` fns).
- Modify: `crates/aura-engine/src/lib.rs:53-54` — add the module declaration.
- Modify: `crates/aura-engine/src/blueprint.rs` — delete three local fixtures
(`:1518-1533`, `:1590-1610`, `:1612-1645`), add one import line to the test
module's `use` block (`:755-758`). No existing import becomes unused.
- Modify: `crates/aura-engine/src/sweep.rs` — delete three local fixtures incl.
two stale doc-comments (`:256-269`, `:271-293`, `:295-328`), and rewrite the
test module's `use` block (`:171-177`) to import the fixtures and drop the
now-unused imports.
**Out of scope — must NOT be touched:** `blueprint.rs` `sma_cross_under_root`
(`:1114`), `hand_wired_sma_cross_harness` (`:1538`); `sweep.rs` `run_point`
(`:335`), `sma_cross_grid` (`:355`); `graph_model.rs` `sma_cross` (`:311`) /
`sample_root` (`:340`) — an independent minimal golden copy.
---
## Task 1: Move the three fixtures into a shared `test_fixtures` module
This is one atomic move: the new module and both consumers must change together
for the crate to build and lint clean. Intermediate steps are intentionally NOT
gated; the build / test / clippy gates run only at the end (Steps 5-7), after
every consumer is threaded — so no "unused fn / unused import" lint can fire on
a half-applied move.
**Files:**
- Create: `crates/aura-engine/src/test_fixtures.rs`
- Modify: `crates/aura-engine/src/lib.rs:53-54`
- Modify: `crates/aura-engine/src/blueprint.rs` (`:755-758`, `:1518-1533`, `:1590-1610`, `:1612-1645`)
- Modify: `crates/aura-engine/src/sweep.rs` (`:171-177`, `:256-269`, `:271-293`, `:295-328`)
- [ ] **Step 1: Create the shared fixture module**
Create `crates/aura-engine/src/test_fixtures.rs` with exactly this content. The
import paths use the crate-root re-exports (`crate::{...}`) — verified present at
`lib.rs:40-45`; this matches the import style `sweep.rs`'s test module already
uses. (`Edge` and `Target` are re-exported from `crate::harness`, not
`crate::blueprint` — the crate-root path sidesteps that.)
```rust
//! Shared `#[cfg(test)]` fixtures for the SMA-cross signal-quality harness,
//! consumed by the `blueprint` and `sweep` unit-test modules. Kept in one place
//! so the harness topology cannot silently diverge between them (see #53).
//! Gated test-only at the declaration site in `lib.rs`
//! (`#[cfg(test)] mod test_fixtures;`), matching the `reexport_tests` precedent.
use crate::{BlueprintNode, Composite, Edge, OutField, Role, Target};
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
use std::sync::mpsc;
/// Seven synthetic F64 ticks driving the harness; deterministic input fixture.
pub(crate) fn synthetic_prices() -> Vec<(Timestamp, Scalar)> {
[
(1_i64, 1.0000_f64),
(2, 1.0010),
(3, 1.0030),
(4, 1.0060),
(5, 1.0040),
(6, 1.0010),
(7, 0.9990),
]
.iter()
.map(|&(t, p)| (Timestamp(t), Scalar::F64(p)))
.collect()
}
/// The SMA-cross signal as a reusable value-empty composite: one input role
/// (price), one output (fast-minus-slow spread). Lengths injected at compile.
pub(crate) fn sma_cross() -> Composite {
Composite::new(
"sma_cross",
vec![
Sma::builder().named("fast").into(),
Sma::builder().named("slow").into(),
Sub::builder().into(),
],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
],
vec![Role {
name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
source: None,
}],
vec![OutField { node: 2, field: 0, name: "out".into() }],
)
}
/// The signal-quality harness as a value-empty composite blueprint with two
/// recording sinks (equity, exposure).
#[allow(clippy::type_complexity)]
pub(crate) fn composite_sma_cross_harness() -> (
Composite,
mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
) {
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
let bp = Composite::new(
"root",
vec![
BlueprintNode::Composite(sma_cross()),
Exposure::builder().into(),
SimBroker::builder(0.0001).into(),
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq).into(),
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex).into(),
],
vec![
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // composite out -> Exposure
Edge { from: 1, to: 2, slot: 0, from_field: 0 }, // exposure -> broker slot 0
Edge { from: 2, to: 3, slot: 0, from_field: 0 }, // equity -> sink
Edge { from: 1, to: 4, slot: 0, from_field: 0 }, // exposure -> sink
],
vec![Role {
name: "src".into(),
targets: vec![
Target { node: 0, slot: 0 }, // price -> sma_cross role 0
Target { node: 2, slot: 1 }, // price -> SimBroker price slot
],
source: Some(ScalarKind::F64),
}],
vec![], // output: the root ends in sinks
);
(bp, rx_eq, rx_ex)
}
```
- [ ] **Step 2: Declare the module in `lib.rs`**
In `crates/aura-engine/src/lib.rs`, the existing test-only module declaration is:
```rust
#[cfg(test)]
mod reexport_tests {
```
Immediately **before** that `#[cfg(test)]` line (at `:53`), add the new module
declaration so it sits as a sibling:
```rust
#[cfg(test)]
mod test_fixtures;
```
(Result: a `#[cfg(test)] mod test_fixtures;` line, a blank line, then the
existing `#[cfg(test)] mod reexport_tests {` block — both gated, both crate-root.)
- [ ] **Step 3: Switch `blueprint.rs` to the shared fixtures**
In `crates/aura-engine/src/blueprint.rs`, inside `#[cfg(test)] mod tests`:
(a) **Delete** the three local fixture definitions entirely (including their
doc-comments and the `#[allow(clippy::type_complexity)]` on the harness):
- `synthetic_prices``:1518-1533`
- `sma_cross``:1590-1610`
- `composite_sma_cross_harness``:1612-1645`
(b) **Add** this import line to the test module's `use` block (currently
`:755-758`, which is `use super::*;` plus the `aura_core` / `aura_std` / `mpsc`
lines). Insert it directly after `use super::*;`:
```rust
use crate::test_fixtures::{composite_sma_cross_harness, sma_cross, synthetic_prices};
```
Do **not** remove any other import: every existing test-mod import
(`Exposure`, `Recorder`, `SimBroker`, `Firing`, `NodeSchema`, `FieldSpec`,
`Ctx`, `Ema`, `mpsc`) still has surviving uses in other tests
(`hand_wired_sma_cross_harness`, the MACD test, the local dummy-node defs).
- [ ] **Step 4: Switch `sweep.rs` to the shared fixtures and drop dead imports**
In `crates/aura-engine/src/sweep.rs`, inside `#[cfg(test)] mod tests`:
(a) **Delete** the three local fixture definitions entirely, including the two
stale "duplicated from `blueprint.rs`" doc-comments:
- `synthetic_prices``:256-269`
- `sma_cross` (+ doc-comment `:271-273`) — `:271-293`
- `composite_sma_cross_harness` (+ doc-comment / `#[allow]` `:295-297`) — `:295-328`
(b) **Replace** the test module's entire `use` block (currently `:171-177`):
```rust
use super::*;
use crate::{
f64_field, summarize, BlueprintNode, Composite, Edge, OutField, Role, RunManifest, Target,
};
use aura_core::{Firing, ParamSpec, Scalar, ScalarKind, Timestamp};
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
use std::sync::mpsc;
```
with exactly this (imports the shared fixtures; drops everything now used only
by the deleted fns — `BlueprintNode`, `Composite`, `Edge`, `OutField`, `Role`,
`Target`, `Firing`, the whole `aura_std` line, and `std::sync::mpsc`; keeps
`f64_field`, `summarize`, `RunManifest`, `ParamSpec`, `Scalar`, `ScalarKind`,
`Timestamp`, which `run_point` / `sma_cross_grid` / the grid tests still use):
```rust
use super::*;
use crate::test_fixtures::{composite_sma_cross_harness, sma_cross, synthetic_prices};
use crate::{f64_field, summarize, RunManifest};
use aura_core::{ParamSpec, Scalar, ScalarKind, Timestamp};
```
- [ ] **Step 5: Build + run the full workspace test suite (primary gate)**
Run: `cargo test --workspace`
Expected: PASS — builds clean, every test green, zero failures. The result count
must match the pre-change baseline (this is a behaviour-preserving move; no test
is added or removed). If the build fails on an import-path error in
`test_fixtures.rs`, the engine types are reachable from the crate root
(`crate::{...}`) per `lib.rs:40-45` — do not invent a path.
- [ ] **Step 6: Confirm the two load-bearing fixture-consuming tests are green**
These two named tests exercise the moved fixtures and are the spec's verification
witnesses (C1 determinism; C11/C12 sweep-equivalence). Run each as its own
single-filter invocation (one positional filter per run):
Run: `cargo test -p aura-engine composite_sma_cross_runs_bit_identical_to_hand_wired`
Expected: PASS — `test result: ok. 1 passed` (the filter matches exactly this
real test in `blueprint.rs:1647`; a `0 passed` here means the filter resolved
nothing and is a failure of this gate).
Run: `cargo test -p aura-engine sweep_equals_n_independent_runs`
Expected: PASS — `test result: ok. 1 passed` (filter matches the real test in
`sweep.rs:369`).
- [ ] **Step 7: Lint gate — no unused imports left behind**
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: clean (exit 0, no warnings). This is the backstop for Step 4's import
removal: an over-removal fails to compile, an under-removal fails the
`unused_imports` lint. A clean run confirms the `sweep.rs` import set is exactly
right and the new module has no dead/unused symbol.
---
## Self-review
1. **Spec coverage:** the spec's single in-scope section (the three-fixture move
+ module creation + consumer rewiring + import cleanup) is fully covered by
Task 1's seven steps. ✓
2. **Placeholder scan:** no "TBD" / "TODO" / "implement later" / "similar to
Task N" / "add appropriate error handling" present. ✓
3. **Type consistency:** fixture names (`synthetic_prices`, `sma_cross`,
`composite_sma_cross_harness`), module name (`test_fixtures`), and import
paths (`crate::test_fixtures::{...}`, `crate::{...}`) are identical across all
steps. ✓
4. **Step granularity:** each step is one file action or one command, 2-5 min. ✓
5. **No commit steps:** none present; the orchestrator commits. ✓
6. **Pin/replacement substring contiguity:** the only presence-pin is Step 6's
`1 passed` against two named tests; no verbatim replacement body must contain
a pinned substring, so no soft-wrap split risk. ✓
7. **Compile-gate vs. deferred-caller ordering:** the move makes the crate fail
to compile/lint until BOTH consumers are switched; all consumer threading
(Steps 3-4) precedes the first build/test/clippy gate (Steps 5-7). No gate
fires on a half-applied move. ✓
8. **Verification-command filter strings resolve:** Step 5 is the unfiltered
workspace suite (the "nothing ran" masquerade is impossible). Step 6's two
filters name real tests verified to exist (`blueprint.rs:1647`,
`sweep.rs:369`) and assert `1 passed`, so a non-matching filter would surface
as `0 passed` and fail the gate. ✓
9. **Parse-the-bytes-you-inline gate:** the project declares no spec-validation
parsers (CLAUDE.md project facts) → documented no-op. Additionally the only
inlined bodies are Rust (the project's source language), which is the
`implement` compile gate's target, not this gate's. ✓
@@ -1,603 +0,0 @@
# GraphBuilder — name-based blueprint wiring — Implementation Plan
> **Parent spec:** `docs/specs/0039-graphbuilder-name-based-wiring.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Add an additive, fluent `GraphBuilder` that authors blueprint topology
by typed node handles + port/field names, resolving them to the existing
index-wired `Composite` at a single fallible `build()`.
**Architecture:** A new `crates/aura-engine/src/builder.rs` module accumulates
typed handles (`NodeHandle`/`RoleHandle`) and unresolved `(handle, name)`
endpoints (`InPort`/`OutPort`); `build() -> Result<Composite, BuildError>`
resolves every name against each node's cached `NodeSchema` by exactly-one-match
(the `PrimitiveBuilder::bind` posture, but fallible) and hands the resolved
`Vec<Edge>`/`Vec<Role>`/`Vec<OutField>` to the unchanged `Composite::new`. A new
`impl From<Composite> for BlueprintNode` lets `add` accept nested composites.
Names never reach the flat graph (C23 holds by construction).
**Tech Stack:** Rust, `aura-engine` (blueprint/harness), `aura-core` (NodeSchema/
PortSpec/FieldSpec/ScalarKind), `aura-std` nodes for tests.
---
**Files this plan creates or modifies:**
- Create: `crates/aura-engine/src/builder.rs` — the `GraphBuilder` accumulator,
`NodeHandle`/`RoleHandle`/`InPort`/`OutPort`, `BuildError`, and the `build()`
resolver, plus a `#[cfg(test)] mod tests`.
- Modify: `crates/aura-engine/src/blueprint.rs:44` — add `impl From<Composite> for
BlueprintNode` beside the existing `From<PrimitiveBuilder>`.
- Modify: `crates/aura-engine/src/lib.rs:34-43` — `mod builder;` plus the
`pub use builder::{...}` re-export.
- Test: `crates/aura-engine/src/builder.rs` (`mod tests`) — structural parity,
harness FlatGraph parity, resolution errors, nested addressing, #21 legibility.
---
### Task 1: `From<Composite> for BlueprintNode` lift
**Files:**
- Modify: `crates/aura-engine/src/blueprint.rs:44-48`
- [ ] **Step 1: Write the failing test**
Append to the existing `#[cfg(test)] mod tests` in `crates/aura-engine/src/blueprint.rs`
(the module that starts at `blueprint.rs:753`, which already has `use super::*;`
and `use aura_std::{... Sub ...};`):
```rust
#[test]
fn composite_lifts_into_blueprint_node_via_from() {
let c = Composite::new(
"leaf",
vec![Sub::builder().into()],
vec![],
vec![],
vec![OutField { node: 0, field: 0, name: "o".into() }],
);
let bn: BlueprintNode = c.into();
assert!(matches!(bn, BlueprintNode::Composite(_)));
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cargo test -p aura-engine composite_lifts_into_blueprint_node_via_from`
Expected: FAIL — compile error `the trait `From<Composite>` is not implemented for `BlueprintNode`` (the `.into()` does not resolve).
- [ ] **Step 3: Write minimal implementation**
Immediately after the existing `impl From<PrimitiveBuilder> for BlueprintNode { ... }`
block at `crates/aura-engine/src/blueprint.rs:44-48`, add:
```rust
/// Ergonomic lift: a nested composite becomes a `Composite` blueprint item, so a
/// builder's `add` can accept a sub-graph the same way it accepts a primitive.
impl From<Composite> for BlueprintNode {
fn from(c: Composite) -> Self {
BlueprintNode::Composite(c)
}
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cargo test -p aura-engine composite_lifts_into_blueprint_node_via_from`
Expected: PASS
---
### Task 2: The `GraphBuilder` module + structural parity
**Files:**
- Create: `crates/aura-engine/src/builder.rs`
- Modify: `crates/aura-engine/src/lib.rs:34-43`
- [ ] **Step 1: Create the module skeleton (types + accumulators + `build()` stub)**
Create `crates/aura-engine/src/builder.rs` with:
```rust
//! `GraphBuilder` — name-based blueprint authoring (spec 0039).
//!
//! A fluent, additive authoring surface that wires a blueprint by typed node
//! handles and port/field *names*, resolving them to the raw-index `Composite`
//! at a single fallible `build()`. The flat graph stays index-wired (C23): names
//! are resolved at the authoring boundary and never reach `FlatGraph` — the same
//! posture param-name resolution already has (`Binder`, blueprint.rs). Resolution
//! mirrors `PrimitiveBuilder::bind`'s collect-then-reject (node.rs), but returns
//! `Err(BuildError)` instead of panicking.
use aura_core::{NodeSchema, ScalarKind};
use crate::blueprint::{BlueprintNode, Composite, OutField, Role};
use crate::harness::{Edge, Target};
/// A typed, `Copy` reference to a node added to a [`GraphBuilder`], carrying the
/// node's index in the builder's `nodes` Vec.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct NodeHandle(usize);
/// A typed, `Copy` reference to a role reserved on a [`GraphBuilder`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RoleHandle(usize);
/// An unresolved consumer endpoint: a node handle's index plus an input-port name.
#[derive(Clone, Copy, Debug)]
pub struct InPort {
node: usize,
name: &'static str,
}
/// An unresolved producer endpoint: a node handle's index plus an output-field name.
#[derive(Clone, Copy, Debug)]
pub struct OutPort {
node: usize,
name: &'static str,
}
impl NodeHandle {
/// Address an input port of this node by name (resolved at [`GraphBuilder::build`]).
pub fn in_(self, name: &'static str) -> InPort {
InPort { node: self.0, name }
}
/// Address this node's output field by name (resolved at [`GraphBuilder::build`]).
pub fn out(self, name: &'static str) -> OutPort {
OutPort { node: self.0, name }
}
}
/// An authoring-layer fault surfaced at [`GraphBuilder::build`]. Resolution by
/// name is necessary, not sufficient: a name-resolved edge that connects
/// mismatched scalar kinds still surfaces downstream as a bootstrap kind-check.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BuildError {
/// A handle whose index is out of range (i.e. minted by a different builder).
BadHandle { node: usize },
/// No input port of `node` is named `name`.
UnknownInPort { node: usize, name: String },
/// More than one input port of `node` is named `name`.
AmbiguousInPort { node: usize, name: String },
/// No output field of `node` is named `name`.
UnknownOutPort { node: usize, name: String },
/// More than one output field of `node` is named `name`.
AmbiguousOutPort { node: usize, name: String },
}
/// A fluent, additive accumulator that authors a [`Composite`] by typed handles
/// and port/field names. See the module docs and spec 0039.
pub struct GraphBuilder {
name: String,
nodes: Vec<BlueprintNode>,
schemas: Vec<NodeSchema>,
edges: Vec<(OutPort, InPort)>,
roles: Vec<(String, Option<ScalarKind>, Vec<InPort>)>,
out: Vec<(String, OutPort)>,
}
impl GraphBuilder {
/// Start a builder for a composite named `name` (a non-load-bearing render symbol).
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
nodes: Vec::new(),
schemas: Vec::new(),
edges: Vec::new(),
roles: Vec::new(),
out: Vec::new(),
}
}
/// Add a node (primitive builder or nested composite); returns its handle.
pub fn add(&mut self, item: impl Into<BlueprintNode>) -> NodeHandle {
let node = item.into();
let schema = node.signature();
let idx = self.nodes.len();
self.nodes.push(node);
self.schemas.push(schema);
NodeHandle(idx)
}
/// Reserve an open input role (wired by an enclosing graph); returns its handle.
pub fn input_role(&mut self, name: &str) -> RoleHandle {
let idx = self.roles.len();
self.roles.push((name.to_string(), None, Vec::new()));
RoleHandle(idx)
}
/// Reserve a bound ingestion role of `kind` (a root source); returns its handle.
pub fn source_role(&mut self, name: &str, kind: ScalarKind) -> RoleHandle {
let idx = self.roles.len();
self.roles.push((name.to_string(), Some(kind), Vec::new()));
RoleHandle(idx)
}
/// Wire a producer's output field into a consumer's input port (resolved at build).
pub fn connect(&mut self, from: OutPort, to: InPort) {
self.edges.push((from, to));
}
/// Fan a role's value into one or more consumer input ports.
pub fn feed(&mut self, role: RoleHandle, into: impl IntoIterator<Item = InPort>) {
self.roles[role.0].2.extend(into);
}
/// Re-export a producer's output field at the composite boundary under `name`.
pub fn expose(&mut self, from: OutPort, name: &str) {
self.out.push((name.to_string(), from));
}
/// Resolve every accumulated name to an index and assemble the [`Composite`].
pub fn build(self) -> Result<Composite, BuildError> {
unimplemented!("GraphBuilder::build is filled in the next step")
}
}
```
- [ ] **Step 2: Wire the module into the crate**
In `crates/aura-engine/src/lib.rs`, add `mod builder;` to the module block (after
`mod blueprint;` at line 34, keeping alphabetical order):
```rust
mod blueprint;
mod builder;
mod graph_model;
mod harness;
mod report;
mod sweep;
```
Then add the re-export immediately after the `pub use blueprint::{...};` block
(which ends at line 43):
```rust
pub use builder::{BuildError, GraphBuilder, InPort, NodeHandle, OutPort, RoleHandle};
```
- [ ] **Step 3: Write the failing structural-parity test**
Append a test module at the end of `crates/aura-engine/src/builder.rs`:
```rust
#[cfg(test)]
mod tests {
use super::{BuildError, GraphBuilder};
use crate::test_fixtures::sma_cross as hand_sma_cross;
use crate::{Composite, OutField, Role, Target};
use aura_core::{Firing, ScalarKind};
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
use std::sync::mpsc;
/// The `sma_cross` sub-composite authored through the builder.
fn built_sma_cross() -> Composite {
let mut g = GraphBuilder::new("sma_cross");
let fast = g.add(Sma::builder().named("fast"));
let slow = g.add(Sma::builder().named("slow"));
let sub = g.add(Sub::builder());
let price = g.input_role("price");
g.feed(price, [fast.in_("series"), slow.in_("series")]);
g.connect(fast.out("value"), sub.in_("lhs"));
g.connect(slow.out("value"), sub.in_("rhs"));
g.expose(sub.out("value"), "out");
g.build().expect("sma_cross resolves")
}
#[test]
fn builder_sma_cross_structurally_equals_hand_wired() {
let built = built_sma_cross();
let hand = hand_sma_cross();
assert_eq!(built.edges(), hand.edges());
assert_eq!(built.input_roles(), hand.input_roles());
assert_eq!(built.output(), hand.output());
}
}
```
- [ ] **Step 4: Run test to verify it fails**
Run: `cargo test -p aura-engine builder_sma_cross_structurally_equals_hand_wired`
Expected: FAIL — panic `not implemented: GraphBuilder::build is filled in the next step` (the `unimplemented!()` body of `build()`).
- [ ] **Step 5: Implement `build()` and the resolvers**
Replace the `build()` stub body in `crates/aura-engine/src/builder.rs` with the
real resolution, and add the two private resolver methods inside the same
`impl GraphBuilder` block (after `build`):
```rust
/// Resolve every accumulated name to an index and assemble the [`Composite`].
pub fn build(self) -> Result<Composite, BuildError> {
let mut edges = Vec::with_capacity(self.edges.len());
for (from, to) in &self.edges {
let from_field = self.resolve_field(from)?;
let (to_node, slot) = self.resolve_slot(to)?;
edges.push(Edge { from: from.node, to: to_node, slot, from_field });
}
let mut roles = Vec::with_capacity(self.roles.len());
for (name, source, ports) in &self.roles {
let mut targets = Vec::with_capacity(ports.len());
for p in ports {
let (node, slot) = self.resolve_slot(p)?;
targets.push(Target { node, slot });
}
roles.push(Role { name: name.clone(), targets, source: *source });
}
let mut output = Vec::with_capacity(self.out.len());
for (name, from) in &self.out {
let field = self.resolve_field(from)?;
output.push(OutField { node: from.node, field, name: name.clone() });
}
Ok(Composite::new(self.name, self.nodes, edges, roles, output))
}
/// Resolve an `InPort` to `(node-index, slot)` by exactly-one-match on the
/// node's input-port names (the `PrimitiveBuilder::bind` posture, fallible).
fn resolve_slot(&self, p: &InPort) -> Result<(usize, usize), BuildError> {
let schema = self
.schemas
.get(p.node)
.ok_or(BuildError::BadHandle { node: p.node })?;
let m: Vec<usize> = schema
.inputs
.iter()
.enumerate()
.filter(|(_, port)| port.name == p.name)
.map(|(i, _)| i)
.collect();
match m.as_slice() {
[i] => Ok((p.node, *i)),
[] => Err(BuildError::UnknownInPort { node: p.node, name: p.name.to_string() }),
_ => Err(BuildError::AmbiguousInPort { node: p.node, name: p.name.to_string() }),
}
}
/// Resolve an `OutPort` to a `from_field` by exactly-one-match on the node's
/// output-field names.
fn resolve_field(&self, p: &OutPort) -> Result<usize, BuildError> {
let schema = self
.schemas
.get(p.node)
.ok_or(BuildError::BadHandle { node: p.node })?;
let m: Vec<usize> = schema
.output
.iter()
.enumerate()
.filter(|(_, f)| f.name == p.name)
.map(|(i, _)| i)
.collect();
match m.as_slice() {
[i] => Ok(*i),
[] => Err(BuildError::UnknownOutPort { node: p.node, name: p.name.to_string() }),
_ => Err(BuildError::AmbiguousOutPort { node: p.node, name: p.name.to_string() }),
}
}
```
- [ ] **Step 6: Run test to verify it passes**
Run: `cargo test -p aura-engine builder_sma_cross_structurally_equals_hand_wired`
Expected: PASS
---
### Task 3: Harness FlatGraph parity (headline acceptance)
**Files:**
- Test: `crates/aura-engine/src/builder.rs` (`mod tests`)
- [ ] **Step 1: Write the parity test**
Append inside the `#[cfg(test)] mod tests` of `crates/aura-engine/src/builder.rs`:
```rust
#[test]
fn builder_harness_compiles_identically_to_hand_wired() {
use crate::test_fixtures::composite_sma_cross_harness;
// hand-wired reference harness -> FlatGraph
let (hand_bp, _rx_eq, _rx_ex) = composite_sma_cross_harness();
let hand_flat = hand_bp.compile().expect("hand harness compiles");
// builder-authored harness (nests a builder-authored sma_cross) -> FlatGraph
let (tx_eq, _r1) = mpsc::channel();
let (tx_ex, _r2) = mpsc::channel();
let mut g = GraphBuilder::new("root");
let xross = g.add(built_sma_cross());
let expo = g.add(Exposure::builder());
let broker = g.add(SimBroker::builder(0.0001));
let eq = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq));
let ex = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex));
let src = g.source_role("src", ScalarKind::F64);
g.feed(src, [xross.in_("price"), broker.in_("price")]);
g.connect(xross.out("out"), expo.in_("signal"));
g.connect(expo.out("exposure"), broker.in_("exposure"));
g.connect(broker.out("equity"), eq.in_("col[0]"));
g.connect(expo.out("exposure"), ex.in_("col[0]"));
let built_flat = g.build().expect("root resolves").compile().expect("built compiles");
assert_eq!(built_flat.edges, hand_flat.edges);
assert_eq!(built_flat.sources, hand_flat.sources);
}
```
- [ ] **Step 2: Run test to verify it passes**
Run: `cargo test -p aura-engine builder_harness_compiles_identically_to_hand_wired`
Expected: PASS (the builder-authored harness lowers to the same raw-index
`FlatGraph` edges and sources as the hand-wired fixture).
---
### Task 4: Resolution-error surface
**Files:**
- Test: `crates/aura-engine/src/builder.rs` (`mod tests`)
- [ ] **Step 1: Write the error tests**
Append inside the `#[cfg(test)] mod tests` of `crates/aura-engine/src/builder.rs`:
```rust
#[test]
fn unknown_in_port_is_reported_at_build() {
let mut g = GraphBuilder::new("x");
let a = g.add(Sma::builder());
let b = g.add(Sub::builder());
g.connect(a.out("value"), b.in_("nope"));
assert_eq!(
g.build().unwrap_err(),
BuildError::UnknownInPort { node: 1, name: "nope".into() }
);
}
#[test]
fn unknown_out_port_is_reported_at_build() {
let mut g = GraphBuilder::new("x");
let a = g.add(Sma::builder());
let b = g.add(Sub::builder());
g.connect(a.out("nope"), b.in_("lhs"));
assert_eq!(
g.build().unwrap_err(),
BuildError::UnknownOutPort { node: 0, name: "nope".into() }
);
}
#[test]
fn out_of_range_handle_is_bad_handle() {
// g1 mints index 2; g2 has only index 0 — using g1's handle in g2 is out of range.
let mut g1 = GraphBuilder::new("g1");
g1.add(Sma::builder());
g1.add(Sma::builder());
let third = g1.add(Sub::builder()); // NodeHandle(2)
let mut g2 = GraphBuilder::new("g2");
let only = g2.add(Sub::builder()); // NodeHandle(0)
g2.connect(third.out("value"), only.in_("lhs"));
assert_eq!(g2.build().unwrap_err(), BuildError::BadHandle { node: 2 });
}
#[test]
fn ambiguous_in_port_is_reported() {
// a composite with two roles both named "x" derives two input ports named "x"
let inner = Composite::new(
"dup_in",
vec![Sub::builder().into()],
vec![],
vec![
Role { name: "x".into(), targets: vec![Target { node: 0, slot: 0 }], source: None },
Role { name: "x".into(), targets: vec![Target { node: 0, slot: 1 }], source: None },
],
vec![OutField { node: 0, field: 0, name: "o".into() }],
);
let mut g = GraphBuilder::new("outer");
let c = g.add(inner);
let src = g.source_role("s", ScalarKind::F64);
g.feed(src, [c.in_("x")]);
assert_eq!(
g.build().unwrap_err(),
BuildError::AmbiguousInPort { node: 0, name: "x".into() }
);
}
#[test]
fn ambiguous_out_port_is_reported() {
// a composite re-exporting the same interior field twice under one name
let inner = Composite::new(
"dup_out",
vec![Sub::builder().into()],
vec![],
vec![],
vec![
OutField { node: 0, field: 0, name: "o".into() },
OutField { node: 0, field: 0, name: "o".into() },
],
);
let mut g = GraphBuilder::new("outer");
let c = g.add(inner);
let snk = g.add(Sub::builder());
g.connect(c.out("o"), snk.in_("lhs"));
assert_eq!(
g.build().unwrap_err(),
BuildError::AmbiguousOutPort { node: 0, name: "o".into() }
);
}
```
- [ ] **Step 2: Run the error tests to verify they pass**
Run: `cargo test -p aura-engine unknown_in_port_is_reported_at_build`
Expected: PASS
Run: `cargo test -p aura-engine unknown_out_port_is_reported_at_build`
Expected: PASS
Run: `cargo test -p aura-engine out_of_range_handle_is_bad_handle`
Expected: PASS
Run: `cargo test -p aura-engine ambiguous_in_port_is_reported`
Expected: PASS
Run: `cargo test -p aura-engine ambiguous_out_port_is_reported`
Expected: PASS
---
### Task 5: #21 legibility + full-suite/coexistence/clippy gate
**Files:**
- Test: `crates/aura-engine/src/builder.rs` (`mod tests`)
- [ ] **Step 1: Write the #21 legibility test**
Append inside the `#[cfg(test)] mod tests` of `crates/aura-engine/src/builder.rs`:
```rust
#[test]
fn simbroker_legs_resolve_by_name_and_a_typo_is_caught() {
// exposure/price (both f64, slot 0/1) addressed by NAME — the #21 legibility win
let mut ok = GraphBuilder::new("ok");
let expo = ok.add(Exposure::builder());
let broker = ok.add(SimBroker::builder(0.0001));
let src = ok.source_role("p", ScalarKind::F64);
ok.feed(src, [expo.in_("signal"), broker.in_("price")]);
ok.connect(expo.out("exposure"), broker.in_("exposure"));
ok.expose(broker.out("equity"), "eq");
assert!(ok.build().is_ok());
// a transposed/typo'd port name is caught, where a bare slot index is not
let mut typo = GraphBuilder::new("typo");
let expo2 = typo.add(Exposure::builder());
let broker2 = typo.add(SimBroker::builder(0.0001));
typo.connect(expo2.out("exposure"), broker2.in_("pirce"));
assert_eq!(
typo.build().unwrap_err(),
BuildError::UnknownInPort { node: 1, name: "pirce".into() }
);
}
```
- [ ] **Step 2: Run the legibility test**
Run: `cargo test -p aura-engine simbroker_legs_resolve_by_name_and_a_typo_is_caught`
Expected: PASS
- [ ] **Step 3: Coexistence — full workspace test suite green (existing index-form tests unchanged)**
Run: `cargo test --workspace`
Expected: PASS — all tests, including the pre-existing raw-index `Composite::new`
tests in `crates/aura-engine/src/blueprint.rs` and `crates/aura-engine/src/sweep.rs`,
which are untouched.
- [ ] **Step 4: Build + lint gate**
Run: `cargo build --workspace`
Expected: PASS (0 errors)
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: PASS (0 warnings)
-639
View File
@@ -1,639 +0,0 @@
# Wiring-totality check — Implementation Plan
> **Parent spec:** `docs/specs/0040-wiring-totality-check.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Reject a graph that leaves an interior input slot unwired or wires one
slot from more than one producer, at compile time, via a single name-free
structural check in `validate_wiring`.
**Architecture:** A new `check_ports_connected(nodes, edges, roles)` is added to
`validate_wiring` (crates/aura-engine/src/blueprint.rs), after the existing
index/kind checks and before the nested-composite recursion. It counts coverage of
each `(interior-node, slot)` uniformly across interior edges and role targets, and
rejects any interior input slot covered 0 times (`UnconnectedPort`) or >1 times
(`DoubleWiredPort`). Because `validate_wiring` is called once from
`compile_with_params` and recurses, both the raw `Composite::new` path and the
`GraphBuilder::build()` path inherit it at every nesting level. The blast radius is
exactly four currently-green tests (empirically probed), fixed here.
**Tech Stack:** `crates/aura-engine/src/blueprint.rs` (the check, the two
`CompileError` variants, raw-surface tests, four flipped-test fixes),
`crates/aura-engine/src/builder.rs` (one builder-surface test), `docs/design/INDEX.md`
(C8 realization note).
---
**Files this plan creates or modifies:**
- Modify: `crates/aura-engine/src/blueprint.rs` — add two `CompileError` variants
(~:489), add `check_ports_connected` + its call site in `validate_wiring`
(~:538/:546), re-wire 3 negative tests (~:1382/:1452/:1473), fully wire 1
param-order test (~:1877), add 5 raw-surface tests (test module).
- Modify: `crates/aura-engine/src/builder.rs` — add 1 builder-surface negative test
(after `builder_harness_compiles_identically_to_hand_wired`, ~:272).
- Modify: `docs/design/INDEX.md` — add the C8 cycle-0040 realization note (after the
cycle-0027 realization block, ~:316).
---
### Task 1: The wiring-totality check (two variants + the function + call site)
**Files:**
- Modify: `crates/aura-engine/src/blueprint.rs:489` (CompileError), `:538`/`:546`
(validate_wiring + new fn)
- [ ] **Step 1: Add the two `CompileError` variants**
In `crates/aura-engine/src/blueprint.rs`, replace:
```rust
/// A root input role `role` has no bound source (`source: None`) — an open port
/// at the root, which has no enclosing graph to wire it. Only a fully source-
/// bound composite is runnable.
UnboundRootRole { role: usize },
}
```
with:
```rust
/// A root input role `role` has no bound source (`source: None`) — an open port
/// at the root, which has no enclosing graph to wire it. Only a fully source-
/// bound composite is runnable.
UnboundRootRole { role: usize },
/// An interior node's input `slot` is covered by no edge and no role target — a
/// required port left unconnected (it would bootstrap a silent empty column).
UnconnectedPort { node: usize, slot: usize },
/// An interior node's input `slot` is covered by more than one edge/role target
/// combined — a slot holds exactly one column, so >1 producer is ill-formed.
DoubleWiredPort { node: usize, slot: usize },
}
```
- [ ] **Step 2: Add `check_ports_connected` and call it from `validate_wiring`**
In `crates/aura-engine/src/blueprint.rs`, replace the tail of `validate_wiring`:
```rust
// recurse into nested composites
for item in nodes {
if let BlueprintNode::Composite(c) = item {
validate_wiring(c.nodes(), c.edges(), c.input_roles(), c.output())?;
}
}
Ok(())
}
```
with (insert the call before the recursion, and define the new fn right after):
```rust
// wiring totality: every interior input slot covered by exactly one wiring act
check_ports_connected(nodes, edges, roles)?;
// recurse into nested composites
for item in nodes {
if let BlueprintNode::Composite(c) = item {
validate_wiring(c.nodes(), c.edges(), c.input_roles(), c.output())?;
}
}
Ok(())
}
/// Every interior node's every declared input slot must be covered by exactly one
/// wiring act — one interior edge OR one role target, counted uniformly. Zero = a
/// forgotten connection (would bootstrap a silent empty column); >1 = an ill-formed
/// slot (a slot holds one column). Index-based, name-free; presupposes in-range
/// edge/role indices (runs after the existing index-range checks). Mirrors the
/// single-site shape of `check_param_namespace_injective` (the wiring-side sibling).
fn check_ports_connected(
nodes: &[BlueprintNode],
edges: &[Edge],
roles: &[Role],
) -> Result<(), CompileError> {
let mut coverage: std::collections::HashMap<(usize, usize), usize> =
std::collections::HashMap::new();
for e in edges {
*coverage.entry((e.to, e.slot)).or_insert(0) += 1;
}
for role in roles {
for t in &role.targets {
*coverage.entry((t.node, t.slot)).or_insert(0) += 1;
}
}
for (n, item) in nodes.iter().enumerate() {
for slot in 0..item.signature().inputs.len() {
match coverage.get(&(n, slot)).copied().unwrap_or(0) {
1 => {}
0 => return Err(CompileError::UnconnectedPort { node: n, slot }),
_ => return Err(CompileError::DoubleWiredPort { node: n, slot }),
}
}
}
Ok(())
}
```
- [ ] **Step 2b: Make `derive_signature` bounds-total (the check exposes a latent panic)**
`check_ports_connected` (and the existing edge/role checks) compute `signature()` on
every interior node *before* the recursion validates that node's interior. For a
structurally-invalid composite (an out-of-range `OutField` or role target), the
current `derive_signature` indexes unguarded and **panics** instead of letting
`validate_wiring`'s guarded loops report `OutputPortOutOfRange` / `BadInteriorIndex`.
Make the two indexers bounds-total (a placeholder kind for an invalid index; the real
fault is still reported by `validate_wiring`).
In `crates/aura-engine/src/blueprint.rs`, replace the `output` map in `derive_signature`:
```rust
let kind = c.nodes()[of.node].signature().output[of.field].kind;
```
with:
```rust
// bounds-total: a structurally-invalid OutField (out-of-range node/field)
// yields a placeholder kind, never a panic — the real fault is reported by
// validate_wiring's guarded output check (OutputPortOutOfRange). signature()
// is computed speculatively by the parent's edge/role/connectivity checks
// before the recursion validates this composite's interior (cycle 0040).
let kind = c
.nodes()
.get(of.node)
.and_then(|n| n.signature().output.get(of.field).map(|f| f.kind))
.unwrap_or(ScalarKind::F64);
```
and replace `interior_slot_kind`'s body:
```rust
nodes[t.node].signature().inputs[t.slot].kind
```
with:
```rust
// bounds-total: a target into a missing node/slot yields a placeholder kind, never a
// panic — the real fault is reported by validate_wiring's guarded index checks.
nodes
.get(t.node)
.and_then(|n| n.signature().inputs.get(t.slot).map(|p| p.kind))
.unwrap_or(ScalarKind::F64)
```
- [ ] **Step 3: Build and confirm exactly the four expected flips**
Run: `cargo build -p aura-engine`
Expected: builds with 0 errors (the two `usize`-only variants break no derive or
exhaustive match; `HashMap` is used by its full path, no new import).
Run: `cargo test -p aura-engine 2>&1 | tail -3`
Expected: `test result: FAILED. ... 4 failed ...` — and the four are exactly
`bad_interior_index_rejected`, `role_kind_mismatch_rejected`,
`output_port_out_of_range_rejected`, and
`param_space_mirrors_compiled_flat_node_param_order_under_nesting`. (Any other
failure is an un-enumerated flip — STOP and re-survey; the probe established these
four are the complete set.)
---
### Task 2: Re-wire the three under-wired negative tests (covering root role)
Each wraps a faulty composite `c` in a root with empty edges + empty roles, so the
new check pre-empts the deep fault with `UnconnectedPort` at the root level. Add a
covering root role targeting `c`'s one input slot (mirroring the existing pattern at
blueprint.rs:1414). The deep fault then surfaces via the recursion (the index/kind/
output checks inside `validate_wiring(c)` run before `c`'s own connectivity check),
so each STILL asserts its original variant.
**Files:**
- Modify: `crates/aura-engine/src/blueprint.rs:1382`, `:1452`, `:1473`
- [ ] **Step 1: Re-wire `bad_interior_index_rejected`**
Replace:
```rust
let bp = Composite::new(
"root",
vec![BlueprintNode::Composite(c)],
vec![],
vec![],
vec![], // output
);
// the Ok arm holds Box<dyn Node> (not Debug), so assert via the Err arm.
assert_eq!(bp.compile().err(), Some(CompileError::BadInteriorIndex));
```
with:
```rust
let bp = Composite::new(
"root",
vec![BlueprintNode::Composite(c)],
vec![],
vec![Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) }],
vec![], // output
);
// the Ok arm holds Box<dyn Node> (not Debug), so assert via the Err arm.
assert_eq!(bp.compile().err(), Some(CompileError::BadInteriorIndex));
```
- [ ] **Step 2: Re-wire `role_kind_mismatch_rejected`**
Replace:
```rust
let bp = Composite::new(
"root",
vec![BlueprintNode::Composite(c)],
vec![],
vec![],
vec![], // output
);
// the Ok arm holds Box<dyn Node> (not Debug), so assert via the Err arm.
assert_eq!(bp.compile().err(), Some(CompileError::RoleKindMismatch { role: 0 }));
```
with:
```rust
let bp = Composite::new(
"root",
vec![BlueprintNode::Composite(c)],
vec![],
vec![Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) }],
vec![], // output
);
// the Ok arm holds Box<dyn Node> (not Debug), so assert via the Err arm.
assert_eq!(bp.compile().err(), Some(CompileError::RoleKindMismatch { role: 0 }));
```
- [ ] **Step 3: Re-wire `output_port_out_of_range_rejected`**
(This is the test whose deep fault is an out-of-range OUTPUT field. The covering role
forces the root's role-kind loop to compute `c.signature()`, which only stays
panic-free because of the `derive_signature` guard in Task 1 Step 2b; the recursion
then reports `OutputPortOutOfRange` as before.)
Replace:
```rust
let bp = Composite::new(
"root",
vec![BlueprintNode::Composite(c)],
vec![],
vec![],
vec![], // output
);
// the Ok arm holds Box<dyn Node> (not Debug), so assert via the Err arm.
assert_eq!(bp.compile().err(), Some(CompileError::OutputPortOutOfRange));
```
with:
```rust
let bp = Composite::new(
"root",
vec![BlueprintNode::Composite(c)],
vec![],
vec![Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) }],
vec![], // output
);
// the Ok arm holds Box<dyn Node> (not Debug), so assert via the Err arm.
assert_eq!(bp.compile().err(), Some(CompileError::OutputPortOutOfRange));
```
- [ ] **Step 4: Verify the three pass**
Run: `cargo test -p aura-engine bad_interior_index_rejected`
Expected: PASS (`1 passed`).
Run: `cargo test -p aura-engine role_kind_mismatch_rejected`
Expected: PASS (`1 passed`).
Run: `cargo test -p aura-engine output_port_out_of_range_rejected`
Expected: PASS (`1 passed`).
---
### Task 3: Fully wire the param-order nesting test
`param_space_mirrors_compiled_flat_node_param_order_under_nesting` compiles a
deliberately under-wired `strategy` (LinComb's two `term` inputs never fed) only to
read its flat param order. Fully wire it: fan `fast_slow`'s output into both LinComb
terms, and add a covering root role for `strategy`'s `price` input. Wiring changes no
node and no param, so the asserted `space == from_flat` equality is unchanged.
**Files:**
- Modify: `crates/aura-engine/src/blueprint.rs:1877`
- [ ] **Step 1: Wire the `strategy` edges and the root role**
In the function `param_space_mirrors_compiled_flat_node_param_order_under_nesting`,
replace (this span is unique to that function — it ends with the
`"nested composite compiles"` compile line, which the sibling param-order test lacks):
```rust
let strategy = Composite::new(
"strategy",
vec![BlueprintNode::Composite(fast_slow), LinComb::builder(2).into()],
vec![],
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
vec![OutField { node: 0, field: 0, name: "out".into() }],
);
let bp = Composite::new(
"root",
vec![BlueprintNode::Composite(strategy)],
vec![],
vec![],
vec![], // output
);
// the aggregated, path-qualified projection (borrows; take it first since
// compile() consumes self — same ordering as the single-level mirror test)
let space = bp.param_space();
// the same blueprint, compiled to its flat node array; each flat node's
// own declared params, concatenated in flat-node order
let flat = bp.compile_with_params(&[Scalar::I64(2), Scalar::I64(4), Scalar::F64(1.0), Scalar::F64(-1.0)]).expect("nested composite compiles");
```
with:
```rust
let strategy = Composite::new(
"strategy",
vec![BlueprintNode::Composite(fast_slow), LinComb::builder(2).into()],
// fan fast_slow's output into both LinComb terms so every interior slot
// is wired (the totality check, cycle 0040); param order is unaffected.
vec![
Edge { from: 0, to: 1, slot: 0, from_field: 0 },
Edge { from: 0, to: 1, slot: 1, from_field: 0 },
],
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
vec![OutField { node: 0, field: 0, name: "out".into() }],
);
let bp = Composite::new(
"root",
vec![BlueprintNode::Composite(strategy)],
vec![],
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) }],
vec![], // output
);
// the aggregated, path-qualified projection (borrows; take it first since
// compile() consumes self — same ordering as the single-level mirror test)
let space = bp.param_space();
// the same blueprint, compiled to its flat node array; each flat node's
// own declared params, concatenated in flat-node order
let flat = bp.compile_with_params(&[Scalar::I64(2), Scalar::I64(4), Scalar::F64(1.0), Scalar::F64(-1.0)]).expect("nested composite compiles");
```
- [ ] **Step 2: Verify it passes**
Run: `cargo test -p aura-engine param_space_mirrors_compiled_flat_node_param_order_under_nesting`
Expected: PASS (`1 passed`).
---
### Task 4: New raw-surface tests (unwired, double-wired, nested, open-role boundary)
Add these five tests to the `#[cfg(test)] mod tests` block in
`crates/aura-engine/src/blueprint.rs` (alongside the existing negative tests). They
use the in-module helpers `pass1()` (one f64 input "in", one output) and `sink_f64()`
(one f64 input "in", no output), and the in-scope `Composite`, `Edge`, `Role`,
`Target`, `OutField`, `BlueprintNode`, `CompileError`, `ScalarKind`.
**Files:**
- Modify: `crates/aura-engine/src/blueprint.rs` (test module)
- [ ] **Step 1: Add the five tests**
```rust
#[test]
fn unconnected_interior_slot_rejected() {
// pass1 is fed by a role; sink_f64's one input is left unwired -> rejected.
let bp = Composite::new(
"root",
vec![pass1(), sink_f64()],
vec![],
vec![Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) }],
vec![],
);
assert_eq!(
bp.compile().err(),
Some(CompileError::UnconnectedPort { node: 1, slot: 0 })
);
}
#[test]
fn double_wired_slot_rejected_edge_and_role() {
// sink_f64's one input is targeted by BOTH an edge (from pass1) and a role.
let bp = Composite::new(
"root",
vec![pass1(), sink_f64()],
vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
vec![
Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) },
Role { name: "extra".into(), targets: vec![Target { node: 1, slot: 0 }], source: Some(ScalarKind::F64) },
],
vec![],
);
assert_eq!(
bp.compile().err(),
Some(CompileError::DoubleWiredPort { node: 1, slot: 0 })
);
}
#[test]
fn double_wired_slot_rejected_two_edges() {
// two producers' edges land on sink_f64's single input slot.
let bp = Composite::new(
"root",
vec![pass1(), pass1(), sink_f64()],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 0, from_field: 0 },
],
vec![
Role { name: "a".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) },
Role { name: "b".into(), targets: vec![Target { node: 1, slot: 0 }], source: Some(ScalarKind::F64) },
],
vec![],
);
assert_eq!(
bp.compile().err(),
Some(CompileError::DoubleWiredPort { node: 2, slot: 0 })
);
}
#[test]
fn unconnected_slot_in_nested_composite_rejected() {
// inner composite c: pass1 (fed by c's role) + sink_f64 (interior slot
// unwired). The root covers c's one input, so the fault surfaces only via
// the recursion into c -> UnconnectedPort at c's interior index (1, 0).
let c = Composite::new(
"c",
vec![pass1(), sink_f64()],
vec![],
vec![Role { name: "in".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
vec![OutField { node: 0, field: 0, name: "out".into() }],
);
let bp = Composite::new(
"root",
vec![BlueprintNode::Composite(c)],
vec![],
vec![Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) }],
vec![],
);
assert_eq!(
bp.compile().err(),
Some(CompileError::UnconnectedPort { node: 1, slot: 0 })
);
}
#[test]
fn open_role_provider_is_not_flagged_unconnected() {
// a composite whose OPEN input role (source: None) feeds its interior slot,
// used as a nested node with that role covered by the enclosing root, must
// compile — the open role is a provider, not an unwired consumer.
let c = Composite::new(
"c",
vec![pass1()],
vec![],
vec![Role { name: "in".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
vec![OutField { node: 0, field: 0, name: "out".into() }],
);
let bp = Composite::new(
"root",
vec![BlueprintNode::Composite(c)],
vec![],
vec![Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) }],
vec![],
);
assert!(bp.compile().is_ok(), "open role as provider must not be mis-flagged");
}
```
- [ ] **Step 2: Verify the five pass**
Run: `cargo test -p aura-engine unconnected_interior_slot_rejected`
Expected: PASS (`1 passed`).
Run: `cargo test -p aura-engine double_wired_slot_rejected_edge_and_role`
Expected: PASS (`1 passed`).
Run: `cargo test -p aura-engine double_wired_slot_rejected_two_edges`
Expected: PASS (`1 passed`).
Run: `cargo test -p aura-engine unconnected_slot_in_nested_composite_rejected`
Expected: PASS (`1 passed`).
Run: `cargo test -p aura-engine open_role_provider_is_not_flagged_unconnected`
Expected: PASS (`1 passed`).
---
### Task 5: Builder-surface forgotten-leg test
Mirror `builder_harness_compiles_identically_to_hand_wired` but omit the
exposure→broker connection: every name resolves so `build()` succeeds, but
`compile_with_params` rejects the uncovered broker exposure slot. Proves the
`GraphBuilder` surface inherits the check.
**Files:**
- Modify: `crates/aura-engine/src/builder.rs` (test module, after
`builder_harness_compiles_identically_to_hand_wired`)
- [ ] **Step 1: Add the test**
```rust
#[test]
fn unconnected_port_rejected_on_builder_surface() {
use crate::CompileError;
use aura_core::Scalar;
// The forgotten-exposure-leg harness: every port/field name resolves, so
// build() succeeds; but broker.input("exposure") is never connected, so the
// wiring-totality check rejects it at compile (broker is node 1, slot 0).
let (tx_eq, _r1) = mpsc::channel();
let mut g = GraphBuilder::new("root");
let expo = g.add(Exposure::builder());
let broker = g.add(SimBroker::builder(0.0001));
let eq = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq));
let price = g.source_role("price", ScalarKind::F64);
g.feed(price, [expo.input("signal"), broker.input("price")]);
// BUG: g.connect(expo.output("exposure"), broker.input("exposure")) missing.
g.connect(broker.output("equity"), eq.input("col[0]"));
let compiled = g
.build()
.expect("all port/field names resolve")
.compile_with_params(&[Scalar::F64(0.5)]);
assert_eq!(
compiled.err(),
Some(CompileError::UnconnectedPort { node: 1, slot: 0 })
);
}
```
- [ ] **Step 2: Verify it passes**
Run: `cargo test -p aura-engine unconnected_port_rejected_on_builder_surface`
Expected: PASS (`1 passed`).
---
### Task 6: C8 ledger realization note
**Files:**
- Modify: `docs/design/INDEX.md:316` (after the cycle-0027 realization block, before
the blank line preceding `### C9`)
- [ ] **Step 1: Insert the cycle-0040 realization note**
Find the end of the C8 "Realization (cycle 0027 — name input ports…)" block (the
paragraph ending "…a name-consuming validation is its own future cycle.", ~line 316),
and insert a blank line then this paragraph immediately after it:
```text
**Realization (cycle 0040 — wiring totality: every input slot connected exactly
once, #65).** The node contract's "the engine provides a window into each input"
presupposes each input is actually fed; until now an unwired interior input slot was
accepted and bootstrapped to a silent empty column (`harness.rs`), and two producers
into one slot — ill-formed, since a slot holds one column — was likewise
uncompiled-against. Cycle 0040 makes a valid graph **total and single-valued** over
its interior input slots: `check_ports_connected` (in `validate_wiring`, run at every
nesting level) requires every interior node's every declared input slot to be covered
by **exactly one** wiring act — one interior `Edge { to, slot }` or one role
`Target { node, slot }`, edges and role targets counted uniformly. Zero coverage is
`CompileError::UnconnectedPort`, more than one is `CompileError::DoubleWiredPort`. A
composite's **own** input roles (`source: None`) are coverage *providers* — the
wired-by-enclosing boundary, the root case already guarded by `UnboundRootRole` —
never consumers, so only interior input slots are subject to the rule. There is **no
optional-input concept**: every declared input port is required (no shipped node runs
meaningfully without one; an unwarmed mode-A input is *wired-but-not-yet-valued*, not
unwired). The check is **index-based and name-free** — it touches no name machinery
and emits nothing into the flat graph, so C23 is untouched (it proves the existing
raw-index wiring is total and single-valued). Inherited identically by the raw
`Composite::new` path and the `GraphBuilder::build()` path (both compile via
`compile_with_params`).
```
- [ ] **Step 2: Confirm the note landed**
Run: `grep -c "cycle 0040 — wiring totality" docs/design/INDEX.md`
Expected: `1`
---
### Task 7: Full-suite + lint gate
**Files:** none (verification only)
- [ ] **Step 1: Full workspace test**
Run: `cargo test --workspace 2>&1 | grep -E "test result:" | grep -v "0 failed" | head`
Expected: no output (every `test result:` line reports `0 failed`).
- [ ] **Step 2: Clippy**
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: finishes with 0 warnings/errors.
-871
View File
@@ -1,871 +0,0 @@
# Source ingestion seam — Implementation Plan
> **Parent spec:** `docs/specs/0041-source-ingestion-seam.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to
> run this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Re-type the engine's ingestion input from a materialized
`Vec<Vec<(Timestamp, Scalar)>>` into a `Source` producer trait the k-way merge
drives by `peek`/`next`, and prove it against a real, lazily-streamed
`data-server` window — closing the cycle-0011 eager-materialization gap.
**Architecture:** `aura-engine` gains a `Source` trait + a `VecSource` adapter;
`Harness::run` is re-typed to `Vec<Box<dyn Source>>` with the merge loop driven
by `peek`/`next`, and every existing call site is wrapped in `VecSource`
(behaviour-preserving, byte-identical). `aura-ingest` gains a streaming
`M1FieldSource` over a `data-server` M1 window (per-pull decode from a borrowed
`Arc` chunk, lazy `next_chunk` refill, `ms→epoch-ns` at the seam), proven by a
gated end-to-end backtest plus a measured residency predicate.
**Tech Stack:** `aura-engine` (`harness.rs`, `lib.rs`), `aura-ingest`
(`lib.rs`, `tests/`), `data-server` (`stream_m1_windowed`, `SymbolChunkIter`,
`next_chunk`, `loader::CHUNK_SIZE`, `M1Parsed`).
---
**Files this plan creates or modifies:**
- Modify: `crates/aura-engine/src/harness.rs` — add `Source` trait + `VecSource`
(after `SourceSpec`, ~line 52); re-type `Harness::run` (line 220) + merge loop
(220-258); re-type test helper `ohlcv_streams()` (line 900); wrap 31 call sites.
- Modify: `crates/aura-engine/src/lib.rs:25-30,47` — refresh module doc; add
`Source`, `VecSource` to the crate-root re-export.
- Modify: `crates/aura-engine/src/blueprint.rs` — wrap 10 call sites.
- Modify: `crates/aura-engine/src/sweep.rs:263` — wrap 1 call site.
- Modify: `crates/aura-engine/src/report.rs:223` — wrap 1 call site.
- Modify: `crates/aura-cli/src/main.rs` — wrap 4 call sites.
- Modify: `crates/aura-ingest/src/lib.rs` — add `M1Field` + `decode` + `M1FieldSource`
(near `load_m1_window`, ~line 110); add `data_server::SymbolChunkIter` import.
- Modify: `crates/aura-ingest/tests/real_bars.rs:72` — wrap 1 call site.
- Create: `crates/aura-ingest/tests/streaming_seam.rs` — gated streaming e2e +
residency-drive + sharing tests.
- Test: `harness.rs` `#[cfg(test)] mod tests``VecSource` unit tests.
- Test: `aura-ingest/src/lib.rs` `#[cfg(test)] mod tests``decode` unit tests.
---
## Task 1: `Source` trait + `VecSource` adapter (aura-engine)
**Files:**
- Modify: `crates/aura-engine/src/harness.rs` (after `SourceSpec`, ~line 52)
- Modify: `crates/aura-engine/src/lib.rs:25-30,47`
- Test: `crates/aura-engine/src/harness.rs` (`#[cfg(test)] mod tests`)
This task adds the new types only; `Harness::run` is NOT re-typed here (that is
Task 2). `Source`/`VecSource` compile and their tests pass standalone.
- [ ] **Step 1: Add the `Source` trait + `VecSource` to `harness.rs`**
Insert immediately after the `SourceSpec` struct (after its closing `}` at
`harness.rs:52`, before the `FlatGraph` doc comment at line 54):
```rust
/// The ingestion-boundary producer the k-way merge drives (C3). A source yields
/// timestamped scalars in ascending timestamp order (the C3 ingestion
/// precondition). Object-safe: the merge holds `Vec<Box<dyn Source>>`.
pub trait Source {
/// The timestamp of the head record, without consuming it. `None` = the
/// source is exhausted. Cheap and side-effect-free: it reads a pre-decoded
/// head, never advancing the underlying stream.
fn peek(&self) -> Option<Timestamp>;
/// Pop the head `(timestamp, scalar)` and advance. `None` = exhausted.
/// After `next` returns `Some`, `peek` reflects the new head.
fn next(&mut self) -> Option<(Timestamp, Scalar)>;
}
/// A `Source` over a fully materialized stream — the cycle-0011 eager shape,
/// named. Every pre-seam call site wraps its `Vec<(Timestamp, Scalar)>` in this,
/// so run output is byte-identical (C1) and residency is unchanged.
pub struct VecSource {
stream: Vec<(Timestamp, Scalar)>,
cursor: usize,
}
impl VecSource {
pub fn new(stream: Vec<(Timestamp, Scalar)>) -> Self {
Self { stream, cursor: 0 }
}
}
impl Source for VecSource {
fn peek(&self) -> Option<Timestamp> {
self.stream.get(self.cursor).map(|&(t, _)| t)
}
fn next(&mut self) -> Option<(Timestamp, Scalar)> {
let item = self.stream.get(self.cursor).copied()?;
self.cursor += 1;
Some(item)
}
}
```
- [ ] **Step 2: Add `VecSource` unit tests**
Append inside the existing `#[cfg(test)] mod tests { ... }` block in
`harness.rs` (the module already has `use super::*;`):
```rust
#[test]
fn vec_source_peek_is_non_consuming_and_idempotent() {
let mut s = VecSource::new(vec![(Timestamp(1), Scalar::F64(10.0)), (Timestamp(2), Scalar::F64(20.0))]);
// peek twice: same head, no advance.
assert_eq!(s.peek(), Some(Timestamp(1)));
assert_eq!(s.peek(), Some(Timestamp(1)));
// next pops it; peek now reflects the new head.
assert_eq!(Source::next(&mut s), Some((Timestamp(1), Scalar::F64(10.0))));
assert_eq!(s.peek(), Some(Timestamp(2)));
}
#[test]
fn vec_source_exhaustion_yields_none() {
let mut s = VecSource::new(vec![(Timestamp(5), Scalar::I64(7))]);
assert_eq!(Source::next(&mut s), Some((Timestamp(5), Scalar::I64(7))));
assert_eq!(s.peek(), None);
assert_eq!(Source::next(&mut s), None);
}
#[test]
fn vec_source_empty_peeks_none() {
let mut s = VecSource::new(vec![]);
assert_eq!(s.peek(), None);
assert_eq!(Source::next(&mut s), None);
}
```
(`Source::next(&mut s)` is spelled fully-qualified so it never collides with
`Iterator::next` in scope.)
- [ ] **Step 3: Re-export the new types and refresh the module doc**
In `crates/aura-engine/src/lib.rs`, change the re-export at line 47 from:
```rust
pub use harness::{BootstrapError, Edge, FlatGraph, Harness, SourceSpec, Target};
```
to:
```rust
pub use harness::{BootstrapError, Edge, FlatGraph, Harness, Source, SourceSpec, Target, VecSource};
```
And in the module doc, change the "Still to come" line at lines 25-26 from:
```rust
//! Still to come (subsequent cycles): the `Source` trait + data-server ingestion
//! and source-native time normalization (C3/C11), the broker-independent
```
to:
```rust
//! Still to come (subsequent cycles): the broker-independent
```
- [ ] **Step 4: Build and run the `VecSource` tests**
Run: `cargo test -p aura-engine vec_source`
Expected: PASS (3 tests: `vec_source_peek_is_non_consuming_and_idempotent`,
`vec_source_exhaustion_yields_none`, `vec_source_empty_peeks_none`).
- [ ] **Step 5: Lint gate**
Run: `cargo clippy -p aura-engine --all-targets -- -D warnings`
Expected: clean (no warnings; the pub re-exported `Source`/`VecSource` are not
dead code).
---
## Task 2: Re-type `Harness::run` + thread all callers (atomic compile unit)
**Files:**
- Modify: `crates/aura-engine/src/harness.rs:220-258` (signature + merge loop),
`:900` (`ohlcv_streams` helper), 31 call sites
- Modify: `crates/aura-engine/src/blueprint.rs` (10 sites)
- Modify: `crates/aura-engine/src/sweep.rs:263` (1 site)
- Modify: `crates/aura-engine/src/report.rs:223` (1 site)
- Modify: `crates/aura-cli/src/main.rs` (4 sites)
- Modify: `crates/aura-ingest/tests/real_bars.rs:72` (1 site)
**Why one task:** re-typing `run`'s signature makes the whole workspace fail to
compile until every one of the 53 callers is threaded. The signature change and
all callers must land together; the build gate at the end is unsatisfiable
otherwise (self-review item 7). The 4 `h.run(ohlcv_streams())` sites are threaded
by re-typing the helper, not the call site.
**The mechanical wrap rule (applies to every `vec![...]` call-site literal):**
each top-level element `X` of the `vec![...]` passed to `.run(` is replaced by
`Box::new(VecSource::new(X))`. The expected parameter type `Vec<Box<dyn Source>>`
drives the unsizing coercion, so no `as Box<dyn Source>` is needed.
Example: `h.run(vec![prices])``h.run(vec![Box::new(VecSource::new(prices))])`;
`h.run(vec![s0, s1])``h.run(vec![Box::new(VecSource::new(s0)), Box::new(VecSource::new(s1))])`.
- [ ] **Step 1: Re-type the `run` signature + merge loop in `harness.rs`**
Replace the current signature + pick scan (`harness.rs:220-257`). Current:
```rust
pub fn run(&mut self, streams: Vec<Vec<(Timestamp, Scalar)>>) {
assert_eq!(
streams.len(),
self.sources.len(),
"run: one stream per source required (got {} streams for {} sources)",
streams.len(),
self.sources.len()
);
// disjoint field borrows so the topo walk can read topo/out_edges/sources
// while mutating nodes
let Harness { nodes, topo, out_edges, sources } = self;
let mut cursor: Vec<usize> = vec![0; streams.len()];
let mut cycle_id: u64 = 0;
let mut scratch: Vec<Scalar> = Vec::new();
loop {
// pick the live source head with the smallest (timestamp, source index)
let mut pick: Option<usize> = None;
for (s, stream) in streams.iter().enumerate() {
if cursor[s] < stream.len() {
match pick {
None => pick = Some(s),
Some(p) => {
if stream[cursor[s]].0 < streams[p][cursor[p]].0 {
pick = Some(s);
}
}
}
}
}
let s = match pick {
Some(s) => s,
None => break, // all streams exhausted
};
let (ts, value) = streams[s][cursor[s]];
cursor[s] += 1;
cycle_id += 1;
```
Replace with (sources arg is `mut`; the forward+eval body below line 257 is
UNCHANGED):
```rust
pub fn run(&mut self, mut sources_in: Vec<Box<dyn Source>>) {
assert_eq!(
sources_in.len(),
self.sources.len(),
"run: one source per SourceSpec required (got {} sources for {} specs)",
sources_in.len(),
self.sources.len()
);
// disjoint field borrows so the topo walk can read topo/out_edges/sources
// while mutating nodes
let Harness { nodes, topo, out_edges, sources } = self;
let mut cycle_id: u64 = 0;
let mut scratch: Vec<Scalar> = Vec::new();
loop {
// pick the live source head with the smallest (timestamp, source index).
// strictly-`<` replace + source-order scan preserves C4 tie-breaking.
let mut pick: Option<(usize, Timestamp)> = None;
for (s, src) in sources_in.iter().enumerate() {
if let Some(ts) = src.peek() {
match pick {
None => pick = Some((s, ts)),
Some((_, best)) if ts < best => pick = Some((s, ts)),
_ => {}
}
}
}
let s = match pick {
Some((s, _)) => s,
None => break, // all sources exhausted
};
// `&mut *sources_in[s]`: deref the Box to `dyn Source` then re-borrow,
// so UFCS resolves Self = dyn Source (Box<dyn Source> does not itself
// impl Source). Fully-qualified to never collide with Iterator::next.
let (ts, value) =
Source::next(&mut *sources_in[s]).expect("peeked Some ⇒ next is Some");
cycle_id += 1;
```
(The local binding is named `sources_in` to avoid shadowing the `sources`
destructured from `self` — the `SourceSpec` wiring used in the forward step at
the unchanged line `for t in sources[s].targets.iter()`.)
- [ ] **Step 2: Verify the forward/eval body still references `sources` (the wiring)**
Confirm the unchanged forwarding line (was `harness.rs:261`) still reads:
```rust
for t in sources[s].targets.iter() {
```
`sources` here is the `Vec<SourceSpec>` destructured from `self` — unchanged. No
edit; this step is a read-check that the rename in Step 1 did not collide.
- [ ] **Step 3: Re-type the `ohlcv_streams()` test helper in `harness.rs:900`**
Replace (lines 899-908):
```rust
/// Build five timestamp-aligned f64 sources feeding Ohlcv's five barrier slots.
fn ohlcv_streams() -> Vec<Vec<(Timestamp, Scalar)>> {
vec![
f64_stream(&[(1, 10.0), (2, 20.0)]), // open
f64_stream(&[(1, 15.0), (2, 25.0)]), // high
f64_stream(&[(1, 8.0), (2, 19.0)]), // low
f64_stream(&[(1, 12.0), (2, 22.0)]), // close
f64_stream(&[(1, 100.0), (2, 200.0)]), // volume
]
}
```
with (returns boxed sources, so the four `h.run(ohlcv_streams())` callers need
no change):
```rust
/// Build five timestamp-aligned f64 sources feeding Ohlcv's five barrier slots.
fn ohlcv_streams() -> Vec<Box<dyn Source>> {
vec![
Box::new(VecSource::new(f64_stream(&[(1, 10.0), (2, 20.0)]))), // open
Box::new(VecSource::new(f64_stream(&[(1, 15.0), (2, 25.0)]))), // high
Box::new(VecSource::new(f64_stream(&[(1, 8.0), (2, 19.0)]))), // low
Box::new(VecSource::new(f64_stream(&[(1, 12.0), (2, 22.0)]))), // close
Box::new(VecSource::new(f64_stream(&[(1, 100.0), (2, 200.0)]))), // volume
]
}
```
- [ ] **Step 4: Wrap the 31 remaining `harness.rs` call sites**
Apply the wrap rule to each site below (the 4 `h.run(ohlcv_streams())` at
`:948/:1000/:1013/:1043` are NOT in this list — they are handled by Step 3).
Each `vec![...]` element becomes `Box::new(VecSource::new(<element>))`:
```text
:594 h.run(vec![f64_stream(&[(1,1.0),(2,2.0),(3,3.0),(4,4.0),(5,5.0)])]);
:642 h.run(vec![prices.clone()]); :657 h2.run(vec![prices]);
:689 h.run(vec![s0.clone(), s1.clone()]); :704 h2.run(vec![s0, s1]);
:735 h.run(vec![s0.clone(), s1.clone()]); :748 h2.run(vec![s0, s1]);
:789 h.run(vec![prices.clone()]); :804 h2.run(vec![prices]);
:834 h.run(vec![s0, s1, s2]);
:1122 h.run(vec![f64_stream(&[(1,10.0),(2,12.0),(3,14.0),(4,16.0),(5,18.0)])]);
:1172 h.run(vec![ f64_stream(..)×5 ]); (wrap each of the 5 elements)
:1219 h.run(vec![ <4 raw inner-vec literals> ]); (wrap each of the 4 — see Step 5)
:1257 h.run(vec![f64_stream(&[(1,10.0),(2,20.0),(3,30.0)])]);
:1292 a.run(vec![prices.clone()]); :1297 b.run(vec![prices]);
:1327 h.run(vec![s0, s1]); :1362 h.run(vec![s0, s1]);
:1447 h.run(vec![prices.clone()]); :1466 h2.run(vec![prices]);
:1512 h.run(vec![prices.clone()]); :1535 h2.run(vec![prices]);
:1579 h.run(vec![a.clone(), b.clone()]); :1600 h2.run(vec![a, b]);
:1640 h.run(vec![prices.clone()]); :1651 h2.run(vec![prices]);
:1701 h.run(vec![prices.clone()]); :1728 h2.run(vec![prices]);
:1788 h.run(vec![prices.clone(), counts.clone()]); :1817 h2.run(vec![prices, counts]);
:1861 h.run(vec![f64_stream(&[...])]); (multi-line; wrap the single element)
:1926 h.run(vec![f64_stream(&[...])]); (multi-line; wrap the single element)
```
(Line numbers drift as edits are applied; match on the call expression, not the
absolute line. After this step, `git grep -n 'run(vec!' crates/aura-engine/src/harness.rs`
should return zero un-wrapped `vec![` — every element is now `Box::new(VecSource::new(...))`.)
- [ ] **Step 5: Wrap the raw inner-vec literal at `harness.rs:1219`**
Replace:
```rust
h.run(vec![
vec![(Timestamp(1), Scalar::I64(7))],
vec![(Timestamp(2), Scalar::F64(1.5))],
vec![(Timestamp(3), Scalar::Bool(true))],
vec![(Timestamp(4), Scalar::Ts(Timestamp(99)))],
]);
```
with:
```rust
h.run(vec![
Box::new(VecSource::new(vec![(Timestamp(1), Scalar::I64(7))])),
Box::new(VecSource::new(vec![(Timestamp(2), Scalar::F64(1.5))])),
Box::new(VecSource::new(vec![(Timestamp(3), Scalar::Bool(true))])),
Box::new(VecSource::new(vec![(Timestamp(4), Scalar::Ts(Timestamp(99)))])),
]);
```
- [ ] **Step 6: Wrap the 10 `blueprint.rs` call sites**
Apply the wrap rule to each (match on the expression):
```text
:1023 named.run(vec![synthetic_prices()]); :1031 positional.run(vec![synthetic_prices()]);
:1742 flat.run(vec![prices.clone()]); :1749 composed.run(vec![prices]);
:1812 h.run(vec![prices]); :1829 a.run(vec![prices.clone()]);
:1835 b.run(vec![prices]); :1873 a.run(vec![prices.clone()]);
:1877 b.run(vec![prices]); :2395 h.run(vec![prices]);
```
Each → `<recv>.run(vec![Box::new(VecSource::new(<element>))]);`.
- [ ] **Step 7: Wrap the `sweep.rs` and `report.rs` call sites**
`crates/aura-engine/src/sweep.rs:263`:
`h.run(vec![synthetic_prices()]);``h.run(vec![Box::new(VecSource::new(synthetic_prices()))]);`
`crates/aura-engine/src/report.rs:223` (multi-line `f64_stream` element): wrap the
single element → `h.run(vec![Box::new(VecSource::new(f64_stream(&[...])))]);`
- [ ] **Step 8: Wrap the 4 `aura-cli/src/main.rs` call sites**
```text
:140 h.run(vec![prices]); :283 h.run(vec![prices]);
:462 h.run(vec![prices]); :527 h.run(vec![showcase_prices()]);
```
Each → `h.run(vec![Box::new(VecSource::new(<element>))]);`. `main.rs` already
imports from `aura_engine`; ensure `VecSource` is in the import list (the file
imports `Harness` etc. from `aura_engine::{...}` — add `VecSource`).
- [ ] **Step 9: Wrap the `real_bars.rs` call site**
`crates/aura-ingest/tests/real_bars.rs:72`:
`h.run(vec![prices]);``h.run(vec![Box::new(VecSource::new(prices))]);`
Add `VecSource` to the `use aura_engine::{...}` import list at `real_bars.rs:11`.
- [ ] **Step 10: Workspace build gate**
Run: `cargo build --workspace --tests`
Expected: 0 errors (every one of the 53 callers threaded; the only `fn run` in
the workspace is `Harness::run`, so there is no method-resolution collision).
- [ ] **Step 11: Behaviour-preserving test gate**
Run: `cargo test --workspace`
Expected: all green — byte-identical to pre-seam. The two-run C1 determinism
pairs (`real_bars.rs` `r1`/`r2` `to_json`, `blueprint.rs` sweep-equality,
`harness.rs` `h`/`h2` pairs) stay green; no test output changes.
- [ ] **Step 12: Lint gate**
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: clean.
---
## Task 3: `M1Field` + `M1FieldSource` streaming source (aura-ingest)
**Files:**
- Modify: `crates/aura-ingest/src/lib.rs` (imports at 15-18; new items near
`load_m1_window` at ~110)
- Test: `crates/aura-ingest/src/lib.rs` (`#[cfg(test)] mod tests`)
`decode` is a **free function** of `(field, bar)` (the spec calls it "a pure
function of `(bar, field)`"), so the hermetic decode test needs no iterator.
- [ ] **Step 0: Move `aura-engine` to `[dependencies]`**
`M1FieldSource` lives in `src/lib.rs` and implements `aura_engine::Source`, so
`aura-engine` must be a regular dependency — it is currently `[dev-dependencies]`
only (`Cargo.toml:17`). In `crates/aura-ingest/Cargo.toml`, add under
`[dependencies]` (after the `data-server` line):
```toml
aura-engine = { path = "../aura-engine" }
```
and REMOVE the now-duplicate `aura-engine = { path = "../aura-engine" }` line
from `[dev-dependencies]` (cargo warns on a dependency present in both). Leave
`aura-std` in `[dev-dependencies]` — only the tests use it. Regular deps are
visible to tests, so `real_bars.rs` still resolves `aura_engine`.
Run: `cargo tree -p aura-ingest -e normal -i aura-engine 2>&1 | head -2`
Expected: `aura-engine` now appears as a normal (non-dev) dependency.
- [ ] **Step 1: Add the `SymbolChunkIter` import**
In `crates/aura-ingest/src/lib.rs`, change line 17:
```rust
use data_server::DataServer;
```
to:
```rust
use data_server::{DataServer, SymbolChunkIter};
```
- [ ] **Step 2: Add `M1Field`, `decode`, and `M1FieldSource`**
Insert after `load_m1_window` (after its closing `}` at `lib.rs:123`):
```rust
/// Which base column of an M1 bar a source streams (C7: a composite window is a
/// bundle of base columns; one `Source` per consumed field).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum M1Field {
Open,
High,
Low,
Close,
Spread,
Volume,
}
/// Project one M1 bar into `(normalized ts, scalar)` for `field`. Pure: a
/// function of `(field, bar)` only — no iterator, no clock — so it is testable
/// in isolation. Time is normalized ms→epoch-ns at this one seam (C3).
fn decode(field: M1Field, bar: &M1Parsed) -> (Timestamp, Scalar) {
let value = match field {
M1Field::Open => Scalar::F64(bar.open),
M1Field::High => Scalar::F64(bar.high),
M1Field::Low => Scalar::F64(bar.low),
M1Field::Close => Scalar::F64(bar.close),
M1Field::Spread => Scalar::F64(bar.spread),
M1Field::Volume => Scalar::I64(bar.volume),
};
(unix_ms_to_epoch_ns(bar.time_ms), value)
}
/// A streaming [`Source`](aura_engine::Source) over a data-server M1 window. Holds
/// at most one `Arc` chunk (a zero-copy clone of the cache's chunk) and a cursor;
/// constructs each `Scalar` per-pull; refills via `next_chunk()` when the chunk
/// drains. Resident footprint is O(one chunk), independent of window length.
pub struct M1FieldSource {
iter: SymbolChunkIter<M1Parsed>,
chunk: Option<Arc<[M1Parsed]>>,
pos: usize,
field: M1Field,
head: Option<(Timestamp, Scalar)>,
}
impl M1FieldSource {
/// Open over `[from_ms, to_ms]` (inclusive Unix-ms, data-server's contract).
/// `None` when no archived file overlaps the window (unknown symbol /
/// far-future window) — propagating `stream_m1_windowed`'s file-level Option.
/// A window that overlaps a file but holds zero matching bars yields a source
/// whose first `peek` is `None` (immediately exhausted), not `None` here.
pub fn open(
server: &Arc<DataServer>,
symbol: &str,
from_ms: Option<i64>,
to_ms: Option<i64>,
field: M1Field,
) -> Option<Self> {
let iter = server.stream_m1_windowed(symbol, from_ms, to_ms)?;
let mut s = Self { iter, chunk: None, pos: 0, field, head: None };
s.advance();
Some(s)
}
/// Decode the head at the cursor, refilling chunks as needed. Sets
/// `self.head = None` at exhaustion.
fn advance(&mut self) {
loop {
if self.chunk.is_none() {
self.chunk = self.iter.next_chunk();
self.pos = 0;
if self.chunk.is_none() {
self.head = None;
return;
}
}
let chunk = self.chunk.as_ref().unwrap();
if self.pos < chunk.len() {
self.head = Some(decode(self.field, &chunk[self.pos]));
return;
}
self.chunk = None; // current chunk drained — loop to refill
}
}
/// Records resident in this source right now: the current chunk's length (or
/// 0 at exhaustion). The residency probe — bounded by one chunk length by
/// construction (the source holds at most one `Arc` chunk and no accumulating
/// field), so it can never grow with window length.
pub fn resident_records(&self) -> usize {
self.chunk.as_ref().map_or(0, |c| c.len())
}
}
impl aura_engine::Source for M1FieldSource {
fn peek(&self) -> Option<Timestamp> {
self.head.map(|(t, _)| t)
}
fn next(&mut self) -> Option<(Timestamp, Scalar)> {
let item = self.head?;
self.pos += 1;
self.advance();
Some(item)
}
}
```
(`aura-engine` is made a regular dependency in Step 0, so `aura_engine::Source`
resolves from `src/lib.rs`.)
- [ ] **Step 4: Add hermetic `decode` unit tests**
Append inside the existing `#[cfg(test)] mod tests` block in
`aura-ingest/src/lib.rs` (it already has `use super::*;` and a `full_bar`
helper at `lib.rs:142`):
```rust
#[test]
fn decode_projects_each_field_to_its_scalar_kind() {
// full_bar: open 1.0, high 2.0, low 0.5, close 1.5, spread 0.1, volume 100.
let bar = full_bar(1_000);
assert_eq!(decode(M1Field::Open, &bar), (Timestamp(1_000_000_000), Scalar::F64(1.0)));
assert_eq!(decode(M1Field::High, &bar), (Timestamp(1_000_000_000), Scalar::F64(2.0)));
assert_eq!(decode(M1Field::Low, &bar), (Timestamp(1_000_000_000), Scalar::F64(0.5)));
assert_eq!(decode(M1Field::Close, &bar), (Timestamp(1_000_000_000), Scalar::F64(1.5)));
assert_eq!(decode(M1Field::Spread, &bar), (Timestamp(1_000_000_000), Scalar::F64(0.1)));
// volume is the one i64 column.
assert_eq!(decode(M1Field::Volume, &bar), (Timestamp(1_000_000_000), Scalar::I64(100)));
}
```
- [ ] **Step 5: Build + run the decode tests + lint**
Run: `cargo test -p aura-ingest decode_projects`
Expected: PASS (`decode_projects_each_field_to_its_scalar_kind`).
Run: `cargo clippy -p aura-ingest --all-targets -- -D warnings`
Expected: clean.
---
## Task 4: Gated streaming e2e + residency proof (aura-ingest)
**Files:**
- Create: `crates/aura-ingest/tests/streaming_seam.rs`
Mirrors the gated pattern of `real_bars.rs` (skip where local data is absent).
Three tests: the streaming backtest e2e, the residency-drive predicate, and the
N-field sharing property.
- [ ] **Step 1: Write the gated streaming test file**
Create `crates/aura-ingest/tests/streaming_seam.rs`:
```rust
//! Gated integration test for the Source ingestion seam: a real data-server M1
//! close stream driven LAZILY through the signal-quality sample harness via
//! `M1FieldSource`, plus the residency predicate (peak resident records ≤ one
//! chunk, independent of window length) and the N-field sharing property. Skips
//! where the local Pepperstone data directory is absent.
use std::sync::mpsc;
use std::sync::Arc;
use aura_core::{Firing, NodeSchema, PortSpec, Scalar, ScalarKind, Timestamp};
use aura_engine::{
f64_field, summarize, Edge, FlatGraph, Harness, RunManifest, RunReport, Source, SourceSpec,
Target, VecSource,
};
use aura_ingest::{M1Field, M1FieldSource};
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
use data_server::loader::CHUNK_SIZE;
use data_server::{DataServer, DEFAULT_DATA_PATH};
const SYMBOL: &str = "AAPL.US";
// 2006-08 in inclusive Unix-ms (a full month of M1 ⇒ many > CHUNK_SIZE bars).
const FROM_MS: i64 = 1_154_390_400_000;
const TO_MS: i64 = 1_157_068_799_999;
/// Bootstrap the cycle-0007 two-sink signal-quality harness and run it on a
/// single boxed source, folding the recorded equity + exposure into a RunReport.
fn run_sample(window: (Timestamp, Timestamp), source: Box<dyn Source>) -> RunReport {
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
let f64_recorder_sig = || NodeSchema {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "in".into() }],
output: vec![],
params: vec![],
};
let mut h = Harness::bootstrap(FlatGraph {
nodes: vec![
Box::new(Sma::new(2)),
Box::new(Sma::new(4)),
Box::new(Sub::new()),
Box::new(Exposure::new(0.5)),
Box::new(SimBroker::new(0.0001)),
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_eq)),
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_ex)),
],
signatures: vec![
Sma::builder().schema().clone(),
Sma::builder().schema().clone(),
Sub::builder().schema().clone(),
Exposure::builder().schema().clone(),
SimBroker::builder(0.0001).schema().clone(),
f64_recorder_sig(),
f64_recorder_sig(),
],
sources: vec![SourceSpec {
kind: ScalarKind::F64,
targets: vec![
Target { node: 0, slot: 0 },
Target { node: 1, slot: 0 },
Target { node: 4, slot: 1 },
],
}],
edges: vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
Edge { from: 2, to: 3, slot: 0, from_field: 0 },
Edge { from: 3, to: 4, slot: 0, from_field: 0 },
Edge { from: 4, to: 5, slot: 0, from_field: 0 },
Edge { from: 3, to: 6, slot: 0, from_field: 0 },
],
})
.expect("valid signal-quality DAG");
h.run(vec![source]);
let eq_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_eq.try_iter().collect();
let ex_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_ex.try_iter().collect();
let equity = f64_field(&eq_rows, 0);
let exposure = f64_field(&ex_rows, 0);
RunReport {
manifest: RunManifest {
commit: "streaming-seam-test".to_string(),
params: vec![
("sma_fast".to_string(), 2.0),
("sma_slow".to_string(), 4.0),
("exposure_scale".to_string(), 0.5),
],
window,
seed: 0,
broker: "sim-optimal(pip_size=0.0001)".to_string(),
},
metrics: summarize(&equity, &exposure),
}
}
fn skip_if_no_data(server: &Arc<DataServer>) -> bool {
if !server.has_symbol(SYMBOL) {
eprintln!("skip: no local data at {DEFAULT_DATA_PATH} (symbol {SYMBOL} absent)");
return true;
}
false
}
#[test]
fn streaming_close_source_backtests_end_to_end_deterministically() {
let server = Arc::new(DataServer::new(DEFAULT_DATA_PATH));
if skip_if_no_data(&server) {
return;
}
let open_close = || {
M1FieldSource::open(&server, SYMBOL, Some(FROM_MS), Some(TO_MS), M1Field::Close)
.expect("AAPL.US has data in the 2006-08 window")
};
// window bounds for the manifest: drain a source once to read first/last ts.
let mut probe = open_close();
let first = probe.peek().expect("non-empty window");
let mut last = first;
while let Some((t, _)) = Source::next(&mut probe) {
last = t;
}
let window = (first, last);
let r1 = run_sample(window, Box::new(open_close()));
assert!(r1.metrics.total_pips.is_finite(), "a backtest ran over the streamed window");
// same window streamed again ⇒ bit-identical report (C1).
let r2 = run_sample(window, Box::new(open_close()));
assert_eq!(r1.to_json(), r2.to_json());
}
#[test]
fn residency_is_bounded_by_one_chunk_independent_of_window_length() {
let server = Arc::new(DataServer::new(DEFAULT_DATA_PATH));
if skip_if_no_data(&server) {
return;
}
let mut src = M1FieldSource::open(&server, SYMBOL, Some(FROM_MS), Some(TO_MS), M1Field::Close)
.expect("AAPL.US has data in the 2006-08 window");
// Drive the source the way `run` does (peek to pick, next to pop), sampling
// resident records at every pull.
let mut peak = 0usize;
let mut total = 0usize;
while src.peek().is_some() {
peak = peak.max(src.resident_records());
Source::next(&mut src);
total += 1;
}
// multi-chunk window: more records than a single chunk holds, so the bound
// below is non-vacuous (a O(window) source would have peaked at `total`).
assert!(total > CHUNK_SIZE, "window must span multiple chunks (got {total} records)");
// O(one chunk), NOT O(window): the per-pull ceiling never exceeds one chunk,
// regardless of how many chunks the window spans.
assert!(peak <= CHUNK_SIZE, "resident records {peak} exceeded one chunk ({CHUNK_SIZE})");
assert!(peak > 0, "a non-empty window resided at least one record");
}
#[test]
fn two_field_sources_share_one_window() {
let server = Arc::new(DataServer::new(DEFAULT_DATA_PATH));
if skip_if_no_data(&server) {
return;
}
// close + volume over the same window: each is an independent Source pulling
// its field; both stream to exhaustion (the N-sources-share-the-ts-axis shape).
let mut close = M1FieldSource::open(&server, SYMBOL, Some(FROM_MS), Some(TO_MS), M1Field::Close)
.expect("close source");
let mut volume = M1FieldSource::open(&server, SYMBOL, Some(FROM_MS), Some(TO_MS), M1Field::Volume)
.expect("volume source");
let mut n_close = 0usize;
while let Some((_, v)) = Source::next(&mut close) {
assert!(matches!(v, Scalar::F64(_)), "close is an f64 column");
n_close += 1;
}
let mut n_volume = 0usize;
while let Some((_, v)) = Source::next(&mut volume) {
assert!(matches!(v, Scalar::I64(_)), "volume is an i64 column");
n_volume += 1;
}
assert_eq!(n_close, n_volume, "both fields share the same ts axis ⇒ same count");
assert!(n_close > 0);
}
```
(The `VecSource` import is retained for parity even though this file uses only
`M1FieldSource`; drop it if clippy flags it unused — see Step 3.)
- [ ] **Step 2: Run the gated streaming tests**
Run: `cargo test -p aura-ingest --test streaming_seam`
Expected (where local data is present): PASS, 3 tests
(`streaming_close_source_backtests_end_to_end_deterministically`,
`residency_is_bounded_by_one_chunk_independent_of_window_length`,
`two_field_sources_share_one_window`).
Expected (where local data is absent): all 3 print `skip:` and pass (hermetic).
- [ ] **Step 3: Lint + unused-import scrub**
Run: `cargo clippy -p aura-ingest --all-targets -- -D warnings`
Expected: clean. If `VecSource` (or any import) is flagged unused in
`streaming_seam.rs`, remove it from the `use aura_engine::{...}` line.
- [ ] **Step 4: Full workspace gate**
Run: `cargo test --workspace`
Expected: all green.
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: clean.
Run: `cargo doc --workspace --no-deps 2>&1`
Expected: clean (the new `pub` items — `Source`, `VecSource`, `M1Field`,
`M1FieldSource` — carry doc comments; no missing-doc or broken-intra-doc-link).
-397
View File
@@ -1,397 +0,0 @@
# Seed-as-input — Implementation Plan
> **Parent spec:** `docs/specs/0042-seed-as-input.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Make `RunManifest.seed` a live captured input — ship a seeded source
(`Fn(u64) -> impl Source`) whose stream is fully seed-determined, and thread the
seed into the manifest at the construction site (Fork B).
**Architecture:** Two crates. (1) `aura-engine` gains a private `SplitMix64`
PRNG and a `pub SyntheticSpec` with `fn source(&self, seed: u64) -> impl Source`,
placed beside the existing `Source` producer `VecSource` in `harness.rs` (same
category, same module as the `Source` trait it implements). (2) `aura-cli`'s
`sim_optimal_manifest` gains a `seed: u64` parameter; its four existing callers
pass `0` (unchanged behaviour), and a test-only `run_sample_seeded` exercises a
live seed and returns the drained sink trace for a trace-level determinism
assertion. The engine event loop is untouched — it sees only a `Source` (C3).
**Tech Stack:** Rust, `aura-engine` (`harness.rs`, `lib.rs`), `aura-cli`
(`main.rs`), the existing `Source`/`VecSource` seam, `sample_harness` +
`f64_field`/`summarize` fold.
---
**Files this plan creates or modifies:**
- Modify: `crates/aura-engine/src/harness.rs` — add `SplitMix64` (private) +
`SyntheticSpec` (pub) + `impl SyntheticSpec::source` after the
`impl Source for VecSource` block (insert after line 91, before `FlatGraph` at
line 98); add two producer RED tests in the `#[cfg(test)] mod tests` block.
- Modify: `crates/aura-engine/src/lib.rs:46` — add `SyntheticSpec` to the
`pub use harness::{…}` re-export list.
- Modify: `crates/aura-cli/src/main.rs``sim_optimal_manifest` def (125-133)
gains `seed: u64`; thread `0` into its four callers (154, 216, 385, 564); add
`SyntheticSpec` to the `use aura_engine::{…}` import (17-20); add `SeededTrace`
+ `run_sample_seeded` + three e2e RED tests in the `#[cfg(test)] mod tests`
block (opens at 608).
---
### Task 1: Seeded source producer (`aura-engine`)
**Files:**
- Modify: `crates/aura-engine/src/harness.rs` (insert after line 91; tests in the `mod tests` block)
- Modify: `crates/aura-engine/src/lib.rs:46`
- [ ] **Step 1: Write the failing producer tests**
In `crates/aura-engine/src/harness.rs`, inside the existing `#[cfg(test)] mod tests`
block (after the `f64_stream` helper, ~line 396), add:
```rust
#[test]
fn seeded_source_same_seed_same_stream() {
// Same seed -> bit-identical stream (C1/C12 determinism).
let spec = SyntheticSpec { start: 1.0, len: 32, step: 1 };
let drain = |seed| {
let mut s = spec.source(seed);
let mut out: Vec<(Timestamp, Scalar)> = Vec::new();
while let Some(item) = Source::next(&mut s) {
out.push(item);
}
out
};
assert_eq!(drain(7), drain(7));
}
#[test]
fn seeded_source_different_seed_differs() {
// Different seeds -> different streams.
let spec = SyntheticSpec { start: 1.0, len: 32, step: 1 };
let drain = |seed| {
let mut s = spec.source(seed);
let mut out: Vec<(Timestamp, Scalar)> = Vec::new();
while let Some(item) = Source::next(&mut s) {
out.push(item);
}
out
};
assert_ne!(drain(1), drain(2));
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `cargo test -p aura-engine seeded_source`
Expected: FAIL — does not compile: `cannot find struct, variant or union type
`SyntheticSpec` in this scope` (the producer does not exist yet).
- [ ] **Step 3: Write the PRNG + seeded source**
In `crates/aura-engine/src/harness.rs`, immediately after the
`impl Source for VecSource { … }` block (which ends at line 91) and before the
`FlatGraph` doc comment (line 93), insert:
```rust
/// A tiny, fully-deterministic, dependency-free PRNG (SplitMix64). The seed
/// completely determines the sequence; no external entropy, no global state.
/// Bit-stable across toolchains and crate versions — the property C1 needs for
/// seed-as-input reproducibility (C12).
struct SplitMix64 {
state: u64,
}
impl SplitMix64 {
fn new(seed: u64) -> Self {
Self { state: seed }
}
fn next_u64(&mut self) -> u64 {
self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// A uniform `f64` in `[0, 1)` from the top 53 bits.
fn next_f64(&mut self) -> f64 {
(self.next_u64() >> 11) as f64 / ((1u64 << 53) as f64)
}
}
/// Config for a seeded synthetic price walk — everything *except* the seed.
/// Partially applying it (`|seed| spec.source(seed)`) yields the
/// `Fn(u64) -> impl Source` contract the Monte-Carlo family (#68) consumes
/// (C12 seed-as-input). The produced stream is an ordinary ingestion-boundary
/// `Source` (C3), generated at the data-generation edge outside the engine graph.
#[derive(Clone, Copy, Debug)]
pub struct SyntheticSpec {
/// Opening price.
pub start: f64,
/// Number of M1 steps.
pub len: usize,
/// Timestamp increment per step.
pub step: i64,
}
impl SyntheticSpec {
/// Produce a synthetic price stream **fully determined by `seed`** (C1/C12):
/// a multiplicative random walk, each step a small seeded perturbation.
/// Returns a `Source` (the C3 ingestion producer), NOT a `Vec` — so a
/// Monte-Carlo family can re-seed without materializing N streams (#68).
/// (First cut collects to a `VecSource` internally; the signature does not
/// name `Vec`, so a later lazy generator is a drop-in replacement.)
pub fn source(&self, seed: u64) -> impl Source {
let mut rng = SplitMix64::new(seed);
let mut price = self.start;
let stream: Vec<(Timestamp, Scalar)> = (0..self.len)
.map(|i| {
let shock = (rng.next_f64() - 0.5) * 0.004; // ±0.2% per step
price *= 1.0 + shock;
(Timestamp(i as i64 * self.step + 1), Scalar::F64(price))
})
.collect();
VecSource::new(stream)
}
}
```
Then in `crates/aura-engine/src/lib.rs:46`, add `SyntheticSpec` to the harness
re-export (alphabetically, after `SourceSpec`, before `Target`). Change:
```rust
pub use harness::{BootstrapError, Edge, FlatGraph, Harness, Source, SourceSpec, Target, VecSource};
```
to:
```rust
pub use harness::{
BootstrapError, Edge, FlatGraph, Harness, Source, SourceSpec, SyntheticSpec, Target, VecSource,
};
```
- [ ] **Step 4: Run the tests to verify they pass**
Run: `cargo test -p aura-engine seeded_source`
Expected: PASS — `test result: ok. 2 passed`.
---
### Task 2: Thread the seed into the manifest + seeded run path (`aura-cli`)
**Files:**
- Modify: `crates/aura-cli/src/main.rs` (def 125-133; callers 154, 216, 385, 564; imports 17-20; tests block at 608)
- [ ] **Step 1: Write the failing end-to-end tests + the seeded run vehicle**
In `crates/aura-cli/src/main.rs`, inside the `#[cfg(test)] mod tests` block
(after `use super::*;` at ~line 609), add `SeededTrace`, `run_sample_seeded`, and
the three tests. `run_sample_seeded` lives here under `#[cfg(test)]` deliberately:
this cycle ships no production caller for it (no `--seed` flag — spec Non-goals),
so a non-test definition would be `dead_code` and fail `clippy -D warnings`. It is
this cycle's test vehicle; #68 writes its own engine-side consumer.
```rust
/// The drained sink trace of a seeded run — the recorded rows of the equity
/// and exposure sinks. Compared row-for-row so the C1 seed-determinism
/// property is tested at the trace level (strictly stronger than the folded
/// 3-field metrics). `PartialEq` not `Eq`: `Scalar` carries `f64`.
#[derive(Debug, PartialEq)]
struct SeededTrace {
equity: Vec<(Timestamp, Vec<Scalar>)>,
exposure: Vec<(Timestamp, Vec<Scalar>)>,
}
/// A seeded run of the sample harness: the synthetic stream is generated
/// from `seed`, that same seed is recorded into the manifest, and the
/// drained sink trace is returned alongside the report. Every byte of both
/// is a function of `seed`.
fn run_sample_seeded(seed: u64) -> (RunReport, SeededTrace) {
let (mut h, rx_eq, rx_ex) = sample_harness();
let spec = SyntheticSpec { start: 1.0, len: 64, step: 1 };
let window = (Timestamp(1), Timestamp((spec.len as i64 - 1) * spec.step + 1));
h.run(vec![Box::new(spec.source(seed))]);
let eq_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_eq.try_iter().collect();
let ex_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_ex.try_iter().collect();
let metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0));
let report = RunReport {
manifest: sim_optimal_manifest(
vec![
("sma_fast".to_string(), 2.0),
("sma_slow".to_string(), 4.0),
("exposure_scale".to_string(), 0.5),
],
window,
seed,
),
metrics,
};
(report, SeededTrace { equity: eq_rows, exposure: ex_rows })
}
#[test]
fn same_seed_bit_identical_trace() {
// Bit-identical sink trace for a fixed seed (acceptance bullet 1, C1).
let (_, trace_a) = run_sample_seeded(42);
let (_, trace_b) = run_sample_seeded(42);
assert_eq!(trace_a, trace_b);
}
#[test]
fn different_seed_different_trace() {
// Different seeds perturb the trace (acceptance bullet 2).
let (a, _) = run_sample_seeded(1);
let (b, _) = run_sample_seeded(2);
assert_ne!(a.metrics, b.metrics);
}
#[test]
fn seed_recorded_in_manifest() {
// The seed that drove the run is recorded (acceptance bullet 3).
let (report, _) = run_sample_seeded(7);
assert_eq!(report.manifest.seed, 7);
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `cargo test -p aura-cli seed`
Expected: FAIL — does not compile: `cannot find … SyntheticSpec` (not yet
imported) and `this function takes 2 arguments but 3 were supplied`
(`sim_optimal_manifest` still has the old signature).
- [ ] **Step 3: Add the seed parameter, thread it, and import `SyntheticSpec`**
3a. In `crates/aura-cli/src/main.rs:17-20`, add `SyntheticSpec` to the
`aura_engine` import (alphabetically). Change:
```rust
use aura_engine::{
f64_field, summarize, Composite, Edge, FlatGraph, GraphBuilder, Harness,
RunManifest, RunReport, SourceSpec, SweepFamily, Target, VecSource,
};
```
to:
```rust
use aura_engine::{
f64_field, summarize, Composite, Edge, FlatGraph, GraphBuilder, Harness,
RunManifest, RunReport, SourceSpec, SweepFamily, SyntheticSpec, Target, VecSource,
};
```
3b. Change the `sim_optimal_manifest` definition (lines 125-133). Replace:
```rust
fn sim_optimal_manifest(params: Vec<(String, f64)>, window: (Timestamp, Timestamp)) -> RunManifest {
RunManifest {
commit: option_env!("AURA_COMMIT").unwrap_or("unknown").to_string(),
params,
window,
seed: 0,
broker: "sim-optimal(pip_size=0.0001)".to_string(),
}
}
```
with (add the `seed: u64` parameter; the body field becomes `seed`):
```rust
fn sim_optimal_manifest(
params: Vec<(String, f64)>,
window: (Timestamp, Timestamp),
seed: u64,
) -> RunManifest {
RunManifest {
commit: option_env!("AURA_COMMIT").unwrap_or("unknown").to_string(),
params,
window,
seed,
broker: "sim-optimal(pip_size=0.0001)".to_string(),
}
}
```
3c. Thread `0` into all four existing callers (every site must be updated in this
step — the signature change is a hard compile error until all four are threaded).
Caller in `run_sample` (lines 154-161) — replace the `window,` line that closes
the argument list with `window,` followed by `0,`:
```rust
manifest: sim_optimal_manifest(
vec![
("sma_fast".to_string(), 2.0),
("sma_slow".to_string(), 4.0),
("exposure_scale".to_string(), 0.5),
],
window,
0,
),
```
Caller in `run_sample_real` (lines 216-223):
```rust
manifest: sim_optimal_manifest(
vec![
("sma_fast".to_string(), 2.0),
("sma_slow".to_string(), 4.0),
("exposure_scale".to_string(), 0.5),
],
window,
0,
),
```
Caller in the sweep grid-report closure (line 385) — replace:
```rust
manifest: sim_optimal_manifest(params, window),
```
with:
```rust
manifest: sim_optimal_manifest(params, window, 0),
```
Caller in `run_macd` (lines 564-572):
```rust
manifest: sim_optimal_manifest(
vec![
("ema_fast".to_string(), 2.0),
("ema_slow".to_string(), 4.0),
("ema_signal".to_string(), 3.0),
("exposure_scale".to_string(), 0.5),
],
window,
0,
),
```
- [ ] **Step 4: Run the tests to verify they pass**
Run: `cargo test -p aura-cli seed`
Expected: PASS — `test result: ok. 3 passed` (the filter `seed` matches exactly
`same_seed_bit_identical_trace`, `different_seed_different_trace`,
`seed_recorded_in_manifest`; no pre-existing aura-cli test name contains `seed`).
- [ ] **Step 5: Full-workspace regression + lint gate**
Run: `cargo test --workspace`
Expected: PASS — the whole suite is green, including the four seed-free
determinism tests that pin the unchanged callers
(`run_sample_is_deterministic_and_non_trivial`, `sweep_report_is_deterministic`,
`run_macd_compiles_from_nested_composite_and_is_deterministic`,
`run_sample_real_streams_real_close_bars_deterministically`) and the
`report.rs` serde tests that pin `"seed":0`.
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: 0 warnings (no `dead_code`: `run_sample_seeded`/`SeededTrace` are under
`#[cfg(test)]`; the `seed` parameter is used by all four production callers).
-524
View File
@@ -1,524 +0,0 @@
# Monte-Carlo Family — Implementation Plan
> **Parent spec:** `docs/specs/0043-monte-carlo-family.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Add `monte_carlo` — the Monte-Carlo orchestration family (C12 axis 4),
an `McFamily` over a seed set analog to `SweepFamily` — reusing the existing
disjoint-parallel executor (extracted into a shared `run_indexed<T>` core) rather
than forking a new run loop.
**Architecture:** Task 1 extracts the index-parallel loop out of
`sweep_with_threads` into a `pub(crate) run_indexed<T>` in `sweep.rs` and rewires
`sweep_with_threads` as a thin adapter — a behaviour-preserving refactor whose
green guard is the three pre-existing `sweep` tests. Task 2 adds the `mc.rs`
module (the five MC types, the private type-7 `quantile`, `McAggregate::from_draws`,
and `monte_carlo`/`monte_carlo_with_threads` driving `run_indexed`). Task 3 wires
the module + public exports into `lib.rs`.
**Tech Stack:** `crates/aura-engine``sweep.rs` (shared executor),
`mc.rs` (new module), `lib.rs` (module decl + exports), reusing `report.rs`
(`RunReport`/`RunMetrics`) and the `pub(crate)` test fixtures in
`test_fixtures.rs`.
---
**Files this plan creates or modifies:**
- Modify: `crates/aura-engine/src/sweep.rs:131-167` — extract `run_indexed<T>`; rewire `sweep_with_threads` as adapter
- Create: `crates/aura-engine/src/mc.rs` — MC family types, `monte_carlo`, `quantile`, `from_draws`, `#[cfg(test)] mod tests`
- Modify: `crates/aura-engine/src/lib.rs:36-53` — add `mod mc;` and the five public exports
- Test: `crates/aura-engine/src/mc.rs` (`#[cfg(test)] mod tests`) — the 7 RED tests per spec §Testing 17
- Test (guard, unchanged): `crates/aura-engine/src/sweep.rs:292,314,325` — the three pre-existing sweep tests must stay green
---
## Task 1: Extract the shared `run_indexed<T>` disjoint-parallel core
**Files:**
- Modify: `crates/aura-engine/src/sweep.rs:131-167`
This is a **behaviour-preserving** refactor: the existing `sweep.rs` tests
(`family_is_deterministic_across_thread_counts`, `sweep_equals_n_independent_runs`,
`distinct_points_produce_distinct_metrics`) are the green guard — they must keep
passing with no edit. No new test is added in this task; the guard tests already
exist and exercise the new code path through the rewired `sweep_with_threads`.
- [ ] **Step 1: Run the guard tests first to confirm the green baseline**
Run: `cargo test -p aura-engine --lib sweep_equals_n_independent_runs`
Expected: PASS (`test result: ok. 1 passed`)
- [ ] **Step 2: Add `run_indexed<T>` and rewire `sweep_with_threads`**
In `crates/aura-engine/src/sweep.rs`, replace the entire `sweep_with_threads`
function body (lines 131167, the function whose signature is
`fn sweep_with_threads<F>(space: &GridSpace, nthreads: usize, run_one: F) -> SweepFamily`)
with the two functions below. `run_indexed` is the generalized core (the old loop,
generic over the per-job result `T`); `sweep_with_threads` becomes a thin adapter.
The `use std::sync::atomic::{AtomicUsize, Ordering};` import already present at
`sweep.rs:9` is reused unchanged.
```rust
/// Run `n` disjoint jobs in parallel and collect their results in **job-index
/// order** — the shared disjoint-parallel core both `sweep` (over grid points)
/// and `monte_carlo` (over seeds) drive (C1: order is the input order, not the
/// completion order). Workers pull job indices from a shared atomic cursor
/// (work-stealing load-balances uneven per-job cost); each tags its result with
/// the index, and the results are sorted on that index after the scope joins.
/// Only the cursor is shared; the results side is lock-free. `nthreads` is
/// clamped to `[1, n.max(1)]` (a 0-job call yields an empty vec; a 0 thread
/// count coerces to 1).
pub(crate) fn run_indexed<T, F>(n: usize, nthreads: usize, run_one: F) -> Vec<T>
where
T: Send,
F: Fn(usize) -> T + Sync,
{
let nthreads = nthreads.clamp(1, n.max(1));
let cursor = AtomicUsize::new(0);
let mut results: Vec<(usize, T)> = std::thread::scope(|scope| {
let handles: Vec<_> = (0..nthreads)
.map(|_| {
scope.spawn(|| {
let mut local: Vec<(usize, T)> = Vec::new();
loop {
let i = cursor.fetch_add(1, Ordering::Relaxed);
if i >= n {
break;
}
local.push((i, run_one(i)));
}
local
})
})
.collect();
handles.into_iter().flat_map(|h| h.join().unwrap()).collect()
});
results.sort_by_key(|&(i, _)| i);
results.into_iter().map(|(_, t)| t).collect()
}
/// The thread-count-explicit core of [`sweep`]. Module-private: the public
/// `sweep` derives the count, while the tests drive it at 1 and at N to pin
/// determinism under concurrency (C1). A thin adapter over [`run_indexed`]: it
/// enumerates the grid points, runs each disjointly, and zips the reports back
/// onto their points in enumeration (odometer) order.
fn sweep_with_threads<F>(space: &GridSpace, nthreads: usize, run_one: F) -> SweepFamily
where
F: Fn(&[Scalar]) -> RunReport + Sync,
{
let points = space.points();
let reports = run_indexed(points.len(), nthreads, |i| run_one(&points[i]));
SweepFamily {
points: points
.into_iter()
.zip(reports)
.map(|(params, report)| SweepPoint { params, report })
.collect(),
}
}
```
- [ ] **Step 3: Build the crate**
Run: `cargo build -p aura-engine`
Expected: builds with 0 errors (the `run_indexed` extraction compiles; no caller
signature changed).
- [ ] **Step 4: Run the refactor-guard tests to verify behaviour preserved**
Run: `cargo test -p aura-engine --lib sweep`
Expected: PASS — all `sweep::tests` green, including
`family_is_deterministic_across_thread_counts`, `sweep_equals_n_independent_runs`,
`distinct_points_produce_distinct_metrics` (the proof the refactor preserved
behaviour, C1/C11/C23). `test result: ok` with 0 failed.
---
## Task 2: The `mc.rs` module — types, quantile, aggregate, `monte_carlo`
**Files:**
- Create: `crates/aura-engine/src/mc.rs`
The module is not yet wired into `lib.rs` (Task 3 does that), so its tests will
not run until Task 3. This task creates the full module incl. its `#[cfg(test)]
mod tests`; the RED→GREEN cycle is observed end-to-end in Task 3 Step 2/3 once
`mod mc;` is declared. The implementation is written here in full (no
placeholder), with the failing-first ordering preserved by Task 3's gate.
- [ ] **Step 1: Create `crates/aura-engine/src/mc.rs` with the module + types + impl**
Create `crates/aura-engine/src/mc.rs` with exactly:
```rust
//! Monte-Carlo orchestration family (C12 axis 4): Monte-Carlo as a sweep over
//! seeds. `monte_carlo(base_point, seeds, run_one)` runs a fixed base point over
//! a seed set — each seed a disjoint C1 realization — and collects an
//! [`McFamily`]: the per-seed [`McDraw`]s in seed-input order plus a stored
//! [`McAggregate`] (mean + quantiles of all three run metrics across the
//! realizations). It reuses the disjoint-parallel [`run_indexed`](crate::sweep)
//! core `sweep` drives — the varying dimension is the *seed*, not a tuning param.
//! Eager-agnostic (C12/#71): the API takes seeds + a per-draw closure, never a
//! materialized stream `Vec`; the seed -> `Source` construction is a closure-body
//! concern.
use crate::sweep::run_indexed;
use crate::{RunMetrics, RunReport, Scalar};
/// One Monte-Carlo realization: the seed that drove it and the full `RunReport`.
/// Self-describing, analog to [`SweepPoint`](crate::SweepPoint).
#[derive(Clone, Debug, PartialEq)]
pub struct McDraw {
pub seed: u64,
pub report: RunReport,
}
/// The result family of a Monte-Carlo run — one [`McDraw`] per seed, in
/// seed-**input** order (independent of thread completion), plus the stored
/// [`McAggregate`]. Analog to [`SweepFamily`](crate::SweepFamily).
#[derive(Clone, Debug, PartialEq)]
pub struct McFamily {
pub draws: Vec<McDraw>,
pub aggregate: McAggregate,
}
/// Distribution summary of all three run metrics across the realizations: covers
/// every metric (not a single "chosen" one). A pure post-run reduction over the
/// draws — stored for the common robustness case; custom statistics read the raw
/// draws directly.
#[derive(Clone, Debug, PartialEq)]
pub struct McAggregate {
pub total_pips: MetricStats,
pub max_drawdown: MetricStats,
pub exposure_sign_flips: MetricStats,
}
/// Mean + a fixed quantile set of one metric across the realizations. `p50` is
/// the median (the two coincide by definition), so "mean/median/quantiles" is
/// `mean` + `p50` + the surrounding quantiles, with no redundant field.
#[derive(Clone, Debug, PartialEq)]
pub struct MetricStats {
pub mean: f64,
pub p5: f64,
pub p25: f64,
pub p50: f64, // == median
pub p75: f64,
pub p95: f64,
}
impl McAggregate {
/// Pure reduction over the realizations — recomputable from `draws` alone (it
/// is exactly what [`monte_carlo`] stored). `draws` must be non-empty (a
/// Monte-Carlo over zero realizations has no defined mean/quantile); on a
/// non-empty slice every field is finite.
pub fn from_draws(draws: &[McDraw]) -> McAggregate {
let pick = |f: fn(&RunMetrics) -> f64| -> MetricStats {
let mut xs: Vec<f64> = draws.iter().map(|d| f(&d.report.metrics)).collect();
xs.sort_by(|a, b| a.partial_cmp(b).expect("run metrics are finite"));
MetricStats {
mean: xs.iter().sum::<f64>() / xs.len() as f64,
p5: quantile(&xs, 0.05),
p25: quantile(&xs, 0.25),
p50: quantile(&xs, 0.50),
p75: quantile(&xs, 0.75),
p95: quantile(&xs, 0.95),
}
};
McAggregate {
total_pips: pick(|m| m.total_pips),
max_drawdown: pick(|m| m.max_drawdown),
exposure_sign_flips: pick(|m| m.exposure_sign_flips as f64),
}
}
}
/// Linear-interpolation quantile (the numpy/Excel "type 7" default) over a
/// pre-sorted, non-empty slice. `p` in `[0, 1]`. `rank = p * (n - 1)`;
/// interpolate between the two bracketing order statistics. `n == 1` returns the
/// sole value.
fn quantile(sorted: &[f64], p: f64) -> f64 {
let n = sorted.len();
if n == 1 {
return sorted[0];
}
let rank = p * (n - 1) as f64;
let lo = rank.floor() as usize;
let frac = rank - lo as f64;
if lo + 1 < n {
sorted[lo] + frac * (sorted[lo + 1] - sorted[lo])
} else {
sorted[n - 1]
}
}
/// Run `run_one(seed, base_point)` over every seed, disjointly in parallel (C1),
/// and collect the family in seed-input order plus the aggregate. The varying
/// dimension is the *seed* (C12 axis 4: MC = sweep over seeds), not a tuning
/// param; `base_point` is constant across draws. Eager-agnostic: the seed ->
/// `Source` construction lives inside `run_one`, never a materialized stream
/// `Vec` in this API (#71).
///
/// Precondition: `seeds` is non-empty (a Monte-Carlo over zero realizations has
/// no defined aggregate). A `debug_assert!` guards it; the `-> McFamily`
/// signature is preserved (no `Result`), matching [`sweep`](crate::sweep).
pub fn monte_carlo<F>(base_point: &[Scalar], seeds: &[u64], run_one: F) -> McFamily
where
F: Fn(u64, &[Scalar]) -> RunReport + Sync,
{
let nthreads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
monte_carlo_with_threads(base_point, seeds, nthreads, run_one)
}
/// The thread-count-explicit core of [`monte_carlo`]. Module-private: the public
/// `monte_carlo` derives the count, while the tests drive it at 1 and at N to pin
/// determinism under concurrency (C1). A thin adapter over
/// [`run_indexed`](crate::sweep): it runs each seed disjointly and zips the
/// reports back onto their seeds in seed-input order.
fn monte_carlo_with_threads<F>(
base_point: &[Scalar],
seeds: &[u64],
nthreads: usize,
run_one: F,
) -> McFamily
where
F: Fn(u64, &[Scalar]) -> RunReport + Sync,
{
debug_assert!(!seeds.is_empty(), "monte_carlo requires a non-empty seed set");
let reports = run_indexed(seeds.len(), nthreads, |i| run_one(seeds[i], base_point));
let draws: Vec<McDraw> = seeds
.iter()
.zip(reports)
.map(|(&seed, report)| McDraw { seed, report })
.collect();
let aggregate = McAggregate::from_draws(&draws);
McFamily { draws, aggregate }
}
```
- [ ] **Step 2: Append the test module to `crates/aura-engine/src/mc.rs`**
Append the following `#[cfg(test)] mod tests` to the end of `mc.rs`. The
`run_draw` helper is the MC analog of `sweep.rs`'s `run_point` (a free `fn`, so
`Copy + Sync`): it builds + bootstraps + runs one `(seed, point)` and folds the
sinks into a `RunReport` whose stream is seeded via `SyntheticSpec::source(seed)`.
```rust
#[cfg(test)]
mod tests {
use super::*;
use crate::test_fixtures::composite_sma_cross_harness;
use crate::{f64_field, summarize, RunManifest, SyntheticSpec, Timestamp};
use aura_core::Scalar;
/// Build + bootstrap + run + drain + summarize one (seed, point) into a
/// `RunReport`. A free `fn` (Copy + Sync) so it serves as the `monte_carlo`
/// closure AND a direct reference for the "draw == independent run"
/// comparison. The stream is generated from `seed` (seed-as-input, #66), and
/// `seed` is recorded into the manifest.
fn run_draw(seed: u64, point: &[Scalar]) -> RunReport {
let (bp, rx_eq, rx_ex) = composite_sma_cross_harness();
let mut h = bp
.bootstrap_with_params(point.to_vec())
.expect("base point is kind-checked against param_space");
let spec = SyntheticSpec { start: 1.0, len: 64, step: 1 };
let window = (Timestamp(1), Timestamp((spec.len as i64 - 1) * spec.step + 1));
h.run(vec![Box::new(spec.source(seed))]);
let equity = f64_field(&rx_eq.try_iter().collect::<Vec<_>>(), 0);
let exposure = f64_field(&rx_ex.try_iter().collect::<Vec<_>>(), 0);
RunReport {
manifest: RunManifest {
commit: "test".to_string(),
params: Vec::new(),
window,
seed,
broker: "test".to_string(),
},
metrics: summarize(&equity, &exposure),
}
}
fn base_point() -> Vec<Scalar> {
// matches the composite_sma_cross param_space order: fast, slow, scale
vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)]
}
fn draws_with_metrics(values: &[f64]) -> Vec<McDraw> {
values
.iter()
.enumerate()
.map(|(i, &v)| McDraw {
seed: i as u64,
report: RunReport {
manifest: RunManifest {
commit: "t".to_string(),
params: Vec::new(),
window: (Timestamp(0), Timestamp(0)),
seed: i as u64,
broker: "t".to_string(),
},
metrics: RunMetrics {
total_pips: v,
max_drawdown: v,
exposure_sign_flips: v as u64,
},
},
})
.collect()
}
#[test]
fn monte_carlo_runs_one_draw_per_seed_in_input_order() {
// spec §Testing 1: N seeds -> N draws, seeds carried in INPUT order.
let family = monte_carlo(&base_point(), &[1, 2, 3], run_draw);
assert_eq!(family.draws.len(), 3);
assert_eq!(
family.draws.iter().map(|d| d.seed).collect::<Vec<_>>(),
vec![1, 2, 3],
);
}
#[test]
fn family_is_deterministic_across_thread_counts() {
// spec §Testing 2: order = seed input, not completion (C1).
let point = base_point();
let seeds: Vec<u64> = (1..=8).collect();
let one = monte_carlo_with_threads(&point, &seeds, 1, run_draw);
let many = monte_carlo_with_threads(&point, &seeds, 8, run_draw);
assert_eq!(one, many);
assert_eq!(one, monte_carlo(&point, &seeds, run_draw));
}
#[test]
fn draw_equals_independent_seeded_run() {
// spec §Testing 3: each draw == an independent run of that (seed, point)
// — MC adds enumeration + execution, never a metrics change (C1).
let point = base_point();
let family = monte_carlo(&point, &[10, 11, 12], run_draw);
for draw in &family.draws {
assert_eq!(draw.report, run_draw(draw.seed, &point));
assert!(draw.report.metrics.total_pips.is_finite());
}
}
#[test]
fn distinct_seeds_produce_distinct_draws() {
// spec §Testing 4: the seed actually perturbs each run — the family does
// not collapse to one metric.
let family = monte_carlo(&base_point(), &[1, 2, 3, 4, 5], run_draw);
let first = family.draws[0].report.metrics.total_pips;
assert!(
family.draws.iter().any(|d| d.report.metrics.total_pips != first),
"a multi-seed family must not collapse to one metric",
);
}
#[test]
fn aggregate_is_a_pure_reduction() {
// spec §Testing 5: the stored aggregate is recomputable from the draws.
let family = monte_carlo(&base_point(), &[1, 2, 3, 4], run_draw);
assert_eq!(McAggregate::from_draws(&family.draws), family.aggregate);
}
#[test]
fn aggregate_stats_on_known_fixture() {
// spec §Testing 6: type-7 quantile + mean over known metric values
// [0,1,2,3,4]: mean=2.0, p50=2.0, p5≈0.2, p95≈3.8.
let agg = McAggregate::from_draws(&draws_with_metrics(&[0.0, 1.0, 2.0, 3.0, 4.0]));
assert_eq!(agg.total_pips.mean, 2.0);
assert_eq!(agg.total_pips.p50, 2.0);
assert!((agg.total_pips.p5 - 0.2).abs() < 1e-9, "p5 = {}", agg.total_pips.p5);
assert!((agg.total_pips.p95 - 3.8).abs() < 1e-9, "p95 = {}", agg.total_pips.p95);
}
#[test]
fn quantile_endpoints_and_singleton() {
// spec §Testing 7: singleton returns the sole value; p=0 -> min, p=1 -> max.
assert_eq!(quantile(&[42.0], 0.0), 42.0);
assert_eq!(quantile(&[42.0], 0.5), 42.0);
assert_eq!(quantile(&[42.0], 1.0), 42.0);
let xs = [1.0, 2.0, 3.0, 4.0, 5.0];
assert_eq!(quantile(&xs, 0.0), 1.0);
assert_eq!(quantile(&xs, 1.0), 5.0);
}
}
```
- [ ] **Step 3: Verify `mc.rs` does not yet compile into the crate (not declared)**
Run: `cargo build -p aura-engine`
Expected: builds 0 errors — `mc.rs` is a file on disk not yet referenced by any
`mod` (so the compiler ignores it). This step confirms the module file is
syntactically isolated until Task 3 wires it; the tests inside it do NOT run yet.
---
## Task 3: Wire the module + public exports into `lib.rs`
**Files:**
- Modify: `crates/aura-engine/src/lib.rs:36-53`
- [ ] **Step 1: Declare the module and add the public exports**
In `crates/aura-engine/src/lib.rs`, the module-declaration block is at lines
3641 and currently reads:
```rust
mod blueprint;
mod builder;
mod graph_model;
mod harness;
mod report;
mod sweep;
```
Add `mod mc;` so the block reads (keep alphabetical placement after `harness`):
```rust
mod blueprint;
mod builder;
mod graph_model;
mod harness;
mod mc;
mod report;
mod sweep;
```
Then, immediately after the existing `pub use sweep::{sweep, GridSpace,
SweepError, SweepFamily, SweepPoint};` line (`lib.rs:53`), add the five new
public exports:
```rust
pub use mc::{monte_carlo, McAggregate, McDraw, McFamily, MetricStats};
```
(`quantile` and `monte_carlo_with_threads` stay private — not exported.)
- [ ] **Step 2: Run the new MC tests to verify they pass (now that the module is wired)**
Run: `cargo test -p aura-engine --lib mc::`
Expected: PASS — all 7 `mc::tests` green:
`monte_carlo_runs_one_draw_per_seed_in_input_order`,
`family_is_deterministic_across_thread_counts`, `draw_equals_independent_seeded_run`,
`distinct_seeds_produce_distinct_draws`, `aggregate_is_a_pure_reduction`,
`aggregate_stats_on_known_fixture`, `quantile_endpoints_and_singleton`.
`test result: ok. 7 passed`.
- [ ] **Step 3: Run the full workspace test suite (regression gate)**
Run: `cargo test --workspace`
Expected: PASS — all tests green, including the unchanged `sweep::tests` guard
(behaviour-preserving refactor) and the new `mc::tests`. 0 failed.
- [ ] **Step 4: Clippy + doc gate**
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: clean, 0 warnings.
Run: `cargo doc -p aura-engine --no-deps 2>&1`
Expected: builds clean — the new intra-doc links (`McDraw`, `McFamily`,
`McAggregate`, `run_indexed`, `sweep`, `SweepPoint`, `SweepFamily`) resolve, 0
warnings.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-534
View File
@@ -1,534 +0,0 @@
# Sweep named-binding — Implementation Plan
> **Parent spec:** `docs/specs/0046-sweep-named-binding.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Replace the three open-coded `param_space()`-name re-zips in the CLI
with one derived projection (`zip_params`) and make a returned `SweepFamily`
self-describing (`named_params`), without touching the run-closure signature.
**Architecture:** A free function `zip_params(space, point) -> Vec<(String,
Scalar)>` in aura-core pairs names with a positional point. `GridSpace` retains
the `ParamSpec` list it already receives in `new()`; `SweepFamily` carries it
(stamped once in `sweep_with_threads`) and exposes `named_params(i)`. The CLI's
`sim_optimal_manifest` takes typed `Scalar` pairs and collapses to f64
internally; all eight of its call sites migrate. Behaviour-preserving:
`SweepPoint`, enumeration, and the closure bound are untouched.
**Tech Stack:** aura-core (`node.rs`, `lib.rs`), aura-engine (`sweep.rs`),
aura-registry (`lib.rs` test), aura-cli (`main.rs`).
---
**Files this plan creates or modifies:**
- Modify: `crates/aura-core/src/node.rs` — add free fn `zip_params` + a unit-test module
- Modify: `crates/aura-core/src/lib.rs:42` — add `zip_params` to the `node` re-export
- Modify: `crates/aura-engine/src/sweep.rs``GridSpace.space` + `param_specs()`; `SweepFamily.space` + `named_params()`; stamp in `sweep_with_threads`; import `zip_params`; 2 tests
- Modify: `crates/aura-registry/src/lib.rs:278` — thread `space: vec![]` into the `optimize` test's `SweepFamily` literal
- Modify: `crates/aura-cli/src/main.rs``sim_optimal_manifest` input type + 8 call sites; import `zip_params`
---
## Task 1: `zip_params` (aura-core)
**Files:**
- Modify: `crates/aura-core/src/node.rs` (ParamSpec at :67-71; module imports at :13)
- Modify: `crates/aura-core/src/lib.rs:42`
- [ ] **Step 1: Write the failing tests**
Append to `crates/aura-core/src/node.rs` (end of file):
```rust
#[cfg(test)]
mod zip_params_tests {
use super::{zip_params, ParamSpec};
use crate::{Scalar, ScalarKind};
#[test]
fn zip_params_pairs_names_with_values_in_slot_order() {
let space = vec![
ParamSpec { name: "fast".into(), kind: ScalarKind::I64 },
ParamSpec { name: "scale".into(), kind: ScalarKind::F64 },
];
let point = vec![Scalar::I64(2), Scalar::F64(0.5)];
assert_eq!(
zip_params(&space, &point),
vec![
("fast".to_string(), Scalar::I64(2)),
("scale".to_string(), Scalar::F64(0.5)),
],
);
}
#[test]
fn zip_params_matches_hand_zip() {
let space = vec![
ParamSpec { name: "a".into(), kind: ScalarKind::I64 },
ParamSpec { name: "b".into(), kind: ScalarKind::I64 },
];
let point = vec![Scalar::I64(7), Scalar::I64(9)];
let hand: Vec<(String, Scalar)> =
space.iter().zip(&point).map(|(ps, v)| (ps.name.clone(), *v)).collect();
assert_eq!(zip_params(&space, &point), hand);
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cargo test -p aura-core zip_params`
Expected: FAIL — compile error `cannot find function `zip_params` in this scope` /
unresolved import `super::zip_params` (E0432/E0425); `zip_params` does not exist yet.
- [ ] **Step 3: Add the function and re-export it**
`Scalar` is already in scope in `node.rs` (its `use crate::{Ctx, Scalar, ScalarKind};`
at line 13). Add this free function to `crates/aura-core/src/node.rs` (immediately
after the `ParamSpec` struct, ~line 71):
```rust
/// Pair each param-space name with the co-indexed positional value — the
/// inverse of positional binding (C23): names are a derived view, not identity.
/// `space` and `point` are co-indexed in `param_space()` slot order; a length
/// mismatch (contract violation) truncates to the shorter, never panics.
pub fn zip_params(space: &[ParamSpec], point: &[Scalar]) -> Vec<(String, Scalar)> {
space.iter().zip(point).map(|(ps, v)| (ps.name.clone(), *v)).collect()
}
```
Then add `zip_params` to the node re-export in `crates/aura-core/src/lib.rs:42`.
Change:
```rust
pub use node::{FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder};
```
to:
```rust
pub use node::{
zip_params, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
};
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cargo test -p aura-core zip_params`
Expected: PASS — 2 tests (`zip_params_pairs_names_with_values_in_slot_order`,
`zip_params_matches_hand_zip`).
---
## Task 2: `GridSpace` retains specs; `SweepFamily` carries them + `named_params` (aura-engine), with the aura-registry literal threaded
**Files:**
- Modify: `crates/aura-engine/src/sweep.rs` (imports :7; `GridSpace` :14-17; `GridSpace::new` :24-44; `SweepFamily` :106-109; `sweep_with_threads` :169-182; test module :184+)
- Modify: `crates/aura-registry/src/lib.rs:278` (the `optimize` test's `SweepFamily` literal)
- [ ] **Step 1: Write the failing tests**
Add to the `#[cfg(test)] mod tests` in `crates/aura-engine/src/sweep.rs` (after
`sweep_equals_n_independent_runs`, ~line 326). These reuse the existing
`composite_sma_cross_harness`, `sma_cross_grid`, and `run_point` helpers:
```rust
#[test]
fn sweep_family_carries_param_space() {
let space = composite_sma_cross_harness().0.param_space();
let family = sweep(&sma_cross_grid(), run_point);
assert_eq!(family.space, space);
}
#[test]
fn family_named_params_round_trips() {
let space = composite_sma_cross_harness().0.param_space();
let family = sweep(&sma_cross_grid(), run_point);
// odometer-first point is [I64(2), I64(4), F64(0.5)]
let expected: Vec<(String, Scalar)> = space
.iter()
.cloned()
.zip(family.points[0].params.clone())
.map(|(ps, v)| (ps.name, v))
.collect();
assert_eq!(family.named_params(0), expected);
assert_eq!(family.named_params(0)[0].1, Scalar::I64(2));
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cargo test -p aura-engine sweep_family_carries_param_space`
Expected: FAIL — compile error `no field `space` on type `&SweepFamily`` (E0609);
the field does not exist yet.
- [ ] **Step 3: Retain the specs on `GridSpace`**
Import `zip_params` in `crates/aura-engine/src/sweep.rs:7`. Change:
```rust
use aura_core::{ParamSpec, Scalar, ScalarKind};
```
to:
```rust
use aura_core::{zip_params, ParamSpec, Scalar, ScalarKind};
```
Change the `GridSpace` struct (`sweep.rs:14-17`):
```rust
#[derive(Debug)]
pub struct GridSpace {
axes: Vec<Vec<Scalar>>,
}
```
to:
```rust
#[derive(Debug)]
pub struct GridSpace {
space: Vec<ParamSpec>,
axes: Vec<Vec<Scalar>>,
}
```
In `GridSpace::new`, change the final constructor (`sweep.rs:43`) from
`Ok(Self { axes })` to:
```rust
Ok(Self { space: space.to_vec(), axes })
```
Add a `param_specs` accessor inside `impl GridSpace` (next to `points`):
```rust
/// The param-space (names + kinds) this grid was validated against, retained
/// for the family to carry — the derived-name source (C23: names, not identity).
pub fn param_specs(&self) -> &[ParamSpec] {
&self.space
}
```
- [ ] **Step 4: Carry the space on `SweepFamily` + add `named_params`; stamp it**
Change the `SweepFamily` struct (`sweep.rs:106-109`):
```rust
#[derive(Clone, Debug, PartialEq)]
pub struct SweepFamily {
pub points: Vec<SweepPoint>,
}
```
to:
```rust
#[derive(Clone, Debug, PartialEq)]
pub struct SweepFamily {
pub space: Vec<ParamSpec>,
pub points: Vec<SweepPoint>,
}
impl SweepFamily {
/// The i-th point's params paired with their names — a derived view over the
/// carried param-space (reuses [`zip_params`]); no new per-point state.
pub fn named_params(&self, i: usize) -> Vec<(String, Scalar)> {
zip_params(&self.space, &self.points[i].params)
}
}
```
Change the family assembly in `sweep_with_threads` (`sweep.rs:175-181`) from:
```rust
SweepFamily {
points: points
.into_iter()
.zip(reports)
.map(|(params, report)| SweepPoint { params, report })
.collect(),
}
```
to:
```rust
SweepFamily {
space: space.param_specs().to_vec(),
points: points
.into_iter()
.zip(reports)
.map(|(params, report)| SweepPoint { params, report })
.collect(),
}
```
- [ ] **Step 5: Thread the new field into the aura-registry test literal**
The field addition breaks the `SweepFamily { points: vec![..] }` literal in the
`optimize` test (`crates/aura-registry/src/lib.rs:278`). `optimize`/`rank_by`
read only `.points[].report`, so an empty space suffices. Change:
```rust
let family = SweepFamily {
points: vec![
point(0.0, 1.0), // 0: also-ran
point(10.0, 3.0), // 1: tied max, earliest -> the winner
point(20.0, 2.0), // 2: also-ran
point(30.0, 3.0), // 3: tied max, but later -> must lose the tie
],
};
```
to:
```rust
let family = SweepFamily {
space: vec![],
points: vec![
point(0.0, 1.0), // 0: also-ran
point(10.0, 3.0), // 1: tied max, earliest -> the winner
point(20.0, 2.0), // 2: also-ran
point(30.0, 3.0), // 3: tied max, but later -> must lose the tie
],
};
```
- [ ] **Step 6: Compile the whole workspace (incl. tests) to confirm the field is fully threaded**
Run: `cargo test --workspace --no-run`
Expected: builds with 0 errors (aura-engine assembly + aura-registry test literal
both threaded; the aura-cli still compiles — it constructs no `SweepFamily`
literal).
- [ ] **Step 7: Run the new tests to verify they pass**
Run: `cargo test -p aura-engine sweep_family_carries_param_space`
Expected: PASS — 1 test.
Run: `cargo test -p aura-engine family_named_params_round_trips`
Expected: PASS — 1 test.
- [ ] **Step 8: Verify the determinism guard still passes**
Run: `cargo test -p aura-engine family_is_deterministic_across_thread_counts`
Expected: PASS — 1 test (enumeration untouched; the new `space` field is equal
across thread counts for the same grid).
---
## Task 3: Migrate `sim_optimal_manifest` to typed `Scalar` pairs + all 8 call sites (aura-cli)
Behaviour-preserving migration (no new RED test): the manifest's stored
`params: Vec<(String, f64)>` is byte-identical before and after; the existing
CLI E2E suite is the regression guard.
**Files:**
- Modify: `crates/aura-cli/src/main.rs` (imports :16; `sim_optimal_manifest` :130-142; closures :386-392, :503-509, :530-536; literal callers :161, :224, :613, :869, :970)
- [ ] **Step 1: Import `zip_params`**
Change `crates/aura-cli/src/main.rs:16`:
```rust
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
```
to:
```rust
use aura_core::{zip_params, Firing, Scalar, ScalarKind, Timestamp};
```
- [ ] **Step 2: Change `sim_optimal_manifest` to take typed pairs, collapsing f64 internally**
Change `crates/aura-cli/src/main.rs:130-142`:
```rust
fn sim_optimal_manifest(
params: Vec<(String, f64)>,
window: (Timestamp, Timestamp),
seed: u64,
) -> RunManifest {
RunManifest {
commit: option_env!("AURA_COMMIT").unwrap_or("unknown").to_string(),
params,
window,
seed,
broker: "sim-optimal(pip_size=0.0001)".to_string(),
}
}
```
to:
```rust
fn sim_optimal_manifest(
params: Vec<(String, Scalar)>,
window: (Timestamp, Timestamp),
seed: u64,
) -> RunManifest {
// The lossy f64 collapse lives here — the manifest field (the deferred typed
// param-space precursor) owns its own lossiness; callers pass typed Scalars.
let params = params.into_iter().map(|(n, s)| (n, scalar_as_param_f64(&s))).collect();
RunManifest {
commit: option_env!("AURA_COMMIT").unwrap_or("unknown").to_string(),
params,
window,
seed,
broker: "sim-optimal(pip_size=0.0001)".to_string(),
}
}
```
- [ ] **Step 3: Migrate the three sweep closures to `zip_params`**
In `sweep_family` (`main.rs:386-392`), delete the hand-zip and pass `zip_params`.
Change:
```rust
let params = space
.iter()
.zip(point)
.map(|(ps, v)| (ps.name.clone(), scalar_as_param_f64(v)))
.collect();
RunReport {
manifest: sim_optimal_manifest(params, window, 0),
metrics: summarize(&equity, &exposure),
}
```
to:
```rust
RunReport {
manifest: sim_optimal_manifest(zip_params(&space, point), window, 0),
metrics: summarize(&equity, &exposure),
}
```
In `sweep_over` (`main.rs:503-509`), the block is byte-identical to
`sweep_family`'s. Change:
```rust
let params = space
.iter()
.zip(point)
.map(|(ps, v)| (ps.name.clone(), scalar_as_param_f64(v)))
.collect();
RunReport {
manifest: sim_optimal_manifest(params, window, 0),
metrics: summarize(&equity, &exposure),
}
```
to:
```rust
RunReport {
manifest: sim_optimal_manifest(zip_params(&space, point), window, 0),
metrics: summarize(&equity, &exposure),
}
```
In `run_oos` (`main.rs:530-536`), the zip is over the `params` argument into
`named`. Change:
```rust
let named = space
.iter()
.zip(params)
.map(|(ps, v)| (ps.name.clone(), scalar_as_param_f64(v)))
.collect();
let report = RunReport {
manifest: sim_optimal_manifest(named, window, 0),
metrics: summarize(&equity, &exposure),
};
```
to:
```rust
let report = RunReport {
manifest: sim_optimal_manifest(zip_params(&space, params), window, 0),
metrics: summarize(&equity, &exposure),
};
```
- [ ] **Step 4: Migrate the five literal callers to `Scalar` values**
In `run_sample` (`main.rs:161`), `run_sample_real` (`main.rs:224`), `mc_family`
(`main.rs:613`), and `run_sample_seeded` (`main.rs:970`) — each passes the same
3-tuple. Change every occurrence of:
```rust
vec![
("sma_fast".to_string(), 2.0),
("sma_slow".to_string(), 4.0),
("exposure_scale".to_string(), 0.5),
],
```
to:
```rust
vec![
("sma_fast".to_string(), Scalar::F64(2.0)),
("sma_slow".to_string(), Scalar::F64(4.0)),
("exposure_scale".to_string(), Scalar::F64(0.5)),
],
```
In `run_macd` (`main.rs:869`), the 4-tuple incl. `ema_signal`. Change:
```rust
vec![
("ema_fast".to_string(), 2.0),
("ema_slow".to_string(), 4.0),
("ema_signal".to_string(), 3.0),
("exposure_scale".to_string(), 0.5),
],
```
to:
```rust
vec![
("ema_fast".to_string(), Scalar::F64(2.0)),
("ema_slow".to_string(), Scalar::F64(4.0)),
("ema_signal".to_string(), Scalar::F64(3.0)),
("exposure_scale".to_string(), Scalar::F64(0.5)),
],
```
- [ ] **Step 5: Compile the whole workspace (incl. tests)**
Run: `cargo test --workspace --no-run`
Expected: builds with 0 errors (all 8 `sim_optimal_manifest` call sites migrated,
incl. the `#[cfg(test)]` helper `run_sample_seeded`).
- [ ] **Step 6: Verify behaviour-preservation via the existing E2E suite**
Run: `cargo test -p aura-cli run_sample_is_deterministic_and_non_trivial`
Expected: PASS (the manifest params for the sample run are unchanged).
Run: `cargo test -p aura-cli sweep_report_renders_four_points_in_odometer_order`
Expected: PASS (the `aura sweep` output is byte-identical).
Run: `cargo test -p aura-cli run_macd_compiles_from_nested_composite_and_is_deterministic`
Expected: PASS (the 4-tuple `run_macd` manifest is unchanged).
- [ ] **Step 7: Full workspace suite + lint**
Run: `cargo test --workspace`
Expected: PASS — all tests green (no regressions; the two new aura-engine tests
and two new aura-core tests included).
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: clean — 0 warnings.
-532
View File
@@ -1,532 +0,0 @@
# Cell as the hot-path value carrier — Implementation Plan
> **Parent spec:** `docs/specs/0047-cell-hot-path-carrier.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Narrow the hot-path value carrier from `Scalar` to the tag-free `Cell`
(`Node::eval -> Option<&[Cell]>`, every node out-buffer, the inter-node forward),
keeping `Scalar` on the self-describing dynamic boundaries (params, `get`, source
ingestion). Behaviour-preserving (C1).
**Architecture:** The `Node::eval` trait return type is the single atomic
compile cut: flipping it breaks all 21 `impl Node` bodies at once, so the
lib-code flip (trait + 8 aura-std nodes + recorder + harness forward loop) lands
as one task gated by a **lib-only** build; the `#[cfg(test)]` fixtures and the
eval-output assertions (which only compile under `--all-targets`) migrate in a
second task gated by the full four-gate run. A new branch-free `AnyColumn::push_cell`
(added first, additively) is the infallible inter-node push; the fallible
`push(Scalar)` and `get -> Option<Scalar>` stay for ingestion / type-erased reads.
**Tech Stack:** `aura-core` (`node.rs`, `any.rs`), `aura-std` (8 nodes),
`aura-engine` (`harness.rs` forward loop + fixtures, `blueprint.rs`/`graph_model.rs`
fixtures), the design ledger.
---
## Files this plan creates or modifies
- Modify: `crates/aura-core/src/node.rs:13,264,286,410``use` adds `Cell`;
`Node::eval` trait sig → `Option<&[Cell]>`; `Bare`/`Probe` eval sigs.
- Modify: `crates/aura-core/src/any.rs:7,62-92` — add `use crate::cell::Cell;`;
add `push_cell(Cell)`; add a `push_cell` round-trip test in the `tests` mod.
- Modify: `crates/aura-std/src/add.rs:5,20,26,58,64,92`
- Modify: `crates/aura-std/src/sub.rs:6,11,17,49,55,83`
- Modify: `crates/aura-std/src/sma.rs:6-9,14,21,45,54,86,97,100`
- Modify: `crates/aura-std/src/ema.rs:20-23,37,52,79,99,131,144,147`
- Modify: `crates/aura-std/src/exposure.rs:6-9,15,22,46,51,81`
- Modify: `crates/aura-std/src/lincomb.rs:9-12,30,40,68,77,105,118,136,168,183`
- Modify: `crates/aura-std/src/sim_broker.rs:7,41,54,82,94,124`
- Modify: `crates/aura-std/src/recorder.rs:10,60``use` adds `Cell` (the return
type names it); eval sig → `Option<&[Cell]>` (body still returns `None`).
- Modify: `crates/aura-engine/src/harness.rs:10,24,373,416,426-428` (forward path)
+ fixtures `AsOfSum`/`BarrierSum`/`MixedSum`/`Ohlcv`/`TwoField`/`TapForward`
(out-buffers, eval sigs, assignments) + their ctor literals
(`:898,944,993,1044,1156,1209,1255,1380,1307,1614,1475,1781`).
- Modify: `crates/aura-engine/src/blueprint.rs:816` (test `use` adds `Cell`) +
fixtures `Join2`/`Pass1`/`SinkF64`/`SinkI64` (`:1057,1063,1069,1076,1082,1087,1098,1110`)
+ ctor literals (`:1119,1127,2283`).
- Modify: `crates/aura-engine/src/graph_model.rs:274-276,286` (test `use` adds
`Cell`; `Bare` eval sig).
- Modify: `docs/design/INDEX.md` (C7 realization note `:195-210`; C8 prose `:221,242`).
- KEEP (no change — param plane / type-erased read / ingestion / channel rows):
`node.rs` param path, `any.rs::push`/`get`, `sweep.rs`/`mc.rs`/`walkforward.rs`
param args + `scalar_as_f64`, `aura-cli/src/main.rs` `run_oos`/`scalar_as_param_f64`,
`report.rs::f64_field`, `recorder.rs` channel row, `graph_model.rs::scalar_str`,
`blueprint.rs::compile_with_params`, `test_fixtures.rs`, `builder.rs`,
`aura-ingest`, `aura-registry`, `ctx.rs`, `column.rs`, `error.rs`, `scalar.rs`,
`cell.rs`.
---
## Task 1: Add `AnyColumn::push_cell` + round-trip test (additive)
**Files:**
- Modify: `crates/aura-core/src/any.rs`
This task is additive: `push_cell` is new `pub` code, the test exercises it, and
nothing existing changes — the workspace stays green throughout.
- [ ] **Step 1: Import `Cell` in any.rs**
In `crates/aura-core/src/any.rs`, after the existing import line
`use crate::error::KindMismatch;` (line ~6), and the line
`use crate::scalar::{Scalar, ScalarKind, Timestamp};` (line 7), add:
```rust
use crate::cell::Cell;
```
- [ ] **Step 2: Add the `push_cell` method**
In `impl AnyColumn`, immediately after the existing `push` method (ends ~line 82),
add:
```rust
/// Branch-free, infallible hot-path push of a bare [`Cell`] into this column's
/// concrete `Column<T>`, reinterpreting the cell by *this* column's kind. The
/// inter-node forward uses this: the edge's `from_field`→slot kind match is
/// verified once at bootstrap (C7), so no per-value kind check is needed here
/// — unlike [`push`](Self::push), which keeps the kind guard for the
/// ingestion boundary.
pub fn push_cell(&mut self, c: Cell) {
match self {
AnyColumn::I64(col) => col.push(c.i64()),
AnyColumn::F64(col) => col.push(c.f64()),
AnyColumn::Bool(col) => col.push(c.bool()),
AnyColumn::Ts(col) => col.push(c.ts()),
}
}
```
- [ ] **Step 3: Add the round-trip test**
In the `#[cfg(test)] mod tests` block (after `same_kind_push_round_trips`, ~line
171), add:
```rust
#[test]
fn push_cell_round_trips_by_column_kind() {
// push_cell reinterprets the bare cell by the column's own kind; reading
// back through `get` reconstructs the self-describing Scalar.
let mut f = AnyColumn::with_capacity(ScalarKind::F64, 2);
f.push_cell(Cell::from_f64(1.5));
assert_eq!(f.get(0), Some(Scalar::f64(1.5)));
assert_eq!(f.run_count(), 1);
let mut i = AnyColumn::with_capacity(ScalarKind::I64, 2);
i.push_cell(Cell::from_i64(42));
assert_eq!(i.get(0), Some(Scalar::i64(42)));
let mut b = AnyColumn::with_capacity(ScalarKind::Bool, 2);
b.push_cell(Cell::from_bool(true));
assert_eq!(b.get(0), Some(Scalar::bool(true)));
let mut t = AnyColumn::with_capacity(ScalarKind::Timestamp, 2);
t.push_cell(Cell::from_ts(Timestamp(7)));
assert_eq!(t.get(0), Some(Scalar::ts(Timestamp(7))));
}
```
- [ ] **Step 4: Verify it compiles and passes**
Run: `cargo test -p aura-core push_cell_round_trips_by_column_kind`
Expected: PASS (1 passed). The rest of the workspace is untouched and still green.
---
## Task 2: Atomic lib-code carrier flip
**Files:**
- Modify: `crates/aura-core/src/node.rs`
- Modify: `crates/aura-std/src/{add,sub,sma,ema,exposure,lincomb,sim_broker,recorder}.rs`
- Modify: `crates/aura-engine/src/harness.rs`
The trait-sig flip breaks every `impl Node` simultaneously; all lib-code impls +
the harness forward loop must change together. Fixtures (`#[cfg(test)]`) and
eval-output assertions are deferred to Task 3, so the gate here is a **lib-only**
build (`cargo build --workspace`, NOT `--all-targets`) — the cfg(test) code does
not compile until Task 3.
- [ ] **Step 1: Flip the `Node::eval` trait signature (node.rs)**
In `crates/aura-core/src/node.rs`:
Line 13 — add `Cell` to the import:
```rust
use crate::{Cell, Ctx, Scalar, ScalarKind};
```
Line 264 — the trait method:
```rust
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]>;
```
Update the trait/struct doc prose that names the old carrier: in the `Node`
doc-comment (~lines 248-255) and the C8 reference, replace the phrase
`returning a borrowed slice into a buffer the node owns` context — specifically
any `Option<&[Scalar]>` mention in rustdoc becomes `Option<&[Cell]>`. (Search the
file for `&[Scalar]` in doc comments; the param-path doc mentions of `Scalar`
stay.)
- [ ] **Step 2: Migrate `add.rs`**
`crates/aura-std/src/add.rs`:
Line 5 — `use aura_core::{Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, Scalar, ScalarKind};`
Line 20 — `out: [Cell; 1],`
Line 26 — `Self { out: [Cell::from_f64(0.0)] }`
Line 58 — `fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {`
Line 64 — `self.out[0] = Cell::from_f64(a[0] + b[0]);`
(`Scalar` stays in the `use` — the test column writes at lines 87, 91 keep it.)
- [ ] **Step 3: Migrate `sub.rs`**
`crates/aura-std/src/sub.rs`:
Line 6 — add `Cell,` to the import (→ `use aura_core::{Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, Scalar, ScalarKind};`)
Line 11 — `out: [Cell; 1],`
Line 17 — `Self { out: [Cell::from_f64(0.0)] }`
Line 49 — `fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {`
Line 55 — `self.out[0] = Cell::from_f64(a[0] - b[0]);`
- [ ] **Step 4: Migrate `sma.rs`**
`crates/aura-std/src/sma.rs`:
Lines 6-9 — add `Cell,` to the multi-line import:
```rust
use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, Scalar,
ScalarKind,
};
```
Line 14 — `out: [Cell; 1],`
Line 21 — `Self { length, out: [Cell::from_f64(0.0)] }`
Line 45 — `fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {`
Line 54 — `self.out[0] = Cell::from_f64(sum / self.length as f64);`
(`Scalar` stays — column writes + `bind(..., Scalar::i64(2))` keep it.)
- [ ] **Step 5: Migrate `ema.rs`**
`crates/aura-std/src/ema.rs`:
Lines 20-23 — add `Cell,` to the multi-line import:
```rust
use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, Scalar,
ScalarKind,
};
```
Line 37 — `out: [Cell; 1],`
Line 52 — `out: [Cell::from_f64(0.0)],`
Line 79 — `fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {`
Line 99 — `self.out[0] = Cell::from_f64(self.ema);`
- [ ] **Step 6: Migrate `exposure.rs`**
`crates/aura-std/src/exposure.rs`:
Lines 6-9 — add `Cell,` to the multi-line import:
```rust
use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, Scalar,
ScalarKind,
};
```
Line 15 — `out: [Cell; 1],`
Line 22 — `Self { scale, out: [Cell::from_f64(0.0)] }`
Line 46 — `fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {`
Line 51 — `self.out[0] = Cell::from_f64((w[0] / self.scale).clamp(-1.0, 1.0));`
- [ ] **Step 7: Migrate `lincomb.rs`**
`crates/aura-std/src/lincomb.rs`:
Lines 9-12 — add `Cell,` to the multi-line import:
```rust
use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, Scalar,
ScalarKind,
};
```
Line 30 — `out: [Cell; 1],`
Line 40 — `Self { weights, out: [Cell::from_f64(0.0)] }`
Line 68 — `fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {`
Line 77 — `self.out[0] = Cell::from_f64(acc);`
- [ ] **Step 8: Migrate `sim_broker.rs` (eval body; test helper is Task 3)**
`crates/aura-std/src/sim_broker.rs`:
Line 7 — add `Cell,` to the import.
Line 41 — `out: [Cell; 1],`
Line 54 — `out: [Cell::from_f64(0.0)],`
Line 82 — `fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {`
Line 94 — `self.out[0] = Cell::from_f64(self.cum);`
(The test helper at line 124 migrates in Task 3.)
- [ ] **Step 9: Migrate `recorder.rs` (eval sig only)**
`crates/aura-std/src/recorder.rs`:
Line 10 — add `Cell` to the import (the return type `Option<&[Cell]>` names it,
even though the body returns `None`):
```rust
use aura_core::{Cell, Ctx, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, Scalar, ScalarKind, Timestamp};
```
Line 60 — `fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {`
The body is unchanged: it builds the `Vec<Scalar>` channel row from `get(0)?`
and returns `None` (pure sink). `Scalar` stays in the `use`.
- [ ] **Step 10: Migrate the harness forward path (harness.rs)**
`crates/aura-engine/src/harness.rs`:
Line 24 — add `Cell` to the import:
```rust
use aura_core::{AnyColumn, Cell, Ctx, Firing, Node, NodeSchema, Scalar, ScalarKind, Timestamp};
```
Line 10 — the module-doc prose `borrowed record (`Option<&[Scalar]>`)`
`borrowed record (`Option<&[Cell]>`)`.
Line 373 — `let mut scratch: Vec<Cell> = Vec::new();`
Line 416 — `let result: Option<&[Cell]> = {`
Lines 426-428 — replace the fallible inter-node push with the infallible cell push:
```rust
nb.inputs[e.slot].push_cell(scratch[e.from_field]);
nb.slots[e.slot] = SlotState { fresh_at: cycle_id, last_ts: ts };
```
(i.e. drop the `.push(...).expect("edge kind checked at wiring")` and call
`push_cell`. The `nb.slots[...] = ...` line that follows is unchanged.)
**Do NOT change line 402** — the source-target forward
`nb.inputs[t.slot].push(value).expect("source kind checked at wiring")` stays on
the fallible `push(Scalar)` (ingestion, keep-set).
- [ ] **Step 11: Lib-only build gate**
Run: `cargo build --workspace`
Expected: `Finished` with 0 errors. (This builds lib + bin targets only; the
`#[cfg(test)]` fixtures and eval-output assertions do NOT compile yet — that is
Task 3. Do **not** run `--all-targets` here; it will fail by design until Task 3.)
---
## Task 3: Migrate `#[cfg(test)]` fixtures + eval-output assertions
**Files:**
- Modify: `crates/aura-core/src/node.rs` (Bare, Probe)
- Modify: `crates/aura-engine/src/harness.rs` (6 fixtures + ctor literals)
- Modify: `crates/aura-engine/src/blueprint.rs` (4 fixtures + ctor literals + use)
- Modify: `crates/aura-engine/src/graph_model.rs` (Bare + use)
- Modify: `crates/aura-std/src/{add,sub,sma,ema,exposure,lincomb,sim_broker}.rs` (assertions)
Per the spec's three-role test-migration rule: **eval-output** sites → `Cell`;
**column writes** (`inputs[i].push(Scalar::f64(x))`) → stay `Scalar`;
**out-of-graph channel sends** (`vec![Scalar::f64(v)]`) → stay `Scalar`.
- [ ] **Step 1: node.rs fixtures Bare + Probe**
`crates/aura-core/src/node.rs`:
Line 288 (`Bare::eval`) — `fn eval(&mut self, _ctx: Ctx<'_>) -> Option<&[Cell]> {`
Line 410 (`Probe::eval`) — `fn eval(&mut self, _ctx: Ctx<'_>) -> Option<&[Cell]> {`
Both bodies return `None` — unchanged. `Probe`'s field `Probe(Vec<Scalar>)`
(line 405) stays `Vec<Scalar>` (a stored param point). All `Scalar::i64/f64` in
the `tests` / `zip_params_tests` modules are param/bind sites — stay `Scalar`.
- [ ] **Step 2: harness.rs fixtures (6) + ctor literals**
`crates/aura-engine/src/harness.rs` — for each fixture, change the `out` field
type, the `eval` signature, and the `Scalar::*` assignment(s) to the `Cell`
carrier:
- `AsOfSum`: line 621 `out: [Cell; 1],`; line 636 eval sig; line 642
`self.out[0] = Cell::from_f64(a[0] + b[0]);`
- `BarrierSum`: line 650 `out: [Cell; 1],`; line 665 eval sig; line 666
`self.out[0] = Cell::from_f64(ctx.f64_in(0)[0] + ctx.f64_in(1)[0]);`
- `MixedSum`: line 675 `out: [Cell; 1],`; line 694 eval sig; line 701
`self.out[0] = Cell::from_f64(a[0] + b[0] + c[0]);`
- `Ohlcv`: line 711 `out: [Cell; 5],`; line 732 eval sig; line 738
`self.out[i] = Cell::from_f64(w[0]);`
- `TwoField`: line 749 `out: [Cell; 2],`; line 767 eval sig; line 768
`self.out[0] = Cell::from_f64(0.0);`; line 769 `self.out[1] = Cell::from_i64(0);`
- `TapForward` (**dual-role**): line 778 `out: [Cell; 1],`; line 794 eval sig;
line 801 `self.out[0] = Cell::from_f64(v);`. **Line 779 keeps**
`tx: mpsc::Sender<(Timestamp, Vec<Scalar>)>` and **line 800 keeps**
`let _ = self.tx.send((ctx.now(), vec![Scalar::f64(v)]));` (out-of-graph row).
Then the fixture **constructor literals** — change each `out:` init to the `Cell`
carrier (the type must match the migrated field):
- Lines 898, 944, 993, 1044, 1781 (AsOfSum/BarrierSum/MixedSum/BarrierSum):
`out: [Cell::from_f64(0.0)]`
- Lines 1156, 1209, 1255, 1380 (`Ohlcv { out: [Scalar::f64(0.0); 5] }`):
`out: [Cell::from_f64(0.0); 5]`
- Lines 1307, 1614 (`TwoField { out: [Scalar::f64(0.0), Scalar::i64(0)] }`):
`out: [Cell::from_f64(0.0), Cell::from_i64(0)]`
- Line 1475 (`TapForward { out: [Scalar::f64(0.0)], tx: ... }`):
`out: [Cell::from_f64(0.0)]` (the `tx:` field unchanged)
**Keep `Scalar`** on every recorded-row / channel-row literal (recorder + Tap
channel sends, `Vec<Scalar>` assertions) at lines 830-832, 877-879, 924-927,
970-971, 1025-1027, 1070-1071, 1235-1236, 1278-1279, 1358-1361, 1367-1368,
1490-1492, 1562-1563, 1598-1601, 1682-1684, 1746-1756, 1813-1821, 1873-1874,
1935-1947, 2022-2036, 2108 — these are out-of-graph rows, not eval output.
- [ ] **Step 3: blueprint.rs fixtures (4) + ctor literals + use**
`crates/aura-engine/src/blueprint.rs`:
Line 816 — add `Cell` to the test-mod import:
```rust
use aura_core::{Cell, Ctx, FieldSpec, Firing, NodeSchema, Timestamp};
```
- `Join2`: line 1057 `out: [Cell; 1],`; line 1063 eval sig; line 1069
`self.out[0] = Cell::from_f64(a[0] + b[0]);`
- `Pass1`: line 1076 `out: [Cell; 1],`; line 1082 eval sig; line 1087
`self.out[0] = Cell::from_f64(w[0]);`
- `SinkF64`: line 1098 eval sig → `Option<&[Cell]>` (body returns `None`).
- `SinkI64`: line 1110 eval sig → `Option<&[Cell]>` (body returns `None`).
Constructor literals:
- Line 1119 (`Pass1 { out: [Scalar::f64(0.0)] }`) → `out: [Cell::from_f64(0.0)]`
- Line 1127 (`Join2 { out: [Scalar::f64(0.0)] }`) → `out: [Cell::from_f64(0.0)]`
- Line 2283 (`Pass1 { out: [Scalar::f64(0.0)] }`) → `out: [Cell::from_f64(0.0)]`
**Keep `Scalar`** on `compile_with_params(&[Scalar::...])` calls (param plane):
lines 1239, 1899, 2058, 2193, 2212, 2265, 2315.
- [ ] **Step 4: graph_model.rs fixture + use**
`crates/aura-engine/src/graph_model.rs`:
Lines 274-276 — add `Cell` to the test-mod import:
```rust
use aura_core::{
Cell, FieldSpec, Node, NodeSchema, PortSpec, PrimitiveBuilder, Scalar,
};
```
Line 286 — `Bare::eval` sig → `fn eval(&mut self, _c: aura_core::Ctx<'_>) -> Option<&[Cell]> {`
(body returns `None`). `scalar_str(s: aura_core::Scalar)` at line 27 is untouched
(renders a `BoundParam.value`). If clippy later flags `Scalar` as unused in this
test module, remove it from the line-274 import — the clippy gate (Step 8) is the
backstop.
- [ ] **Step 5: aura-std eval-output assertions + sim_broker helper**
Migrate ONLY the eval-output assertion lines (`Some([Scalar::f64(x)].as_slice())`
`Some([Cell::from_f64(x)].as_slice())`); leave every `inputs[i].push(Scalar::f64(x))`
column-write untouched.
- `add.rs:92``Some([Cell::from_f64(14.0)].as_slice())`
- `sub.rs:83``Some([Cell::from_f64(6.0)].as_slice())`
- `sma.rs:86,97,100` — wrap each expected mean with `Cell::from_f64(...)`
(line 86 is inside the `Some(m) => assert_eq!(got, Some([Cell::from_f64(m)].as_slice()))`
match arm; lines 97, 100 are `Some([Cell::from_f64(7.0)]...)` / `Some([Cell::from_f64(9.0)]...)`).
- `ema.rs:131,144,147` — same: line 131 match arm `Some([Cell::from_f64(m)]...)`;
lines 144, 147 `Some([Cell::from_f64(7.0)]...)` / `Some([Cell::from_f64(9.0)]...)`.
- `exposure.rs:81``Some([Cell::from_f64(want)].as_slice())`
- `lincomb.rs:105,118,136,168,183` — each `Some([Cell::from_f64(x)].as_slice())`
(the expected weighted sums: 11.0, 12.0, 6.0, 11.0, 11.0 respectively).
- `sim_broker.rs:124` — the test helper match arm:
`Some([c]) => c.f64(),` (was `Some([s]) => s.as_f64(),`). The `panic!` arm and
the column-write seeds (lines 120, 122) are unchanged.
- [ ] **Step 6: Full-target build gate**
Run: `cargo build --workspace --all-targets`
Expected: `Finished` with 0 errors. (Now the test code compiles against the
`Cell` carrier.)
- [ ] **Step 7: Test gate**
Run: `cargo test --workspace`
Expected: all tests pass, 0 failed. (Behaviour-preserving: the only changed
test expectations are eval-output re-spellings; values are identical.)
- [ ] **Step 8: Clippy gate**
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: 0 warnings. (Catches any now-unused `Scalar` import left in a fully
migrated file.)
- [ ] **Step 9: Doc gate**
Run: `cargo doc --workspace --no-deps 2>&1`
Expected: builds with no warnings (the migrated rustdoc comments + intra-doc
links to `Cell` resolve).
---
## Task 4: Record the carrier swap in the design ledger
**Files:**
- Modify: `docs/design/INDEX.md`
The C7 realization note promised "its own note (and touch C8's eval contract)";
this task delivers it so the ledger no longer asserts a now-false "not yet".
- [ ] **Step 1: Update the C7 realization note**
In `docs/design/INDEX.md`, the C7 `Realization (Cell carrier split, 2026-06)`
note (lines ~195-210) currently ends:
```
the `scalar_eq_is_value_not_bitwise` fixture. Behaviour-preserving. `Cell` is
**not yet** the hot-path carrier — `eval -> Option<&[Scalar]>` and the edges
still flow `Scalar`; replacing the carrier with `Cell` is a larger step that will
get its own note (and touch C8's `eval` contract).
```
Replace the "not yet" sentence with the realization (keep the value-PartialEq
sentence before it):
```
the `scalar_eq_is_value_not_bitwise` fixture. Behaviour-preserving.
**Realization (`Cell` becomes the hot-path carrier, 2026-06, #74).** The carrier
swap deferred above has landed: `Node::eval` now returns `Option<&[Cell]>` and
every node out-buffer is `[Cell; N]`, so the inter-node forward carries tag-free
8-byte words. The kind lives only at the schema/column: the harness forwards each
field via the new branch-free `AnyColumn::push_cell` (infallible — the edge
kind match is verified once at bootstrap, the surviving guard
`bootstrap_rejects_*_kind_mismatch`). `Scalar` remains on the self-describing
dynamic boundaries: the param plane (`build`/`bind`/`compile_with_params`/sweep
points/`RunManifest`), `AnyColumn::get` (the type-erased read for sinks/serde),
and source ingestion (`Source::next`, the heterogeneous C3 merge). The removed
per-value runtime kind check on node output is the same authoring-bug class C8
already leaves to a `debug_assert` (output width); node-output-kind correctness
is the node's declared `FieldSpec` contract, caught by each node's own test.
Behaviour-preserving (C1).
```
- [ ] **Step 2: Update the C8 prose mentions of the old carrier**
In `docs/design/INDEX.md`, the C8 guarantee (line ~221) and the C8 cycle-0005
realization (line ~242) state `eval(ctx) -> Option<&[Scalar]>` /
`eval` returns `Option<&[Scalar]>`. Update both `Option<&[Scalar]>`
`Option<&[Cell]>`, and in the 0005 realization append a brief pointer: append to
that sentence ` (the carrier is now a tag-free `Cell` — see the C7 carrier note)`.
- [ ] **Step 3: Glossary check (no write expected)**
Run: `grep -n "not yet" docs/glossary.md; grep -n -A2 "^### cell" docs/glossary.md`
Expected: the `cell` entry already says cell "is what the SoA hot path reads
without a per-value branch" — forward-compatible, no "not yet" claim. If the grep
shows a "not yet"-style claim about cell being the carrier, fix it; otherwise no
glossary change.
- [ ] **Step 4: Verify no stale "not yet the hot-path carrier" remains**
Run: `grep -rn "not yet.*hot-path carrier\|not yet.*carrier" docs/`
Expected: no matches (the C7 note's "not yet" line is gone).
Run: `cargo doc --workspace --no-deps 2>&1`
Expected: no new warnings (sanity re-check after the ledger edit; the ledger is
markdown, but this confirms nothing in code regressed).
-604
View File
@@ -1,604 +0,0 @@
# Walk-forward param plane: space on the family, tag-free chosen_params, schema-checked param_stability — Implementation Plan
> **Parent spec:** `docs/specs/0048-walk-forward-param-plane.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run this
> plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Make the walk-forward param plane C7-clean and symmetric with the sweep param
plane — kinds live once on `WalkForwardResult.space`, per-window chosen points become
tag-free `Vec<Cell>`, and `param_stability` coerces against the schema (once per slot) not
the value (once per window-value) — a single, behaviour-preserving (C1) cut.
**Architecture:** β1 changes the two struct shapes; [A] threads `space` through
`walk_forward`/`walk_forward_with_threads` as a by-value sibling of the run-window closure
(the `Fn` generic untouched); [C]/[D] rewrite `param_stability` to a per-slot coercer over
`result.space` and delete `scalar_as_f64`. The cut is compile-coupled within
`aura-engine`, so Task 1 lands all of `walkforward.rs` atomically (its gate is an
`aura-engine`-only build/test — `aura-cli` is a separate crate, threaded in Task 2).
**Tech Stack:** `crates/aura-engine/src/walkforward.rs` (the bulk),
`crates/aura-cli/src/main.rs` (`walkforward_family`), `crates/aura-registry/src/lineage.rs`
(verify-only). Types from `aura_core`: `Cell`, `ParamSpec`, `ScalarKind`.
---
**Files this plan creates or modifies:**
- Modify: `crates/aura-engine/src/walkforward.rs` — struct shapes, `walk_forward`(`_with_threads`) signatures+bodies, `param_stability` rewrite, delete `scalar_as_f64`, migrate in-file test fixtures, add the Bool-slot test.
- Modify: `crates/aura-cli/src/main.rs:452-475``walkforward_family`: compute `space` once, thread into `walk_forward`, simplify the closure.
- Verify (no edit): `crates/aura-registry/src/lineage.rs:167-168` — reads `oos_report` only; confirm by grep.
---
## Task 1: `aura-engine/walkforward.rs` — the atomic param-plane cut
**Files:**
- Modify: `crates/aura-engine/src/walkforward.rs`
This whole task is one `aura-engine` compile unit: the struct field-type change, the
signature change, and the `param_stability` rewrite must land together (any subset leaves
a type error). All in-file callers (the test block) are threaded inside this task, so the
task-closing gate (`cargo test -p aura-engine`) is satisfiable. `aura-cli`'s caller is a
separate crate, deferred to Task 2.
- [ ] **Step 1: Fix the production imports**
`crates/aura-engine/src/walkforward.rs:15` is currently:
```rust
use crate::{MetricStats, RunReport, Scalar, Timestamp};
```
Replace with (drop `Scalar` — unused after this task; add `ParamSpec` + `ScalarKind`, both
re-exported by `aura-engine`'s `lib.rs`; add `Cell` direct from `aura_core`, mirroring
`sweep.rs:7`, since `aura-engine` does not re-export `Cell`):
```rust
use crate::{MetricStats, ParamSpec, RunReport, ScalarKind, Timestamp};
use aura_core::Cell;
```
- [ ] **Step 2: β1 — `WindowRun.chosen_params: Vec<Scalar>` → `Vec<Cell>` + doc**
At `walkforward.rs:123-132`, the struct is:
```rust
#[derive(Clone, Debug, PartialEq)]
pub struct WindowRun {
/// The chosen params, kept as self-describing [`Scalar`]s — the record carrier
/// (serializable). `param_stability` reduces them to `f64` only at the
/// statistic boundary (a distribution over windows is intrinsically
/// real-valued); the typed value is preserved up to that one reduction.
pub chosen_params: Vec<Scalar>,
pub oos_equity: Vec<(Timestamp, f64)>,
pub oos_report: RunReport,
}
```
Replace with (field type + doc rewrite; the kind now lives on the family, not the value):
```rust
#[derive(Clone, Debug, PartialEq)]
pub struct WindowRun {
/// The chosen params as the tag-free coordinate [`Cell`]s the inner sweep
/// produced (C7: the kind lives once on [`WalkForwardResult::space`], not at the
/// value). The named/typed view is `zip_params(&result.space, &chosen_params)` —
/// the same idiom as [`SweepFamily::named_params`](crate::SweepFamily).
pub chosen_params: Vec<Cell>,
pub oos_equity: Vec<(Timestamp, f64)>,
pub oos_report: RunReport,
}
```
- [ ] **Step 3: β1 — `WalkForwardResult` gains `space: Vec<ParamSpec>`**
At `walkforward.rs:147-160`, the struct is:
```rust
#[derive(Clone, Debug, PartialEq)]
pub struct WalkForwardResult {
/// Per-window outcomes in roll order (window 0 first), independent of thread
/// completion (C1).
pub windows: Vec<WindowOutcome>,
/// The stitched continuous OOS pip-equity curve: each window's OOS segment
/// offset by the running sum of all prior segments' final cumulative values,
/// so equity carries forward across the IS/OOS boundaries (continuous, not a
/// per-window sawtooth). An empty OOS segment contributes `0.0` to the running
/// offset and no points, so an equity-less window leaves the curve unbroken.
/// For the canonical `step == oos_len` tiling the segment timestamps are also
/// contiguous.
pub stitched_oos_equity: Vec<(Timestamp, f64)>,
}
```
Insert the `space` field as the first field (mirrors `SweepFamily.space`):
```rust
#[derive(Clone, Debug, PartialEq)]
pub struct WalkForwardResult {
/// The param-space schema (kinds + slot names), once for the whole family —
/// the kinds the per-window [`WindowRun::chosen_params`] cells are read against
/// (C7). Identical for every window (same blueprint). Mirrors
/// [`SweepFamily::space`](crate::SweepFamily); the two stay distinct types.
pub space: Vec<ParamSpec>,
/// Per-window outcomes in roll order (window 0 first), independent of thread
/// completion (C1).
pub windows: Vec<WindowOutcome>,
/// The stitched continuous OOS pip-equity curve: each window's OOS segment
/// offset by the running sum of all prior segments' final cumulative values,
/// so equity carries forward across the IS/OOS boundaries (continuous, not a
/// per-window sawtooth). An empty OOS segment contributes `0.0` to the running
/// offset and no points, so an equity-less window leaves the curve unbroken.
/// For the canonical `step == oos_len` tiling the segment timestamps are also
/// contiguous.
pub stitched_oos_equity: Vec<(Timestamp, f64)>,
}
```
- [ ] **Step 4: [A] — `walk_forward` signature + the forwarding call**
At `walkforward.rs:170-176`, the public `walk_forward` is:
```rust
pub fn walk_forward<F>(roller: WindowRoller, run_window: F) -> WalkForwardResult
where
F: Fn(WindowBounds) -> WindowRun + Sync,
{
let nthreads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
walk_forward_with_threads(roller, nthreads, run_window)
}
```
Add the `space` parameter (a by-value sibling of the closure) and forward it; also add a
one-line doc note on the param. Replace with:
```rust
pub fn walk_forward<F>(roller: WindowRoller, space: Vec<ParamSpec>, run_window: F) -> WalkForwardResult
where
F: Fn(WindowBounds) -> WindowRun + Sync,
{
let nthreads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
walk_forward_with_threads(roller, space, nthreads, run_window)
}
```
- [ ] **Step 5: [A] — `walk_forward_with_threads` signature + body (arity `debug_assert` + space onto the result)**
At `walkforward.rs:184-202`, the function is:
```rust
fn walk_forward_with_threads<F>(
roller: WindowRoller,
nthreads: usize,
run_window: F,
) -> WalkForwardResult
where
F: Fn(WindowBounds) -> WindowRun + Sync,
{
let bounds: Vec<WindowBounds> = roller.collect();
debug_assert!(!bounds.is_empty(), "walk_forward requires a non-empty roll");
let runs = run_indexed(bounds.len(), nthreads, |i| run_window(bounds[i]));
let windows: Vec<WindowOutcome> = bounds
.into_iter()
.zip(runs)
.map(|(bounds, run)| WindowOutcome { bounds, run })
.collect();
let stitched_oos_equity = stitch(&windows);
WalkForwardResult { windows, stitched_oos_equity }
}
```
Replace with (add `space` param after `roller`; `run_indexed` line unchanged — `space` is
never captured by the `Sync` closure; add the arity `debug_assert` on `windows` since
`runs` is already moved into the `.zip`; move `space` onto the result):
```rust
fn walk_forward_with_threads<F>(
roller: WindowRoller,
space: Vec<ParamSpec>,
nthreads: usize,
run_window: F,
) -> WalkForwardResult
where
F: Fn(WindowBounds) -> WindowRun + Sync,
{
let bounds: Vec<WindowBounds> = roller.collect();
debug_assert!(!bounds.is_empty(), "walk_forward requires a non-empty roll");
let runs = run_indexed(bounds.len(), nthreads, |i| run_window(bounds[i]));
let windows: Vec<WindowOutcome> = bounds
.into_iter()
.zip(runs)
.map(|(bounds, run)| WindowOutcome { bounds, run })
.collect();
let stitched_oos_equity = stitch(&windows);
debug_assert!(
windows.iter().all(|w| w.run.chosen_params.len() == space.len()),
"every window's chosen point must match the param-space arity (same blueprint)"
);
WalkForwardResult { space, windows, stitched_oos_equity }
}
```
- [ ] **Step 6: [C]/[D] — rewrite `param_stability` (schema-pass coercer, type-blind loop, no-windows guard) + doc**
At `walkforward.rs:223-248`, the doc + function are:
```rust
/// On-demand param-stability summary: one [`MetricStats`](crate::MetricStats) per
/// param slot, over the chosen values (`Scalar` coerced to `f64`) across all
/// windows — reuses [`MetricStats::from_values`](crate::MetricStats::from_values).
/// A pure reduction over `result.windows[*].run.chosen_params`, NOT stored on the
/// result (sweep-precedent: a summary is computed on demand, not cached), so it
/// stays non-redundant and the caller can compute any other measure (std, IQR,
/// distinct-count) from the same raw substrate. One entry per param slot, in slot
/// order; empty if there are no windows or the strategy has no params. The slot
/// count is taken from the first window (all windows of one blueprint share the
/// param-space arity, C11).
pub fn param_stability(result: &WalkForwardResult) -> Vec<MetricStats> {
let Some(first) = result.windows.first() else {
return Vec::new();
};
let nslots = first.run.chosen_params.len();
(0..nslots)
.map(|slot| {
let vals: Vec<f64> = result
.windows
.iter()
.map(|w| scalar_as_f64(w.run.chosen_params[slot]))
.collect();
MetricStats::from_values(&vals)
})
.collect()
}
```
Replace with (read kinds from `result.space` into a per-slot coercer **before** the
reduction; keep the documented "empty if no windows or no params" contract via the
`is_empty` guard + the empty-`space` ⇒ empty-`coerce` path):
```rust
/// On-demand param-stability summary: one [`MetricStats`](crate::MetricStats) per
/// param slot, over the chosen values across all windows — reuses
/// [`MetricStats::from_values`](crate::MetricStats::from_values). A pure reduction
/// over `result.windows[*].run.chosen_params`, NOT stored on the result
/// (sweep-precedent: a summary is computed on demand, not cached). One entry per
/// param slot, in slot order; empty if there are no windows or the strategy has no
/// params. The numeric coercion is resolved once per slot against the schema
/// (`result.space`, C7) — never per window-value: an `i64`/`f64` slot maps to its
/// value, a `bool` slot to `0.0`/`1.0` (so `mean` is the fraction of windows that
/// chose `true`). A `timestamp` slot is structurally impossible (C20: a timestamp
/// is a structural axis, never a numeric knob).
pub fn param_stability(result: &WalkForwardResult) -> Vec<MetricStats> {
if result.windows.is_empty() {
return Vec::new();
}
let coerce: Vec<fn(Cell) -> f64> = result
.space
.iter()
.map(|ps| match ps.kind {
ScalarKind::I64 => (|c: Cell| c.i64() as f64) as fn(Cell) -> f64,
ScalarKind::F64 => |c: Cell| c.f64(),
ScalarKind::Bool => |c: Cell| c.bool() as i64 as f64,
ScalarKind::Timestamp => {
unreachable!("timestamp is a structural axis (C20), never a param knob")
}
})
.collect();
coerce
.iter()
.enumerate()
.map(|(slot, f)| {
let vals: Vec<f64> =
result.windows.iter().map(|w| f(w.run.chosen_params[slot])).collect();
MetricStats::from_values(&vals)
})
.collect()
}
```
- [ ] **Step 7: Delete `scalar_as_f64`**
At `walkforward.rs:250-260`, delete the whole doc + function:
```rust
/// Coerce a numeric `Scalar` to `f64` for stability statistics — the one place a
/// distribution over windows demands a real-valued reduction. Params are
/// `i64`/`f64` typed values; a non-numeric scalar in a param slot is a wiring
/// bug, surfaced like the engine's other "checked at wiring" violations.
fn scalar_as_f64(s: Scalar) -> f64 {
match s.kind() {
aura_core::ScalarKind::I64 => s.as_i64() as f64,
aura_core::ScalarKind::F64 => s.as_f64(),
other => unreachable!("non-numeric param scalar: {other:?}"),
}
}
```
(Its only call site, in `param_stability`, was removed in Step 6.)
- [ ] **Step 8: Migrate test fixtures to `Cell` + add a `space` fixture helper, and drop the now-unused test `use`**
In `walkforward.rs`, the test block opens (`:264-266`) with:
```rust
use super::*;
use crate::{summarize, RunManifest};
use aura_core::Scalar;
```
Remove the `use aura_core::Scalar;` line (Scalar is no longer used in the tests after this
step; `Cell`/`ParamSpec`/`ScalarKind` reach the tests via `use super::*;`):
```rust
use super::*;
use crate::{summarize, RunManifest};
```
Then add a shared 2-slot space fixture helper (place it right after the `dummy_report`
helper, before `outcome` at `:283`):
```rust
/// The 2-slot param-space the run-window fixtures choose into: an i64 length
/// and an f64 scale. Matches `run_window_fixture` / `mk` arity (debug_assert).
fn fixture_space() -> Vec<ParamSpec> {
vec![
ParamSpec { name: "len".to_string(), kind: ScalarKind::I64 },
ParamSpec { name: "scale".to_string(), kind: ScalarKind::F64 },
]
}
```
Migrate the `outcome` helper signature (`:283`) — `chosen: Vec<Scalar>``Vec<Cell>`:
```rust
fn outcome(oos_equity: Vec<(Timestamp, f64)>, chosen: Vec<Cell>) -> WindowOutcome {
```
(Its callers all pass `vec![]`, so they are type-only churn and need no edit.)
Migrate `run_window_fixture` (`:299`) — the `chosen_params` literal:
```rust
chosen_params: vec![Scalar::i64(k % 3), Scalar::f64(k as f64 * 0.5)],
```
becomes:
```rust
chosen_params: vec![Cell::from_i64(k % 3), Cell::from_f64(k as f64 * 0.5)],
```
- [ ] **Step 9: Thread `space` into the in-file `walk_forward` / `walk_forward_with_threads` calls**
In `walk_forward_runs_one_window_per_split_in_roll_order` (`:315`):
```rust
let result = walk_forward(roller, run_window_fixture);
```
becomes:
```rust
let result = walk_forward(roller, fixture_space(), run_window_fixture);
```
In `walk_forward_is_deterministic_across_thread_counts` (`:330-333`):
```rust
let one = walk_forward_with_threads(cfg(), 1, run_window_fixture);
let many = walk_forward_with_threads(cfg(), 8, run_window_fixture);
assert_eq!(one, many);
assert_eq!(one, walk_forward(cfg(), run_window_fixture));
```
becomes (every call passes the same `fixture_space()`, so the `assert_eq!` over
`WalkForwardResult` — which now includes `space` — still holds):
```rust
let one = walk_forward_with_threads(cfg(), fixture_space(), 1, run_window_fixture);
let many = walk_forward_with_threads(cfg(), fixture_space(), 8, run_window_fixture);
assert_eq!(one, many);
assert_eq!(one, walk_forward(cfg(), fixture_space(), run_window_fixture));
```
- [ ] **Step 10: Migrate the `param_stability` test (Cell points + space fixture; MetricStats identical)**
In `param_stability_reduces_chosen_params_per_slot` (`:374-388`), the `mk` helper and the
result literal are:
```rust
let mk = |a: i64, b: f64| WindowOutcome {
bounds: WindowBounds {
is: (Timestamp(0), Timestamp(0)),
oos: (Timestamp(0), Timestamp(0)),
},
run: WindowRun {
chosen_params: vec![Scalar::i64(a), Scalar::f64(b)],
oos_equity: vec![],
oos_report: dummy_report(),
},
};
let result = WalkForwardResult {
windows: vec![mk(2, 1.0), mk(2, 1.0), mk(3, 2.0), mk(3, 2.0)],
stitched_oos_equity: vec![],
};
```
Replace with (Cell points; add the `space` field; the asserted `MetricStats` at `:390-393`
stay byte-identical — `I64→f64` / `F64` coerce to the same values):
```rust
let mk = |a: i64, b: f64| WindowOutcome {
bounds: WindowBounds {
is: (Timestamp(0), Timestamp(0)),
oos: (Timestamp(0), Timestamp(0)),
},
run: WindowRun {
chosen_params: vec![Cell::from_i64(a), Cell::from_f64(b)],
oos_equity: vec![],
oos_report: dummy_report(),
},
};
let result = WalkForwardResult {
space: fixture_space(),
windows: vec![mk(2, 1.0), mk(2, 1.0), mk(3, 2.0), mk(3, 2.0)],
stitched_oos_equity: vec![],
};
```
(The four assertions below it — `stab.len()==2`, `stab[0].mean==2.5`, `stab[0].p50==2.5`,
`stab[1].mean==1.5` — are unchanged.)
- [ ] **Step 11: Add the new Bool-slot test (the one genuinely new behaviour, [D])**
Insert this test immediately after `param_stability_reduces_chosen_params_per_slot`
(after `:394`):
```rust
#[test]
fn param_stability_reduces_bool_slot_to_fraction_true() {
// spec §Testing: a Bool param slot coerces 0/1, so mean = fraction of
// windows that chose `true`. 3 of 4 true -> 0.75.
let mk = |flag: bool| WindowOutcome {
bounds: WindowBounds {
is: (Timestamp(0), Timestamp(0)),
oos: (Timestamp(0), Timestamp(0)),
},
run: WindowRun {
chosen_params: vec![Cell::from_bool(flag)],
oos_equity: vec![],
oos_report: dummy_report(),
},
};
let result = WalkForwardResult {
space: vec![ParamSpec { name: "flag".to_string(), kind: ScalarKind::Bool }],
windows: vec![mk(true), mk(false), mk(true), mk(true)],
stitched_oos_equity: vec![],
};
let stab = param_stability(&result);
assert_eq!(stab.len(), 1);
assert_eq!(stab[0].mean, 0.75);
}
```
- [ ] **Step 12: Build + test the `aura-engine` crate (partial gate — `aura-cli` is Task 2)**
Run: `cargo test -p aura-engine`
Expected: PASS — compiles with 0 errors; all `aura-engine` tests green, including the three
migrated walk-forward tests (`walk_forward_runs_one_window_per_split_in_roll_order`,
`walk_forward_is_deterministic_across_thread_counts`,
`param_stability_reduces_chosen_params_per_slot` with **identical** MetricStats) and the new
`param_stability_reduces_bool_slot_to_fraction_true`. (`aura-cli` is not built by `-p
aura-engine`; its caller is threaded in Task 2.)
---
## Task 2: `aura-cli/walkforward_family` + workspace gates
**Files:**
- Modify: `crates/aura-cli/src/main.rs:452-475`
- Verify (no edit): `crates/aura-registry/src/lineage.rs`
- [ ] **Step 1: Thread `space` into `walkforward_family` and simplify the closure**
At `crates/aura-cli/src/main.rs:452-475`, the function is:
```rust
fn walkforward_family() -> WalkForwardResult {
let sources: Vec<Box<dyn aura_engine::Source>> =
vec![Box::new(VecSource::new(walkforward_prices()))];
let span = window_of(&sources).expect("non-empty synthetic stream");
let roller = WindowRoller::new(span, 24, 12, 12, RollMode::Rolling)
.expect("built-in walk-forward config fits the 60-bar synthetic span");
walk_forward(roller, |w: WindowBounds| {
let is_family = sweep_over(w.is.0, w.is.1);
let best = optimize(&is_family, "total_pips").expect("total_pips is a known metric");
let (oos_equity, oos_report) = run_oos(&best.params, w.oos.0, w.oos.1);
WindowRun {
// best.params is the enumerated cell winner; reconstruct the
// self-describing report record (Option A) via the in-sample space.
chosen_params: best
.params
.iter()
.zip(&is_family.space)
.map(|(c, ps)| Scalar::from_cell(ps.kind, *c))
.collect(),
oos_equity,
oos_report,
}
})
}
```
Replace with (compute the space once before the roll — same blueprint every window — pass
it to `walk_forward`, and simplify `chosen_params` to the tag-free winner directly):
```rust
fn walkforward_family() -> WalkForwardResult {
let sources: Vec<Box<dyn aura_engine::Source>> =
vec![Box::new(VecSource::new(walkforward_prices()))];
let span = window_of(&sources).expect("non-empty synthetic stream");
let roller = WindowRoller::new(span, 24, 12, 12, RollMode::Rolling)
.expect("built-in walk-forward config fits the 60-bar synthetic span");
let space = sample_blueprint_with_sinks().0.param_space();
walk_forward(roller, space, |w: WindowBounds| {
let is_family = sweep_over(w.is.0, w.is.1);
let best = optimize(&is_family, "total_pips").expect("total_pips is a known metric");
let (oos_equity, oos_report) = run_oos(&best.params, w.oos.0, w.oos.1);
WindowRun {
// The tag-free sweep winner is the chosen point; its kinds live on
// WalkForwardResult.space (computed once above from the same blueprint).
chosen_params: best.params,
oos_equity,
oos_report,
}
})
}
```
- [ ] **Step 2: Build the whole workspace (the deferred compile gate from Task 1)**
Run: `cargo build --workspace --all-targets`
Expected: PASS — 0 errors. The `walk_forward` signature change + the `chosen_params` /
`WalkForwardResult` shape change are now threaded at every site across all crates.
- [ ] **Step 3: Confirm `aura-registry/lineage.rs` is unaffected**
Run: `grep -n "chosen_params\|\.space\|WalkForwardResult {" crates/aura-registry/src/lineage.rs`
Expected: no hit for `chosen_params`, no `WalkForwardResult { … }` literal, no `.space`
read — only `w.run.oos_report` is touched (around `:168`). (A bare match confirming the
file reads `oos_report` only; if any `chosen_params`/`space` hit appears, STOP — the recon
said there is none.)
- [ ] **Step 4: The four project gates**
Run: `cargo build --workspace --all-targets`
Expected: PASS — 0 errors.
Run: `cargo test --workspace`
Expected: PASS — all tests green (the migrated WF tests with identical MetricStats, the new
Bool-slot test, and the CLI in-binary `walkforward_report_*` shape tests at
`main.rs:914/921`, which pin line-count + substring only and are untouched by a
behaviour-preserving change).
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: PASS — clean. (In particular: no unused-import warning for the dropped `Scalar`
in `walkforward.rs`, and no unused `Scalar` in `aura-cli` — it stays used at other CLI
sites.)
Run: `cargo doc --workspace --no-deps 2>&1`
Expected: PASS — clean (the `WindowRun` / `WalkForwardResult` / `param_stability` doc edits
resolve; intra-doc links `SweepFamily` / `MetricStats::from_values` resolve).
- [ ] **Step 5: Acceptance spot-check greps**
Run: `grep -rn "scalar_as_f64" crates/`
Expected: no hits (the function and its call site are gone).
Run: `grep -n "chosen_params: Vec<Cell>\|pub space: Vec<ParamSpec>" crates/aura-engine/src/walkforward.rs`
Expected: both present (one each).
Run: `grep -n "unreachable!" crates/aura-engine/src/walkforward.rs`
Expected: exactly one hit, inside the `param_stability` schema pass (the `Timestamp` arm) —
no `unreachable!` in any per-window-value path.
-700
View File
@@ -1,700 +0,0 @@
# Random param-sweep: `RandomSpace` + a `Space` trait + typed `ParamRange` — Implementation Plan
> **Parent spec:** `docs/specs/0049-random-param-sweep.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to
> run this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Ship the random half of the C12.1 param-sweep axis — a `RandomSpace`
that draws `N` seeded uniform points over declared continuous ranges, fed to the
same enumeration-agnostic `sweep` core the grid already drives, via a new `Space`
trait both enumerations implement.
**Architecture:** All work lands in `crates/aura-engine`. A new `Space` trait
(`points`/`param_specs`) generalizes `sweep`/`sweep_with_threads` from `&GridSpace`
to `&S: Space` (behaviour-preserving — `GridSpace`'s trait impl forwards to its
existing inherent methods, C1). `RandomSpace` is the second `Space` producer: a
typed `ParamRange { lo, hi }` per slot, validated in `new` against the param-space,
sampled by a code-path-disjoint `SplitMix64` instance (the #52/#71 World-II
source-seam firewall — the param-sampling RNG and the data-edge seed RNG share
only the `u64` type, never a code path).
**Tech Stack:** Rust; `crates/aura-engine/src/sweep.rs` (trait + `ParamRange` +
`RandomSpace` + signature generalization + three new `SweepError` variants),
`crates/aura-engine/src/harness.rs` (`SplitMix64``pub(crate)`),
`crates/aura-engine/src/lib.rs` (exports). Tests live in the existing
`sweep.rs` `mod tests`.
---
**Files this plan creates or modifies:**
- Modify: `crates/aura-engine/src/sweep.rs``Space` trait + `impl Space for GridSpace`
(forwarding); `ParamRange`; three new `SweepError` variants; `RandomSpace` +
`RandomSpace::new` + `impl Space for RandomSpace`; `sweep`/`sweep_with_threads`
generalized to `&S: Space`. New tests in `mod tests`.
- Modify: `crates/aura-engine/src/harness.rs:131-150``SplitMix64` struct + its
`new`/`next_u64`/`next_f64` methods from module-private to `pub(crate)`.
- Modify: `crates/aura-engine/src/lib.rs:65` — add `RandomSpace`, `ParamRange`,
`Space` to the `pub use sweep::{…}` line.
- Test: `crates/aura-engine/src/sweep.rs` `mod tests``Space`-trait satisfaction,
`ParamRange` constructors, `RandomSpace::new` validation (one per error variant +
accepted I64 `lo==hi`), sampler bounds/determinism/seed-sensitivity/zero-count,
and the random-`sweep` integration (== N independent runs, determinism across
thread counts, named-view round-trip).
---
### Task 1: `Space` trait + `impl Space for GridSpace` + generalize `sweep`/`sweep_with_threads`
The behaviour-preserving refactor (C1): introduce the trait, have `GridSpace`
implement it by forwarding to its inherent methods, and make the execution core
generic. The existing grid sweep tests are the regression guard — they must stay
green; the new test pins that `GridSpace` satisfies `Space`.
**Files:**
- Modify: `crates/aura-engine/src/sweep.rs` (trait + impl after line 91; signatures at `:136-142` and `:189-203`)
- Test: `crates/aura-engine/src/sweep.rs` `mod tests`
- [ ] **Step 1: Write the failing test**
Add to `mod tests` in `crates/aura-engine/src/sweep.rs` (after `points_enumerate_in_odometer_order`, around line 238):
```rust
#[test]
fn grid_space_satisfies_space_trait() {
// A generic helper that can only call the trait surface — proves GridSpace
// is usable through `Space`, the property `sweep` now relies on.
fn via_trait<S: Space>(s: &S) -> (Vec<Vec<Cell>>, Vec<ParamSpec>) {
(s.points(), s.param_specs().to_vec())
}
let grid = sma_cross_grid();
let (pts, specs) = via_trait(&grid);
// the trait surface forwards to the inherent methods — identical results
assert_eq!(pts, grid.points());
assert_eq!(specs, grid.param_specs().to_vec());
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cargo test -p aura-engine grid_space_satisfies_space_trait`
Expected: FAIL — compilation error: `cannot find trait Space in this scope` (the
trait does not exist yet, so the `via_trait<S: Space>` bound is unresolved).
- [ ] **Step 3: Add the `Space` trait, the `GridSpace` impl, and generalize the signatures**
In `crates/aura-engine/src/sweep.rs`, insert the trait and impl immediately after
the `impl GridSpace { … }` block (after line 91, before the `SweepError` enum):
```rust
/// The enumeration interface `sweep` runs over: a producer of param-space points.
/// Both `GridSpace` (cartesian product) and `RandomSpace` (seeded draws) implement
/// it, so the disjoint execution core (`run_indexed`) carries either enumeration
/// through one path (C1).
pub trait Space {
/// The enumerated points, each a tag-free coordinate in `param_specs()` order
/// (the kind lives once, in `param_specs()`).
fn points(&self) -> Vec<Vec<Cell>>;
/// The param-space (names + kinds) the points are coordinates in.
fn param_specs(&self) -> &[ParamSpec];
}
impl Space for GridSpace {
// `GridSpace::points(self)` is path syntax that selects the *inherent* method
// (inherent methods win method resolution), so this forward does not recurse;
// the grid path is behaviour-preserving (C1).
fn points(&self) -> Vec<Vec<Cell>> {
GridSpace::points(self)
}
fn param_specs(&self) -> &[ParamSpec] {
GridSpace::param_specs(self)
}
}
```
Then change `sweep`'s signature (currently `sweep.rs:136-139`) from:
```rust
pub fn sweep<F>(space: &GridSpace, run_one: F) -> SweepFamily
where
F: Fn(&[Cell]) -> RunReport + Sync,
{
```
to:
```rust
pub fn sweep<S, F>(space: &S, run_one: F) -> SweepFamily
where
S: Space,
F: Fn(&[Cell]) -> RunReport + Sync,
{
```
(the body — `available_parallelism` + `sweep_with_threads(space, nthreads, run_one)` — is unchanged.)
And change `sweep_with_threads`'s signature (currently `sweep.rs:189-192`) from:
```rust
fn sweep_with_threads<F>(space: &GridSpace, nthreads: usize, run_one: F) -> SweepFamily
where
F: Fn(&[Cell]) -> RunReport + Sync,
{
```
to:
```rust
fn sweep_with_threads<S, F>(space: &S, nthreads: usize, run_one: F) -> SweepFamily
where
S: Space,
F: Fn(&[Cell]) -> RunReport + Sync,
{
```
(the body — `space.points()`, `run_indexed`, `space.param_specs().to_vec()`, the
zip — is unchanged; it already calls only the two `Space` methods.)
- [ ] **Step 4: Run the new test and the existing grid suite to verify green**
Run: `cargo test -p aura-engine grid_space_satisfies_space_trait`
Expected: PASS.
Run: `cargo test -p aura-engine sweep`
Expected: PASS — all existing grid tests (`sweep_equals_n_independent_runs`,
`family_is_deterministic_across_thread_counts`, `sweep_family_carries_param_space`,
`family_named_params_round_trips`, `points_enumerate_in_odometer_order`, …) stay
green: the `&GridSpace``&S: Space` swap is behaviour-preserving, and the
cross-module caller `blueprint.rs:392` compiles unchanged because `GridSpace: Space`.
---
### Task 2: `ParamRange` — a typed, kind-tagged range
A `{ lo, hi }` pair carrying a shared kind by construction; the home for the
non-empty-range invariant (validated in Task 3). No `bool`/`timestamp`
constructors — those kinds are not range-sampleable.
**Files:**
- Modify: `crates/aura-engine/src/sweep.rs` (insert after the `SweepError` enum, around line 103)
- Test: `crates/aura-engine/src/sweep.rs` `mod tests`
- [ ] **Step 1: Write the failing test**
Add to `mod tests` in `crates/aura-engine/src/sweep.rs`:
```rust
#[test]
fn param_range_constructors_carry_kind() {
let ri = ParamRange::i64(2, 50);
assert_eq!(ri.kind(), ScalarKind::I64);
assert_eq!(ri.lo, Scalar::i64(2));
assert_eq!(ri.hi, Scalar::i64(50));
let rf = ParamRange::f64(0.1, 2.0);
assert_eq!(rf.kind(), ScalarKind::F64);
assert_eq!(rf.lo, Scalar::f64(0.1));
assert_eq!(rf.hi, Scalar::f64(2.0));
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cargo test -p aura-engine param_range_constructors_carry_kind`
Expected: FAIL — compilation error: `cannot find ... ParamRange in this scope`
(the type does not exist yet).
- [ ] **Step 3: Add `ParamRange`**
In `crates/aura-engine/src/sweep.rs`, insert after the `SweepError` enum (after
the closing `}` at line 103):
```rust
/// A typed, kind-tagged continuous range for one `RandomSpace` param slot, carried
/// positional-parallel to the param-space. `lo`/`hi` share a kind by construction;
/// this is the home for the non-empty-range invariant (validated in
/// [`RandomSpace::new`]) and, later, a distribution tag.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ParamRange {
pub lo: Scalar,
pub hi: Scalar,
}
impl ParamRange {
/// An inclusive `[lo, hi]` I64 range.
pub fn i64(lo: i64, hi: i64) -> Self {
Self { lo: Scalar::i64(lo), hi: Scalar::i64(hi) }
}
/// A half-open `[lo, hi)` F64 range.
pub fn f64(lo: f64, hi: f64) -> Self {
Self { lo: Scalar::f64(lo), hi: Scalar::f64(hi) }
}
/// The kind of this range (`lo`/`hi` share it by construction).
pub fn kind(&self) -> ScalarKind {
self.lo.kind()
}
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cargo test -p aura-engine param_range_constructors_carry_kind`
Expected: PASS.
---
### Task 3: `RandomSpace` struct + `RandomSpace::new` validation + three new `SweepError` variants
The typed gate: every structural fault is caught in `new`, before any run — exactly
as `GridSpace::new`. Adds the three error variants `new` produces.
**Files:**
- Modify: `crates/aura-engine/src/sweep.rs` (three `SweepError` variants append into the enum at `:95-103`; `RandomSpace` + `impl RandomSpace` after `ParamRange`)
- Test: `crates/aura-engine/src/sweep.rs` `mod tests`
- [ ] **Step 1: Write the failing tests**
Add to `mod tests` in `crates/aura-engine/src/sweep.rs`. (The helper `i64_space` already
exists at `sweep.rs:212`; add `one_i64_space` once, alongside the tests.)
```rust
fn one_i64_space() -> Vec<ParamSpec> {
vec![ParamSpec { name: "p0".into(), kind: ScalarKind::I64 }]
}
#[test]
fn random_space_new_arity_mismatch() {
let space = i64_space(2);
let err = RandomSpace::new(&space, vec![ParamRange::i64(0, 1)], 10, 0).unwrap_err();
assert_eq!(err, SweepError::Arity { expected: 2, got: 1 });
}
#[test]
fn random_space_new_non_numeric_slot() {
let space = vec![ParamSpec { name: "b".into(), kind: ScalarKind::Bool }];
let err = RandomSpace::new(&space, vec![ParamRange::i64(0, 1)], 10, 0).unwrap_err();
assert_eq!(err, SweepError::NonNumericRange { slot: 0, kind: ScalarKind::Bool });
}
#[test]
fn random_space_new_range_kind_mismatch() {
let space = one_i64_space();
let err = RandomSpace::new(&space, vec![ParamRange::f64(0.0, 1.0)], 10, 0).unwrap_err();
assert_eq!(
err,
SweepError::RangeKindMismatch { slot: 0, expected: ScalarKind::I64, got: ScalarKind::F64 },
);
}
#[test]
fn random_space_new_empty_i64_range() {
// inclusive [lo, hi] is empty iff lo > hi
let space = one_i64_space();
let err = RandomSpace::new(&space, vec![ParamRange::i64(5, 4)], 10, 0).unwrap_err();
assert_eq!(err, SweepError::EmptyRange { slot: 0 });
}
#[test]
fn random_space_new_empty_f64_range() {
// half-open [lo, hi) is empty at lo == hi
let space = vec![ParamSpec { name: "f".into(), kind: ScalarKind::F64 }];
let err = RandomSpace::new(&space, vec![ParamRange::f64(1.0, 1.0)], 10, 0).unwrap_err();
assert_eq!(err, SweepError::EmptyRange { slot: 0 });
}
#[test]
fn random_space_new_accepts_i64_single_point() {
// inclusive [lo, hi] with lo == hi is the valid single point {lo}
let space = one_i64_space();
let rs = RandomSpace::new(&space, vec![ParamRange::i64(7, 7)], 3, 0)
.expect("lo == hi is a valid single-point I64 range");
assert_eq!(rs.len(), 3);
}
#[test]
fn random_space_len_and_is_empty() {
let space = one_i64_space();
let rs = RandomSpace::new(&space, vec![ParamRange::i64(0, 10)], 5, 0).unwrap();
assert_eq!(rs.len(), 5);
assert!(!rs.is_empty());
let empty = RandomSpace::new(&space, vec![ParamRange::i64(0, 10)], 0, 0).unwrap();
assert_eq!(empty.len(), 0);
assert!(empty.is_empty());
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cargo test -p aura-engine random_space_`
Expected: FAIL — compilation error: `cannot find ... RandomSpace` and
`no variant ... NonNumericRange` / `RangeKindMismatch` / `EmptyRange` on
`SweepError` (the type and variants do not exist yet).
- [ ] **Step 3: Append the three `SweepError` variants and add `RandomSpace`**
In `crates/aura-engine/src/sweep.rs`, append the three variants inside the
`SweepError` enum, immediately after the `EmptyAxis { slot: usize },` line (`:102`):
```rust
/// A `RandomSpace` slot's declared kind is not range-sampleable (`I64`/`F64`):
/// a `Bool` is degenerate, a `Timestamp` is a structural axis (C20).
NonNumericRange { slot: usize, kind: ScalarKind },
/// A `ParamRange`'s kind does not match its slot's declared kind.
RangeKindMismatch { slot: usize, expected: ScalarKind, got: ScalarKind },
/// A `ParamRange` admits no sampleable value: `lo > hi` for an inclusive I64
/// range, or `lo >= hi` for a half-open F64 range.
EmptyRange { slot: usize },
```
Then add `RandomSpace` after the `ParamRange` impl (from Task 2):
```rust
/// `count` seeded uniform points over per-slot continuous `ranges`, validated
/// against a blueprint's param-space — the random sibling to `GridSpace` (C12.1).
/// The points are fully determined by `seed` before any run (C1).
#[derive(Debug)]
pub struct RandomSpace {
space: Vec<ParamSpec>,
ranges: Vec<ParamRange>,
count: usize,
seed: u64,
}
impl RandomSpace {
/// Validate `ranges` against `space` (the blueprint's `param_space()`): one
/// range per slot (`Arity`); each slot numeric, i.e. `I64`/`F64`
/// (`NonNumericRange` otherwise); each range's kind == the slot's declared kind
/// (`RangeKindMismatch`); a non-empty range (`EmptyRange`: I64 `lo > hi`, F64
/// `lo >= hi`). A `count` of 0 is valid and yields an empty family.
pub fn new(
space: &[ParamSpec],
ranges: Vec<ParamRange>,
count: usize,
seed: u64,
) -> Result<Self, SweepError> {
if ranges.len() != space.len() {
return Err(SweepError::Arity { expected: space.len(), got: ranges.len() });
}
for (slot, (r, ps)) in ranges.iter().zip(space).enumerate() {
if !matches!(ps.kind, ScalarKind::I64 | ScalarKind::F64) {
return Err(SweepError::NonNumericRange { slot, kind: ps.kind });
}
if r.kind() != ps.kind {
return Err(SweepError::RangeKindMismatch { slot, expected: ps.kind, got: r.kind() });
}
// a range must admit at least one value: I64 [lo,hi] is non-empty iff
// lo <= hi (lo==hi is the valid single point); F64 [lo,hi) is non-empty
// iff lo < hi (at lo==hi the half-open interval is empty -> rejected).
let empty = match ps.kind {
ScalarKind::I64 => r.lo.as_i64() > r.hi.as_i64(),
_ => r.lo.as_f64() >= r.hi.as_f64(),
};
if empty {
return Err(SweepError::EmptyRange { slot });
}
}
Ok(Self { space: space.to_vec(), ranges, count, seed })
}
/// The number of points this space draws (`count`).
pub fn len(&self) -> usize {
self.count
}
/// `true` iff `count == 0` (an explicit empty family). Present alongside
/// `len` to satisfy clippy's `len_without_is_empty`.
pub fn is_empty(&self) -> bool {
self.count == 0
}
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cargo test -p aura-engine random_space_`
Expected: PASS — all seven `random_space_*` tests green.
---
### Task 4: `SplitMix64` → `pub(crate)` + `impl Space for RandomSpace` (the seeded sampler)
Promote the existing bit-stable PRNG to crate scope (so `sweep.rs` reuses the same
algorithm, not a copy) and implement the sampler. A code-path-disjoint instance from
the data-edge seed RNG (#52/#71 firewall).
**Files:**
- Modify: `crates/aura-engine/src/harness.rs:131-150` (`SplitMix64` + methods → `pub(crate)`)
- Modify: `crates/aura-engine/src/sweep.rs` (import `SplitMix64`; `impl Space for RandomSpace`)
- Test: `crates/aura-engine/src/sweep.rs` `mod tests`
- [ ] **Step 1: Write the failing tests**
Add to `mod tests` in `crates/aura-engine/src/sweep.rs`:
```rust
#[test]
fn random_points_respect_bounds() {
let space = vec![
ParamSpec { name: "i".into(), kind: ScalarKind::I64 },
ParamSpec { name: "f".into(), kind: ScalarKind::F64 },
];
let ranges = vec![ParamRange::i64(2, 50), ParamRange::f64(0.1, 2.0)];
let rs = RandomSpace::new(&space, ranges, 500, 0xC0FFEE).unwrap();
let pts = rs.points();
assert_eq!(pts.len(), 500);
for p in &pts {
let i = p[0].i64();
let f = p[1].f64();
assert!((2..=50).contains(&i), "I64 inclusive [2,50], got {i}");
assert!((0.1..2.0).contains(&f), "F64 half-open [0.1,2.0), got {f}");
}
}
#[test]
fn random_points_are_deterministic() {
let space = one_i64_space();
let mk = || {
RandomSpace::new(&space, vec![ParamRange::i64(0, 1_000_000)], 100, 42)
.unwrap()
.points()
};
assert_eq!(mk(), mk(), "same (ranges, count, seed) => identical points");
}
#[test]
fn random_points_seed_sensitive() {
let space = one_i64_space();
let a = RandomSpace::new(&space, vec![ParamRange::i64(0, 1_000_000)], 100, 1)
.unwrap()
.points();
let b = RandomSpace::new(&space, vec![ParamRange::i64(0, 1_000_000)], 100, 2)
.unwrap()
.points();
assert_ne!(a, b, "different seeds => different point sequences");
}
#[test]
fn random_points_zero_count_is_empty() {
let space = one_i64_space();
let rs = RandomSpace::new(&space, vec![ParamRange::i64(0, 10)], 0, 0).unwrap();
assert!(rs.points().is_empty());
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cargo test -p aura-engine random_points`
Expected: FAIL — compilation error: `no method named points found for struct
RandomSpace` (the `Space` impl for `RandomSpace` does not exist yet).
- [ ] **Step 3a: Promote `SplitMix64` to `pub(crate)` in `harness.rs`**
In `crates/aura-engine/src/harness.rs`, change the four visibility keywords only
(bodies unchanged). Line `:131`:
```rust
struct SplitMix64 {
```
becomes:
```rust
pub(crate) struct SplitMix64 {
```
and the three methods (`:136`, `:139`, `:147`):
```rust
fn new(seed: u64) -> Self {
```
```rust
fn next_u64(&mut self) -> u64 {
```
```rust
fn next_f64(&mut self) -> f64 {
```
become:
```rust
pub(crate) fn new(seed: u64) -> Self {
```
```rust
pub(crate) fn next_u64(&mut self) -> u64 {
```
```rust
pub(crate) fn next_f64(&mut self) -> f64 {
```
(The existing in-crate user `SplitMix64::new(seed)` at `harness.rs:180` keeps
compiling under the wider visibility.)
- [ ] **Step 3b: Import `SplitMix64` and add `impl Space for RandomSpace` in `sweep.rs`**
In `crates/aura-engine/src/sweep.rs`, add the import after line 8
(`use crate::RunReport;`):
```rust
use crate::harness::SplitMix64;
```
Then add the sampler impl after the `impl RandomSpace { … }` block (from Task 3):
```rust
impl Space for RandomSpace {
fn param_specs(&self) -> &[ParamSpec] {
&self.space
}
/// `count` points drawn from a single `SplitMix64` seeded with `self.seed`;
/// per point, each slot is sampled in declared `param_specs()` order (points in
/// sequence, slots within a point in order). Deterministic: same
/// `(ranges, count, seed)` => identical points, identical to a re-run (C1).
/// This RNG instance is code-path-disjoint from the data-edge seed RNG (the
/// #52/#71 World-II firewall): they share only the `u64` type, never a path.
fn points(&self) -> Vec<Vec<Cell>> {
let mut rng = SplitMix64::new(self.seed);
(0..self.count)
.map(|_| {
self.ranges
.iter()
.map(|r| match r.kind() {
// inclusive [lo, hi]; span via i128 then u64 handles a negative lo.
// (Modulo bias for spans not dividing 2^64 is an accepted,
// documented simplification — param search needs no crypto
// uniformity, spec §"Error handling".)
ScalarKind::I64 => {
let (lo, hi) = (r.lo.as_i64(), r.hi.as_i64());
let span = (hi as i128 - lo as i128 + 1) as u64;
Cell::from_i64(lo + (rng.next_u64() % span) as i64)
}
// half-open [lo, hi)
ScalarKind::F64 => {
let (lo, hi) = (r.lo.as_f64(), r.hi.as_f64());
Cell::from_f64(lo + rng.next_f64() * (hi - lo))
}
_ => unreachable!("RandomSpace::new rejects non-numeric ranges"),
})
.collect()
})
.collect()
}
}
```
- [ ] **Step 4: Run the sampler tests and a crate build to verify green**
Run: `cargo test -p aura-engine random_points`
Expected: PASS — all four `random_points*` tests green.
Run: `cargo build -p aura-engine`
Expected: clean build (0 errors) — the data-edge `SplitMix64::new` user at
`harness.rs:180` still compiles under `pub(crate)`.
---
### Task 5: `lib.rs` exports + the random-`sweep` integration tests
Make `RandomSpace`/`ParamRange`/`Space` public, and pin that a `RandomSpace` flows
through the *same* `sweep` as the grid: equals N independent runs, deterministic
across thread counts, named view round-trips. Final regression + lint gate.
**Files:**
- Modify: `crates/aura-engine/src/lib.rs:65` (export line)
- Test: `crates/aura-engine/src/sweep.rs` `mod tests`
- [ ] **Step 1: Write the failing tests**
Add to `mod tests` in `crates/aura-engine/src/sweep.rs` (reuses the existing
`run_point` fixture and `composite_sma_cross_harness`, whose param-space is
`[fast: I64, slow: I64, scale: F64]`):
```rust
fn sma_cross_random() -> RandomSpace {
let space = composite_sma_cross_harness().0.param_space();
RandomSpace::new(
&space,
vec![
// integer windows drawn from exactly the grid test's proven domain
// ({2,3}×{4,5}): all <= the 7-tick fixture length, so the SMAs warm
// up and metrics are finite & non-degenerate. scale is continuous
// (a bounded multiplier over a clamped exposure), spanning the grid's
// 0.5.
ParamRange::i64(2, 3), // fast ∈ [2, 3]
ParamRange::i64(4, 5), // slow ∈ [4, 5]
ParamRange::f64(0.25, 1.5), // scale ∈ [0.25, 1.5)
],
8,
0xABCDEF,
)
.expect("ranges match the sample param-space kinds")
}
#[test]
fn random_sweep_equals_n_independent_runs() {
let rs = sma_cross_random();
let family = sweep(&rs, run_point);
assert_eq!(family.points.len(), 8);
// each point's metrics equal a direct, independent run of the same point:
// the random sweep adds enumeration + execution, never a metrics change (C1).
for pt in &family.points {
assert_eq!(pt.report, run_point(&pt.params));
assert!(pt.report.metrics.total_pips.is_finite());
}
}
#[test]
fn random_sweep_is_deterministic_across_thread_counts() {
let rs = sma_cross_random();
let one = sweep_with_threads(&rs, 1, run_point);
let many = sweep_with_threads(&rs, 8, run_point);
// same family at 1 worker and at N (C1: order = enumeration, not completion)
assert_eq!(one, many);
assert_eq!(one, sweep(&rs, run_point));
}
#[test]
fn random_sweep_family_named_view_round_trips() {
let rs = sma_cross_random();
let family = sweep(&rs, run_point);
let space = composite_sma_cross_harness().0.param_space();
let expected: Vec<(String, Scalar)> = space
.iter()
.zip(&family.points[0].params)
.map(|(ps, c)| (ps.name.clone(), Scalar::from_cell(ps.kind, *c)))
.collect();
assert_eq!(family.named_params(0), expected);
}
```
- [ ] **Step 2: Run tests to verify they pass (behaviour already shipped in Tasks 14)**
Run: `cargo test -p aura-engine random_sweep`
Expected: PASS — the three `random_sweep_*` tests green. (`RandomSpace: Space` and
`sweep`'s generic signature already exist; these tests assert the integration.)
- [ ] **Step 3: Add the public exports in `lib.rs`**
In `crates/aura-engine/src/lib.rs`, change line 65 from:
```rust
pub use sweep::{sweep, GridSpace, SweepError, SweepFamily, SweepPoint};
```
to:
```rust
pub use sweep::{sweep, GridSpace, ParamRange, RandomSpace, Space, SweepError, SweepFamily, SweepPoint};
```
- [ ] **Step 4: Final workspace build, full suite, and lint gate**
Run: `cargo build --workspace`
Expected: clean build (0 errors) — the new exports resolve; no consumer breaks.
Run: `cargo test -p aura-engine`
Expected: PASS — the full `aura-engine` suite is green, including every pre-existing
grid sweep test (C1 behaviour-preservation) alongside the new `RandomSpace` tests.
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: clean — no warnings.