From 28958f211075cbd01ab5395846ea22d4ec1035f6 Mon Sep 17 00:00:00 2001 From: Brummel Date: Thu, 18 Jun 2026 11:06:37 +0200 Subject: [PATCH] chore: retire docs/specs and docs/plans as committed artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 4 + CLAUDE.md | 8 +- docs/plans/.gitkeep | 0 docs/plans/0001-core-streaming-substrate.md | 679 -------- docs/plans/0002-node-contract-and-ctx.md | 545 ------- docs/plans/0003-deterministic-sim-loop.md | 548 ------- docs/plans/0004-firing-policies-and-merge.md | 1059 ------------- docs/plans/0005-node-output-record.md | 1140 -------------- docs/plans/0006-sink-recording.md | 1374 ----------------- docs/plans/0007-signal-quality-loop.md | 503 ------ docs/plans/0008-sum-combinators.md | 356 ----- docs/plans/0009-run-metrics-and-manifest.md | 688 --------- docs/plans/0010-aura-run-cli.md | 584 ------- docs/plans/0011-ingest-data-server-m1.md | 567 ------- .../0012-blueprint-compile-composites.md | 902 ----------- docs/plans/0013-aura-graph-ascii-dag.md | 798 ---------- docs/plans/0014-tidy-dedup-reexport.md | 161 -- docs/plans/0015-node-param-declaration.md | 525 ------- docs/plans/0016-param-set-injection.md | 696 --------- docs/plans/0017-blueprint-view-definitions.md | 384 ----- .../0018-composite-multi-output-record.md | 623 -------- docs/plans/0019-name-composite-boundary.md | 740 --------- docs/plans/0020-composite-signature-render.md | 380 ----- docs/plans/0021-fan-in-distinguishability.md | 647 -------- .../0022-composite-output-binding-render.md | 357 ----- docs/plans/0023-unify-blueprint-render.md | 51 - .../plans/0024-node-signature-in-blueprint.md | 1183 -------------- docs/plans/0025-render-root-slot-stubs.md | 407 ----- docs/plans/0026-graph-model-serializer.md | 645 -------- docs/plans/0026-graph-render-viewer.md | 847 ---------- docs/plans/0027-name-input-ports.md | 370 ----- docs/plans/0028-param-sweep-grid.md | 590 ------- docs/plans/0028-sweep-cli-demonstrator.md | 432 ------ docs/plans/0029-run-registry-cli.md | 433 ------ .../0029-run-registry-engine-and-registry.md | 686 -------- docs/plans/0029-run-registry-foundation.md | 353 ----- .../0030-named-param-binding-single-run.md | 385 ----- docs/plans/0030-named-param-binding-sweep.md | 425 ----- docs/plans/0031-node-instance-naming.md | 496 ------ .../plans/0032-param-namespace-injectivity.md | 488 ------ docs/plans/0033-unify-runreport-json-serde.md | 280 ---- docs/plans/0034-blueprint-constant-bind.md | 385 ----- docs/plans/0035-node-name-in-graph-model.md | 475 ------ docs/plans/0036-sample-showcase-blueprint.md | 497 ------ docs/plans/0037-bound-param-in-graph-model.md | 649 -------- .../0038-dedup-sma-cross-test-fixtures.md | 298 ---- .../0039-graphbuilder-name-based-wiring.md | 603 -------- docs/plans/0040-wiring-totality-check.md | 639 -------- docs/plans/0041-source-ingestion-seam.md | 871 ----------- docs/plans/0042-seed-as-input.md | 397 ----- docs/plans/0043-monte-carlo-family.md | 524 ------- docs/plans/0044-walk-forward-family.md | 1134 -------------- docs/plans/0045-registry-lineage-families.md | 1356 ---------------- docs/plans/0046-sweep-named-binding.md | 534 ------- docs/plans/0047-cell-hot-path-carrier.md | 532 ------- docs/plans/0048-walk-forward-param-plane.md | 604 -------- docs/plans/0049-random-param-sweep.md | 700 --------- docs/specs/.gitkeep | 0 docs/specs/0001-core-streaming-substrate.md | 331 ---- docs/specs/0002-node-contract-and-ctx.md | 294 ---- docs/specs/0003-deterministic-sim-loop.md | 314 ---- docs/specs/0004-firing-policies-and-merge.md | 351 ----- docs/specs/0005-node-output-record.md | 324 ---- docs/specs/0006-sink-recording.md | 330 ---- docs/specs/0007-signal-quality-loop.md | 297 ---- docs/specs/0008-sum-combinators.md | 287 ---- docs/specs/0009-run-metrics-and-manifest.md | 289 ---- docs/specs/0010-aura-run-cli.md | 258 ---- docs/specs/0011-ingest-data-server-m1.md | 331 ---- .../0012-blueprint-compile-composites.md | 316 ---- docs/specs/0013-aura-graph-ascii-dag.md | 375 ----- docs/specs/0015-node-param-declaration.md | 311 ---- docs/specs/0016-param-set-injection.md | 355 ----- docs/specs/0017-blueprint-view-definitions.md | 275 ---- .../0018-composite-multi-output-record.md | 389 ----- docs/specs/0019-name-composite-boundary.md | 405 ----- docs/specs/0020-composite-signature-render.md | 307 ---- docs/specs/0021-fan-in-distinguishability.md | 391 ----- .../0022-composite-output-binding-render.md | 312 ---- docs/specs/0023-unify-blueprint-render.md | 343 ---- .../specs/0024-node-signature-in-blueprint.md | 297 ---- docs/specs/0025-render-root-slot-stubs.md | 256 --- docs/specs/0026-graph-render-redesign.md | 301 ---- docs/specs/0027-name-input-ports.md | 292 ---- docs/specs/0028-param-sweep-grid.md | 484 ------ docs/specs/0029-run-registry-sweep-family.md | 353 ----- docs/specs/0030-named-param-binding.md | 343 ---- docs/specs/0031-node-instance-naming.md | 336 ---- .../specs/0032-param-namespace-injectivity.md | 434 ------ docs/specs/0033-unify-runreport-json-serde.md | 267 ---- docs/specs/0034-blueprint-constant-bind.md | 312 ---- docs/specs/0035-node-name-in-graph-model.md | 358 ----- docs/specs/0036-sample-showcase-blueprint.md | 285 ---- docs/specs/0037-bound-param-in-graph-model.md | 482 ------ .../0038-dedup-sma-cross-test-fixtures.md | 269 ---- .../0039-graphbuilder-name-based-wiring.md | 277 ---- docs/specs/0040-wiring-totality-check.md | 336 ---- docs/specs/0041-source-ingestion-seam.md | 462 ------ docs/specs/0042-seed-as-input.md | 318 ---- docs/specs/0043-monte-carlo-family.md | 421 ----- docs/specs/0044-walk-forward-family.md | 430 ------ docs/specs/0045-registry-lineage-families.md | 429 ----- docs/specs/0046-sweep-named-binding.md | 320 ---- docs/specs/0047-cell-hot-path-carrier.md | 388 ----- docs/specs/0048-walk-forward-param-plane.md | 280 ---- docs/specs/0049-random-param-sweep.md | 343 ---- .../0050-session-breakout-node-vocabulary.md | 144 -- .../specs/0051-strategy-as-world-blueprint.md | 138 -- docs/specs/fieldtest-0006-substrate.md | 155 -- docs/specs/fieldtest-0007-signal-quality.md | 197 --- docs/specs/fieldtest-0008-sum-combinators.md | 179 --- docs/specs/fieldtest-0009-run-metrics.md | 173 --- docs/specs/fieldtest-0010-aura-run.md | 163 -- docs/specs/fieldtest-0011-ingest.md | 187 --- docs/specs/fieldtest-0031-node-naming.md | 194 --- docs/specs/fieldtest-0049-random-sweep.md | 183 --- .../fieldtest-milestone-construction-layer.md | 246 --- ...estone-the-world-orchestration-families.md | 197 --- ...eldtest-milestone-the-world-param-sweep.md | 262 ---- .../fieldtest-milestone-walking-skeleton.md | 183 --- .../fieldtest-research-breakout-deepdive.md | 246 --- 121 files changed, 10 insertions(+), 51632 deletions(-) delete mode 100644 docs/plans/.gitkeep delete mode 100644 docs/plans/0001-core-streaming-substrate.md delete mode 100644 docs/plans/0002-node-contract-and-ctx.md delete mode 100644 docs/plans/0003-deterministic-sim-loop.md delete mode 100644 docs/plans/0004-firing-policies-and-merge.md delete mode 100644 docs/plans/0005-node-output-record.md delete mode 100644 docs/plans/0006-sink-recording.md delete mode 100644 docs/plans/0007-signal-quality-loop.md delete mode 100644 docs/plans/0008-sum-combinators.md delete mode 100644 docs/plans/0009-run-metrics-and-manifest.md delete mode 100644 docs/plans/0010-aura-run-cli.md delete mode 100644 docs/plans/0011-ingest-data-server-m1.md delete mode 100644 docs/plans/0012-blueprint-compile-composites.md delete mode 100644 docs/plans/0013-aura-graph-ascii-dag.md delete mode 100644 docs/plans/0014-tidy-dedup-reexport.md delete mode 100644 docs/plans/0015-node-param-declaration.md delete mode 100644 docs/plans/0016-param-set-injection.md delete mode 100644 docs/plans/0017-blueprint-view-definitions.md delete mode 100644 docs/plans/0018-composite-multi-output-record.md delete mode 100644 docs/plans/0019-name-composite-boundary.md delete mode 100644 docs/plans/0020-composite-signature-render.md delete mode 100644 docs/plans/0021-fan-in-distinguishability.md delete mode 100644 docs/plans/0022-composite-output-binding-render.md delete mode 100644 docs/plans/0023-unify-blueprint-render.md delete mode 100644 docs/plans/0024-node-signature-in-blueprint.md delete mode 100644 docs/plans/0025-render-root-slot-stubs.md delete mode 100644 docs/plans/0026-graph-model-serializer.md delete mode 100644 docs/plans/0026-graph-render-viewer.md delete mode 100644 docs/plans/0027-name-input-ports.md delete mode 100644 docs/plans/0028-param-sweep-grid.md delete mode 100644 docs/plans/0028-sweep-cli-demonstrator.md delete mode 100644 docs/plans/0029-run-registry-cli.md delete mode 100644 docs/plans/0029-run-registry-engine-and-registry.md delete mode 100644 docs/plans/0029-run-registry-foundation.md delete mode 100644 docs/plans/0030-named-param-binding-single-run.md delete mode 100644 docs/plans/0030-named-param-binding-sweep.md delete mode 100644 docs/plans/0031-node-instance-naming.md delete mode 100644 docs/plans/0032-param-namespace-injectivity.md delete mode 100644 docs/plans/0033-unify-runreport-json-serde.md delete mode 100644 docs/plans/0034-blueprint-constant-bind.md delete mode 100644 docs/plans/0035-node-name-in-graph-model.md delete mode 100644 docs/plans/0036-sample-showcase-blueprint.md delete mode 100644 docs/plans/0037-bound-param-in-graph-model.md delete mode 100644 docs/plans/0038-dedup-sma-cross-test-fixtures.md delete mode 100644 docs/plans/0039-graphbuilder-name-based-wiring.md delete mode 100644 docs/plans/0040-wiring-totality-check.md delete mode 100644 docs/plans/0041-source-ingestion-seam.md delete mode 100644 docs/plans/0042-seed-as-input.md delete mode 100644 docs/plans/0043-monte-carlo-family.md delete mode 100644 docs/plans/0044-walk-forward-family.md delete mode 100644 docs/plans/0045-registry-lineage-families.md delete mode 100644 docs/plans/0046-sweep-named-binding.md delete mode 100644 docs/plans/0047-cell-hot-path-carrier.md delete mode 100644 docs/plans/0048-walk-forward-param-plane.md delete mode 100644 docs/plans/0049-random-param-sweep.md delete mode 100644 docs/specs/.gitkeep delete mode 100644 docs/specs/0001-core-streaming-substrate.md delete mode 100644 docs/specs/0002-node-contract-and-ctx.md delete mode 100644 docs/specs/0003-deterministic-sim-loop.md delete mode 100644 docs/specs/0004-firing-policies-and-merge.md delete mode 100644 docs/specs/0005-node-output-record.md delete mode 100644 docs/specs/0006-sink-recording.md delete mode 100644 docs/specs/0007-signal-quality-loop.md delete mode 100644 docs/specs/0008-sum-combinators.md delete mode 100644 docs/specs/0009-run-metrics-and-manifest.md delete mode 100644 docs/specs/0010-aura-run-cli.md delete mode 100644 docs/specs/0011-ingest-data-server-m1.md delete mode 100644 docs/specs/0012-blueprint-compile-composites.md delete mode 100644 docs/specs/0013-aura-graph-ascii-dag.md delete mode 100644 docs/specs/0015-node-param-declaration.md delete mode 100644 docs/specs/0016-param-set-injection.md delete mode 100644 docs/specs/0017-blueprint-view-definitions.md delete mode 100644 docs/specs/0018-composite-multi-output-record.md delete mode 100644 docs/specs/0019-name-composite-boundary.md delete mode 100644 docs/specs/0020-composite-signature-render.md delete mode 100644 docs/specs/0021-fan-in-distinguishability.md delete mode 100644 docs/specs/0022-composite-output-binding-render.md delete mode 100644 docs/specs/0023-unify-blueprint-render.md delete mode 100644 docs/specs/0024-node-signature-in-blueprint.md delete mode 100644 docs/specs/0025-render-root-slot-stubs.md delete mode 100644 docs/specs/0026-graph-render-redesign.md delete mode 100644 docs/specs/0027-name-input-ports.md delete mode 100644 docs/specs/0028-param-sweep-grid.md delete mode 100644 docs/specs/0029-run-registry-sweep-family.md delete mode 100644 docs/specs/0030-named-param-binding.md delete mode 100644 docs/specs/0031-node-instance-naming.md delete mode 100644 docs/specs/0032-param-namespace-injectivity.md delete mode 100644 docs/specs/0033-unify-runreport-json-serde.md delete mode 100644 docs/specs/0034-blueprint-constant-bind.md delete mode 100644 docs/specs/0035-node-name-in-graph-model.md delete mode 100644 docs/specs/0036-sample-showcase-blueprint.md delete mode 100644 docs/specs/0037-bound-param-in-graph-model.md delete mode 100644 docs/specs/0038-dedup-sma-cross-test-fixtures.md delete mode 100644 docs/specs/0039-graphbuilder-name-based-wiring.md delete mode 100644 docs/specs/0040-wiring-totality-check.md delete mode 100644 docs/specs/0041-source-ingestion-seam.md delete mode 100644 docs/specs/0042-seed-as-input.md delete mode 100644 docs/specs/0043-monte-carlo-family.md delete mode 100644 docs/specs/0044-walk-forward-family.md delete mode 100644 docs/specs/0045-registry-lineage-families.md delete mode 100644 docs/specs/0046-sweep-named-binding.md delete mode 100644 docs/specs/0047-cell-hot-path-carrier.md delete mode 100644 docs/specs/0048-walk-forward-param-plane.md delete mode 100644 docs/specs/0049-random-param-sweep.md delete mode 100644 docs/specs/0050-session-breakout-node-vocabulary.md delete mode 100644 docs/specs/0051-strategy-as-world-blueprint.md delete mode 100644 docs/specs/fieldtest-0006-substrate.md delete mode 100644 docs/specs/fieldtest-0007-signal-quality.md delete mode 100644 docs/specs/fieldtest-0008-sum-combinators.md delete mode 100644 docs/specs/fieldtest-0009-run-metrics.md delete mode 100644 docs/specs/fieldtest-0010-aura-run.md delete mode 100644 docs/specs/fieldtest-0011-ingest.md delete mode 100644 docs/specs/fieldtest-0031-node-naming.md delete mode 100644 docs/specs/fieldtest-0049-random-sweep.md delete mode 100644 docs/specs/fieldtest-milestone-construction-layer.md delete mode 100644 docs/specs/fieldtest-milestone-the-world-orchestration-families.md delete mode 100644 docs/specs/fieldtest-milestone-the-world-param-sweep.md delete mode 100644 docs/specs/fieldtest-milestone-walking-skeleton.md delete mode 100644 docs/specs/fieldtest-research-breakout-deepdive.md diff --git a/.gitignore b/.gitignore index 2ddfa15..a97baf1 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,7 @@ Cargo.lock.orig # `aura sweep` persists run records to ./runs/runs.jsonl by default; that store # is local run telemetry, not a repo artifact. /runs/ + +# Specs and plans are kept as local working artifacts, not checked into the repo. +/docs/specs/ +/docs/plans/ diff --git a/CLAUDE.md b/CLAUDE.md index 3ce5d5d..13ea82c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,8 +7,12 @@ freeze a validated strategy into a standalone bot with a broker connection. This file is the project sittenkodex. It imports the universal discipline from `~/dev/skills/templates/CLAUDE.md.fragment` and adds aura's domain invariants. -The full architecture lives in the design ledger (`docs/design/`) and the specs -(`docs/specs/`), not here. +The full architecture lives in the design ledger (`docs/design/`), not here. +Per-cycle specs (`docs/specs/`) and plans (`docs/plans/`) are local-only, +ephemeral working artifacts — git-ignored, valid only for the cycle that +produces them. Durable rationale is lifted to the ledger; a spec/plan is never +treated as a live API reference (its code snippets drift the moment the code +moves). The canonical record of past cycles is the ledger plus the git history. ## Roles diff --git a/docs/plans/.gitkeep b/docs/plans/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/docs/plans/0001-core-streaming-substrate.md b/docs/plans/0001-core-streaming-substrate.md deleted file mode 100644 index db50fad..0000000 --- a/docs/plans/0001-core-streaming-substrate.md +++ /dev/null @@ -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` 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` 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 for Scalar { - fn from(v: i64) -> Self { - Scalar::I64(v) - } -} -impl From for Scalar { - fn from(v: f64) -> Self { - Scalar::F64(v) - } -} -impl From for Scalar { - fn from(v: bool) -> Self { - Scalar::Bool(v) - } -} -impl From 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` — 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 { - buf: Box<[T]>, - head: usize, // next write slot - len: usize, // valid elements, saturates at capacity - run_count: u64, // monotonic total pushes (C5) -} - -impl Column { - /// 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 { - 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, -} - -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 { - 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 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::::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::::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::::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::::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::::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` 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), - F64(Column), - Bool(Column), - Ts(Column), -} - -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` 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 { - 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> { - match self { - AnyColumn::I64(c) => Some(c), - _ => None, - } - } - - pub fn as_f64_mut(&mut self) -> Option<&mut Column> { - match self { - AnyColumn::F64(c) => Some(c), - _ => None, - } - } - - pub fn as_bool_mut(&mut self) -> Option<&mut Column> { - match self { - AnyColumn::Bool(c) => Some(c), - _ => None, - } - } - - pub fn as_ts_mut(&mut self) -> Option<&mut Column> { - 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. diff --git a/docs/plans/0002-node-contract-and-ctx.md b/docs/plans/0002-node-contract-and-ctx.md deleted file mode 100644 index 23e94c6..0000000 --- a/docs/plans/0002-node-contract-and-ctx.md +++ /dev/null @@ -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> { - match self { - AnyColumn::I64(c) => Some(c), - _ => None, - } - } - - pub fn as_f64(&self) -> Option<&Column> { - match self { - AnyColumn::F64(c) => Some(c), - _ => None, - } - } - - pub fn as_bool(&self) -> Option<&Column> { - match self { - AnyColumn::Bool(c) => Some(c), - _ => None, - } - } - - pub fn as_ts(&self) -> Option<&Column> { - 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, - 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; -} -``` - -- [ ] **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 { - 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. diff --git a/docs/plans/0003-deterministic-sim-loop.md b/docs/plans/0003-deterministic-sim-loop.md deleted file mode 100644 index 95a56c1..0000000 --- a/docs/plans/0003-deterministic-sim-loop.md +++ /dev/null @@ -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 { - 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, - inputs: Vec, -} - -/// A bootstrapped, frozen root graph instance plus its deterministic run loop. -pub struct Sim { - nodes: Vec, - topo: Vec, - out_edges: Vec>, - source_targets: Vec, - 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>, - source_targets: Vec, - edges: Vec, - source_kind: ScalarKind, - observe: usize, - ) -> Result { - 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 = Vec::with_capacity(n); - for (nd, schema) in nodes.into_iter().zip(schemas.iter()) { - let inputs: Vec = 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![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 = (0..n).filter(|&i| indeg[i] == 0).collect(); - let mut topo: Vec = 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, - ) -> Vec> { - // 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 + '_ { - 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> = 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> = - 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> = 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> = 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> = 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` is the intended -node object (set at bootstrap, dispatched by vtable), not a `dyn Any` payload, and -does not match the pattern. diff --git a/docs/plans/0004-firing-policies-and-merge.md b/docs/plans/0004-firing-policies-and-merge.md deleted file mode 100644 index afd54ed..0000000 --- a/docs/plans/0004-firing-policies-and-merge.md +++ /dev/null @@ -1,1059 +0,0 @@ -# Firing Policies + Ingestion Merge — Implementation Plan - -> **Parent spec:** `docs/specs/0004-firing-policies-and-merge.md` -> -> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run -> this plan. Steps use `- [ ]` checkboxes for tracking. - -**Goal:** Ship both firing policies (C6 — A fire-on-any-fresh+hold, B all-fresh -barrier) on top of a k-way ingestion merge (C3), the monotonic cycle_id clock -(C4), and freshness-gated recompute (C5), fully tested on both rails. - -**Architecture:** `Firing` is a per-input enum on `InputSpec` (aura-core). The -engine merges N in-memory timestamped sources into one chronological cycle stream -(linear-scan k-way, ties by source index), stamping each input slot per push with -two read clocks — `fresh_at` (cycle_id, freshness epoch C5) and `last_ts` -(timestamp, barrier token C6). A node fires when any `Any` input is fresh or any -`Barrier` group has all members at the current timestamp; a non-firing node holds -(no push → consumers see the held value via `window[0]`). - -**Tech Stack:** aura-core (`Firing`, `InputSpec`), aura-std (`Sma`/`Sub` firing -annotation), aura-engine (`SourceSpec`, `SlotState`, `Harness::bootstrap`/`run`, -the `fires` predicate). Gates: `cargo build/test/clippy --workspace -- -D -warnings`, surface-purity grep. - -**Note on validation gates:** every inlined code body below is Rust (the project's -source language), so the per-task `cargo build`/`cargo test` gates are its -validation — there is no separate surface-language parse gate (the profile -declares no `spec_validation`; the parse-every-block gate is a documented no-op). - -**Task ordering rationale (compile-gate discipline):** Task 1 adds a field to -`InputSpec`, which breaks `Sma`/`Sub` — both callers are threaded inside Task 1, -so it ends on a full green workspace. Task 2 changes the `bootstrap`/`run` -signatures, which breaks the in-module tests; Task 2 therefore ends on a -**partial** gate (`cargo build -p aura-engine --lib`, which excludes `#[cfg(test)]`), -and Task 3 finishes threading by rewriting the tests, ending on the full -workspace gate. - ---- - -## Files this plan creates or modifies - -- Modify: `crates/aura-core/src/node.rs` — add `Firing` enum + `firing` field on `InputSpec`; fix module doc. -- Modify: `crates/aura-core/src/lib.rs:41` — re-export `Firing`; update roadmap doc. -- Modify: `crates/aura-std/src/sma.rs:6,24` — import `Firing`; declare `Firing::Any`. -- Modify: `crates/aura-std/src/sub.rs:6,23-25` — import `Firing`; declare `Firing::Any` (×2). -- Modify: `crates/aura-engine/src/harness.rs` — `SourceSpec`, `SlotState`, `NodeBox`/`Harness` fields, `Debug`, `bootstrap`, `run`, `fires`/`group_id`, module doc; rewrite the test module. -- Modify: `crates/aura-engine/src/lib.rs:24` — re-export `SourceSpec`; update module doc. -- Test: `crates/aura-core/src/node.rs` — `input_spec_carries_firing`. -- Test: `crates/aura-engine/src/harness.rs` — 8 tests (chain, fan-out compat+determinism, mode A, mode B, mixed A+B, three bootstrap rejects). - ---- - -## Task 1: aura-core `Firing` + `InputSpec.firing`, threaded through aura-std - -**Files:** -- Modify: `crates/aura-core/src/node.rs` -- Modify: `crates/aura-core/src/lib.rs:41` -- Modify: `crates/aura-std/src/sma.rs:6,24` -- Modify: `crates/aura-std/src/sub.rs:6,23-25` - -- [ ] **Step 1: Add the `Firing` enum and the `firing` field, fix the module doc** - -In `crates/aura-core/src/node.rs`, replace the module-doc lines 1-4: - -```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". -``` - -with: - -```rust -//! The node contract (C8): the interface every node implements. A node declares -//! its inputs (each with its scalar kind, lookback depth, and firing policy, C6) -//! and its output kind via `schema`, and computes one cycle's output via `eval`. -//! Tunable params (C12/C19) are deliberately not part of the schema yet — see -//! spec 0002's "Out of scope". -``` - -Then replace the `InputSpec` definition (lines 8-14): - -```rust -/// 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, -} -``` - -with the new `Firing` enum followed by the extended `InputSpec`: - -```rust -/// The firing policy of one input (C6): how the engine decides whether a node -/// re-evaluates this cycle. A node fires when *any* of its input groups fires -/// (OR over groups). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Firing { - /// Mode A — fire-on-any-fresh + hold (as-of join): fires the node whenever - /// this input is fresh this cycle; a stale input contributes its held value. - Any, - /// Mode B — all-fresh barrier (synchronizing join): this input is a member of - /// barrier group `N`; the group fires the node only when every member shares - /// the current cycle's timestamp. - Barrier(u8), -} - -/// One declared input of a node: its scalar kind, the lookback depth the engine -/// must pre-size for it (must be >= 1), and its firing policy (C6). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct InputSpec { - pub kind: ScalarKind, - pub lookback: usize, - pub firing: Firing, -} -``` - -- [ ] **Step 2: Add the `input_spec_carries_firing` test to node.rs** - -Append to `crates/aura-core/src/node.rs` (the file currently ends after the -`Node` trait at line 31, with no test module): - -```rust - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn input_spec_carries_firing() { - let a = InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }; - let b = InputSpec { kind: ScalarKind::F64, lookback: 2, firing: Firing::Barrier(0) }; - assert_eq!(a.firing, Firing::Any); - assert_eq!(b.firing, Firing::Barrier(0)); - assert_ne!(a.firing, b.firing); - } -} -``` - -- [ ] **Step 3: Re-export `Firing` and update the aura-core roadmap doc** - -In `crates/aura-core/src/lib.rs`, replace line 41: - -```rust -pub use node::{InputSpec, Node, NodeSchema}; -``` - -with: - -```rust -pub use node::{Firing, InputSpec, Node, NodeSchema}; -``` - -Then replace the roadmap doc (lines 26-28): - -```rust -//! 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. -``` - -with: - -```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 run registry -//! (C18/C22). -``` - -- [ ] **Step 4: Thread `Firing::Any` through `Sma`** - -In `crates/aura-std/src/sma.rs`, replace the import line 6: - -```rust -use aura_core::{Ctx, InputSpec, Node, NodeSchema, Scalar, ScalarKind}; -``` - -with: - -```rust -use aura_core::{Ctx, Firing, InputSpec, Node, NodeSchema, Scalar, ScalarKind}; -``` - -Then replace the `schema` inputs line 24: - -```rust - inputs: vec![InputSpec { kind: ScalarKind::F64, lookback: self.length }], -``` - -with: - -```rust - inputs: vec![InputSpec { - kind: ScalarKind::F64, - lookback: self.length, - firing: Firing::Any, - }], -``` - -- [ ] **Step 5: Thread `Firing::Any` through `Sub`** - -In `crates/aura-std/src/sub.rs`, replace the import line 6: - -```rust -use aura_core::{Ctx, InputSpec, Node, NodeSchema, Scalar, ScalarKind}; -``` - -with: - -```rust -use aura_core::{Ctx, Firing, InputSpec, Node, NodeSchema, Scalar, ScalarKind}; -``` - -Then replace the two `InputSpec` constructions (lines 23-26): - -```rust - inputs: vec![ - InputSpec { kind: ScalarKind::F64, lookback: 1 }, - InputSpec { kind: ScalarKind::F64, lookback: 1 }, - ], -``` - -with: - -```rust - inputs: vec![ - InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }, - InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }, - ], -``` - -- [ ] **Step 6: Build and test the full workspace** - -Run: `cargo build --workspace` -Expected: 0 errors (aura-engine still uses the old single-source API, untouched -by this task — it reads `schema.inputs[i].kind`/`.lookback`, not constructing -`InputSpec`, so the new field does not break it). - -Run: `cargo test --workspace` -Expected: PASS. aura-core gains `input_spec_carries_firing` (19 passed); aura-std -`Sma`/`Sub` tests unchanged (3 passed); aura-engine's 5 existing tests still pass -(5 passed). 27 total, 0 failed. - -Run: `cargo clippy --workspace --all-targets -- -D warnings` -Expected: 0 warnings. - ---- - -## Task 2: aura-engine production — `SourceSpec`, merge, firing gate - -**Files:** -- Modify: `crates/aura-engine/src/harness.rs` (production code only; the test module is rewritten in Task 3) -- Modify: `crates/aura-engine/src/lib.rs:24` - -- [ ] **Step 1: Rewrite the harness.rs module doc + import `Firing`** - -In `crates/aura-engine/src/harness.rs`, replace the module-doc block (lines 1-11): - -```rust -//! The harness — the closed root graph that runs (glossary: `harness`, C20) — -//! and its deterministic single-source run loop. A `Harness` 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). -``` - -with: - -```rust -//! The harness — the closed root graph that runs (glossary: `harness`, C20) — -//! and its deterministic run loop. A `Harness` is a bootstrapped, frozen root -//! graph: a flat node array plus an index edge table, topologically ordered, -//! driven cycle by cycle by a k-way merge of timestamped sources (C3/C4). 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. -//! -//! Firing (C5/C6) gates re-evaluation. Two read clocks drive it, both stamped per -//! input slot on every push: `fresh_at` (the `cycle_id` of the last push — -//! freshness epoch, C5) and `last_ts` (the data timestamp of that push — barrier -//! token, C6). A node fires when any `Firing::Any` input is fresh this cycle, or -//! when a `Firing::Barrier` group has every member at the current timestamp; a -//! node that does not fire pushes nothing, so consumers keep seeing the held -//! value via `window[0]` (sample-and-hold falls out of the push model). The -//! barrier token is the timestamp, not the `cycle_id`: under C4 four same-time -//! sources are four cycles, so RustAst's `cycle_id`-equality barrier could never -//! fire across sources — the timestamp generalizes it faithfully. -``` - -Then replace the import line 13: - -```rust -use aura_core::{AnyColumn, Ctx, Node, Scalar, ScalarKind, Timestamp}; -``` - -with: - -```rust -use aura_core::{AnyColumn, Ctx, Firing, Node, Scalar, ScalarKind, Timestamp}; -``` - -- [ ] **Step 2: Add `SourceSpec` after the `Target` struct** - -In `crates/aura-engine/src/harness.rs`, immediately after the `Target` struct -(which ends at line 28 with its closing `}`), insert: - -```rust - -/// A declared source: the scalar kind it produces and the input slots each of -/// its records is forwarded into. The multi-source generalization of cycle -/// 0003's `(source_targets, source_kind)` pair — sources are k-way-merged by -/// timestamp at ingestion (C3); there is no merge inside the graph. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SourceSpec { - pub kind: ScalarKind, - pub targets: Vec, -} -``` - -- [ ] **Step 3: Add `SlotState` and extend `NodeBox`** - -In `crates/aura-engine/src/harness.rs`, replace the `NodeBox` struct (lines 43-46): - -```rust -struct NodeBox { - node: Box, - inputs: Vec, -} -``` - -with the `SlotState` declaration followed by the extended `NodeBox`: - -```rust -/// Per-input engine bookkeeping (invisible to nodes): the two read clocks of the -/// firing machinery. `fresh_at` is the `cycle_id` of the last push into this slot -/// (freshness epoch, C5: fresh this cycle iff `fresh_at == cycle_id`); `last_ts` -/// is the data timestamp of that push (barrier token, C6: a barrier group is -/// complete iff all members carry `last_ts == T`). A never-pushed slot keeps the -/// cold sentinel `Timestamp(i64::MIN)`, which no real timestamp equals. -struct SlotState { - fresh_at: u64, - last_ts: Timestamp, -} - -struct NodeBox { - node: Box, - inputs: Vec, - firing: Vec, - slots: Vec, -} -``` - -- [ ] **Step 4: Rename the `Harness.source_targets` field and its `Debug` print** - -In `crates/aura-engine/src/harness.rs`, replace the `Harness` struct (lines 48-55): - -```rust -pub struct Harness { - nodes: Vec, - topo: Vec, - out_edges: Vec>, - source_targets: Vec, - observe: usize, -} -``` - -with: - -```rust -pub struct Harness { - nodes: Vec, - topo: Vec, - out_edges: Vec>, - sources: Vec, - observe: usize, -} -``` - -Then in the manual `Debug` impl, replace the `source_targets` field-print line 67: - -```rust - .field("source_targets", &self.source_targets) -``` - -with: - -```rust - .field("sources", &self.sources) -``` - -- [ ] **Step 5: Rewrite `bootstrap` to take `sources: Vec`** - -In `crates/aura-engine/src/harness.rs`, replace the entire `bootstrap` function -(lines 73-159, from ` /// Bind nodes + wiring` through the closing ` }` of -`bootstrap`) with: - -```rust - /// Bind nodes + wiring into a frozen, runnable graph. Sizes each node's input - /// columns from its `schema`, lifts each input's firing policy, initializes - /// per-slot freshness state, kind-checks every source target and edge, and - /// topologically orders the nodes (Kahn), rejecting any directed cycle. - pub fn bootstrap( - nodes: Vec>, - sources: Vec, - edges: Vec, - observe: usize, - ) -> Result { - 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 input columns from its schema; lift firing; init slots - let mut boxes: Vec = Vec::with_capacity(n); - for (nd, schema) in nodes.into_iter().zip(schemas.iter()) { - let inputs: Vec = schema - .inputs - .iter() - .map(|spec| AnyColumn::with_capacity(spec.kind, spec.lookback)) - .collect(); - let firing: Vec = schema.inputs.iter().map(|spec| spec.firing).collect(); - let slots: Vec = schema - .inputs - .iter() - .map(|_| SlotState { fresh_at: 0, last_ts: Timestamp(i64::MIN) }) - .collect(); - boxes.push(NodeBox { node: nd, inputs, firing, slots }); - } - - // source targets: each source's value must match each of its target slots' kind - for src in &sources { - for t in &src.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 != src.kind { - return Err(BootstrapError::KindMismatch { - producer: src.kind, - consumer: slot.kind, - }); - } - } - } - - // edges: indices in range, producer output kind == consumer slot kind - let mut out_edges: Vec> = 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 = (0..n).filter(|&i| indeg[i] == 0).collect(); - let mut topo: Vec = 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(Harness { - nodes: boxes, - topo, - out_edges, - sources, - observe, - }) - } -``` - -- [ ] **Step 6: Rewrite `run` as the merge + firing loop** - -In `crates/aura-engine/src/harness.rs`, replace the entire `run` function -(lines 161-202, from ` /// Drive the records` through the closing ` }` of -`run`) with: - -```rust - /// Drive the sources, k-way-merged in timestamp order (ties by source index, - /// C4); returns the observed node's per-cycle emission (`Some` when it fired - /// and produced output, `None` when it held or filtered). One stream per - /// source, each ascending in timestamp (C3 ingestion precondition). Allocates - /// nothing per cycle beyond the output vector. - pub fn run(&mut self, streams: Vec>) -> Vec> { - 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, observe } = self; - let observe = *observe; - - let mut cursor: Vec = vec![0; streams.len()]; - let mut cycle_id: u64 = 0; - let mut out = Vec::new(); - - loop { - // pick the live source head with the smallest (timestamp, source index) - let mut pick: Option = 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; - - // forward the source value into its target slots, stamping freshness - for t in sources[s].targets.iter() { - let nb = &mut nodes[t.node]; - nb.inputs[t.slot].push(value).expect("source kind checked at wiring"); - nb.slots[t.slot] = SlotState { fresh_at: cycle_id, last_ts: ts }; - } - - // evaluate in topological order; gate by firing; forward Some outputs - let mut observed = None; - for &nidx in topo.iter() { - let fired = { - let nb = &nodes[nidx]; - fires(&nb.firing, &nb.slots, cycle_id, ts) - }; - if !fired { - continue; // hold: no eval, no push - } - 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() { - let nb = &mut nodes[e.to]; - nb.inputs[e.slot].push(v).expect("edge kind checked at wiring"); - nb.slots[e.slot] = SlotState { fresh_at: cycle_id, last_ts: ts }; - } - } - } - out.push(observed); - } - out - } -``` - -- [ ] **Step 7: Add the `fires` predicate and `group_id` helper** - -In `crates/aura-engine/src/harness.rs`, immediately after the closing `}` of the -`impl Harness { ... }` block (after `run`), insert the two free functions: - -```rust - -/// The firing predicate (C5/C6): does this node re-evaluate this cycle? A node -/// fires when *any* of its input groups fires (OR). A `Firing::Any` input fires -/// the node when it is fresh this cycle (`fresh_at == cycle_id`). A barrier group -/// fires when >=1 member is fresh this cycle AND every member carries -/// `last_ts == ts` — the ">=1 fresh" clause is the once-per-timestamp guard (a -/// group completed in a prior cycle has no fresh member now, so it does not -/// re-fire). -fn fires(firing: &[Firing], slots: &[SlotState], cycle_id: u64, ts: Timestamp) -> bool { - // mode A: any fire-on-any-fresh input that is fresh this cycle - for (i, f) in firing.iter().enumerate() { - if matches!(f, Firing::Any) && slots[i].fresh_at == cycle_id { - return true; - } - } - // mode B: each distinct barrier group fires when complete this cycle - if let Some(max_group) = firing.iter().filter_map(group_id).max() { - for g in 0..=max_group { - let mut has_member = false; - let mut any_fresh = false; - let mut all_at_ts = true; - for (i, f) in firing.iter().enumerate() { - if group_id(f) == Some(g) { - has_member = true; - if slots[i].fresh_at == cycle_id { - any_fresh = true; - } - if slots[i].last_ts != ts { - all_at_ts = false; - } - } - } - if has_member && any_fresh && all_at_ts { - return true; - } - } - } - false -} - -/// The barrier group id of a firing policy, or `None` for mode A. -fn group_id(f: &Firing) -> Option { - match f { - Firing::Any => None, - Firing::Barrier(g) => Some(*g), - } -} -``` - -- [ ] **Step 8: Re-export `SourceSpec` and update the aura-engine module doc** - -In `crates/aura-engine/src/lib.rs`, replace the module-doc lines 3-18: - -```rust -//! Delivered in cycle 0003 — the harness and its deterministic single-source -//! run loop: -//! -//! - [`Harness`] — the closed root graph that runs (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. -``` - -with: - -```rust -//! Delivered across cycles 0003-0004 — the harness and its deterministic run -//! loop: -//! -//! - [`Harness`] — the closed root graph that runs (a flat node array + an index -//! edge table, topologically ordered) and its deterministic `run` loop: a -//! k-way merge of timestamped sources (C3/C4) driven through a wired DAG of -//! nodes, cycle by cycle, with freshness-gated recompute and the two firing -//! policies (C5/C6) deciding when each node re-evaluates and what it holds. -//! - [`Edge`] / [`Target`] / [`SourceSpec`] — producer->consumer wiring, -//! source->consumer wiring, and a declared source (its kind + target slots). -//! - [`BootstrapError`] — wiring faults caught once, at bootstrap (kind -//! mismatch, bad index, directed cycle). -//! -//! 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. -``` - -Then replace the re-export line 24: - -```rust -pub use harness::{BootstrapError, Edge, Harness, Target}; -``` - -with: - -```rust -pub use harness::{BootstrapError, Edge, Harness, SourceSpec, Target}; -``` - -- [ ] **Step 9: Partial build gate (library only; the test module is rewritten in Task 3)** - -Run: `cargo build -p aura-engine --lib` -Expected: 0 errors. This compiles the aura-engine library without its -`#[cfg(test)]` module (which still references the old `bootstrap`/`run` signatures -and is rewritten in Task 3). Do **not** run `cargo test` or -`cargo build --all-targets` in this task — the test module does not compile yet, -by design. - -Run: `cargo clippy -p aura-engine --lib -- -D warnings` -Expected: 0 warnings. - ---- - -## Task 3: aura-engine tests — rewrite the 5 existing onto the new API, add the firing rails - -**Files:** -- Modify: `crates/aura-engine/src/harness.rs` (the `#[cfg(test)] mod tests` block, lines 205-328) - -- [ ] **Step 1: Replace the entire test module** - -In `crates/aura-engine/src/harness.rs`, replace the whole `#[cfg(test)] mod tests` -block (lines 205-328, from `#[cfg(test)]` to the final closing `}` of the module) -with: - -```rust -#[cfg(test)] -mod tests { - use super::*; - // InputSpec / NodeSchema are not imported by harness.rs production code (it - // only reads `nd.schema()` fields, never naming the types), so they are not - // brought in by `use super::*` — the fixtures construct them, so import here. - use aura_core::{InputSpec, NodeSchema}; - use aura_std::{Sma, Sub}; - - /// Build an f64 source stream from (timestamp, value) points. - fn f64_stream(points: &[(i64, f64)]) -> Vec<(Timestamp, Scalar)> { - points.iter().map(|&(t, v)| (Timestamp(t), Scalar::F64(v))).collect() - } - - // --- firing-policy fixtures (test-local; not library nodes — C9: examples - // for the engine's own tests, no speculative aura-std surface) --- - - /// Mode A as-of join: a 2-input f64 sum that fires whenever either input is - /// fresh, holding the other. Warm-up returns None until both have a value. - struct AsOfSum; - impl Node for AsOfSum { - 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: ScalarKind::F64, - } - } - fn eval(&mut self, ctx: Ctx<'_>) -> Option { - 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])) - } - } - - /// Mode B barrier join: a 2-input f64 sum that fires only when both inputs - /// share the current cycle timestamp (both warm by construction when it fires). - struct BarrierSum; - impl Node for BarrierSum { - fn schema(&self) -> NodeSchema { - NodeSchema { - inputs: vec![ - InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Barrier(0) }, - InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Barrier(0) }, - ], - output: ScalarKind::F64, - } - } - fn eval(&mut self, ctx: Ctx<'_>) -> Option { - Some(Scalar::F64(ctx.f64_in(0)[0] + ctx.f64_in(1)[0])) - } - } - - /// Mixed A+B: barrier pair (inputs 0,1 in group 0) plus an as-of input - /// (input 2). Fires when the pair completes (holding input 2) OR when input 2 - /// ticks (holding the pair) — the OR-combine. - struct MixedSum; - impl Node for MixedSum { - fn schema(&self) -> NodeSchema { - NodeSchema { - inputs: vec![ - InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Barrier(0) }, - InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Barrier(0) }, - InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }, - ], - output: ScalarKind::F64, - } - } - fn eval(&mut self, ctx: Ctx<'_>) -> Option { - let a = ctx.f64_in(0); - let b = ctx.f64_in(1); - let c = ctx.f64_in(2); - if a.is_empty() || b.is_empty() || c.is_empty() { - return None; - } - Some(Scalar::F64(a[0] + b[0] + c[0])) - } - } - - #[test] - fn chain_source_sma_runs() { - // node 0 = SMA(3); one source -> SMA(3).in0; observe node 0 - let mut h = Harness::bootstrap( - vec![Box::new(Sma::new(3))], - vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }], - vec![], - 0, - ) - .expect("valid"); - let out = h.run(vec![f64_stream(&[(1, 1.0), (2, 2.0), (3, 3.0), (4, 4.0), (5, 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; one source fans into both SMAs (all - // Any), SMAs join into Sub — the 0003 compat baseline on the new API. - let build = || { - Harness::bootstrap( - vec![Box::new(Sma::new(2)), Box::new(Sma::new(4)), Box::new(Sub::new())], - 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 }, Edge { from: 1, to: 2, slot: 1 }], - 2, - ) - .expect("valid DAG") - }; - let prices = f64_stream(&[(1, 10.0), (2, 12.0), (3, 14.0), (4, 16.0), (5, 18.0), (6, 20.0)]); - - let mut h = build(); - let out = h.run(vec![prices.clone()]); - // Sub fires once SMA(4) is warm (cycle 4): 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 h2 = build(); - assert_eq!(h2.run(vec![prices]), out); - } - - #[test] - fn mode_a_as_of_fires_on_any_fresh_and_holds() { - // source 0 ticks t=1,2,3,4; source 1 ticks t=2,4 (slower); both inputs Any. - let build = || { - Harness::bootstrap( - vec![Box::new(AsOfSum)], - vec![ - SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }, - SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 1 }] }, - ], - vec![], - 0, - ) - .expect("valid") - }; - let s0 = f64_stream(&[(1, 10.0), (2, 20.0), (3, 30.0), (4, 40.0)]); - let s1 = f64_stream(&[(2, 100.0), (4, 200.0)]); - - let mut h = build(); - let out = h.run(vec![s0.clone(), s1.clone()]); - // holds s1=100 across t=3 and the t=4 s0-cycle; emits on every tick once warm. - assert_eq!( - out, - vec![ - None, - None, - Some(Scalar::F64(120.0)), - Some(Scalar::F64(130.0)), - Some(Scalar::F64(140.0)), - Some(Scalar::F64(240.0)), - ] - ); - - let mut h2 = build(); - assert_eq!(h2.run(vec![s0, s1]), out); // deterministic - } - - #[test] - fn mode_b_barrier_fires_only_on_timestamp_coincidence() { - // identical wiring to mode A, but both inputs are Barrier(0). - let build = || { - Harness::bootstrap( - vec![Box::new(BarrierSum)], - vec![ - SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }, - SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 1 }] }, - ], - vec![], - 0, - ) - .expect("valid") - }; - let s0 = f64_stream(&[(1, 10.0), (2, 20.0), (3, 30.0), (4, 40.0)]); - let s1 = f64_stream(&[(2, 100.0), (4, 200.0)]); - - let mut h = build(); - let out = h.run(vec![s0.clone(), s1.clone()]); - // emits ONLY at t=2 and t=4 where both inputs share the timestamp; holds otherwise. - assert_eq!( - out, - vec![ - None, - None, - Some(Scalar::F64(120.0)), - None, - None, - Some(Scalar::F64(240.0)), - ] - ); - - let mut h2 = build(); - assert_eq!(h2.run(vec![s0, s1]), out); // deterministic - } - - #[test] - fn mixed_a_and_b_or_combine_on_one_node() { - // in0,in1 = barrier group 0 (sources 0,1); in2 = as-of (source 2). - let mut h = Harness::bootstrap( - vec![Box::new(MixedSum)], - vec![ - SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }, - SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 1 }] }, - SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 2 }] }, - ], - vec![], - 0, - ) - .expect("valid"); - let s0 = f64_stream(&[(2, 20.0), (5, 50.0)]); // in0 (barrier) - let s1 = f64_stream(&[(2, 200.0)]); // in1 (barrier) - let s2 = f64_stream(&[(1, 1.0), (3, 3.0)]); // in2 (as-of) - - let out = h.run(vec![s0, s1, s2]); - // cycle order (ts, source idx): (1,s2) (2,s0) (2,s1) (3,s2) (5,s0). - // c3: barrier pair completes at t=2, holds c=1 -> 20+200+1 = 221. - // c4: as-of input ticks at t=3, holds the pair -> 20+200+3 = 223. - // c1 fires on the as-of input but filters (pair cold); c2,c5 hold. - assert_eq!( - out, - vec![ - None, - None, - Some(Scalar::F64(221.0)), - Some(Scalar::F64(223.0)), - None, - ] - ); - } - - #[test] - fn bootstrap_rejects_a_cycle() { - // two SMA(1) nodes wired a -> b -> a - let err = Harness::bootstrap( - vec![Box::new(Sma::new(1)), Box::new(Sma::new(1))], - vec![], - vec![Edge { from: 0, to: 1, slot: 0 }, Edge { from: 1, to: 0, slot: 0 }], - 0, - ) - .unwrap_err(); - assert_eq!(err, BootstrapError::Cycle); - } - - #[test] - fn bootstrap_rejects_a_kind_mismatch() { - // SMA(1) declares an f64 input; an i64 source mismatches - let err = Harness::bootstrap( - vec![Box::new(Sma::new(1))], - vec![SourceSpec { kind: ScalarKind::I64, targets: vec![Target { node: 0, slot: 0 }] }], - vec![], - 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 err = Harness::bootstrap( - vec![Box::new(Sma::new(1))], - vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }], - vec![], - 5, - ) - .unwrap_err(); - assert_eq!(err, BootstrapError::BadIndex); - } -} -``` - -- [ ] **Step 2: Full workspace build** - -Run: `cargo build --workspace --all-targets` -Expected: 0 errors (the test module now compiles against the new API). - -- [ ] **Step 3: Full workspace test suite** - -Run: `cargo test --workspace` -Expected: PASS, 0 failed. Per-crate: aura-core 19, aura-std 3, aura-engine 8 -(30 total). The 8 aura-engine tests are: `chain_source_sma_runs`, -`fan_out_join_dag_runs_deterministically`, -`mode_a_as_of_fires_on_any_fresh_and_holds`, -`mode_b_barrier_fires_only_on_timestamp_coincidence`, -`mixed_a_and_b_or_combine_on_one_node`, `bootstrap_rejects_a_cycle`, -`bootstrap_rejects_a_kind_mismatch`, `bootstrap_rejects_a_bad_index`. - -- [ ] **Step 4: Lint gate** - -Run: `cargo clippy --workspace --all-targets -- -D warnings` -Expected: 0 warnings. - -- [ ] **Step 5: Surface-purity grep** - -Run: `grep -rnE 'RefCell|Rc<|dyn Any' crates/*/src` -Expected: no output (exit code 1) — the harness module doc phrases RustAst's model -as "reference-counted, interior-mutable" without the literal `Rc<`/`RefCell`/`dyn -Any` tokens, and no production code uses them. diff --git a/docs/plans/0005-node-output-record.md b/docs/plans/0005-node-output-record.md deleted file mode 100644 index 37594e2..0000000 --- a/docs/plans/0005-node-output-record.md +++ /dev/null @@ -1,1140 +0,0 @@ -# Node output is a record — Implementation Plan - -> **Parent spec:** `docs/specs/0005-node-output-record.md` -> -> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run -> this plan. Steps use `- [ ]` checkboxes for tracking. - -**Goal:** Generalize a node's output from one scalar to a record of K ≥ 1 -base-scalar columns (scalar = degenerate K = 1), bound field-wise via -`Edge::from_field`, proven with a neutral OHLCV bundler. - -**Architecture:** `aura-core` changes the contract (`NodeSchema.output: Vec`, -`Node::eval -> Option<&[Scalar]>`); `aura-std` migrates Sma/Sub to the 1-field -degenerate case; `aura-engine` adds `Edge::from_field`, a per-field bootstrap -kind/range check, and a reused-scratch field-indexed forward in the run loop -(`run -> Vec>>`). The design ledger records the C8 revision and -C7 sharpening. - -**Tech Stack:** Rust workspace (`aura-core`, `aura-std`, `aura-engine`); the -`Node`/`NodeSchema`/`Edge`/`Harness` types; `cargo build/test/clippy`. - ---- - -**Files this plan creates or modifies:** - -- Modify: `crates/aura-core/src/node.rs:1-47` — add `FieldSpec`; `NodeSchema.output` → `Vec`; `Node::eval` → `Option<&[Scalar]>`; module/struct/trait docs. -- Modify: `crates/aura-core/src/lib.rs:21,42` — re-export `FieldSpec`; doc wording. -- Modify: `crates/aura-std/src/sma.rs:6-83` — `out` buffer; 1-field schema; slice return; in-crate tests. -- Modify: `crates/aura-std/src/sub.rs:6-62` — `out` buffer; 1-field schema; slice return; manual `Default`; in-crate test. -- Modify: `crates/aura-engine/src/harness.rs:21-329` — `Edge.from_field`; `NodeBox.out_len`; per-field bootstrap check; run loop scratch + return type; module/Edge docs. -- Modify: `crates/aura-engine/src/harness.rs:331-669` (Test) — migrate 3 fixtures to slice return; add `Ohlcv`/`TwoField`; field-binding + must-fail tests; adapt all Edge literals + expected vectors. -- Modify: `docs/design/INDEX.md:168-197` — C8 revision + C7 sharpening. - ---- - -## Task 1: aura-core contract + aura-std degenerate migration - -**Files:** -- Modify: `crates/aura-core/src/node.rs` -- Modify: `crates/aura-core/src/lib.rs` -- Modify: `crates/aura-std/src/sma.rs` -- Modify: `crates/aura-std/src/sub.rs` - -> **Compile-gate note:** this task's gate is **scoped to `aura-core` + `aura-std`**. -> Changing `Node::eval`/`NodeSchema.output` knowingly breaks `aura-engine` (its -> production reads `from.output` and its fixtures impl the old `eval` signature); -> `aura-engine` is repaired in Tasks 2–3. A workspace-wide gate here is -> unsatisfiable by design — do **not** run `--workspace` in this task. - -- [ ] **Step 1: Add `FieldSpec` and change `NodeSchema`/`Node` in `node.rs`** - -In `crates/aura-core/src/node.rs`, replace the `NodeSchema` struct (currently -lines 32-38) and the `Node` trait (currently lines 40-47) with the block below. -This adds `FieldSpec` immediately before `NodeSchema`, changes the `output` field -type, the `eval` return type, and the surrounding doc comments: - -```rust -/// One declared output column of a node's record: its name (metadata for sinks / -/// the playground, C18) and its scalar kind. The position of a `FieldSpec` in -/// `NodeSchema.output` is what an `Edge` binds (`Edge::from_field`); the name is -/// not load-bearing for wiring. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct FieldSpec { - pub name: &'static str, - pub kind: ScalarKind, -} - -/// A node's declared interface: its inputs (in order) and its output record — an -/// ordered list of named base columns; length 1 is a scalar (the degenerate -/// case). Built once at wiring, never on the hot path — the `Vec`s are fine here. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct NodeSchema { - pub inputs: Vec, - pub output: Vec, -} - -/// The universal composable dataflow unit (C8): one output port carrying a record -/// of 1..K base columns, a producer or transformer. `schema` declares the -/// interface; `eval` computes one cycle's row, returning a borrowed slice into a -/// buffer the node owns (`None` = filter / not-yet-warmed-up). `&mut self` because -/// a node may keep its own derived state (and its output buffer). -pub trait Node { - fn schema(&self) -> NodeSchema; - fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]>; -} -``` - -- [ ] **Step 2: Update the `node.rs` module doc** - -In `crates/aura-core/src/node.rs`, replace the module-doc line (currently line 3): - -```rust -//! and its output kind via `schema`, and computes one cycle's output via `eval`. -``` - -with: - -```rust -//! and its output record (1..K base columns, C7) via `schema`, and computes one -//! cycle's row via `eval`. -``` - -- [ ] **Step 3: Re-export `FieldSpec` and fix the lib doc in `aura-core/src/lib.rs`** - -In `crates/aura-core/src/lib.rs`, replace the `node` re-export (currently line 42): - -```rust -pub use node::{Firing, InputSpec, Node, NodeSchema}; -``` - -with: - -```rust -pub use node::{FieldSpec, Firing, InputSpec, Node, NodeSchema}; -``` - -Then replace the doc line (currently line 21) that reads: - -```rust -//! [`NodeSchema`] / [`InputSpec`] declaring inputs (kind + lookback) and the -//! single output kind; -``` - -with: - -```rust -//! [`NodeSchema`] / [`InputSpec`] declaring inputs (kind + lookback) and the -//! output record ([`FieldSpec`] columns; length 1 = scalar); -``` - -- [ ] **Step 4: Migrate `Sma` (`aura-std/src/sma.rs`)** - -In `crates/aura-std/src/sma.rs`, replace the import (line 6): - -```rust -use aura_core::{Ctx, Firing, InputSpec, Node, NodeSchema, Scalar, ScalarKind}; -``` - -with: - -```rust -use aura_core::{Ctx, FieldSpec, Firing, InputSpec, Node, NodeSchema, Scalar, ScalarKind}; -``` - -Replace the struct (lines 9-11) and ctor (lines 13-19): - -```rust -/// Simple moving average over the last `length` values of one f64 input. -pub struct Sma { - length: usize, - out: [Scalar; 1], -} - -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, out: [Scalar::F64(0.0)] } - } -} -``` - -Replace the `impl Node for Sma` body (lines 21-44) with: - -```rust -impl Node for Sma { - 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 }], - } - } - - 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) - } - self.out[0] = Scalar::F64(sum / self.length as f64); - Some(&self.out) - } -} -``` - -- [ ] **Step 5: Adapt the `Sma` in-crate tests** - -In `crates/aura-std/src/sma.rs`, replace the body of `sma_warms_up_then_tracks_the_window_mean` -(the loop currently at lines 66-69) with: - -```rust - for (v, want) in feed.iter().zip(expect) { - inputs[0].push(Scalar::F64(*v)).unwrap(); - let got = sma.eval(Ctx::new(&inputs)); - match want { - None => assert_eq!(got, None), - Some(m) => assert_eq!(got, Some([Scalar::F64(m)].as_slice())), - } - } -``` - -Replace the two assertions in `sma_length_one_is_identity` (currently lines 77-81): - -```rust - inputs[0].push(Scalar::F64(7.0)).unwrap(); - assert_eq!(sma.eval(Ctx::new(&inputs)), Some([Scalar::F64(7.0)].as_slice())); - - inputs[0].push(Scalar::F64(9.0)).unwrap(); - assert_eq!(sma.eval(Ctx::new(&inputs)), Some([Scalar::F64(9.0)].as_slice())); -``` - -- [ ] **Step 6: Migrate `Sub` (`aura-std/src/sub.rs`)** - -In `crates/aura-std/src/sub.rs`, replace the import (line 6): - -```rust -use aura_core::{Ctx, Firing, InputSpec, Node, NodeSchema, Scalar, ScalarKind}; -``` - -with: - -```rust -use aura_core::{Ctx, FieldSpec, Firing, InputSpec, Node, NodeSchema, Scalar, ScalarKind}; -``` - -Replace the struct + ctor (lines 10-18) with (the `#[derive(Default)]` is dropped -because `Scalar` has no `Default`; a manual `Default` keeps clippy's -`new_without_default` quiet): - -```rust -/// Two-input f64 difference: input 0 minus input 1. Emits `None` until both -/// inputs have a value. -pub struct Sub { - out: [Scalar; 1], -} - -impl Sub { - /// Build a `Sub` node. - pub fn new() -> Self { - Self { out: [Scalar::F64(0.0)] } - } -} - -impl Default for Sub { - fn default() -> Self { - Self::new() - } -} -``` - -Replace the `impl Node for Sub` body (lines 20-39) with: - -```rust -impl Node for Sub { - 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) - } -} -``` - -- [ ] **Step 7: Adapt the `Sub` in-crate test** - -In `crates/aura-std/src/sub.rs`, replace the two assertions in -`sub_is_difference_once_both_inputs_present` (currently lines 55-60): - -```rust - // 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)].as_slice())); -``` - -- [ ] **Step 8: Scoped gate — aura-core + aura-std build/test/clippy** - -Run: `cargo build -p aura-core -p aura-std --all-targets` -Expected: `Finished` — 0 errors. (`aura-engine` is NOT built here; it is knowingly broken until Task 3.) - -Run: `cargo test -p aura-core -p aura-std` -Expected: PASS — `aura-core` 19 passed, `aura-std` 3 passed (`sma` 2 + `sub` 1), 0 failed. - -Run: `cargo clippy -p aura-core -p aura-std --all-targets -- -D warnings` -Expected: `Finished` — 0 warnings. - ---- - -## Task 2: aura-engine production — Edge.from_field, per-field bootstrap, field-indexed forward - -**Files:** -- Modify: `crates/aura-engine/src/harness.rs` (production only; the `#[cfg(test)]` module is Task 3) - -> **Compile-gate note:** this task changes the `bootstrap`/`run` signatures and the -> `Edge` struct, which knowingly breaks the in-module `#[cfg(test)]` fixtures and -> tests. Its gate is therefore a **partial `--lib`** build that excludes test code; -> the tests are rewritten and the full gate runs in Task 3. Do **not** run -> `cargo test` or `--all-targets` in this task. - -- [ ] **Step 1: Add `from_field` to `Edge` and update its doc** - -In `crates/aura-engine/src/harness.rs`, replace the `Edge` doc + struct (lines 23-29): - -```rust -/// Forwards one field (`from_field`) of a producer's output record into a -/// consumer's input slot. Consuming a whole record is N such edges, one per field -/// (there is no "bind whole record" mechanism). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Edge { - pub from: usize, - pub to: usize, - pub slot: usize, - pub from_field: usize, -} -``` - -- [ ] **Step 2: Add `out_len` to `NodeBox`** - -In `crates/aura-engine/src/harness.rs`, replace the `NodeBox` struct (lines 72-77): - -```rust -struct NodeBox { - node: Box, - inputs: Vec, - firing: Vec, - slots: Vec, - out_len: usize, -} -``` - -- [ ] **Step 3: Set `out_len` at bootstrap** - -In `crates/aura-engine/src/harness.rs`, replace the per-node box construction loop -(lines 123-137) with (it lifts the output width from the schema alongside firing -and slots): - -```rust - let mut boxes: Vec = Vec::with_capacity(n); - for (nd, schema) in nodes.into_iter().zip(schemas.iter()) { - let inputs: Vec = schema - .inputs - .iter() - .map(|spec| AnyColumn::with_capacity(spec.kind, spec.lookback)) - .collect(); - let firing: Vec = schema.inputs.iter().map(|spec| spec.firing).collect(); - let slots: Vec = schema - .inputs - .iter() - .map(|_| SlotState { fresh_at: 0, last_ts: Timestamp(i64::MIN) }) - .collect(); - let out_len = schema.output.len(); - boxes.push(NodeBox { node: nd, inputs, firing, slots, out_len }); - } -``` - -- [ ] **Step 4: Per-field edge kind/range check at bootstrap** - -In `crates/aura-engine/src/harness.rs`, replace the edge-check loop (lines 153-166). -Also update the leading comment so it reads "field kind" rather than "output kind": - -```rust - // edges: indices in range, producer output field kind == consumer slot kind - let mut out_edges: Vec> = 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 field = from.output.get(e.from_field).ok_or(BootstrapError::BadIndex)?; - let slot = to.inputs.get(e.slot).ok_or(BootstrapError::BadIndex)?; - if field.kind != slot.kind { - return Err(BootstrapError::KindMismatch { - producer: field.kind, - consumer: slot.kind, - }); - } - out_edges[e.from].push(e); - } -``` - -- [ ] **Step 5: Change the `run` signature and declare the scratch row** - -In `crates/aura-engine/src/harness.rs`, replace the `run` signature (line 205): - -```rust - pub fn run(&mut self, streams: Vec>) -> Vec>> { -``` - -Then, immediately after the `let mut out = Vec::new();` line (line 221), add one -reused scratch buffer (allocated once; its capacity stabilizes after the first -fires, so the inter-node forward path allocates nothing per cycle): - -```rust - let mut scratch: Vec = Vec::new(); -``` - -- [ ] **Step 6: Field-indexed forward in the run loop** - -In `crates/aura-engine/src/harness.rs`, replace the per-node evaluate/forward block -(lines 254-278) — from `let mut observed = None;` through `out.push(observed);` — -with the block below. `result` is `Option<&[Scalar]>` (which is `Copy`, since -`&[Scalar]` is a shared reference); the observed row is cloned out, the producer -row is copied into `scratch` (ending the borrow), and each out-edge forwards its -single field: - -```rust - let mut observed: Option> = None; - for &nidx in topo.iter() { - let out_len = nodes[nidx].out_len; - let fired = { - let nb = &nodes[nidx]; - fires(&nb.firing, &nb.slots, cycle_id, ts) - }; - if !fired { - continue; // hold: no eval, no push - } - let result: Option<&[Scalar]> = { - let nb = &mut nodes[nidx]; - nb.node.eval(Ctx::new(&nb.inputs)) - }; - if nidx == observe { - observed = result.map(|row| row.to_vec()); - } - if let Some(row) = result { - debug_assert_eq!(row.len(), out_len, "node returned a row of the wrong width"); - scratch.clear(); - scratch.extend_from_slice(row); - for e in out_edges[nidx].iter() { - let nb = &mut nodes[e.to]; - nb.inputs[e.slot] - .push(scratch[e.from_field]) - .expect("edge kind checked at wiring"); - nb.slots[e.slot] = SlotState { fresh_at: cycle_id, last_ts: ts }; - } - } - } - out.push(observed); -``` - -- [ ] **Step 7: Update the harness module doc for the record-forward framing** - -In `crates/aura-engine/src/harness.rs`, replace the module-doc sentence about the -node owning its input columns (line 8, currently -`//! the cycle-0002 shape), so \`Ctx\` is unchanged.`) with the two lines below -(the `Ctx`-unchanged claim stays true; the addition states the output-record -forward): - -```rust -//! the cycle-0002 shape), so `Ctx` is unchanged. A node's `eval` returns a -//! borrowed record (`Option<&[Scalar]>`); each out-edge forwards one field of it -//! (`Edge::from_field`) into a consumer slot, so the K fields are co-fresh (C6). -``` - -- [ ] **Step 8: Partial gate — aura-engine lib build/clippy (tests excluded)** - -Run: `cargo build -p aura-engine --lib` -Expected: `Finished` — 0 errors. (The `#[cfg(test)]` module is NOT compiled by -`--lib`; it is rewritten in Task 3.) - -Run: `cargo clippy -p aura-engine --lib -- -D warnings` -Expected: `Finished` — 0 warnings. - ---- - -## Task 3: aura-engine tests — fixtures migration, OHLCV proof, must-fail cases - -**Files:** -- Modify: `crates/aura-engine/src/harness.rs` (the `#[cfg(test)] mod tests` block, lines 331-669) - -- [ ] **Step 1: Add `FieldSpec` to the test-module imports** - -In `crates/aura-engine/src/harness.rs`, replace the test-module import (line 337): - -```rust - use aura_core::{FieldSpec, InputSpec, NodeSchema}; -``` - -- [ ] **Step 2: Migrate `AsOfSum` to the slice return** - -In `crates/aura-engine/src/harness.rs`, replace the `AsOfSum` fixture (lines 350-369) -with (it gains a 1-field output buffer; behaviour identical): - -```rust - struct AsOfSum { - out: [Scalar; 1], - } - impl Node for AsOfSum { - 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) - } - } -``` - -- [ ] **Step 3: Migrate `BarrierSum` to the slice return** - -In `crates/aura-engine/src/harness.rs`, replace the `BarrierSum` fixture (lines 373-387): - -```rust - struct BarrierSum { - out: [Scalar; 1], - } - impl Node for BarrierSum { - fn schema(&self) -> NodeSchema { - NodeSchema { - inputs: vec![ - InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Barrier(0) }, - InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Barrier(0) }, - ], - output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }], - } - } - fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> { - self.out[0] = Scalar::F64(ctx.f64_in(0)[0] + ctx.f64_in(1)[0]); - Some(&self.out) - } - } -``` - -- [ ] **Step 4: Migrate `MixedSum` to the slice return** - -In `crates/aura-engine/src/harness.rs`, replace the `MixedSum` fixture (lines 392-413): - -```rust - struct MixedSum { - out: [Scalar; 1], - } - impl Node for MixedSum { - fn schema(&self) -> NodeSchema { - NodeSchema { - inputs: vec![ - InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Barrier(0) }, - InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Barrier(0) }, - 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); - let c = ctx.f64_in(2); - if a.is_empty() || b.is_empty() || c.is_empty() { - return None; - } - self.out[0] = Scalar::F64(a[0] + b[0] + c[0]); - Some(&self.out) - } - } -``` - -- [ ] **Step 5: Add the `Ohlcv` and `TwoField` fixtures** - -In `crates/aura-engine/src/harness.rs`, immediately after the `MixedSum` fixture -(after the block replaced in Step 4), insert these two new fixtures: - -```rust - /// A neutral multi-field producer: five f64 inputs bundled into one 5-field - /// record. No trading-domain logic — it proves the K > 1 output mechanism in - /// isolation. The five inputs are a Barrier(0) group, so the node emits one - /// complete bar only when all five share the cycle's timestamp. - struct Ohlcv { - out: [Scalar; 5], - } - impl Node for Ohlcv { - fn schema(&self) -> NodeSchema { - NodeSchema { - inputs: vec![ - InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Barrier(0) }, - InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Barrier(0) }, - InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Barrier(0) }, - InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Barrier(0) }, - InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Barrier(0) }, - ], - output: vec![ - FieldSpec { name: "open", kind: ScalarKind::F64 }, - FieldSpec { name: "high", kind: ScalarKind::F64 }, - FieldSpec { name: "low", kind: ScalarKind::F64 }, - FieldSpec { name: "close", kind: ScalarKind::F64 }, - FieldSpec { name: "volume", kind: ScalarKind::F64 }, - ], - } - } - fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> { - for i in 0..5 { - let w = ctx.f64_in(i); - if w.is_empty() { - return None; // not yet warmed - } - self.out[i] = Scalar::F64(w[0]); - } - Some(&self.out) // one 5-field record, all fields co-fresh - } - } - - /// A producer whose output record mixes kinds: field 0 is f64, field 1 is i64. - /// Used only to prove the bootstrap kind check is per-field (field 0 would bind - /// into an f64 slot; field 1 would not). Its `eval` never runs in these tests — - /// bootstrap rejects the wiring first. - struct TwoField { - out: [Scalar; 2], - } - impl Node for TwoField { - fn schema(&self) -> NodeSchema { - NodeSchema { - inputs: vec![InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }], - output: vec![ - FieldSpec { name: "f", kind: ScalarKind::F64 }, - FieldSpec { name: "i", kind: ScalarKind::I64 }, - ], - } - } - fn eval(&mut self, _ctx: Ctx<'_>) -> Option<&[Scalar]> { - self.out[0] = Scalar::F64(0.0); - self.out[1] = Scalar::I64(0); - Some(&self.out) - } - } -``` - -- [ ] **Step 6: Adapt `chain_source_sma_runs`** - -In `crates/aura-engine/src/harness.rs`, replace the expected vector of -`chain_source_sma_runs` (the `assert_eq!` currently at lines 426-435) with the -record-wrapped form: - -```rust - assert_eq!( - out, - vec![ - None, - None, - Some(vec![Scalar::F64(2.0)]), - Some(vec![Scalar::F64(3.0)]), - Some(vec![Scalar::F64(4.0)]), - ] - ); -``` - -- [ ] **Step 7: Adapt `fan_out_join_dag_runs_deterministically`** - -In `crates/aura-engine/src/harness.rs`, replace the two `Edge` literals (line 449) -with `from_field`-bearing forms: - -```rust - vec![Edge { from: 0, to: 2, slot: 0, from_field: 0 }, Edge { from: 1, to: 2, slot: 1, from_field: 0 }], -``` - -Replace the expected vector (currently lines 459-469) with: - -```rust - assert_eq!( - out, - vec![ - None, - None, - None, - Some(vec![Scalar::F64(2.0)]), - Some(vec![Scalar::F64(2.0)]), - Some(vec![Scalar::F64(2.0)]), - ] - ); -``` - -- [ ] **Step 8: Adapt the three fixture-construction sites and the mode-A/mode-B/mixed expected vectors** - -In `crates/aura-engine/src/harness.rs`: - -Replace `vec![Box::new(AsOfSum)]` (line 482) with: - -```rust - vec![Box::new(AsOfSum { out: [Scalar::F64(0.0)] })], -``` - -Replace the `mode_a_as_of_fires_on_any_fresh_and_holds` expected vector (lines 498-507): - -```rust - assert_eq!( - out, - vec![ - None, - None, - Some(vec![Scalar::F64(120.0)]), - Some(vec![Scalar::F64(130.0)]), - Some(vec![Scalar::F64(140.0)]), - Some(vec![Scalar::F64(240.0)]), - ] - ); -``` - -Replace `vec![Box::new(BarrierSum)]` in `mode_b_barrier_fires_only_on_timestamp_coincidence` -(line 519) with: - -```rust - vec![Box::new(BarrierSum { out: [Scalar::F64(0.0)] })], -``` - -Replace the `mode_b_barrier_fires_only_on_timestamp_coincidence` expected vector (lines 535-544): - -```rust - assert_eq!( - out, - vec![ - None, - None, - Some(vec![Scalar::F64(120.0)]), - None, - None, - Some(vec![Scalar::F64(240.0)]), - ] - ); -``` - -Replace `vec![Box::new(MixedSum)]` (line 597) with: - -```rust - vec![Box::new(MixedSum { out: [Scalar::F64(0.0)] })], -``` - -Replace the `mixed_a_and_b_or_combine_on_one_node` expected vector (lines 616-625): - -```rust - assert_eq!( - out, - vec![ - None, - None, - Some(vec![Scalar::F64(221.0)]), - Some(vec![Scalar::F64(223.0)]), - None, - ] - ); -``` - -- [ ] **Step 9: Adapt `within_source_diamond_rejoin_barrier_fires`** - -In `crates/aura-engine/src/harness.rs`, replace the `BarrierSum` construction in the -node list (line 560, `vec![Box::new(Sma::new(2)), Box::new(Sma::new(4)), Box::new(BarrierSum)]`): - -```rust - vec![Box::new(Sma::new(2)), Box::new(Sma::new(4)), Box::new(BarrierSum { out: [Scalar::F64(0.0)] })], -``` - -Replace the two `Edge` literals (line 565): - -```rust - vec![Edge { from: 0, to: 2, slot: 0, from_field: 0 }, Edge { from: 1, to: 2, slot: 1, from_field: 0 }], -``` - -Replace the expected vector (lines 577-586): - -```rust - assert_eq!( - out, - vec![ - None, - None, - None, - Some(vec![Scalar::F64(28.0)]), - Some(vec![Scalar::F64(32.0)]), - Some(vec![Scalar::F64(36.0)]), - ] - ); -``` - -- [ ] **Step 10: Adapt `bootstrap_rejects_a_cycle`** - -In `crates/aura-engine/src/harness.rs`, replace the two `Edge` literals in -`bootstrap_rejects_a_cycle` (line 634): - -```rust - vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }, Edge { from: 1, to: 0, slot: 0, from_field: 0 }], -``` - -(`bootstrap_rejects_a_kind_mismatch` and `bootstrap_rejects_a_bad_index` use a -source kind mismatch and a bad observe index respectively — no `Edge` literal, no -`run` output — so they need no change.) - -- [ ] **Step 11: Add the K > 1 output test** - -In `crates/aura-engine/src/harness.rs`, at the end of the `mod tests` block (after -`bootstrap_rejects_a_bad_index`, before the closing `}` of the module), add: - -```rust - /// Build five timestamp-aligned f64 sources feeding Ohlcv's five barrier slots. - fn ohlcv_streams() -> Vec> { - 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 - ] - } - - fn ohlcv_sources() -> Vec { - (0..5) - .map(|slot| SourceSpec { - kind: ScalarKind::F64, - targets: vec![Target { node: 0, slot }], - }) - .collect() - } - - #[test] - fn ohlcv_bundles_five_field_record() { - // node 0 = Ohlcv; five sources feed O/H/L/C/V; observe node 0. The barrier - // fires once all five share the timestamp, so each bar appears on the fifth - // cycle of its timestamp (the four partial cycles hold -> None). - let mut h = Harness::bootstrap( - vec![Box::new(Ohlcv { out: [Scalar::F64(0.0); 5] })], - ohlcv_sources(), - vec![], - 0, - ) - .expect("valid"); - let out = h.run(ohlcv_streams()); - assert_eq!( - out, - vec![ - None, - None, - None, - None, - Some(vec![ - Scalar::F64(10.0), - Scalar::F64(15.0), - Scalar::F64(8.0), - Scalar::F64(12.0), - Scalar::F64(100.0), - ]), - None, - None, - None, - None, - Some(vec![ - Scalar::F64(20.0), - Scalar::F64(25.0), - Scalar::F64(19.0), - Scalar::F64(22.0), - Scalar::F64(200.0), - ]), - ] - ); - } -``` - -- [ ] **Step 12: Add the field-wise binding proof (high − low) + determinism** - -In `crates/aura-engine/src/harness.rs`, after the test added in Step 11, add: - -```rust - #[test] - fn edge_binds_single_field_high_minus_low() { - // nodes [Ohlcv (0), Sub (1)]; Sub binds field 1 (high) and field 2 (low) - // of the Ohlcv record -> high - low == the bar range. Observing Sub proves - // from_field routes the right columns (not field 0), and that the two bound - // fields are co-fresh (Sub's Any inputs both fire in the bar's cycle). - let build = || { - Harness::bootstrap( - vec![ - Box::new(Ohlcv { out: [Scalar::F64(0.0); 5] }), - Box::new(Sub::new()), - ], - ohlcv_sources(), - vec![ - Edge { from: 0, to: 1, slot: 0, from_field: 1 }, // high - Edge { from: 0, to: 1, slot: 1, from_field: 2 }, // low - ], - 1, - ) - .expect("valid DAG") - }; - let mut h = build(); - let out = h.run(ohlcv_streams()); - // bar1: 15 - 8 = 7; bar2: 25 - 19 = 6 (each on the bar's fifth cycle). - assert_eq!( - out, - vec![ - None, - None, - None, - None, - Some(vec![Scalar::F64(7.0)]), - None, - None, - None, - None, - Some(vec![Scalar::F64(6.0)]), - ] - ); - - // determinism (C1): a second identical run is bit-identical - let mut h2 = build(); - assert_eq!(h2.run(ohlcv_streams()), out); - } -``` - -- [ ] **Step 13: Add the distinct-fields proof (close − open)** - -In `crates/aura-engine/src/harness.rs`, after the test added in Step 12, add: - -```rust - #[test] - fn distinct_edges_read_distinct_fields() { - // Same Ohlcv, a different consumer: Sub binds field 3 (close) and field 0 - // (open) -> close - open. Proves two different edges on one record read two - // different fields (3 and 0, neither of them the high/low pair above). - let mut h = Harness::bootstrap( - vec![ - Box::new(Ohlcv { out: [Scalar::F64(0.0); 5] }), - Box::new(Sub::new()), - ], - ohlcv_sources(), - vec![ - Edge { from: 0, to: 1, slot: 0, from_field: 3 }, // close - Edge { from: 0, to: 1, slot: 1, from_field: 0 }, // open - ], - 1, - ) - .expect("valid DAG"); - let out = h.run(ohlcv_streams()); - // bar1: 12 - 10 = 2; bar2: 22 - 20 = 2. - assert_eq!( - out, - vec![ - None, - None, - None, - None, - Some(vec![Scalar::F64(2.0)]), - None, - None, - None, - None, - Some(vec![Scalar::F64(2.0)]), - ] - ); - } -``` - -- [ ] **Step 14: Add the must-fail tests (from_field OOB; per-field kind mismatch)** - -In `crates/aura-engine/src/harness.rs`, after the test added in Step 13, add: - -```rust - #[test] - fn bootstrap_rejects_from_field_out_of_range() { - // Sma(0) has a 1-field output (index 0 only); an edge reading field 9 is - // out of range -> BadIndex (caught before any kind check). - let err = Harness::bootstrap( - vec![Box::new(Sma::new(1)), Box::new(Sma::new(1))], - vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }], - vec![Edge { from: 0, to: 1, slot: 0, from_field: 9 }], - 0, - ) - .unwrap_err(); - assert_eq!(err, BootstrapError::BadIndex); - } - - #[test] - fn bootstrap_rejects_per_field_kind_mismatch() { - // TwoField(0) output: field 0 f64, field 1 i64. Binding field 1 (i64) into - // Sma(1)'s f64 input slot is a per-field kind mismatch -> KindMismatch. (The - // mismatch is field-specific: from_field 0 would have matched.) - let err = Harness::bootstrap( - vec![Box::new(TwoField { out: [Scalar::F64(0.0), Scalar::I64(0)] }), Box::new(Sma::new(1))], - vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }], - vec![Edge { from: 0, to: 1, slot: 0, from_field: 1 }], - 1, - ) - .unwrap_err(); - assert_eq!( - err, - BootstrapError::KindMismatch { producer: ScalarKind::I64, consumer: ScalarKind::F64 } - ); - } -``` - -- [ ] **Step 15: Full workspace gate + purity grep** - -Run: `cargo build --workspace --all-targets` -Expected: `Finished` — 0 errors. - -Run: `cargo test --workspace` -Expected: PASS — 0 failed. Per-crate: `aura-core` 19 passed; `aura-std` 3 passed; -`aura-engine` 14 passed (the 9 prior tests + `ohlcv_bundles_five_field_record`, -`edge_binds_single_field_high_minus_low`, `distinct_edges_read_distinct_fields`, -`bootstrap_rejects_from_field_out_of_range`, `bootstrap_rejects_per_field_kind_mismatch`). - -Run: `cargo clippy --workspace --all-targets -- -D warnings` -Expected: `Finished` — 0 warnings. - -Run: `grep -rnE 'RefCell|Rc<|dyn Any' crates/*/src` -Expected: no output (exit code 1) — the record is a bundle of base columns, no -fifth type, no heterogeneous payload. - ---- - -## Task 4: Design ledger — C8 revision + C7 sharpening - -**Files:** -- Modify: `docs/design/INDEX.md` (C7 at lines 168-179; C8 at lines 181-197) - -> This is a docs-only task (no compile gate). The glossary `composite`/`node` -> record-reality pass is explicitly an **audit-time** follow-up, NOT this plan. - -- [ ] **Step 1: Sharpen C7 — composite-bundle is the node-output model** - -In `docs/design/INDEX.md`, in the C7 guarantee, replace the sentence (line 170-171): - -``` -sim, as columnar Structure-of-Arrays. Composite streams -(OHLCV) are bundles of base columns. Edges are type-erased to these four kinds; -``` - -with: - -``` -sim, as columnar Structure-of-Arrays. Composite streams -(OHLCV) are bundles of base columns — this is the **node-output model** too: a -node emits a record of 1..K base columns (C8), each forwarded field-wise to a -consumer slot; the bundle is structural, never a fifth scalar type. Edges are -type-erased to these four kinds; -``` - -- [ ] **Step 2: Revise C8 — one output port carrying a record** - -In `docs/design/INDEX.md`, in the C8 guarantee, replace `eval(ctx) -> Option` -(line 185) with `eval(ctx) -> Option<&[Scalar]>`. Concretely, replace (lines 184-185): - -``` -param-space the optimizer sweeps, C12/C19/C20) + `eval(ctx) -> Option`. The -engine provides read-only, zero-copy windows into each input's SoA ring buffer -``` - -with: - -``` -param-space the optimizer sweeps, C12/C19/C20) + `eval(ctx) -> Option<&[Scalar]>`. The -engine provides read-only, zero-copy windows into each input's SoA ring buffer -``` - -- [ ] **Step 3: Revise the C8 output-arity sentence** - -In `docs/design/INDEX.md`, replace (lines 189-192): - -``` -not-yet-warmed-up. A node is a **producer, a consumer, or both**: a -producer/transformer exposes **at most one** output (one series per node); a -**pure consumer (sink)** — chart, equity, logger — has **no** output. Sources -are pure producers; sinks are pure consumers. -``` - -with: - -``` -not-yet-warmed-up. A node is a **producer, a consumer, or both**: a -producer/transformer exposes **one output port**, whose payload is a **record of -1..K base-scalar columns** (a scalar is the degenerate K=1 record; an `eval` -returns a borrowed row, one value per column); a **pure consumer (sink)** — chart, -equity, logger — has **no** output. Sources are pure producers; sinks are pure -consumers. -``` - -- [ ] **Step 4: Revise the C8 Forbids + add the cycle-0005 realization** - -In `docs/design/INDEX.md`, replace the C8 Forbids/Why block (lines 193-197): - -``` -**Forbids.** A node sizing/growing its input lookback at runtime; more than one -output per node (model as multiple nodes); copy-on-read of input history. -**Why.** Engine-provided windows mean LLM-authored code cannot mis-manage -lookback bookkeeping, and history passes through zero-copy. Fixed, pre-sized -buffers suit deterministic, pre-dimensioned sims (no realloc in the hot loop). -``` - -with: - -``` -**Forbids.** A node sizing/growing its input lookback at runtime; more than one -output **port** per node; a fifth scalar type or a heterogeneous output payload -(a record is a bundle of base columns, C7); copy-on-read of input history. -**Why.** Engine-provided windows mean LLM-authored code cannot mis-manage -lookback bookkeeping, and history passes through zero-copy. Fixed, pre-sized -buffers suit deterministic, pre-dimensioned sims (no realloc in the hot loop). -**Realization (cycle 0005).** `NodeSchema.output` is a `Vec` (named base -columns; length 1 = scalar). Binding is **field-wise only**: `Edge::from_field` -selects one producer column per edge; consuming a whole record is N edges (no -"bind whole record" mechanism). The K fields of one record are **co-fresh by -construction** (one `eval`, one timestamp), so C6 is untouched. `eval` returns -`Option<&[Scalar]>` — a borrowed row into a node-owned buffer — so the forward -path allocates nothing per cycle (C7). -``` - -- [ ] **Step 5: Sanity-check the ledger edits** - -Run: `grep -n 'Option<&\[Scalar\]>' docs/design/INDEX.md` -Expected: at least one hit (the C8 guarantee line). - -Run: `grep -n 'one series per node' docs/design/INDEX.md` -Expected: no output (exit 1) — the old single-output framing is gone from C8. - -Run: `grep -n 'Realization (cycle 0005)' docs/design/INDEX.md` -Expected: one hit — the cycle-0005 realization note is present. diff --git a/docs/plans/0006-sink-recording.md b/docs/plans/0006-sink-recording.md deleted file mode 100644 index 5ee6c51..0000000 --- a/docs/plans/0006-sink-recording.md +++ /dev/null @@ -1,1374 +0,0 @@ -# Sink recording — recording is a node role, not a type — Implementation Plan - -> **Parent spec:** `docs/specs/0006-sink-recording.md` -> -> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run -> this plan. Steps use `- [ ]` checkboxes for tracking. - -**Goal:** Replace the engine's single `observe: usize` recording affordance with -recording-by-node, so one run records many streams; recording is an out-of-graph -`eval` side effect to a destination the node holds, with no `Sink` type. - -**Architecture:** Three production changes — `Ctx` gains `now: Timestamp` + -`now()` (aura-core); `Harness` loses `observe` (field, bootstrap param + check, -per-cycle collection) and `run` returns `()` constructing `Ctx::new(&inputs, ts)` -(aura-engine). A test-local `Recorder` fixture (holding an `mpsc::Sender`) proves -multi-stream recording; existing firing/DAG tests migrate from the dense -`Vec>` to a sparse, timestamped drained channel. Ledger C8/C22 gain -cycle-0006 realization notes. - -**Tech Stack:** Rust workspace (aura-core ← aura-std ← aura-engine); -`std::sync::mpsc` for the test read-back; the existing per-field kind check (0005) -covers recorder edges. - ---- - -## Files this plan creates or modifies - -- Modify: `crates/aura-core/src/ctx.rs:10-19` — `Ctx` gains `now: Timestamp` + - `now()`; `Ctx::new` takes `now`; 3 in-crate test call sites updated; new unit - test `ctx_now_returns_cycle_timestamp`. -- Modify: `crates/aura-std/src/sma.rs:51,70,84,87` — 3 `Ctx::new` test call sites - thread a timestamp; test-module import gains `Timestamp`. -- Modify: `crates/aura-std/src/sub.rs:52,64,68` — 2 `Ctx::new` test call sites - thread a timestamp; test-module import gains `Timestamp`. -- Modify: `crates/aura-engine/src/harness.rs` — production: remove `observe` - (field `:91`, Debug `:105`, bootstrap param `:119` + check `:122-124`, - construction `:204`, destructure `:224-225`, loop bookkeeping `:263,:277-279, - :293,:295`), change `run` to `()` (`:213`), thread `Ctx::new(&nb.inputs, ts)` - (`:275`), fix doc-comments (`:8,:59,:208-212`). Tests: add `Recorder` + - `TapForward` fixtures; migrate the 14 bootstrap/run tests; add 8 new proof - tests. -- Modify: `docs/design/INDEX.md:210,495` — C8 + C22 cycle-0006 realization notes. - -**Decision recorded (orchestrator):** the spec lists both a migrated -`ohlcv_bundles_five_field_record` and a new `recorder_taps_all_fields_of_a_record`; -they prove overlapping mechanics. Both are kept and differentiated: the migrated -test records **two** bars (focus: barrier timing — one record per bar, on the -fifth cycle of each timestamp), the new test records **one** bar (focus: a 5-input -recorder taps all five fields via five field-wise edges, 0005 — asserts the row -has five fields). No spec-named test is dropped. - -**Out of scope (stays #3):** the exhaustive multi-producer × multi-consumer × -multi-sink stress matrix. This plan ships the substrate (#2) only. - ---- - -## Task 1: `Ctx::now()` (aura-core) - -**Files:** -- Modify: `crates/aura-core/src/ctx.rs` - -- [ ] **Step 1: Add the `now` field, the `now()` accessor, and update `Ctx::new`** - -Replace the struct + `impl` opening (`ctx.rs:8-19`): - -```rust -/// Read-only, zero-copy view of a node's inputs for one `eval`, in schema -/// order, plus the cycle's timestamp (C4). `Copy` because it is just a borrow of -/// the input slice plus a `Copy` timestamp. -#[derive(Clone, Copy)] -pub struct Ctx<'a> { - inputs: &'a [AnyColumn], - now: Timestamp, -} - -impl<'a> Ctx<'a> { - /// Wrap the per-input columns (in schema-declared order) and the cycle - /// timestamp for one `eval`. - pub fn new(inputs: &'a [AnyColumn], now: Timestamp) -> Self { - Self { inputs, now } - } - - /// The current cycle's timestamp (C4). Causal — the present cycle's - /// timestamp, never the future (C2) — so reading it introduces no look-ahead. - pub fn now(&self) -> Timestamp { - self.now - } -``` - -(`Timestamp` is already imported at `ctx.rs:6`.) - -- [ ] **Step 2: Thread the timestamp through the 3 in-crate test call sites** - -In `ctx.rs` `#[cfg(test)] mod tests`, the three `Ctx::new(&inputs)` calls become -`Ctx::new(&inputs, Timestamp(0))`: - -- `ctx.rs:72` (in `ctx_hands_financial_indexed_windows`): -```rust - let ctx = Ctx::new(&inputs, Timestamp(0)); -``` -- `ctx.rs:87` (in `ctx_addresses_multiple_inputs`): -```rust - let ctx = Ctx::new(&inputs, Timestamp(0)); -``` -- `ctx.rs:97` (in `ctx_panics_on_kind_mismatch`): -```rust - let ctx = Ctx::new(&inputs, Timestamp(0)); -``` - -The test-module import (`ctx.rs:64`, `use crate::{Scalar, ScalarKind};`) gains -`Timestamp`: -```rust - use crate::{Scalar, ScalarKind, Timestamp}; -``` - -- [ ] **Step 3: Add the `ctx_now_returns_cycle_timestamp` unit test** - -Append inside `#[cfg(test)] mod tests` (after `ctx_panics_on_kind_mismatch`, before -the closing `}` at `ctx.rs:100`): - -```rust - #[test] - fn ctx_now_returns_cycle_timestamp() { - let inputs: Vec = vec![]; - let ctx = Ctx::new(&inputs, Timestamp(42)); - assert_eq!(ctx.now(), Timestamp(42)); - } -``` - -- [ ] **Step 4: Verify aura-core compiles and its tests pass** - -Run: `cargo test -p aura-core` -Expected: PASS — `test result: ok. 20 passed; 0 failed` (the prior 19 + the new -`ctx_now_returns_cycle_timestamp`). The downstream crates are not compiled by a -`-p aura-core` run, so their still-old `Ctx::new` calls do not break this gate. - ---- - -## Task 2: thread `Ctx::new` through aura-std (caller-threading forced by Task 1) - -**Files:** -- Modify: `crates/aura-std/src/sma.rs` -- Modify: `crates/aura-std/src/sub.rs` - -The `Ctx::new` signature change in Task 1 breaks every aura-std test call site. -This task threads all five (compile-driven enumeration: `sma.rs:70,84,87`; -`sub.rs:64,68`). Mechanical — the node tests do not assert on `now()`, so a -`Timestamp(0)` placeholder is correct. - -- [ ] **Step 1: Update the three `Ctx::new` call sites in `sma.rs`** - -Add `Timestamp` to the test-module import (`sma.rs:51`, `use aura_core::AnyColumn;`): -```rust - use aura_core::{AnyColumn, Timestamp}; -``` - -`sma.rs:70` (in `sma_warms_up_then_tracks_the_window_mean`): -```rust - let got = sma.eval(Ctx::new(&inputs, Timestamp(0))); -``` -`sma.rs:84` (in `sma_length_one_is_identity`): -```rust - assert_eq!(sma.eval(Ctx::new(&inputs, Timestamp(0))), Some([Scalar::F64(7.0)].as_slice())); -``` -`sma.rs:87` (same test): -```rust - assert_eq!(sma.eval(Ctx::new(&inputs, Timestamp(0))), Some([Scalar::F64(9.0)].as_slice())); -``` - -- [ ] **Step 2: Update the two `Ctx::new` call sites in `sub.rs`** - -Add `Timestamp` to the test-module import (`sub.rs:52`, `use aura_core::AnyColumn;`): -```rust - use aura_core::{AnyColumn, Timestamp}; -``` - -`sub.rs:64` (in `sub_is_difference_once_both_inputs_present`): -```rust - assert_eq!(sub.eval(Ctx::new(&inputs, Timestamp(0))), None); -``` -`sub.rs:68` (same test): -```rust - assert_eq!(sub.eval(Ctx::new(&inputs, Timestamp(0))), Some([Scalar::F64(6.0)].as_slice())); -``` - -- [ ] **Step 3: Verify aura-std compiles and its tests pass** - -Run: `cargo test -p aura-std` -Expected: PASS — `test result: ok. 3 passed; 0 failed`. aura-engine (downstream) -is not compiled by this gate, so its still-old API does not break it. - ---- - -## Task 3: shrink the engine surface — remove `observe`, `run -> ()` (aura-engine production) - -**Files:** -- Modify: `crates/aura-engine/src/harness.rs` (production code only; the - `#[cfg(test)] mod tests` is migrated in Task 4) - -This task changes three production surfaces (`observe` removal, `run` return type, -the run-loop `Ctx::new`) and the doc-comments that reference them. The -`#[cfg(test)]` test module is **left broken on purpose** — it still calls the old -4-arg `bootstrap` and binds `run`'s return — and is restored in Task 4. The gate -here is therefore a **production-only build** (`cargo build`, which does not -compile `#[cfg(test)]` modules), per the planner's compile-gate-ordering rule. - -- [ ] **Step 1: Fix the module doc and the `BadIndex` doc** - -`harness.rs:8` — the module doc currently reads (exact substring to replace): -```rust -//! (the cycle-0002 shape), so `Ctx` is unchanged. A node's `eval` returns a -``` -Replace that one line with: -```rust -//! (the cycle-0002 shape), so `Ctx` borrows them read-only and additionally -//! carries the cycle timestamp (`ctx.now()`, C4). A node's `eval` returns a -``` - -`harness.rs:59` — drop the observe clause from the `BadIndex` doc: -```rust - /// A node or slot index in an edge or target is out of range. - BadIndex, -``` - -- [ ] **Step 2: Remove the `observe` field and its `Debug` line** - -`harness.rs:86-92` — the struct loses `observe`: -```rust -/// A bootstrapped, frozen root graph instance plus its deterministic run loop. -pub struct Harness { - nodes: Vec, - topo: Vec, - out_edges: Vec>, - sources: Vec, -} -``` - -`harness.rs:100-106` — the `Debug` impl loses the `observe` field line: -```rust - f.debug_struct("Harness") - .field("nodes", &self.nodes.len()) - .field("topo", &self.topo) - .field("out_edges", &self.out_edges) - .field("sources", &self.sources) - .finish() -``` - -- [ ] **Step 3: Drop the `observe` parameter, its bounds check, and its construction** - -`harness.rs:115-124` — `bootstrap` loses its fourth parameter and the -`observe >= n` check: -```rust - pub fn bootstrap( - nodes: Vec>, - sources: Vec, - edges: Vec, - ) -> Result { - let n = nodes.len(); - - let schemas: Vec<_> = nodes.iter().map(|nd| nd.schema()).collect(); -``` -(The `let n = nodes.len();` line stays — `n` is still used to size `out_edges` and -`indeg`. Only the `if observe >= n { ... }` block is removed.) - -`harness.rs:199-205` — the construction loses `observe`: -```rust - Ok(Harness { - nodes: boxes, - topo, - out_edges, - sources, - }) -``` - -- [ ] **Step 4: Change `run` to return `()`, drop the observe bookkeeping, thread `ts` into `Ctx`** - -Replace the **entire** `run` function (`harness.rs:208-296`, from the `/// Drive -the sources` doc-comment through the closing `}` of `run`) with the block below. -Changes vs. the original: return type `()`; doc rewritten; `observe` dropped from -the destructure and the `let observe = *observe;` line gone; `let mut out` gone; -`let mut observed` gone; `Ctx::new(&nb.inputs, ts)`; the `if nidx == observe` -branch gone; `out.push(observed)` and the trailing `out` return gone. - -```rust - /// Drive the sources, k-way-merged in timestamp order (ties by source index, - /// C4). One stream per source, each ascending in timestamp (C3 ingestion - /// precondition). Recording is a node-side concern: a recording node pushes - /// its record to a destination it holds (out of graph) inside `eval`; the - /// engine only routes in-graph edges and is oblivious to the side effect. - /// Allocates nothing per cycle beyond the reused scratch buffer. - pub fn run(&mut self, streams: Vec>) { - 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 = vec![0; streams.len()]; - let mut cycle_id: u64 = 0; - let mut scratch: Vec = Vec::new(); - - loop { - // pick the live source head with the smallest (timestamp, source index) - let mut pick: Option = 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; - - // forward the source value into its target slots, stamping freshness - for t in sources[s].targets.iter() { - let nb = &mut nodes[t.node]; - nb.inputs[t.slot].push(value).expect("source kind checked at wiring"); - nb.slots[t.slot] = SlotState { fresh_at: cycle_id, last_ts: ts }; - } - - // evaluate in topological order; gate by firing; forward Some outputs - for &nidx in topo.iter() { - let out_len = nodes[nidx].out_len; - let fired = { - let nb = &nodes[nidx]; - fires(&nb.firing, &nb.slots, cycle_id, ts) - }; - if !fired { - continue; // hold: no eval, no push - } - let result: Option<&[Scalar]> = { - let nb = &mut nodes[nidx]; - nb.node.eval(Ctx::new(&nb.inputs, ts)) - }; - if let Some(row) = result { - debug_assert_eq!(row.len(), out_len, "node returned a row of the wrong width"); - scratch.clear(); - scratch.extend_from_slice(row); - for e in out_edges[nidx].iter() { - let nb = &mut nodes[e.to]; - nb.inputs[e.slot] - .push(scratch[e.from_field]) - .expect("edge kind checked at wiring"); - nb.slots[e.slot] = SlotState { fresh_at: cycle_id, last_ts: ts }; - } - } - } - } - } - -- [ ] **Step 5: Verify the production library builds (test module intentionally still broken)** - -Run: `cargo build --workspace` -Expected: PASS — `Finished` with no errors. `cargo build` does not compile -`#[cfg(test)]` modules, so the not-yet-migrated `harness.rs` tests do not break -this gate. aura-core and aura-std production are already on the new API (Tasks 1-2); -no production caller of `run`/`bootstrap` exists outside the test module -(verified: aura-cli does not reference them). - ---- - -## Task 4: migrate the engine test suite onto the recording API (aura-engine tests) - -**Files:** -- Modify: `crates/aura-engine/src/harness.rs` (`#[cfg(test)] mod tests` only) - -This is the "finish-threading" task: the `harness.rs` test module is one -compilation unit, so it compiles only once **all** 14 `bootstrap` call sites drop -their 4th argument and all run-binding sites move to the drained channel. The gate -is therefore the full `cargo test -p aura-engine`. Behaviour is preserved: a -recorded stream is exactly the old `Some(row)` entries, now sparse and tagged with -each firing cycle's timestamp. - -- [ ] **Step 1: Add the test-module `mpsc` import and the `Recorder` fixture** - -At the top of `#[cfg(test)] mod tests` (`harness.rs:348-353`), add the `mpsc` -import after the existing `use` lines: -```rust - use std::sync::mpsc; -``` - -Add the `Recorder` fixture alongside the other fixtures (after `TwoField`, before -the first `#[test]` at `harness.rs:501`): -```rust - /// A recording node (test-local fixture; stands in for a downstream author's - /// chart/registry sink). It declares typed input slots and holds an - /// `mpsc::Sender`; on every fired cycle it reads the newest of each input plus - /// `ctx.now()`, sends the timestamped record out of the graph, and returns - /// `None` (pure consumer — C8). Read-back is via the channel, never `Rc`/ - /// `RefCell`, so `aura-engine/src` stays free of the interior-mutability the - /// purity invariant (C7) forbids. - struct Recorder { - kinds: Vec, - firing: Firing, - tx: mpsc::Sender<(Timestamp, Vec)>, - } - impl Recorder { - fn new( - kinds: &[ScalarKind], - firing: Firing, - tx: mpsc::Sender<(Timestamp, Vec)>, - ) -> 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![], // pure sink: no output port - } - } - 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() { - let v = match kind { - ScalarKind::I64 => { - let w = ctx.i64_in(i); - if w.is_empty() { - return None; // not yet warmed - } - Scalar::I64(w[0]) - } - ScalarKind::F64 => { - let w = ctx.f64_in(i); - if w.is_empty() { - return None; - } - Scalar::F64(w[0]) - } - ScalarKind::Bool => { - let w = ctx.bool_in(i); - if w.is_empty() { - return None; - } - Scalar::Bool(w[0]) - } - ScalarKind::Timestamp => { - let w = ctx.ts_in(i); - if w.is_empty() { - return None; - } - Scalar::Ts(w[0]) - } - }; - row.push(v); - } - let _ = self.tx.send((ctx.now(), row)); // out-of-graph side effect - None // records, forwards nothing - } - } -``` - -- [ ] **Step 2: Migrate `chain_source_sma_runs`** - -Replace `harness.rs:501-522`: -```rust - #[test] - fn chain_source_sma_runs() { - // node 0 = SMA(3); source -> SMA(3).in0; node 1 = Recorder taps node 0. - let (tx, rx) = mpsc::channel(); - let mut h = Harness::bootstrap( - vec![ - Box::new(Sma::new(3)), - Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx)), - ], - vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }], - vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }], - ) - .expect("valid"); - h.run(vec![f64_stream(&[(1, 1.0), (2, 2.0), (3, 3.0), (4, 4.0), (5, 5.0)])]); - let got: Vec<(Timestamp, Vec)> = rx.try_iter().collect(); - // SMA(3) warms at cycle 3; the recorder captures only fired cycles, each - // tagged with the cycle's timestamp (sparse — no None hold-rows). - assert_eq!( - got, - vec![ - (Timestamp(3), vec![Scalar::F64(2.0)]), - (Timestamp(4), vec![Scalar::F64(3.0)]), - (Timestamp(5), vec![Scalar::F64(4.0)]), - ] - ); - } -``` - -- [ ] **Step 3: Migrate `fan_out_join_dag_runs_deterministically`** - -Replace `harness.rs:524-560`: -```rust - #[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; node 3 = Recorder taps Sub — the 0003 baseline on the new API. - let build = |tx| { - Harness::bootstrap( - vec![ - Box::new(Sma::new(2)), - Box::new(Sma::new(4)), - Box::new(Sub::new()), - 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 }], - }], - 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 }, - ], - ) - .expect("valid DAG") - }; - let prices = f64_stream(&[(1, 10.0), (2, 12.0), (3, 14.0), (4, 16.0), (5, 18.0), (6, 20.0)]); - - let (tx, rx) = mpsc::channel(); - let mut h = build(tx); - h.run(vec![prices.clone()]); - let out: Vec<(Timestamp, Vec)> = rx.try_iter().collect(); - // Sub fires once SMA(4) is warm (cycle 4): 15-13, 17-15, 19-17 -> 2. - assert_eq!( - out, - vec![ - (Timestamp(4), vec![Scalar::F64(2.0)]), - (Timestamp(5), vec![Scalar::F64(2.0)]), - (Timestamp(6), vec![Scalar::F64(2.0)]), - ] - ); - - // determinism (C1): a second identical run drains a bit-identical stream. - let (tx2, rx2) = mpsc::channel(); - let mut h2 = build(tx2); - h2.run(vec![prices]); - let out2: Vec<(Timestamp, Vec)> = rx2.try_iter().collect(); - assert_eq!(out2, out); - } -``` - -- [ ] **Step 4: Migrate `mode_a_as_of_fires_on_any_fresh_and_holds`** - -Replace `harness.rs:562-597`: -```rust - #[test] - fn mode_a_as_of_fires_on_any_fresh_and_holds() { - // AsOfSum @0; node 1 = Recorder taps it. source 0 ticks t=1..4; source 1 - // ticks t=2,4 (slower); both AsOfSum inputs Any. - let build = |tx| { - Harness::bootstrap( - vec![ - Box::new(AsOfSum { out: [Scalar::F64(0.0)] }), - Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx)), - ], - vec![ - SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }, - SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 1 }] }, - ], - vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }], - ) - .expect("valid") - }; - let s0 = f64_stream(&[(1, 10.0), (2, 20.0), (3, 30.0), (4, 40.0)]); - let s1 = f64_stream(&[(2, 100.0), (4, 200.0)]); - - let (tx, rx) = mpsc::channel(); - let mut h = build(tx); - h.run(vec![s0.clone(), s1.clone()]); - let out: Vec<(Timestamp, Vec)> = rx.try_iter().collect(); - // holds s1=100 across t=3 and the t=4 s0-cycle; emits on every tick once warm. - assert_eq!( - out, - vec![ - (Timestamp(2), vec![Scalar::F64(120.0)]), - (Timestamp(3), vec![Scalar::F64(130.0)]), - (Timestamp(4), vec![Scalar::F64(140.0)]), - (Timestamp(4), vec![Scalar::F64(240.0)]), - ] - ); - - let (tx2, rx2) = mpsc::channel(); - let mut h2 = build(tx2); - h2.run(vec![s0, s1]); - let out2: Vec<(Timestamp, Vec)> = rx2.try_iter().collect(); - assert_eq!(out2, out); // deterministic - } -``` - -- [ ] **Step 5: Migrate `mode_b_barrier_fires_only_on_timestamp_coincidence`** - -Replace `harness.rs:599-634`: -```rust - #[test] - fn mode_b_barrier_fires_only_on_timestamp_coincidence() { - // identical wiring to mode A, but both BarrierSum inputs are Barrier(0). - let build = |tx| { - Harness::bootstrap( - vec![ - Box::new(BarrierSum { out: [Scalar::F64(0.0)] }), - Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx)), - ], - vec![ - SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }, - SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 1 }] }, - ], - vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }], - ) - .expect("valid") - }; - let s0 = f64_stream(&[(1, 10.0), (2, 20.0), (3, 30.0), (4, 40.0)]); - let s1 = f64_stream(&[(2, 100.0), (4, 200.0)]); - - let (tx, rx) = mpsc::channel(); - let mut h = build(tx); - h.run(vec![s0.clone(), s1.clone()]); - let out: Vec<(Timestamp, Vec)> = rx.try_iter().collect(); - // records ONLY at t=2 and t=4 where both inputs share the timestamp. - assert_eq!( - out, - vec![ - (Timestamp(2), vec![Scalar::F64(120.0)]), - (Timestamp(4), vec![Scalar::F64(240.0)]), - ] - ); - - let (tx2, rx2) = mpsc::channel(); - let mut h2 = build(tx2); - h2.run(vec![s0, s1]); - let out2: Vec<(Timestamp, Vec)> = rx2.try_iter().collect(); - assert_eq!(out2, out); // deterministic - } -``` - -- [ ] **Step 6: Migrate `within_source_diamond_rejoin_barrier_fires`** - -Replace `harness.rs:636-677`: -```rust - #[test] - fn within_source_diamond_rejoin_barrier_fires() { - // One source fans out through SMA(2), SMA(4) that rejoin at a Barrier(0) - // node; node 3 = Recorder taps the barrier. Every push in a cycle carries - // that cycle's timestamp, so once both SMAs warm and emit in the same - // cycle, both barrier inputs share the timestamp and the barrier fires. - let build = |tx| { - Harness::bootstrap( - vec![ - Box::new(Sma::new(2)), - Box::new(Sma::new(4)), - Box::new(BarrierSum { out: [Scalar::F64(0.0)] }), - 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 }], - }], - 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 }, - ], - ) - .expect("valid DAG") - }; - let prices = f64_stream(&[(1, 10.0), (2, 12.0), (3, 14.0), (4, 16.0), (5, 18.0), (6, 20.0)]); - - let (tx, rx) = mpsc::channel(); - let mut h = build(tx); - h.run(vec![prices.clone()]); - let out: Vec<(Timestamp, Vec)> = rx.try_iter().collect(); - // SMA(4) warms at cycle 4; from then both paths emit each cycle at the same - // timestamp, so the barrier fires: SMA(2)+SMA(4) = 15+13, 17+15, 19+17. - assert_eq!( - out, - vec![ - (Timestamp(4), vec![Scalar::F64(28.0)]), - (Timestamp(5), vec![Scalar::F64(32.0)]), - (Timestamp(6), vec![Scalar::F64(36.0)]), - ] - ); - - let (tx2, rx2) = mpsc::channel(); - let mut h2 = build(tx2); - h2.run(vec![prices]); - let out2: Vec<(Timestamp, Vec)> = rx2.try_iter().collect(); - assert_eq!(out2, out); - } -``` - -- [ ] **Step 7: Migrate `mixed_a_and_b_or_combine_on_one_node`** - -Replace `harness.rs:679-712`: -```rust - #[test] - fn mixed_a_and_b_or_combine_on_one_node() { - // MixedSum @0 (in0,in1 barrier group 0; in2 as-of); node 1 = Recorder taps it. - let (tx, rx) = mpsc::channel(); - let mut h = Harness::bootstrap( - vec![ - Box::new(MixedSum { out: [Scalar::F64(0.0)] }), - Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx)), - ], - vec![ - SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }, - SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 1 }] }, - SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 2 }] }, - ], - vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }], - ) - .expect("valid"); - let s0 = f64_stream(&[(2, 20.0), (5, 50.0)]); // in0 (barrier) - let s1 = f64_stream(&[(2, 200.0)]); // in1 (barrier) - let s2 = f64_stream(&[(1, 1.0), (3, 3.0)]); // in2 (as-of) - - h.run(vec![s0, s1, s2]); - let out: Vec<(Timestamp, Vec)> = rx.try_iter().collect(); - // c3: barrier pair completes at t=2, holds c=1 -> 221. c4: as-of input - // ticks at t=3, holds the pair -> 223. c1 filters; c2,c5 hold (no record). - assert_eq!( - out, - vec![ - (Timestamp(2), vec![Scalar::F64(221.0)]), - (Timestamp(3), vec![Scalar::F64(223.0)]), - ] - ); - } -``` - -- [ ] **Step 8: Drop the `observe` argument from the four unchanged `bootstrap_rejects_*` tests** - -These tests call `bootstrap(...).unwrap_err()` and do not run; the only change is -removing the trailing 4th argument. - -`bootstrap_rejects_a_cycle` (`harness.rs:717-722`) — remove the `0,` at `:721`: -```rust - let err = Harness::bootstrap( - vec![Box::new(Sma::new(1)), Box::new(Sma::new(1))], - vec![], - vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }, Edge { from: 1, to: 0, slot: 0, from_field: 0 }], - ) - .unwrap_err(); -``` - -`bootstrap_rejects_a_kind_mismatch` (`harness.rs:730-735`) — remove the `0,` at `:735`: -```rust - let err = Harness::bootstrap( - vec![Box::new(Sma::new(1))], - vec![SourceSpec { kind: ScalarKind::I64, targets: vec![Target { node: 0, slot: 0 }] }], - vec![], - ) - .unwrap_err(); -``` - -`bootstrap_rejects_from_field_out_of_range` (`harness.rs:904-909`) — remove the -`0,` at `:908`: -```rust - let err = Harness::bootstrap( - vec![Box::new(Sma::new(1)), Box::new(Sma::new(1))], - vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }], - vec![Edge { from: 0, to: 1, slot: 0, from_field: 9 }], - ) - .unwrap_err(); -``` - -`bootstrap_rejects_per_field_kind_mismatch` (`harness.rs:919-924`) — remove the -`1,` at `:923`: -```rust - let err = Harness::bootstrap( - vec![Box::new(TwoField { out: [Scalar::F64(0.0), Scalar::I64(0)] }), Box::new(Sma::new(1))], - vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }], - vec![Edge { from: 0, to: 1, slot: 0, from_field: 1 }], - ) - .unwrap_err(); -``` - -- [ ] **Step 9: Repurpose `bootstrap_rejects_a_bad_index` (the `observe 5` trigger no longer exists)** - -Replace `harness.rs:743-754`: -```rust - #[test] - fn bootstrap_rejects_a_bad_index() { - // an edge target node (9) that does not exist -> BadIndex. (The old trigger - // — an out-of-range observe index — is gone with `observe`; BadIndex itself - // is unchanged, only the path that reaches it.) - let err = Harness::bootstrap( - vec![Box::new(Sma::new(1)), Box::new(Sma::new(1))], - vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }], - vec![Edge { from: 0, to: 9, slot: 0, from_field: 0 }], - ) - .unwrap_err(); - assert_eq!(err, BootstrapError::BadIndex); - } -``` - -- [ ] **Step 10: Migrate `ohlcv_bundles_five_field_record` (two bars, all five fields)** - -Replace `harness.rs:776-816`: -```rust - #[test] - fn ohlcv_bundles_five_field_record() { - // node 0 = Ohlcv; five sources feed O/H/L/C/V; node 1 = a 5-input Recorder - // taps all five fields via five edges. The barrier fires once all five share - // the timestamp, so each bar is recorded once, on the fifth cycle of its ts. - let (tx, rx) = mpsc::channel(); - let mut h = Harness::bootstrap( - vec![ - Box::new(Ohlcv { out: [Scalar::F64(0.0); 5] }), - Box::new(Recorder::new( - &[ScalarKind::F64, ScalarKind::F64, ScalarKind::F64, ScalarKind::F64, ScalarKind::F64], - Firing::Any, - tx, - )), - ], - ohlcv_sources(), - vec![ - Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // open - Edge { from: 0, to: 1, slot: 1, from_field: 1 }, // high - Edge { from: 0, to: 1, slot: 2, from_field: 2 }, // low - Edge { from: 0, to: 1, slot: 3, from_field: 3 }, // close - Edge { from: 0, to: 1, slot: 4, from_field: 4 }, // volume - ], - ) - .expect("valid"); - h.run(ohlcv_streams()); - let out: Vec<(Timestamp, Vec)> = rx.try_iter().collect(); - assert_eq!( - out, - vec![ - (Timestamp(1), vec![ - Scalar::F64(10.0), - Scalar::F64(15.0), - Scalar::F64(8.0), - Scalar::F64(12.0), - Scalar::F64(100.0), - ]), - (Timestamp(2), vec![ - Scalar::F64(20.0), - Scalar::F64(25.0), - Scalar::F64(19.0), - Scalar::F64(22.0), - Scalar::F64(200.0), - ]), - ] - ); - } -``` - -- [ ] **Step 11: Migrate `edge_binds_single_field_high_minus_low`** - -Replace `harness.rs:818-861`: -```rust - #[test] - fn edge_binds_single_field_high_minus_low() { - // [Ohlcv (0), Sub (1), Recorder (2)]; Sub binds high (field 1) and low - // (field 2) of the Ohlcv record -> high - low; the Recorder taps Sub. - // Proves from_field routes the right columns (not field 0) and the two - // bound fields are co-fresh (Sub's Any inputs both fire in the bar's cycle). - let build = |tx| { - Harness::bootstrap( - vec![ - Box::new(Ohlcv { out: [Scalar::F64(0.0); 5] }), - Box::new(Sub::new()), - Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx)), - ], - ohlcv_sources(), - vec![ - Edge { from: 0, to: 1, slot: 0, from_field: 1 }, // high - Edge { from: 0, to: 1, slot: 1, from_field: 2 }, // low - Edge { from: 1, to: 2, slot: 0, from_field: 0 }, // Sub -> Recorder - ], - ) - .expect("valid DAG") - }; - let (tx, rx) = mpsc::channel(); - let mut h = build(tx); - h.run(ohlcv_streams()); - let out: Vec<(Timestamp, Vec)> = rx.try_iter().collect(); - // bar1: 15 - 8 = 7; bar2: 25 - 19 = 6 (each on the bar's fifth cycle). - assert_eq!( - out, - vec![ - (Timestamp(1), vec![Scalar::F64(7.0)]), - (Timestamp(2), vec![Scalar::F64(6.0)]), - ] - ); - - let (tx2, rx2) = mpsc::channel(); - let mut h2 = build(tx2); - h2.run(ohlcv_streams()); - let out2: Vec<(Timestamp, Vec)> = rx2.try_iter().collect(); - assert_eq!(out2, out); - } -``` - -- [ ] **Step 12: Migrate `distinct_edges_read_distinct_fields`** - -Replace `harness.rs:863-898`: -```rust - #[test] - fn distinct_edges_read_distinct_fields() { - // Same Ohlcv, a different consumer: Sub binds close (field 3) and open - // (field 0) -> close - open; the Recorder taps Sub. Proves two edges on one - // record read two different fields (3 and 0, not the high/low pair above). - let (tx, rx) = mpsc::channel(); - let mut h = Harness::bootstrap( - vec![ - Box::new(Ohlcv { out: [Scalar::F64(0.0); 5] }), - Box::new(Sub::new()), - Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx)), - ], - ohlcv_sources(), - vec![ - Edge { from: 0, to: 1, slot: 0, from_field: 3 }, // close - Edge { from: 0, to: 1, slot: 1, from_field: 0 }, // open - Edge { from: 1, to: 2, slot: 0, from_field: 0 }, // Sub -> Recorder - ], - ) - .expect("valid DAG"); - h.run(ohlcv_streams()); - let out: Vec<(Timestamp, Vec)> = rx.try_iter().collect(); - // bar1: 12 - 10 = 2; bar2: 22 - 20 = 2. - assert_eq!( - out, - vec![ - (Timestamp(1), vec![Scalar::F64(2.0)]), - (Timestamp(2), vec![Scalar::F64(2.0)]), - ] - ); - } -``` - -- [ ] **Step 13: Verify the migrated suite compiles and is green** - -Run: `cargo test -p aura-engine` -Expected: PASS — `test result: ok. 14 passed; 0 failed` (the 14 pre-existing -tests, now on the recording API; behaviour preserved). This confirms the test -module compiles again (every `bootstrap`/`run` call site migrated). - ---- - -## Task 5: new proof tests for node-recording (aura-engine tests) - -**Files:** -- Modify: `crates/aura-engine/src/harness.rs` (`#[cfg(test)] mod tests` only) - -These are the #2 deliverable. They are additive — they compile against the -now-migrated API and the `Recorder` fixture from Task 4. - -- [ ] **Step 1: Add the `TapForward` fixture (producer-and-sink in one node)** - -Add after the `Recorder` fixture (before the first `#[test]`): -```rust - /// A node that records AND forwards: it sends `(now, value)` out of the graph - /// (sink side effect) and returns its value as a one-field output the engine - /// forwards downstream (producer). Proves the C8 "both" role. - struct TapForward { - out: [Scalar; 1], - tx: mpsc::Sender<(Timestamp, Vec)>, - } - impl Node for TapForward { - fn schema(&self) -> NodeSchema { - NodeSchema { - inputs: vec![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 w = ctx.f64_in(0); - if w.is_empty() { - return None; - } - let v = w[0]; - let _ = self.tx.send((ctx.now(), vec![Scalar::F64(v)])); // sink side effect - self.out[0] = Scalar::F64(v); - Some(&self.out) // producer output: engine forwards it - } - } -``` - -- [ ] **Step 2: Add `multi_sink_records_distinct_interior_streams` (the headline)** - -Append at the end of `#[cfg(test)] mod tests` (before the module's closing `}`): -```rust - #[test] - fn multi_sink_records_distinct_interior_streams() { - // Two recorders tap SMA(2) and SMA(4) in ONE run -> one run records many - // streams (the #2 headline). Each drained stream is individually correct. - let (tx_fast, rx_fast) = mpsc::channel(); - let (tx_slow, rx_slow) = mpsc::channel(); - let mut h = Harness::bootstrap( - vec![ - Box::new(Sma::new(2)), - Box::new(Sma::new(4)), - Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_fast)), - Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_slow)), - ], - 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 }, // SMA(2) -> recorder fast - Edge { from: 1, to: 3, slot: 0, from_field: 0 }, // SMA(4) -> recorder slow - ], - ) - .expect("valid DAG"); - h.run(vec![f64_stream(&[(1, 10.0), (2, 12.0), (3, 14.0), (4, 16.0), (5, 18.0)])]); - let fast: Vec<(Timestamp, Vec)> = rx_fast.try_iter().collect(); - let slow: Vec<(Timestamp, Vec)> = rx_slow.try_iter().collect(); - // SMA(2) warms at cycle 2, SMA(4) at cycle 4 — two different-rate streams. - assert_eq!( - fast, - vec![ - (Timestamp(2), vec![Scalar::F64(11.0)]), - (Timestamp(3), vec![Scalar::F64(13.0)]), - (Timestamp(4), vec![Scalar::F64(15.0)]), - (Timestamp(5), vec![Scalar::F64(17.0)]), - ] - ); - assert_eq!( - slow, - vec![ - (Timestamp(4), vec![Scalar::F64(13.0)]), - (Timestamp(5), vec![Scalar::F64(15.0)]), - ] - ); - } -``` - -- [ ] **Step 3: Add `recorder_taps_all_fields_of_a_record` (one bar, five edges)** - -Append: -```rust - #[test] - fn recorder_taps_all_fields_of_a_record() { - // A 5-input Recorder taps all five OHLCV fields via five field-wise edges - // (0005: N edges, no whole-record bind); its recorded row is the whole bar. - let (tx, rx) = mpsc::channel(); - let mut h = Harness::bootstrap( - vec![ - Box::new(Ohlcv { out: [Scalar::F64(0.0); 5] }), - Box::new(Recorder::new( - &[ScalarKind::F64, ScalarKind::F64, ScalarKind::F64, ScalarKind::F64, ScalarKind::F64], - Firing::Any, - tx, - )), - ], - ohlcv_sources(), - vec![ - Edge { from: 0, to: 1, slot: 0, from_field: 0 }, - Edge { from: 0, to: 1, slot: 1, from_field: 1 }, - Edge { from: 0, to: 1, slot: 2, from_field: 2 }, - Edge { from: 0, to: 1, slot: 3, from_field: 3 }, - Edge { from: 0, to: 1, slot: 4, from_field: 4 }, - ], - ) - .expect("valid"); - h.run(vec![ - f64_stream(&[(1, 10.0)]), - f64_stream(&[(1, 15.0)]), - f64_stream(&[(1, 8.0)]), - f64_stream(&[(1, 12.0)]), - f64_stream(&[(1, 100.0)]), - ]); - let out: Vec<(Timestamp, Vec)> = rx.try_iter().collect(); - assert_eq!(out.len(), 1); - assert_eq!(out[0].1.len(), 5); // all five fields recorded as one row - assert_eq!( - out, - vec![(Timestamp(1), vec![ - Scalar::F64(10.0), - Scalar::F64(15.0), - Scalar::F64(8.0), - Scalar::F64(12.0), - Scalar::F64(100.0), - ])] - ); - } -``` - -- [ ] **Step 4: Add `recorder_records_mixed_scalar_kinds`** - -Append: -```rust - #[test] - fn recorder_records_mixed_scalar_kinds() { - // A recorder with i64 + f64 + bool + timestamp inputs records a four-field - // mixed-kind row -> recording is not f64-only. Four sources tick once each - // at t=1,2,3,4; only on cycle 4 are all slots warm, so it records once, - // holding the earlier-ticked values. - let (tx, rx) = mpsc::channel(); - let mut h = Harness::bootstrap( - vec![Box::new(Recorder::new( - &[ScalarKind::I64, ScalarKind::F64, ScalarKind::Bool, ScalarKind::Timestamp], - Firing::Any, - tx, - ))], - vec![ - SourceSpec { kind: ScalarKind::I64, targets: vec![Target { node: 0, slot: 0 }] }, - SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 1 }] }, - SourceSpec { kind: ScalarKind::Bool, targets: vec![Target { node: 0, slot: 2 }] }, - SourceSpec { kind: ScalarKind::Timestamp, targets: vec![Target { node: 0, slot: 3 }] }, - ], - vec![], - ) - .expect("valid"); - 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)))], - ]); - let out: Vec<(Timestamp, Vec)> = rx.try_iter().collect(); - assert_eq!( - out, - vec![(Timestamp(4), vec![ - Scalar::I64(7), - Scalar::F64(1.5), - Scalar::Bool(true), - Scalar::Ts(Timestamp(99)), - ])] - ); - } -``` - -- [ ] **Step 5: Add `node_is_producer_and_sink_at_once`** - -Append: -```rust - #[test] - fn node_is_producer_and_sink_at_once() { - // TapForward records its input AND forwards it downstream; a second - // Recorder taps the forwarded output. Both channels see the same stream -> - // one node is producer and sink at once (C8 "both"). - let (tx_tap, rx_tap) = mpsc::channel(); - let (tx_down, rx_down) = mpsc::channel(); - let mut h = Harness::bootstrap( - vec![ - Box::new(TapForward { out: [Scalar::F64(0.0)], tx: tx_tap }), - Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_down)), - ], - vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }], - vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }], - ) - .expect("valid"); - h.run(vec![f64_stream(&[(1, 10.0), (2, 20.0), (3, 30.0)])]); - let tapped: Vec<(Timestamp, Vec)> = rx_tap.try_iter().collect(); - let downstream: Vec<(Timestamp, Vec)> = rx_down.try_iter().collect(); - let expected = vec![ - (Timestamp(1), vec![Scalar::F64(10.0)]), - (Timestamp(2), vec![Scalar::F64(20.0)]), - (Timestamp(3), vec![Scalar::F64(30.0)]), - ]; - assert_eq!(tapped, expected); // it recorded (sink side effect) - assert_eq!(downstream, expected); // and forwarded (producer output) - } -``` - -- [ ] **Step 6: Add `recording_is_deterministic`** - -Append: -```rust - #[test] - fn recording_is_deterministic() { - // Two fresh harnesses, two channels, identical input -> bit-identical - // recorded streams (C1). - let build = |tx| { - Harness::bootstrap( - vec![ - Box::new(Sma::new(3)), - Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx)), - ], - vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }], - vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }], - ) - .expect("valid") - }; - let prices = f64_stream(&[(1, 1.0), (2, 2.0), (3, 3.0), (4, 4.0), (5, 5.0)]); - - let (tx_a, rx_a) = mpsc::channel(); - let mut a = build(tx_a); - a.run(vec![prices.clone()]); - let run_a: Vec<(Timestamp, Vec)> = rx_a.try_iter().collect(); - - let (tx_b, rx_b) = mpsc::channel(); - let mut b = build(tx_b); - b.run(vec![prices]); - let run_b: Vec<(Timestamp, Vec)> = rx_b.try_iter().collect(); - - assert_eq!(run_a, run_b); - assert!(!run_a.is_empty()); // and it actually recorded something - } -``` - -- [ ] **Step 7: Add the two recorder firing-mode tests** - -Append: -```rust - #[test] - fn recorder_barrier_firing_records_only_on_coincidence() { - // A 2-input Barrier(0) recorder records only on cycles where both inputs - // share the timestamp — the recorder's OWN firing policy gates recording. - let (tx, rx) = mpsc::channel(); - let mut h = Harness::bootstrap( - vec![Box::new(Recorder::new( - &[ScalarKind::F64, ScalarKind::F64], - Firing::Barrier(0), - tx, - ))], - vec![ - SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }, - SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 1 }] }, - ], - vec![], - ) - .expect("valid"); - let s0 = f64_stream(&[(1, 10.0), (2, 20.0), (3, 30.0), (4, 40.0)]); - let s1 = f64_stream(&[(2, 100.0), (4, 200.0)]); - h.run(vec![s0, s1]); - let out: Vec<(Timestamp, Vec)> = rx.try_iter().collect(); - // records ONLY at t=2 and t=4 (both inputs coincide); holds otherwise. - assert_eq!( - out, - vec![ - (Timestamp(2), vec![Scalar::F64(20.0), Scalar::F64(100.0)]), - (Timestamp(4), vec![Scalar::F64(40.0), Scalar::F64(200.0)]), - ] - ); - } - - #[test] - fn recorder_any_firing_records_on_each_fresh() { - // A 2-input Any recorder records on any-fresh once both are warm (as-of), - // holding the stale input. - let (tx, rx) = mpsc::channel(); - let mut h = Harness::bootstrap( - vec![Box::new(Recorder::new( - &[ScalarKind::F64, ScalarKind::F64], - Firing::Any, - tx, - ))], - vec![ - SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }, - SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 1 }] }, - ], - vec![], - ) - .expect("valid"); - let s0 = f64_stream(&[(1, 10.0), (2, 20.0), (3, 30.0), (4, 40.0)]); - let s1 = f64_stream(&[(2, 100.0), (4, 200.0)]); - h.run(vec![s0, s1]); - let out: Vec<(Timestamp, Vec)> = rx.try_iter().collect(); - // from t=2 on, records every cycle holding the stale input; two cycles fall - // on t=4 (the s0 tick then the s1 tick). - assert_eq!( - out, - vec![ - (Timestamp(2), vec![Scalar::F64(20.0), Scalar::F64(100.0)]), - (Timestamp(3), vec![Scalar::F64(30.0), Scalar::F64(100.0)]), - (Timestamp(4), vec![Scalar::F64(40.0), Scalar::F64(100.0)]), - (Timestamp(4), vec![Scalar::F64(40.0), Scalar::F64(200.0)]), - ] - ); - } -``` - -- [ ] **Step 8: Add `bootstrap_rejects_kind_mismatched_recorder_edge`** - -Append: -```rust - #[test] - fn bootstrap_rejects_kind_mismatched_recorder_edge() { - // TwoField output: field 0 f64, field 1 i64. Binding field 1 (i64) into a - // Recorder's f64 input slot is a per-field kind mismatch -> KindMismatch - // (0005's check already covers recorder edges; recording adds no new hole). - let (tx, _rx) = mpsc::channel(); - let err = Harness::bootstrap( - vec![ - Box::new(TwoField { out: [Scalar::F64(0.0), Scalar::I64(0)] }), - Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx)), - ], - vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }], - vec![Edge { from: 0, to: 1, slot: 0, from_field: 1 }], - ) - .unwrap_err(); - assert_eq!( - err, - BootstrapError::KindMismatch { producer: ScalarKind::I64, consumer: ScalarKind::F64 } - ); - } -``` - -- [ ] **Step 9: Verify the full engine suite is green** - -Run: `cargo test -p aura-engine` -Expected: PASS — `test result: ok. 22 passed; 0 failed` (14 migrated + 8 new). - ---- - -## Task 6: ledger realization notes + final ship gate - -**Files:** -- Modify: `docs/design/INDEX.md` - -- [ ] **Step 1: Append the C8 cycle-0006 realization note** - -After the existing `**Realization (cycle 0005).**` paragraph in C8 (ends at -`INDEX.md:210`), insert: -```markdown -**Realization (cycle 0006).** The pure-consumer (sink) half of this contract is -now realized at the substrate: **recording is a node role, not a type.** A -recording node reads its typed input windows + `ctx.now()` in `eval` and pushes -the record to a destination it holds as a field (a channel, a chart handle) — an -**out-of-graph side effect**. There is no `Sink` type, trait, or engine flag: a -node that only records returns `None` (pure consumer), and a node may record -**and** return an output the engine forwards in the same `eval` (the "both" -case). In-graph routing stays engine-owned data (the edge table); the escape out -of the graph is the node's own side effect — and that boundary is the -determinism / graph-as-data boundary (C1/C7). -``` - -- [ ] **Step 2: Append the C22 cycle-0006 realization note** - -After the C22 `**Why.**` paragraph (ends at `INDEX.md:495`, before the `---` at -`:497`), insert: -```markdown -**Realization (cycle 0006).** Sinks-as-recording-mechanism is realized at the -substrate level: a recorded trace is exactly what a recording node pushed out of -the graph (no engine recording registry; the constructing World holds each -recording node's destination). The engine's single `observe: usize` affordance is -removed — `Harness::run` returns `()` and recording is a node-side concern, so one -run records *many* streams (one per recording node) instead of exactly one row. -Recorded streams are sparse and timestamped (a record per fired cycle, tagged -`ctx.now()`), matching a trace of timestamped events (C18). No new contract; the -`Harness` API change (observe removed, `run -> ()`) is recorded here. -``` - -- [ ] **Step 3: Final ship gate — full suite, clippy, purity grep** - -Run: `cargo test --workspace` -Expected: PASS — `0 failed` across all crates (aura-core 20, aura-std 3, -aura-engine 22). - -Run: `cargo clippy --workspace --all-targets -- -D warnings` -Expected: PASS — `Finished` with no warnings (compiles every test target too). - -Run: `git grep -nE 'dyn Any|Rc<|RefCell' crates/aura-engine/src; echo "exit=$status"` -Expected: no matching lines — the purity grep finds nothing in engine source (the -`Recorder` read-back uses `mpsc`, not interior mutability). Output is just -`exit=1` (fish: grep's no-match exit code), with no preceding match lines. - -Run: `git grep -nE '\bobserve\b' crates/aura-engine/src` -Expected: no matches that name the removed field — the only acceptable residue is -none (the module-doc "observer push" phrase at `harness.rs:6` uses "observer", not -"observe", and is RustAst-contrast prose left intact). If any `observe` field -reference remains, it is a missed deletion from Task 3. diff --git a/docs/plans/0007-signal-quality-loop.md b/docs/plans/0007-signal-quality-loop.md deleted file mode 100644 index 395fb2b..0000000 --- a/docs/plans/0007-signal-quality-loop.md +++ /dev/null @@ -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, - 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 { - 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, 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)> = 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![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::)>>() - }; - 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.) diff --git a/docs/plans/0008-sum-combinators.md b/docs/plans/0008-sum-combinators.md deleted file mode 100644 index 6a2f5c4..0000000 --- a/docs/plans/0008-sum-combinators.md +++ /dev/null @@ -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` 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, - 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) -> 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. diff --git a/docs/plans/0009-run-metrics-and-manifest.md b/docs/plans/0009-run-metrics-and-manifest.md deleted file mode 100644 index ca04802..0000000 --- a/docs/plans/0009-run-metrics-and-manifest.md +++ /dev/null @@ -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`-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` 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 = 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)], 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, - firing: Firing, - tx: mpsc::Sender<(Timestamp, Vec)>, - } - impl Recorder { - fn new( - kinds: &[ScalarKind], - firing: Firing, - tx: mpsc::Sender<(Timestamp, Vec)>, - ) -> 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)>, - mpsc::Receiver<(Timestamp, Vec)>, - ) { - 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)> = rx_eq.try_iter().collect(); - let ex_rows: Vec<(Timestamp, Vec)> = 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). diff --git a/docs/plans/0010-aura-run-cli.md b/docs/plans/0010-aura-run-cli.md deleted file mode 100644 index a9315a9..0000000 --- a/docs/plans/0010-aura-run-cli.md +++ /dev/null @@ -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)>`, 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, - firing: Firing, - tx: Sender<(Timestamp, Vec)>, -} - -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)>) -> 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)> = 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)> = 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)>, - Receiver<(Timestamp, Vec)>, -) { - 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)> = rx_eq.try_iter().collect(); - let ex_rows: Vec<(Timestamp, Vec)> = 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. diff --git a/docs/plans/0011-ingest-data-server-m1.md b/docs/plans/0011-ingest-data-server-m1.md deleted file mode 100644 index 997af0a..0000000 --- a/docs/plans/0011-ingest-data-server-m1.md +++ /dev/null @@ -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) -> Self` -- `DataServer::has_symbol(&self, &str) -> bool` -- `DataServer::stream_m1_windowed(self: &Arc, symbol: &str, from_ms: Option, to_ms: Option) -> Option>` -- `SymbolChunkIter::::next_chunk(&mut self) -> Option>` -- `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, - pub open: Vec, - pub high: Vec, - pub low: Vec, - pub close: Vec, - pub spread: Vec, - pub volume: Vec, -} - -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, - symbol: &str, - from_ms: i64, - to_ms: i64, -) -> Option { - let mut it = server.stream_m1_windowed(symbol, Some(from_ms), Some(to_ms))?; - let mut bars: Vec = 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)> = rx_eq.try_iter().collect(); - let ex_rows: Vec<(Timestamp, Vec)> = 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. diff --git a/docs/plans/0012-blueprint-compile-composites.md b/docs/plans/0012-blueprint-compile-composites.md deleted file mode 100644 index 53d7f56..0000000 --- a/docs/plans/0012-blueprint-compile-composites.md +++ /dev/null @@ -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` 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), - Composite(Composite), -} - -/// Ergonomic lift: any concrete `Node` becomes a `Leaf` blueprint item. -impl From 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, - edges: Vec, - input_roles: Vec>, - 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, - edges: Vec, - input_roles: Vec>, - 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, - sources: Vec, - edges: Vec, -} - -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, sources: Vec, edges: Vec) -> 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>, Vec, Vec), CompileError> { - let mut flat_nodes: Vec> = Vec::new(); - let mut flat_edges: Vec = 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 = Vec::with_capacity(self.sources.len()); - for src in &self.sources { - let mut targets: Vec = 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 { - 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> }, -} - -/// 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, - flat_nodes: &mut Vec>, - flat_edges: &mut Vec, -) -> Result, 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>, - flat_edges: &mut Vec, -) -> Result { - 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::with_capacity(input_roles.len()); - for (r, role) in input_roles.iter().enumerate() { - let mut flat_targets: Vec = 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], -) -> Result, 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, 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]) -> Result { - 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)>, - mpsc::Receiver<(Timestamp, Vec)>, - ) { - 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)>, - mpsc::Receiver<(Timestamp, Vec)>, - ) { - 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::>(); - let flat_ex_v = flat_ex.try_iter().collect::>(); - let comp_eq_v = comp_eq.try_iter().collect::>(); - let comp_ex_v = comp_ex.try_iter().collect::>(); - - // 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.) diff --git a/docs/plans/0013-aura-graph-ascii-dag.md b/docs/plans/0013-aura-graph-ascii-dag.md deleted file mode 100644 index e1e68d5..0000000 --- a/docs/plans/0013-aura-graph-ascii-dag.md +++ /dev/null @@ -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::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 ` 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, - edges: Vec, - input_roles: Vec>, - output: OutPort, -} -``` - -to: - -```rust -pub struct Composite { - name: String, - nodes: Vec, - edges: Vec, - input_roles: Vec>, - output: OutPort, -} -``` - -- [ ] **Step 2: Widen `Composite::new` to take a name** - -Change `Composite::new` (lines 64-71) from: - -```rust - pub fn new( - nodes: Vec, - edges: Vec, - input_roles: Vec>, - 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, - nodes: Vec, - edges: Vec, - input_roles: Vec>, - 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] { - &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, - output_interior: usize, - input_roles: Vec>, - }, -} - -/// 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 { - 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 = Vec::new(); - let mut sg_names: Vec = Vec::new(); - let mut memberships: Vec<(usize, Vec)> = Vec::new(); - let mut edges: Vec<(usize, usize)> = Vec::new(); - let mut item_display: Vec = 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 = 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 = 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` labels itself; node display id = node index. -pub fn render_flat_graph(nodes: &[Box], sources: &[SourceSpec], edges: &[Edge]) -> String { - let mut labels: Vec = 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 = "<>"; - 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 = "<>"; - assert_eq!(out, expected, "compiled render drifted; re-capture if intended"); - } -``` - -> The `<>` 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. diff --git a/docs/plans/0014-tidy-dedup-reexport.md b/docs/plans/0014-tidy-dedup-reexport.md deleted file mode 100644 index 49cc607..0000000 --- a/docs/plans/0014-tidy-dedup-reexport.md +++ /dev/null @@ -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)>)`, 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(&[], , )`, 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. diff --git a/docs/plans/0015-node-param-declaration.md b/docs/plans/0015-node-param-declaration.md deleted file mode 100644 index b0c15f9..0000000 --- a/docs/plans/0015-node-param-declaration.md +++ /dev/null @@ -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 ` (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, - pub output: Vec, - pub params: Vec, -} -``` - -- [ ] **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 { - 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) { - 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. diff --git a/docs/plans/0016-param-set-injection.md b/docs/plans/0016-param-set-injection.md deleted file mode 100644 index bba9158..0000000 --- a/docs/plans/0016-param-set-injection.md +++ /dev/null @@ -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`, `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, - build: Box Box>, -} - -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, - build: impl Fn(&[Scalar]) -> Box + '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 { - (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 { - 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 { - 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::::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 ` 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, - firing: Firing, - tx: Sender<(Timestamp, Vec)>, - ) -> 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` -(`blueprint.rs:33-37`): - -```rust -pub enum BlueprintNode { - Leaf(LeafFactory), - Composite(Composite), -} - -impl From 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, - params: &[Scalar], - cursor: &mut usize, - flat_nodes: &mut Vec>, - flat_edges: &mut Vec, -) -> Result, 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>, - flat_edges: &mut Vec, -) -> Result { -``` - -- [ ] **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>, Vec, Vec), CompileError> { - let expected = self.param_space().len(); - if params.len() != expected { - return Err(CompileError::ParamArity { expected, got: params.len() }); - } - let mut flat_nodes: Vec> = Vec::new(); - let mut flat_edges: Vec = 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 = Vec::with_capacity(self.sources.len()); - for src in &self.sources { - let mut targets: Vec = 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>, Vec, Vec), 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) -> Result { - 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 { - 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::>(); - - 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::>(); - - 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::>(), eq2.try_iter().collect::>()); - } -``` - -(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. diff --git a/docs/plans/0017-blueprint-view-definitions.md b/docs/plans/0017-blueprint-view-definitions.md deleted file mode 100644 index f987140..0000000 --- a/docs/plans/0017-blueprint-view-definitions.md +++ /dev/null @@ -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 = Vec::new(); - let mut item_ids: Vec = 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 = 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::>().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 `":\n"`. -fn render_definition(c: &Composite) -> String { - let mut labels: Vec = 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#""#; - assert_eq!(out, expected, "blueprint render drifted; re-capture if intended"); - } -``` - -Replace `` 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`). diff --git a/docs/plans/0018-composite-multi-output-record.md b/docs/plans/0018-composite-multi-output-record.md deleted file mode 100644 index 783c609..0000000 --- a/docs/plans/0018-composite-multi-output-record.md +++ /dev/null @@ -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`) 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`, `Edge::from_field` -selects a column, leaf multi-output already works). This cycle replaces the -single `OutPort` with a `Vec` 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:]` 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>, - output: Vec, -``` - -(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>, - output: Vec, - ) -> 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> }, -``` - -(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:]` 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:]` 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. diff --git a/docs/plans/0019-name-composite-boundary.md b/docs/plans/0019-name-composite-boundary.md deleted file mode 100644 index 086a6fb..0000000 --- a/docs/plans/0019-name-composite-boundary.md +++ /dev/null @@ -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, -} - -/// 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, - edges: Vec, - input_roles: Vec>, - output: Vec, -} -``` - -with: - -```rust -pub struct Composite { - name: String, - nodes: Vec, - edges: Vec, - input_roles: Vec, - params: Vec, - output: Vec, -} -``` - -- [ ] **Step 3: Change `Composite::new` signature + body** - -`blueprint.rs:62-70`. Replace: - -```rust - pub fn new( - name: impl Into, - nodes: Vec, - edges: Vec, - input_roles: Vec>, - output: Vec, - ) -> 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, - nodes: Vec, - edges: Vec, - input_roles: Vec, - params: Vec, - output: Vec, - ) -> 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] { - &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`): - -```rust - for t in &role.targets { -``` - -(The accumulator `let mut roles: Vec>` 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 = 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 { - let mut out = Vec::new(); - collect_params(&self.nodes, "", &mut out); - out - } -``` - -with: - -```rust - pub fn param_space(&self) -> Vec { - 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, -) { - 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 = 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 = 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:]` 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:]` and -`[param:]` markers (replace `[in:k]` with `[in:]`; 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` 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>" crates/` -Expected: matches ONLY the internal flat-roles accumulator/`ItemLowering` in -`blueprint.rs` (`let mut roles: Vec>` and the `ItemLowering::Composite` -field) — NOT any `Composite` boundary field or `Composite::new` signature. (The -boundary now uses `Vec`; the internal post-resolution flat type legitimately -stays `Vec>`.) diff --git a/docs/plans/0020-composite-signature-render.md b/docs/plans/0020-composite-signature-render.md deleted file mode 100644 index 16fc2e9..0000000 --- a/docs/plans/0020-composite-signature-render.md +++ /dev/null @@ -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 = 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 = 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 = 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 = if slots.len() > 1 { - slots.iter().map(|s| format!("#{}", (b'A' + *s as u8) as char)).collect() - } else { - Vec::new() - }; - - let parts: Vec = 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:]` entry marker per -/// input role (wired to its interior targets), a `[param:]` marker per param -/// alias (wired to the leaf it relabels), and an `[out:]` marker per -/// re-exported output field (wired from its producer). Prefixed `":\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:]` -/// entry marker per input role (wired to its interior targets), and an `[]` -/// 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(":")` count assert must repin to `"("` (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). diff --git a/docs/plans/0021-fan-in-distinguishability.md b/docs/plans/0021-fan-in-distinguishability.md deleted file mode 100644 index 856821a..0000000 --- a/docs/plans/0021-fan-in-distinguishability.md +++ /dev/null @@ -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 = 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 = 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 { - // 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 { - if others.iter().any(|o| o.as_str() == sig) { - return None; - } - let chars: Vec = 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). diff --git a/docs/plans/0022-composite-output-binding-render.md b/docs/plans/0022-composite-output-binding-render.md deleted file mode 100644 index 4a6d4ab..0000000 --- a/docs/plans/0022-composite-output-binding-render.md +++ /dev/null @@ -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 := `) -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 `[]` -/// entry marker per input role (wired to its interior targets), and an `[]` -/// 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 = 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 `[]` -/// 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 = 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 { - 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. diff --git a/docs/plans/0023-unify-blueprint-render.md b/docs/plans/0023-unify-blueprint-render.md deleted file mode 100644 index de52b69..0000000 --- a/docs/plans/0023-unify-blueprint-render.md +++ /dev/null @@ -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. diff --git a/docs/plans/0024-node-signature-in-blueprint.md b/docs/plans/0024-node-signature-in-blueprint.md deleted file mode 100644 index d35e9c3..0000000 --- a/docs/plans/0024-node-signature-in-blueprint.md +++ /dev/null @@ -1,1183 +0,0 @@ -# Node Signature Lives in the Blueprint — Implementation Plan - -> **Parent spec:** `docs/specs/0024-node-signature-in-blueprint.md` -> -> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run -> this plan. Steps use `- [ ]` checkboxes for tracking. - -**Goal:** Consolidate the node data structure so every node's signature -(`NodeSchema`) is declared once and exists in the blueprint pre-build; merge #43 -and #36; collapse `Blueprint` into the root `Composite`. Behaviour-preserving (C1). - -**Architecture:** `NodeSchema` becomes the static signature (`InputSpec`→`PortSpec`, -`lookback` removed); the one param-dependent quantity (input buffer lookback) moves -to `Node::lookbacks()`, consumed only by bootstrap. `LeafFactory`→`PrimitiveBuilder` -carries the schema. `Blueprint` is deleted; the root is a `Composite` whose input -roles are all source-bound (`Role.source: Option`). `compile` validates -structurally pre-build via `signature()` and emits a `FlatGraph { nodes, signatures, -sources, edges }`; `bootstrap` consumes the `FlatGraph`. - -**Tech Stack:** aura-core (type epicentre), aura-std (8 nodes), aura-engine -(blueprint.rs + harness.rs), aura-cli (sample constructors + compile-only render), -aura-ingest (one bootstrap caller). - ---- - -## Compile-gate sequencing (read before executing) - -This refactor breaks the whole workspace until every site is threaded (removing -`Node::schema()`, renaming `LeafFactory`/`Leaf`, deleting `struct Blueprint`, -reshaping `compile`/`bootstrap`). The crate dependency order is -**aura-core → aura-std → aura-engine → aura-cli/aura-ingest**. Tasks gate on -**per-crate partial builds** in that order; the **workspace-wide** `cargo build ---workspace` / `cargo test --workspace` / clippy gate lands only in the final task -(Task 5). No intermediate task may gate on a workspace build — it would be -unsatisfiable while downstream crates are still stale. Because the workspace is red -between Task 1 and Task 5, the **whole plan is one working-tree-consistent unit**: -the orchestrator commits once, after Task 5 is green. No partial commits. - -**Files this plan creates or modifies:** - -- Modify: `crates/aura-core/src/node.rs` — `PortSpec`, `NodeSchema`, - `PrimitiveBuilder`, `Node` trait (Task 1) -- Modify: `crates/aura-core/src/lib.rs:42,20` — re-exports/doc (Task 1) -- Modify: `crates/aura-std/src/{add,sub,exposure,ema,sma,lincomb,recorder,sim_broker}.rs` - (Task 2) -- Modify: `crates/aura-engine/src/blueprint.rs` — Blueprint→Composite, signature, - FlatGraph wiring, pre-build validation (Task 3, tests Task 4) -- Modify: `crates/aura-engine/src/harness.rs` — `FlatGraph` def, `bootstrap` reshape - (Task 3, fixtures Task 4) -- Modify: `crates/aura-cli/src/{main.rs,graph.rs}` (Task 5) -- Modify: `crates/aura-ingest/tests/real_bars.rs:25` (Task 5) - ---- - -## Task 1: aura-core — the signature/builder types - -**Files:** -- Modify: `crates/aura-core/src/node.rs:29-34,64-106,113-118,130-144,150-207` -- Modify: `crates/aura-core/src/lib.rs:20,42` - -- [ ] **Step 1: Replace `InputSpec` with `PortSpec` (drop `lookback`)** - -In `crates/aura-core/src/node.rs`, replace the `InputSpec` definition at 27-34: - -```rust -/// One declared input **port** of a node: its scalar kind and firing policy (C6). -/// 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`). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct PortSpec { - pub kind: ScalarKind, - pub firing: Firing, -} -``` - -- [ ] **Step 2: Point `NodeSchema.inputs` at `PortSpec`** - -In `node.rs`, the `NodeSchema` struct at 113-118 — change the `inputs` field type -(the `output`/`params` fields and the doc are unchanged): - -```rust -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct NodeSchema { - pub inputs: Vec, - pub output: Vec, - pub params: Vec, -} -``` - -- [ ] **Step 3: Replace `LeafFactory` with `PrimitiveBuilder` carrying the schema** - -In `node.rs`, replace the `LeafFactory` struct (64-71) and its `impl` (73-106) with: - -```rust -/// A param-generic blueprint **primitive** recipe (C19): a node's full declared -/// signature (`NodeSchema` — inputs/output/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). The signature is declared -/// here ONCE — the built node no longer re-declares it (closes the param-declared- -/// twice drift, #36) and a value-empty recipe now exposes its full I/O interface -/// pre-build (#43). -pub struct PrimitiveBuilder { - name: &'static str, - 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 Box>, -} - -impl PrimitiveBuilder { - /// `name` is the param-generic render label (the node type, e.g. `"SMA"`); - /// `schema` the full declared signature; `build` constructs a sized node from a - /// kind-checked param slice. - pub fn new( - name: &'static str, - schema: NodeSchema, - build: impl Fn(&[Scalar]) -> Box + 'static, - ) -> Self { - Self { name, schema, build: Box::new(build) } - } - /// The full declared signature (read pre-build by `Composite::param_space`, - /// `BlueprintNode::signature`, and the renderer). - pub fn schema(&self) -> &NodeSchema { - &self.schema - } - /// The declared tunable params (a view into the signature; pre-build). - pub fn params(&self) -> &[ParamSpec] { - &self.schema.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 { - (self.build)(params) - } - /// The param-generic render label for the blueprint view (C22): just the node - /// type, e.g. `SMA`. - pub fn label(&self) -> String { - self.name.to_string() - } -} -``` - -- [ ] **Step 4: Swap `Node::schema()` for `Node::lookbacks()`** - -In `node.rs`, the `Node` trait (130-144) — replace the `fn schema` declaration at -131 with `fn lookbacks`. The exact trait body: - -```rust -pub trait Node { - /// The per-input buffer **lookback** depth (each `>= 1`), in input-slot order — - /// the only signature-adjacent quantity that may depend on an injected param - /// (e.g. `Sma`'s window = its `length`). Read once by `Harness::bootstrap` to - /// size each input column; never on the hot path. Length MUST equal the node's - /// declared `signature().inputs.len()`. - fn lookbacks(&self) -> Vec; - - /// Compute one cycle's output row, or `None` (filter / warm-up / pure sink). A - /// returned `Some(row)` must satisfy `row.len() == signature().output.len()`. - fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]>; - - /// A human-readable label for the built (valued) node, e.g. `SMA(2)`. - fn label(&self) -> String; -} -``` - -> Note: the existing trait doc comment block above `pub trait Node` and the -> `eval`/`label` doc text are preserved; only the `schema` method becomes -> `lookbacks`. If the trait has other methods (e.g. a provided `label` default), -> keep them unchanged. - -- [ ] **Step 5: Migrate aura-core's own `#[cfg(test)]` module** - -In `node.rs`, the test module (≈150-207) uses the old API. Apply: -- The `Bare` test-fixture node's `fn schema` (153-155) → remove it, add - `fn lookbacks(&self) -> Vec { vec![] }` (Bare has no inputs — confirm by - reading its current schema; if it declares inputs, return one `1` per input). -- `InputSpec { kind, lookback, firing }` constructions (≈163-164) → `PortSpec { kind, - firing }` (drop the `lookback` field). -- `leaf_factory_label_is_the_bare_type` (177-188): the two `LeafFactory::new(name, - params, build)` calls → `PrimitiveBuilder::new(name, NodeSchema { inputs: vec![], - output: vec![], params }, build)` (supply the schema the fixture needs; if the - fixture only checks `label()`, an empty-inputs/empty-output schema with the same - `params` is correct). Rename the test to `primitive_builder_label_is_the_bare_type`. -- `leaf_factory_build_runs_the_closure` (191-194): `.schema().params` read at 193 → - the built node has no `schema()` now; assert against the builder's `params()` - instead (`f.params()`), and rename to `primitive_builder_build_runs_the_closure`. -- `schema_carries_declared_params` (197-206): retarget onto `PrimitiveBuilder::schema()` - (a builder-level assertion) — `assert_eq!(b.schema().params, vec![...])`. - -Read the exact current bodies before editing; preserve each test's intent (label, -closure-runs, params-declared), only swapping the removed API. - -- [ ] **Step 6: Update `lib.rs` re-exports and doc** - -In `crates/aura-core/src/lib.rs`: the re-export line (≈42) names `LeafFactory` and -`InputSpec` — rename to `PrimitiveBuilder` and `PortSpec` in the `pub use` list. -The module doc (≈20) mentioning these names — update prose to the new names. - -- [ ] **Step 7: Gate — aura-core builds and tests green** - -Run: `cargo build -p aura-core && cargo test -p aura-core` -Expected: PASS — `aura-core` lib compiles; its unit tests (label, closure, params, -PortSpec) pass. (Downstream crates are still red; that is expected until Task 5.) - ---- - -## Task 2: aura-std — every node declares its signature on the builder - -Each of the 8 nodes gets the SAME shape of change: `factory()`→`builder()` returning -`PrimitiveBuilder::new(name, , build)` with -`InputSpec{...,lookback,...}`→`PortSpec{...}` (drop lookback); `impl Node` drops -`fn schema`, adds `fn lookbacks`; the per-node `factory_params_match_built_node_schema` -test is **deleted**. The exact per-node code follows; the `eval`/`label`/`new` bodies -and all doc comments are unchanged. - -**Files:** `crates/aura-std/src/{add,sub,exposure,ema,sma,lincomb,recorder,sim_broker}.rs` - -- [ ] **Step 1: `add.rs`** - -Import line 5: `InputSpec, LeafFactory` → `PortSpec, PrimitiveBuilder`. Replace -`factory()` (31-33) and `impl Node`'s `fn schema` (43-52) with: - -```rust - pub fn builder() -> PrimitiveBuilder { - PrimitiveBuilder::new( - "Add", - NodeSchema { - inputs: vec![ - PortSpec { kind: ScalarKind::F64, firing: Firing::Any }, - PortSpec { kind: ScalarKind::F64, firing: Firing::Any }, - ], - output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }], - params: vec![], - }, - |_| Box::new(Add::new()), - ) - } -``` -```rust - fn lookbacks(&self) -> Vec { - vec![1, 1] - } -``` -Delete the `factory_params_match_built_node_schema` test (≈75-79). - -- [ ] **Step 2: `sub.rs`** - -Import line 6: `InputSpec, LeafFactory` → `PortSpec, PrimitiveBuilder`. Replace -`factory()` (22-24) and `impl Node`'s `fn schema` (34-43) with: - -```rust - pub fn builder() -> PrimitiveBuilder { - PrimitiveBuilder::new( - "Sub", - NodeSchema { - inputs: vec![ - PortSpec { kind: ScalarKind::F64, firing: Firing::Any }, - PortSpec { kind: ScalarKind::F64, firing: Firing::Any }, - ], - output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }], - params: vec![], - }, - |_| Box::new(Sub::new()), - ) - } -``` -```rust - fn lookbacks(&self) -> Vec { - vec![1, 1] - } -``` -Delete the `factory_params_match_built_node_schema` test (≈66-70). - -- [ ] **Step 3: `exposure.rs`** - -Import line 7: `InputSpec, LeafFactory` → `PortSpec, PrimitiveBuilder`. `factory()` -(27-33) → `builder()`; `fn schema` (37-43) → `fn lookbacks`: - -```rust - pub fn builder() -> PrimitiveBuilder { - PrimitiveBuilder::new( - "Exposure", - NodeSchema { - inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any }], - output: vec![FieldSpec { name: "exposure", kind: ScalarKind::F64 }], - params: vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }], - }, - |p| Box::new(Exposure::new(p[0].as_f64().expect("scale slot is F64"))), - ) - } -``` -```rust - fn lookbacks(&self) -> Vec { - vec![1] - } -``` -Delete the lockstep test (≈86-90). - -- [ ] **Step 4: `ema.rs`** - -Import line 21-22: `InputSpec, LeafFactory` → `PortSpec, PrimitiveBuilder`. -`factory()` (58-64) → `builder()`; `fn schema` (68-81) → `fn lookbacks`: - -```rust - pub fn builder() -> PrimitiveBuilder { - PrimitiveBuilder::new( - "EMA", - NodeSchema { - inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any }], - output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }], - params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }], - }, - |p| Box::new(Ema::new(p[0].as_i64().expect("length slot is I64") as usize)), - ) - } -``` -```rust - // recursive: the running average lives in internal state, so only the newest - // sample is read — `length` sizes alpha, not the window. - fn lookbacks(&self) -> Vec { - vec![1] - } -``` -Delete the lockstep test (≈160). The other `.schema()` reads in ema.rs tests -(≈163-166) — retarget onto the new API: any `built.schema()` becomes a check against -`Ema::builder().schema()` (builder-level) or is dropped if it only re-checked the -lockstep; preserve any behavioural assertion (warm-up values) unchanged. - -- [ ] **Step 5: `sma.rs`** - -Import line 7: `InputSpec, LeafFactory` → `PortSpec, PrimitiveBuilder`. `factory()` -(26-32) → `builder()`; `fn schema` (36-47) → `fn lookbacks` (the lookback IS the -window — this is the proof case): - -```rust - pub fn builder() -> PrimitiveBuilder { - PrimitiveBuilder::new( - "SMA", - NodeSchema { - inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any }], - output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }], - params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }], - }, - |p| Box::new(Sma::new(p[0].as_i64().expect("length slot is I64") as usize)), - ) - } -``` -```rust - fn lookbacks(&self) -> Vec { - vec![self.length] - } -``` -`sma.rs` is the std test-aggregation hub. Migrate its tests: -- Delete `factory_params_match_built_node_schema` (≈128). -- `sma_warms_up_then_tracks_the_window_mean` (≈79-): it sizes the input column from - `schema.inputs[0].kind`/`.lookback`. Replace with a directly-built `Sma` and - `sma.lookbacks()[0]` for the depth, `ScalarKind::F64` for the kind (the node has no - `schema()`): `AnyColumn::with_capacity(ScalarKind::F64, sma.lookbacks()[0])`. -- The `*_declares_*` / `nodes_declare_expected_params` / `paramless_nodes_have_no_params` - reads at ≈74,131,140,144,148,154-158 use `X::new(..).schema().params` — retarget - each onto `X::builder().schema().params` (the declared params now live on the - builder, not the built node). Keep every asserted value identical (e.g. LinComb's - `weights[0]`/`weights[1]`, Exposure's `scale`, SMA's `length`, the empty-params - nodes). Update imports in that test to use `builder()` where it called `factory()` - or `new().schema()`. -- `labels_carry_identifying_params` (≈) asserts `X::new(..).label()` — unchanged - (`label()` stays on `Node`). - -- [ ] **Step 6: `lincomb.rs`** - -Import line 10: `InputSpec, LeafFactory` → `PortSpec, PrimitiveBuilder`. `factory(arity)` -(45-58) → `builder(arity)` — arity stays a builder ARG (the schema is built from it, -static per blueprint); `fn schema` (60-72) → `fn lookbacks`: - -```rust - pub fn builder(arity: usize) -> PrimitiveBuilder { - let inputs = (0..arity) - .map(|_| PortSpec { kind: ScalarKind::F64, firing: Firing::Any }) - .collect(); - let params = (0..arity) - .map(|i| ParamSpec { name: format!("weights[{i}]"), kind: ScalarKind::F64 }) - .collect(); - PrimitiveBuilder::new( - "LinComb", - NodeSchema { inputs, output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }], params }, - |p| Box::new(LinComb::new( - p.iter().map(|s| s.as_f64().expect("weight slot is F64")).collect(), - )), - ) - } -``` -```rust - fn lookbacks(&self) -> Vec { - vec![1; self.weights.len()] - } -``` -Delete the lockstep test (≈146). - -- [ ] **Step 7: `recorder.rs`** - -Import line 10: `InputSpec, LeafFactory` → `PortSpec, PrimitiveBuilder`. The -`kinds`/`firing` are builder ARGS threaded into the schema (static per blueprint, -like LinComb arity). `factory(kinds, firing, tx)` (33-42) → `builder(...)`; `fn schema` -(45-53) → `fn lookbacks`. The schema must be built BEFORE the `move` closure captures -`kinds`/`tx`, so clone for the closure: - -```rust - pub fn builder( - kinds: Vec, - firing: Firing, - tx: Sender<(Timestamp, Vec)>, - ) -> PrimitiveBuilder { - let inputs = kinds.iter().map(|&kind| PortSpec { kind, firing }).collect(); - let build_kinds = kinds.clone(); - PrimitiveBuilder::new( - "Recorder", - NodeSchema { inputs, output: vec![], params: vec![] }, // sink: empty output (C8) - move |_| Box::new(Recorder::new(&build_kinds, firing, tx.clone())), - ) - } -``` -```rust - fn lookbacks(&self) -> Vec { - vec![1; self.kinds.len()] - } -``` -Delete the lockstep test (≈85-90). The `rec.schema()` read at ≈98 → drop or retarget -onto `Recorder::builder(...).schema()` preserving its intent (likely asserting empty -output / the input kinds); keep the behavioural recording assertions unchanged. - -- [ ] **Step 8: `sim_broker.rs`** - -Import line 7: `InputSpec, LeafFactory` → `PortSpec, PrimitiveBuilder`. `pip_size` -stays a builder ARG. `factory(pip_size)` (61-63) → `builder(pip_size)`; `fn schema` -(67-76) → `fn lookbacks`: - -```rust - pub fn builder(pip_size: f64) -> PrimitiveBuilder { - PrimitiveBuilder::new( - "SimBroker", - NodeSchema { - inputs: vec![ - PortSpec { kind: ScalarKind::F64, firing: Firing::Any }, // 0 exposure - PortSpec { kind: ScalarKind::F64, firing: Firing::Any }, // 1 price - ], - output: vec![FieldSpec { name: "equity", kind: ScalarKind::F64 }], - params: vec![], - }, - move |_| Box::new(SimBroker::new(pip_size)), - ) - } -``` -```rust - fn lookbacks(&self) -> Vec { - vec![1, 1] - } -``` -Delete the lockstep test (≈126). - -- [ ] **Step 9: Gate — aura-std builds and tests green** - -Run: `cargo build -p aura-std && cargo test -p aura-std` -Expected: PASS. The suite is smaller by 8 deleted lockstep tests; all behavioural -node tests (warm-up, clamp, sum, difference, EMA seeding, recording) pass. -Verify the 8 deletions landed: `! grep -rn "factory_params_match_built_node_schema" crates/aura-std/` -Expected: no matches. - ---- - -## Task 3: aura-engine lib — Composite absorbs Blueprint; signature; FlatGraph; bootstrap - -**Files:** -- Modify: `crates/aura-engine/src/harness.rs:49,114-200` (FlatGraph def + bootstrap) -- Modify: `crates/aura-engine/src/blueprint.rs:15,32-42,48-51,126-257,266-310,415-450,466-571` - -- [ ] **Step 1: Define `FlatGraph` in `harness.rs`** - -In `crates/aura-engine/src/harness.rs`, next to `SourceSpec` (≈49), add: - -```rust -/// The flat, type-erased, index-wired output of `Composite::compile` — the target -/// `Harness::bootstrap` consumes (C23's "flat graph", now a named type). `signatures[i]` -/// is the static signature of `nodes[i]` (gathered from each primitive's builder at -/// lowering); `bootstrap` reads kinds/output from it and `nodes[i].lookbacks()` for -/// buffer depth. `sources` are the lowered bound roles, in role-declaration order. -pub struct FlatGraph { - pub nodes: Vec>, - pub signatures: Vec, - pub sources: Vec, - pub edges: Vec, -} -``` -Ensure `NodeSchema` is imported in harness.rs (add to the `aura_core` use list if -not already present). - -- [ ] **Step 2: Reshape `Harness::bootstrap` to consume a `FlatGraph`** - -Replace the `bootstrap` signature (114-118) and its schema-collection/sizing -(121-137). The new head: - -```rust - pub fn bootstrap(flat: FlatGraph) -> Result { - let FlatGraph { nodes, signatures, sources, edges } = flat; - let n = nodes.len(); - - // size each node's input columns: KIND/firing from the carried signature, - // DEPTH from the built node's lookbacks() (the one param-dependent quantity) - let mut boxes: Vec = Vec::with_capacity(n); - for (nd, sig) in nodes.into_iter().zip(signatures.iter()) { - let depths = nd.lookbacks(); - debug_assert_eq!(depths.len(), sig.inputs.len(), "lookbacks() arity == signature inputs"); - let inputs: Vec = sig - .inputs - .iter() - .zip(depths) - .map(|(spec, depth)| AnyColumn::with_capacity(spec.kind, depth)) - .collect(); - let firing: Vec = sig.inputs.iter().map(|spec| spec.firing).collect(); - let slots: Vec = sig - .inputs - .iter() - .map(|_| SlotState { fresh_at: 0, last_ts: Timestamp(i64::MIN) }) - .collect(); - let out_len = sig.output.len(); - boxes.push(NodeBox { node: nd, inputs, firing, slots, out_len }); - } -``` -Then the source-target and edge kind-checks (the loops after 137) change their -oracle from `schemas` to `signatures` — replace every `schemas.get(...)` with -`signatures.get(...)`; the `.inputs`/`.output` field reads are identical (PortSpec -still has `.kind`). Keep the topological sort and the rest of the function unchanged. - -- [ ] **Step 3: Rename the `LeafFactory` import and the `BlueprintNode::Leaf` arm** - -In `crates/aura-engine/src/blueprint.rs`: -- Import line 15: `LeafFactory` → `PrimitiveBuilder`. -- `BlueprintNode` enum (32-35): `Leaf(LeafFactory)` → `Primitive(PrimitiveBuilder)`. -- The `From for BlueprintNode` impl (38-42): `LeafFactory` → - `PrimitiveBuilder`, the arm `BlueprintNode::Leaf` → `BlueprintNode::Primitive`. -- Every production `BlueprintNode::Leaf` match arm → `BlueprintNode::Primitive`, at: - **275, 303, 328, 399, 423, 476, 494** (the `::Composite` arm beside each is - unchanged). The bodies (reading `f.label()`/`f.params()`) are unchanged — `f` is - now `&PrimitiveBuilder`, whose `params()` returns `&self.schema.params` (same - accessor, so all param/alias/fan-in code at 279/328/400/424 works verbatim). - -- [ ] **Step 4: Add `source` to `Role`** - -`Role` struct (48-51) gains the field: - -```rust -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Role { - pub name: String, - pub targets: Vec, - /// `None` = an open interior port (wired by the enclosing graph's edges); - /// `Some(kind)` = a bound ingestion feed of `kind` (only meaningful at the root, - /// where it lowers to a `FlatGraph` source). C3: sources bind at ingestion only. - pub source: Option, -} -``` - -- [ ] **Step 5: Add `UnboundRootRole` to `CompileError`** - -In the `CompileError` enum (126-147) add: - -```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 }, -``` - -- [ ] **Step 6: Add `BlueprintNode::signature` and `derive_signature`** - -After the `BlueprintNode` enum / its `From` impl (≈42), add: - -```rust -impl BlueprintNode { - /// The node's declared signature, pre-build, uniform across both arms — a - /// primitive returns its builder's declared schema; a composite derives it from - /// its interior. This is "every node has a signature in the blueprint". - pub fn signature(&self) -> NodeSchema { - match self { - BlueprintNode::Primitive(b) => b.schema().clone(), - BlueprintNode::Composite(c) => derive_signature(c), - } - } -} - -/// Derive a composite's signature from its interior (no build): one input port per -/// input role (kind = the role's interior target slot kind; firing is a non-load- -/// bearing `Any` placeholder — a composite's ports dissolve at inline, only the kind -/// is consulted by an enclosing graph's wiring check), one output field per -/// re-exported `OutField` (kind = the interior producer's field kind), and the -/// aggregated param-space. -fn derive_signature(c: &Composite) -> NodeSchema { - let inputs = c - .input_roles() - .iter() - .map(|role| { - let kind = role - .targets - .first() - .map(|t| interior_slot_kind(c.nodes(), c.edges(), t)) - .unwrap_or(ScalarKind::F64); - PortSpec { kind, firing: Firing::Any } - }) - .collect(); - let output = c - .output() - .iter() - .map(|of| { - let kind = c.nodes()[of.node].signature().output[of.field].kind; - FieldSpec { name: leak_name(&of.name), kind } - }) - .collect(); - let mut params = Vec::new(); - collect_params(c.nodes(), "", c.params(), &mut params); - NodeSchema { inputs, output, params } -} - -/// The scalar kind of the interior input slot a composite target addresses, -/// resolving one level (a target into a nested composite reads that composite's -/// derived input-port kind). -fn interior_slot_kind(nodes: &[BlueprintNode], _edges: &[Edge], t: &Target) -> ScalarKind { - nodes[t.node].signature().inputs[t.slot].kind -} -``` - -> `FieldSpec.name` is `&'static str` but `OutField.name` is a `String`. Deriving an -> owned signature needs a `&'static str` for the field name. Resolve with a small -> helper `leak_name(&str) -> &'static str` using `Box::leak(s.to_string().into_boxed_str())` -> — acceptable because `derive_signature` is a cold, pre-build, render/validation -> path (never the hot loop), and the leaked names are bounded by the static blueprint. -> Add it near `derive_signature`: -> ```rust -> fn leak_name(s: &str) -> &'static str { -> Box::leak(s.to_string().into_boxed_str()) -> } -> ``` -> Import `PortSpec`, `FieldSpec`, `Firing`, `ScalarKind` from `aura_core` in -> blueprint.rs as needed. - -- [ ] **Step 7: Delete `struct Blueprint`; move its methods onto `Composite`** - -Delete `struct Blueprint` (151-155) and the entire `impl Blueprint` block (157-257). -Add the following methods to `impl Composite` (the block at 78-122), so the root -graph IS a `Composite`: - -```rust - /// The aggregated, flat, path-qualified param-space (C12): every node's declared - /// params, concatenated in lowering order. The ROOT uses an empty path prefix - /// (its own name does not prefix — preserving the pre-refactor param names); - /// interior composite names prefix via the recursion in `collect_params`. - pub fn param_space(&self) -> Vec { - let mut out = Vec::new(); - collect_params(&self.nodes, "", &self.params, &mut out); - out - } - - /// Compile this composite as the ROOT graph under an injected param vector: - /// validate structurally pre-build (via `signature()`, no node built), require - /// every root role bound, then lower (build each primitive, gather its signature, - /// inline composites, rewrite edges, lower bound roles to flat sources). - pub fn compile_with_params(self, params: &[Scalar]) -> Result { - // 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() }); - } - - let mut flat_nodes: Vec> = Vec::new(); - let mut flat_signatures: Vec = Vec::new(); - let mut flat_edges: Vec = Vec::new(); - let mut cursor = 0usize; - - let lowerings = lower_items( - self.nodes, - params, - &mut cursor, - &mut flat_nodes, - &mut flat_signatures, - &mut flat_edges, - )?; - - for e in &self.edges { - for fe in rewrite_edge(e, &lowerings, &flat_signatures)? { - flat_edges.push(fe); - } - } - - // each bound root role lowers to a flat source, in role-declaration order - let mut flat_sources: Vec = Vec::with_capacity(self.input_roles.len()); - for role in &self.input_roles { - let kind = role.source.expect("root role bound (checked above)"); - let mut targets: Vec = Vec::new(); - for t in &role.targets { - targets.extend(resolve_target(t, &lowerings)?); - } - flat_sources.push(SourceSpec { kind, targets }); - } - - Ok(FlatGraph { nodes: flat_nodes, signatures: flat_signatures, sources: flat_sources, edges: flat_edges }) - } - - /// No-param compile (errors `ParamArity` if any param is declared). - pub fn compile(self) -> Result { - self.compile_with_params(&[]) - } - - /// Compile under an injected vector, then bootstrap the flat graph. - pub fn bootstrap_with_params(self, params: Vec) -> Result { - let flat = self.compile_with_params(¶ms)?; - Harness::bootstrap(flat).map_err(CompileError::Bootstrap) - } - - /// No-param bootstrap. - pub fn bootstrap(self) -> Result { - self.bootstrap_with_params(vec![]) - } -``` - -- [ ] **Step 8: Add the pre-build `validate_wiring` pass** - -Add a free function (near `check_fan_in_distinguishability`, ≈340) that performs the -output-range and edge/role kind checks the lowering used to do off built `schema()` -(539/586/619) — now off `signature()`, pre-build, recursing into composites: - -```rust -/// Pre-build structural validation via `signature()` (no node constructed): every -/// edge's producer field and consumer slot are in range and kind-matched; every -/// output re-export and role target is in range and kind-consistent. Recurses into -/// nested composites so the checks hold at every level. This is what lets `compile` -/// reject a wiring fault before any build closure fires. -fn validate_wiring( - nodes: &[BlueprintNode], - edges: &[Edge], - roles: &[Role], - output: &[OutField], -) -> Result<(), CompileError> { - // edges: index-range + producer/consumer kind match. The kind-mismatch variant - // is the SAME one bootstrap returns today (Bootstrap(KindMismatch)), just raised - // pre-build — so existing tests asserting that variant for a compiled graph stay - // green, while the fault is now caught before any build closure fires. - for e in edges { - let from = nodes.get(e.from).ok_or(CompileError::BadInteriorIndex)?.signature(); - let to = nodes.get(e.to).ok_or(CompileError::BadInteriorIndex)?.signature(); - let f = from.output.get(e.from_field).ok_or(CompileError::BadInteriorIndex)?; - let s = to.inputs.get(e.slot).ok_or(CompileError::BadInteriorIndex)?; - if f.kind != s.kind { - return Err(CompileError::Bootstrap(BootstrapError::KindMismatch { - producer: f.kind, - consumer: s.kind, - })); - } - } - // roles: every target in range, and all targets of one role share a kind - // (RoleKindMismatch — the existing variant, today read off built schema()). - for (r, role) in roles.iter().enumerate() { - let mut role_kind: Option = None; - for t in &role.targets { - let sig = nodes.get(t.node).ok_or(CompileError::BadInteriorIndex)?.signature(); - let k = sig.inputs.get(t.slot).ok_or(CompileError::BadInteriorIndex)?.kind; - match role_kind { - None => role_kind = Some(k), - Some(k0) if k0 != k => return Err(CompileError::RoleKindMismatch { role: r }), - Some(_) => {} - } - } - } - // outputs: each re-export's field index in range - for of in output { - let sig = nodes.get(of.node).ok_or(CompileError::OutputPortOutOfRange)?.signature(); - if of.field >= sig.output.len() { - return Err(CompileError::OutputPortOutOfRange); - } - } - // 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(()) -} -``` - -> `BootstrapError` is already in scope (the `CompileError::Bootstrap(BootstrapError)` -> variant at blueprint.rs:136). This preserves every existing error variant — the -> composite-level output/role/edge checks that lowering performed (lower_composite at -> 532-568) stay as the index resolution they already are; their kind/range *faults* -> are now pre-empted here. Keep lowering's `ok_or(...)` guards as defensive duplicates -> (they will not fire after validation, but cost nothing and keep lowering total). -> **Bootstrap keeps its own edge/source kind-checks unchanged** (Task 3 Step 2) — a -> hand-wired `FlatGraph` passed straight to `bootstrap` (not through `compile`) is -> still validated there, so the `bootstrap-rejects` test suite is unaffected. - -- [ ] **Step 9: Thread `flat_signatures` through `lower_items`/`inline_composite`/`rewrite_edge`** - -The lowering helpers gain a `flat_signatures: &mut Vec` parameter so each -built primitive's signature is gathered parallel to the built node, and the -index-range reads switch from built `schema()` to the gathered signatures. - -`lower_items` (466-500): add the param; in the `Primitive` arm, gather the signature -at build: - -```rust - BlueprintNode::Primitive(builder) => { - let n = builder.params().len(); - let slice = ¶ms[*cursor..*cursor + n]; // in range: arity checked up front - for (i, spec) in builder.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_signatures.push(builder.schema().clone()); - flat_nodes.push(builder.build(slice)); - *cursor += n; - lowerings.push(ItemLowering::Leaf { index }); - } -``` -The `Composite` arm forwards `flat_signatures` into `inline_composite`. - -`inline_composite` (504-) and `lower_composite` (the inner): add the `flat_signatures` -param and forward it into the recursive `lower_items`. Their existing reads of -`flat_nodes[*index].schema().output.len()` (539, 586) → `flat_signatures[*index].output.len()`, -and `slot_kind`'s `flat_nodes[t.node].schema().inputs...` (619-625) → -`flat_signatures[t.node].inputs...`. Pass `flat_signatures` (a `&[NodeSchema]`) to -`rewrite_edge`/`slot_kind` in place of the `flat_nodes: &[Box]` they -currently take for the schema read. - -`rewrite_edge` (576-600) and `slot_kind` (618-626): change the parameter from -`flat_nodes: &[Box]` to `flat_signatures: &[NodeSchema]` and read -`flat_signatures[*index].output.len()` / `flat_signatures[t.node].inputs.get(t.slot)`. - -> All four call sites of these helpers (compile's edge loop, lower_composite's edge -> loop, the output/role resolution) pass `&flat_signatures` now. Thread consistently -> so the whole module compiles. - -- [ ] **Step 10: Update `signature_of`'s match arm** - -`signature_of` (266-310): the `BlueprintNode::Leaf(f)` arm at 275 → `Primitive(f)`; -body unchanged (`f.label()`, `aliases_on`, recursion). The `Composite` arm unchanged. - -- [ ] **Step 11: `check_alias_indices` / `leaf_has_unaliased_param` arms** - -`check_alias_indices` (325-334) `matches!(&nodes[a.node], BlueprintNode::Leaf(f) ...)` -→ `BlueprintNode::Primitive(f)`. `leaf_has_unaliased_param` (397-406) `BlueprintNode::Leaf(f)` -→ `Primitive(f)`. Bodies unchanged (read `f.params()`). - -- [ ] **Step 12: Gate — aura-engine lib builds** - -Run: `cargo build -p aura-engine` -Expected: PASS — the lib compiles. (aura-engine's own `#[cfg(test)]` is still stale; -it migrates in Task 4. `cargo test -p aura-engine` is NOT run here.) - ---- - -## Task 4: aura-engine tests — migrate fixtures + behaviour-preservation + new tests - -**Files:** -- Modify: `crates/aura-engine/src/harness.rs` (test fixtures ≈350-510, tests 522-1700) -- Modify: `crates/aura-engine/src/blueprint.rs` (test fixtures 641-705, tests 760-1640) - -- [ ] **Step 1: Migrate the test-fixture `Node` impls** - -Both crates' test modules define fixture nodes (`impl Node for ...` with `fn schema`): -harness.rs at **357,384,407,438,477,502**; blueprint.rs at **641,667,687,703**. For -each fixture: remove `fn schema`, add `fn lookbacks(&self) -> Vec` returning -one `1` per declared input (read the fixture's current `schema().inputs.len()` to get -the count); and wherever that fixture is wired into a blueprint, its signature must be -declared on the `PrimitiveBuilder` that constructs it. If a fixture is constructed via -a raw `Box::new(Fixture)` rather than a builder, its signature is provided where it is -lowered — convert those fixtures to expose a `builder()` returning -`PrimitiveBuilder::new(name, NodeSchema { inputs: vec![PortSpec{...}; k], output, params }, |_| Box::new(...))` -mirroring the schema they used to return. Replace every `InputSpec { kind, lookback, -firing }` in fixtures (harness.rs 360-445/479/504; blueprint.rs 644-705) with -`PortSpec { kind, firing }`. - -- [ ] **Step 2: Migrate `Blueprint::new` call sites to `Composite::new` (root)** - -Every `Blueprint::new(nodes, sources, edges)` in the engine tests (blueprint.rs at -**432-style sites: 767,977,1146,...** — the recon's `Blueprint::new` callers) becomes -a root `Composite::new(name, nodes, edges, bound_roles, params, output)`: -- the `sources: Vec` argument converts to bound roles: each - `SourceSpec { kind, targets }` → `Role { name: "src".into(), targets, source: Some(kind) }` - pushed into the `input_roles` vec; -- `name` = a test-local string (e.g. `"root"`); `params` = `vec![]`; `output` = - `vec![]` (these test roots end in sinks/taps, no re-export — confirm per test; if a - test re-exported an output, carry its `OutField`s). -Then `.compile_with_params(...)` returns a `FlatGraph`; `Harness::bootstrap(nodes, -sources, edges)` → `Harness::bootstrap(flat)`. Update each affected test body. - -- [ ] **Step 3: Migrate `Harness::bootstrap` test-fixture callers** - -The ≈35 `Harness::bootstrap(...)` calls in harness.rs tests (525-1695) currently pass -`(nodes, sources, edges)`. Each constructs a `FlatGraph` now. Where a test hand-wires -nodes directly (not via compile), it must also supply `signatures` — build a -`FlatGraph { nodes, signatures, sources, edges }` where `signatures` is each node's -declared `NodeSchema` (the fixtures expose it via their `builder().schema()` or an -inline `NodeSchema { ... }` matching the fixture). For tests that bootstrap a -compiled graph, pass the `FlatGraph` from `compile_with_params` straight through. - -> This is the largest test-migration surface. Work fixture-by-fixture; the gate -> (Step 6) is the arbiter. Keep every asserted run output (recorded rows, equity, -> determinism) byte-identical — only the construction API changes, never the values. - -- [ ] **Step 4: Pin the behaviour-preservation tests (must stay green, values unchanged)** - -These existing tests assert run output / determinism and MUST pass unchanged after -migration (confirmed present by recon): -- `composite_sma_cross_runs_bit_identical_to_hand_wired` (blueprint.rs:1198) -- `same_vector_bootstraps_identically` (blueprint.rs:1329) -- `injecting_a_different_vector_changes_the_run` (blueprint.rs:1285) -- `param_space_is_deterministic` (blueprint.rs:1615) -- `multi_output_composite_taps_distinct_fields_through_a_run` (blueprint.rs:1233) -- `chain_source_sma_runs` (harness.rs:522), `fan_out_join_dag_runs_deterministically` - (harness.rs:549), `recording_is_deterministic` (harness.rs:1136), - `milestone_end_to_end_mixed_dag_records_every_stream_deterministically` (harness.rs:1554), - `signal_quality_loop_is_deterministic` (harness.rs:1692) - -Run (after migration): `cargo test -p aura-engine composite_sma_cross_runs_bit_identical_to_hand_wired same_vector_bootstraps_identically param_space_is_deterministic recording_is_deterministic` -Expected: PASS (4+ named tests run, 0 failed) — names verified against the tree, so -the filter resolves. - -- [ ] **Step 5: Add the new tests for this cycle's behaviour** - -Add to blueprint.rs's test module: - -```rust - #[test] - fn primitive_signature_equals_builder_schema() { - // a primitive's pre-build signature IS its builder's declared schema - let b = Sma::builder(); - let node = BlueprintNode::Primitive(Sma::builder()); - assert_eq!(node.signature(), b.schema().clone()); - } - - #[test] - fn composite_signature_is_derived_from_interior() { - // macd composite: 1 f64 input role; output macd/signal/histogram (all f64) - let node = BlueprintNode::Composite(macd_fixture()); // a test-local macd builder - let sig = node.signature(); - assert_eq!(sig.inputs.len(), 1); - assert_eq!(sig.inputs[0].kind, ScalarKind::F64); - assert_eq!(sig.output.iter().map(|f| f.kind).collect::>(), vec![ScalarKind::F64; 3]); - assert_eq!(sig.params.len(), 3); // fast, slow, signal - } - - #[test] - fn compile_rejects_kind_mismatch_without_building() { - // a builder whose build closure PANICS if called — proves validation is pre-build - let exploding = PrimitiveBuilder::new( - "Boom", - NodeSchema { - inputs: vec![PortSpec { kind: ScalarKind::I64, firing: Firing::Any }], - output: vec![FieldSpec { name: "v", kind: ScalarKind::I64 }], - params: vec![], - }, - |_| panic!("build must not run when validation fails pre-build"), - ); - // an f64 producer wired into Boom's i64 slot - let root = Composite::new( - "root", - vec![Sma::builder().into(), exploding.into()], - vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }], // f64 -> i64 slot - vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) }], - vec![], - vec![], - ); - let err = root.compile_with_params(&[Scalar::I64(3)]); - // kind fault caught pre-build (no panic) — same variant bootstrap would give - assert!(matches!( - err, - Err(CompileError::Bootstrap(BootstrapError::KindMismatch { .. })) - )); - } - - #[test] - fn unbound_root_role_is_rejected() { - let root = Composite::new( - "root", - vec![Sma::builder().into()], - vec![], - vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }], - vec![], - vec![], - ); - assert_eq!(root.compile_with_params(&[Scalar::I64(3)]), Err(CompileError::UnboundRootRole { role: 0 })); - } -``` - -Add `macd_fixture()` as a test helper mirroring the CLI `macd(name)` composite (or -reuse an existing engine test composite that has a typed multi-output boundary). -`compile_rejects_kind_mismatch_without_building` asserts `Bootstrap(KindMismatch)` -(the variant `validate_wiring` returns for an edge kind fault, Task 3 Step 8 — the -same one bootstrap gives today, so the test and the code agree). Ensure -`BootstrapError` is imported in the engine test module. - -Add to aura-std (or engine) a sizing-invariant test: - -```rust - #[test] - fn lookbacks_arity_matches_signature_inputs() { - // every std node: one lookback per declared input - assert_eq!(Sma::new(3).lookbacks(), vec![3]); - assert_eq!(Sma::new(3).lookbacks().len(), Sma::builder().schema().inputs.len()); - assert_eq!(Add::new().lookbacks().len(), Add::builder().schema().inputs.len()); - } -``` - -- [ ] **Step 6: Gate — aura-engine tests green** - -Run: `cargo test -p aura-engine` -Expected: PASS — all migrated behaviour-preservation tests + the new signature/ -validation tests pass; 0 failed. - ---- - -## Task 5: aura-cli + aura-ingest — migrate call sites; compile-only render; workspace gate - -**Files:** -- Modify: `crates/aura-cli/src/main.rs:122-176,194-360, tests 381-720` -- Modify: `crates/aura-cli/src/graph.rs:18,21,61,169,212-213,250,325,337,399-400,482` -- Modify: `crates/aura-ingest/tests/real_bars.rs:25` - -- [ ] **Step 1: Migrate the `sma_cross` / `macd` composites (interior roles open)** - -In `main.rs`, `sma_cross` (122-140) and `macd` (194-): `Sma::factory()`/`Ema::factory()`/ -`Sub::factory()` → `::builder()`; each interior `Role { name, targets }` → -`Role { name, targets, source: None }` (interior roles are open ports). The -`ParamAlias`/`OutField`/`Edge`/`Target` args are unchanged. Return type stays -`Composite`. - -- [ ] **Step 2: Migrate the root blueprints to root composites** - -`build_sample` (146-171): return type `Blueprint` → `Composite`. Body: - -```rust -fn build_sample() -> Composite { - let (tx_eq, _rx_eq) = mpsc::channel(); - let (tx_ex, _rx_ex) = mpsc::channel(); - 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 }, - Edge { from: 1, to: 2, slot: 0, from_field: 0 }, - Edge { from: 2, to: 3, slot: 0, from_field: 0 }, - Edge { from: 1, to: 4, slot: 0, from_field: 0 }, - ], - vec![Role { - name: "price".into(), - targets: vec![Target { node: 0, slot: 0 }, Target { node: 2, slot: 1 }], - source: Some(ScalarKind::F64), - }], - vec![], // params: the interior sma_cross carries the aliases - vec![], // output: the root ends in sinks, no re-export - ) -} -``` -`sample_blueprint` (174-176) return type → `Composite`. - -`macd_strategy_blueprint` (234-): same transform — return `Composite`, the source -becomes one bound `Role { name: "price", targets: [node0/slot0, node2/slot1], -source: Some(ScalarKind::F64) }`, `name: "macd_strategy"`, `params: vec![]`, -`output: vec![]`, the four `Edge`s unchanged (note the `from_field: 2` histogram edge -is preserved). `macd_blueprint` (264-) return type → `Composite`. - -- [ ] **Step 3: Migrate the compile/bootstrap call sites in main.rs** - -`run_macd` (≈300-302): `let (nodes, sources, edges) = ...compile_with_params(&macd_point())...` -→ `let flat = macd_strategy_blueprint(tx_eq, tx_ex).compile_with_params(&macd_point()).expect("valid macd blueprint");` -and `Harness::bootstrap(nodes, sources, edges)` → `Harness::bootstrap(flat)`. - -`render_compiled` (336-337): signature `bp: Blueprint` → `bp: Composite`; body -`let flat = bp.compile_with_params(point).expect("valid blueprint"); graph::render_flat_graph(&flat.nodes, &flat.sources, &flat.edges, color)`. - -`run_sample`/`sample_harness` (the bootstrap at main.rs:53): same `FlatGraph` -threading as `run_macd`. - -- [ ] **Step 4: Bring `graph.rs` to compile (rename only, no render tuning)** - -In `crates/aura-cli/src/graph.rs`: -- Import 18: `LeafFactory` → `PrimitiveBuilder`. -- `render_blueprint(bp: &Blueprint, ...)` (61) and `collect_distinct_composites(bp: &Blueprint)` - (169): parameter type `&Blueprint` → `&Composite` (the root is a composite now). - Their bodies read `bp.nodes()`/`bp.edges()` (unchanged on `Composite`); they also - read `bp.sources()` — the root's sources now come from bound roles. For the - compile-only mandate, replace any `bp.sources()` read with an iteration over - `bp.input_roles()` filtered to `role.source.is_some()` (a bound role is the entry - the old source was). If the renderer mapped a `SourceSpec` to an entry label, map a - bound `Role` to the same entry shape (name + targets). **Render-output regression is - accepted** — the goal is "compiles + runs", not fidelity. -- Match arms `BlueprintNode::Leaf` → `Primitive` at **118, 212-213, 325, 337, 399-400**. -- `factory: &LeafFactory` param at **250** → `&PrimitiveBuilder` (its `.params()`/ - `.label()` calls are unchanged). -- The `sources: &[SourceSpec]` param at 482 (render_flat_graph) is unchanged - (`FlatGraph.sources` is still `Vec`). - -- [ ] **Step 5: Migrate aura-cli's `#[cfg(test)]` module** - -The cli tests (≈381-720) build `Blueprint::new` (432,448,624,670), use -`.factory()` (418,426,452,618,637-643), `.compile_with_params` (472,487,490,538), -and `Role {...}` (130,211,420,428,653). Apply the same transforms as Tasks 2/5: -`Blueprint::new` → root `Composite::new` (source→bound role), `.factory()`→`.builder()`, -`Role` gains `source` (`None` for interior, `Some(kind)` for a root entry), -`.compile_with_params` returns a `FlatGraph` (destructure `flat.nodes`/`flat.sources`/ -`flat.edges` or pass `flat` whole to bootstrap). Keep every behavioural assertion -(determinism, recorded values) identical. - -- [ ] **Step 6: Re-capture the two render goldens (value-asserted to the new output)** - -`blueprint_view_golden` (main.rs:496) and `compiled_view_golden` (main.rs:535) assert -exact rendered strings that may change under the root-composite migration. After the -suite compiles, run each and update its expected literal to the renderer's actual -post-migration output (a deliberate, inspected re-capture — render tuning is the next -cycle; this only pins current behaviour so the gate is green). Leave a comment on each -golden: `// re-captured in cycle 0024 (compile-only render migration); fidelity tuned next cycle`. -The behaviour tests `run_macd_compiles_from_nested_composite_and_is_deterministic` -(563) and `run_sample_is_deterministic_and_non_trivial` (713) must pass with values -unchanged (they assert run output, not render strings). - -- [ ] **Step 7: Migrate the aura-ingest bootstrap caller** - -`crates/aura-ingest/tests/real_bars.rs:25` calls `Harness::bootstrap(...)`. Thread the -`FlatGraph`: if it compiles a blueprint, take the `FlatGraph` from -`compile_with_params` and pass it; if it hand-wires, build a `FlatGraph { nodes, -signatures, sources, edges }` with the declared signatures. Keep the integration -assertion unchanged. - -- [ ] **Step 8: Workspace gate — build, test, clippy all green** - -Run: `cargo build --workspace` -Expected: PASS (0 errors) — every crate compiles; no `Blueprint`/`LeafFactory`/ -`InputSpec`/`.schema()`/`Leaf` symbol remains. - -Run: `cargo test --workspace` -Expected: PASS — all behaviour-preservation tests green; the 8 lockstep tests are -gone; the new signature/validation/lookbacks tests pass; the two goldens match their -re-captured literals. - -Run: `cargo clippy --workspace --all-targets -- -D warnings` -Expected: PASS (0 warnings). - -Run (symbol sweep — the rename is complete): `! git grep -nE '\b(LeafFactory|InputSpec)\b|BlueprintNode::Leaf|struct Blueprint\b|fn schema\(' -- 'crates/*'` -Expected: no matches (every removed/renamed symbol is gone from the source tree). - ---- - -## Notes for the orchestrator (not tasks) - -- **Design ledger drift (deferred to cycle-close audit):** `docs/design/INDEX.md` - documents `Node::schema()`, `LeafFactory`, `BlueprintNode::Leaf` as live contracts - (≈195,232,249,265-266,594-595,610). This plan does NOT edit the ledger (out of the - spec's component scope); the architect drift review at cycle-close reconciles the - renamed-contract prose. -- **Interior bound roles (out of scope):** `compile_with_params` reads `role.source` - only at the root; `inline_composite` ignores it (interior roles are open). A bound - role inside a nested composite (a source mid-graph) violates C3 and is not modelled - or validated this cycle — no test exercises it. -- **`Box::leak` in `derive_signature`:** bounded by the static blueprint, on a cold - pre-build path. If a future cycle makes `FieldSpec.name` a `String`, this leak - disappears; tracked implicitly by the render cycle that follows. diff --git a/docs/plans/0025-render-root-slot-stubs.md b/docs/plans/0025-render-root-slot-stubs.md deleted file mode 100644 index 1caa77f..0000000 --- a/docs/plans/0025-render-root-slot-stubs.md +++ /dev/null @@ -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 = 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 = 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 ` →` 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 ` →` 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 = match stub_ctx { - Some(c) if slots.len() > 1 => fan_in_identifiers(c, index, &slots), - _ => Vec::new(), - }; -``` - -New: - -```rust - let stubs: Vec = 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). - ---- diff --git a/docs/plans/0026-graph-model-serializer.md b/docs/plans/0026-graph-model-serializer.md deleted file mode 100644 index d2556bb..0000000 --- a/docs/plans/0026-graph-model-serializer.md +++ /dev/null @@ -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": , - "composites": { "": , ... } // first-seen order -} -``` - -- **scope** = `{ "nodes": { "": , ... }, "edges": [ , ... ] }` -- **composite-def** = `{ "inputs": [ , ... ], "outputs": [ [name,kind], ... ], - "nodes": {...}, "edges": [...] }` -- **node** (one of): - - primitive: `{ "prim": { "type":