From 386e1a9c3d25e85bbce125d0160d53f144aeaf8c Mon Sep 17 00:00:00 2001 From: Brummel Date: Thu, 4 Jun 2026 17:10:14 +0200 Subject: [PATCH] plan: 0007 signal-quality loop (exposure + sim-optimal broker) Bite-sized, placeholder-free plan for spec 0007: Task 1 ships the Exposure node (clamp signal/scale to [-1,+1]) in aura-std; Task 2 the SimBroker node (causal lagged exposure*return -> cumulative pips); Task 3 two aura-engine end-to-end tests (pip-equity recording with a hand-computed curve + determinism); Task 4 the full workspace gates (test/clippy/purity grep). Each node's lib.rs module registration is folded into its own task so every per-task compile gate is satisfiable. refs #4 #5 Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/plans/0007-signal-quality-loop.md | 503 +++++++++++++++++++++++++ 1 file changed, 503 insertions(+) create mode 100644 docs/plans/0007-signal-quality-loop.md diff --git a/docs/plans/0007-signal-quality-loop.md b/docs/plans/0007-signal-quality-loop.md new file mode 100644 index 0000000..395fb2b --- /dev/null +++ b/docs/plans/0007-signal-quality-loop.md @@ -0,0 +1,503 @@ +# 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.)