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:
@@ -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 2–4 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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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.)
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.)
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 1–2 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 = ¶ms[*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(¶ms)?;
|
||||
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:105–129` — `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:18–23)**
|
||||
|
||||
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:43–49)**
|
||||
|
||||
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:56–85)**
|
||||
|
||||
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 (`:83–85`):
|
||||
|
||||
```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:243–249)**
|
||||
|
||||
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:292–355)**
|
||||
|
||||
Remove the single pre-check at `:306–308`:
|
||||
|
||||
```rust
|
||||
if output.node >= item_count {
|
||||
return Err(CompileError::OutputPortOutOfRange);
|
||||
}
|
||||
```
|
||||
|
||||
Replace the single-output resolution block (`:318–333`, 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:375–381)**
|
||||
|
||||
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:124–126` (`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:124–126)**
|
||||
|
||||
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 `:101–104` 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 (`:178–184`): 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 (`:234–236`) 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:464–502)**
|
||||
|
||||
The `[out]` → `[out:cross]` change drifts this golden. Follow the test's own
|
||||
re-capture protocol (comment at `:466–468`): 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:505–530)**
|
||||
|
||||
`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.
|
||||
@@ -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 ¶ms {
|
||||
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, ¶m_aliases, e.from),
|
||||
leaf_has_unaliased_param(&nodes, ¶m_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.
|
||||
@@ -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
@@ -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).
|
||||
|
||||
---
|
||||
@@ -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).
|
||||
@@ -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`).
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 1–6 (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)
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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 1–7
|
||||
- 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 131–167, 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
|
||||
36–41 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
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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 1–4)**
|
||||
|
||||
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.
|
||||
Reference in New Issue
Block a user