From 5de8576fd03f37b0599dabf5f89f55e147013feb Mon Sep 17 00:00:00 2001 From: Brummel Date: Wed, 24 Jun 2026 00:37:53 +0200 Subject: [PATCH] plan: 0066 stage1-r primitives + spec correction (VolStop -> composition) VolStop was a design error (a fused node justified by a missing primitive). A node is a primitive only if not DAG-expressible from other primitives; a missing primitive is added, not fused away. Adds Mul + Sqrt primitives, removes the fused VolStop, expresses the vol stop as a composition (rolling EWMA stddev = k * Sqrt(Ema(Mul(d,d), length))). Spec 0065 b gets a correction note; full record on #117. refs #117 #119 --- .../plans/0066-stage1-r-primitives-volstop.md | 353 ++++++++++++++++++ docs/specs/0065-stage1-r-signal-quality.md | 26 +- 2 files changed, 374 insertions(+), 5 deletions(-) create mode 100644 docs/plans/0066-stage1-r-primitives-volstop.md diff --git a/docs/plans/0066-stage1-r-primitives-volstop.md b/docs/plans/0066-stage1-r-primitives-volstop.md new file mode 100644 index 0000000..a4dd8cd --- /dev/null +++ b/docs/plans/0066-stage1-r-primitives-volstop.md @@ -0,0 +1,353 @@ +# Stage-1 R — primitives Mul/Sqrt + VolStop as a composition — Implementation Plan + +> **Parent spec:** `docs/specs/0065-stage1-r-signal-quality.md` (§(b) CORRECTION, 2026-06-24) +> +> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run +> this plan. Steps use `- [ ]` checkboxes for tracking. + +**Goal:** Correct the VolStop design error — add the genuinely-missing primitives +`Mul` and `Sqrt` to `aura-std`, remove the fused `VolStop` node, and express the +volatility stop as a composition of primitives (rolling EWMA stddev). + +**Architecture:** A node is a primitive only if it is NOT DAG-expressible from other +primitives. The vol stop is pure feed-forward arithmetic, so it is a composition: +`stop_distance = k · Sqrt(Ema(Mul(Δ,Δ), length))`, `Δ = Sub(price, Delay(price,1))`, +`k·σ` via `LinComb([σ],[k])`. `Mul` (two-stream product) and `Sqrt` are the missing +primitives; `Abs` is not needed (Δ² not |Δ|). `FixedStop` stays (a triggered-constant +primitive). The `vol_stop` composite lives in `aura-engine` (composites need +`GraphBuilder`, which `aura-std` lacks). + +**Tech Stack:** Rust, `aura-std` (new `Mul`, `Sqrt` primitives; `VolStop` removed), +`aura-engine` (the `vol_stop` composite test). + +--- + +**Files this plan creates or modifies:** + +- Create: `crates/aura-std/src/mul.rs` — `Mul` (two-input f64 product). +- Create: `crates/aura-std/src/sqrt.rs` — `Sqrt` (one-input f64 square root). +- Modify: `crates/aura-std/src/lib.rs` — add `mod mul; mod sqrt;` + `pub use`; drop `VolStop` from the `stop_rule` re-export. +- Modify: `crates/aura-std/src/stop_rule.rs` — remove the fused `VolStop` struct/impl/tests; keep `FixedStop`. +- Create: `crates/aura-engine/tests/vol_stop_composite.rs` — the `vol_stop(length,k)` composite-builder + a bootstrap test. + +--- + +## Task 1: `Mul` primitive (two-stream f64 product) + +**Files:** +- Create: `crates/aura-std/src/mul.rs` +- Modify: `crates/aura-std/src/lib.rs` + +- [ ] **Step 1: Write `mul.rs` (mirror `sub.rs`, with `*`) + RED tests** + +```rust +//! `Mul` — two-input f64 product (input 0 times input 1). The fundamental +//! multiplication primitive (e.g. squaring a return for a variance estimate: +//! `Mul(delta, delta)`). Emits `None` until both inputs have a value. + +use aura_core::{Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, ScalarKind}; + +/// Two-input f64 product: input 0 times input 1. Emits `None` until both inputs +/// have a value. +pub struct Mul { + out: [Cell; 1], +} + +impl Mul { + pub fn new() -> Self { + Self { out: [Cell::from_f64(0.0)] } + } + pub fn builder() -> PrimitiveBuilder { + PrimitiveBuilder::new( + "Mul", + NodeSchema { + inputs: vec![ + PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "lhs".into() }, + PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "rhs".into() }, + ], + output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }], + params: vec![], + }, + |_| Box::new(Mul::new()), + ) + } +} + +impl Default for Mul { + fn default() -> Self { Self::new() } +} + +impl Node for Mul { + fn lookbacks(&self) -> Vec { vec![1, 1] } + fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> { + let a = ctx.f64_in(0); + let b = ctx.f64_in(1); + if a.is_empty() || b.is_empty() { + return None; + } + self.out[0] = Cell::from_f64(a[0] * b[0]); + Some(&self.out) + } + fn label(&self) -> String { "Mul".to_string() } +} + +#[cfg(test)] +mod tests { + use super::*; + use aura_core::{AnyColumn, Scalar, Timestamp}; + + #[test] + fn mul_is_product_once_both_inputs_present() { + let mut m = Mul::new(); + let mut inputs = vec![ + AnyColumn::with_capacity(ScalarKind::F64, 1), + AnyColumn::with_capacity(ScalarKind::F64, 1), + ]; + inputs[0].push(Scalar::f64(3.0)).unwrap(); + assert_eq!(m.eval(Ctx::new(&inputs, Timestamp(0))), None); // only one leg + inputs[1].push(Scalar::f64(4.0)).unwrap(); + assert_eq!(m.eval(Ctx::new(&inputs, Timestamp(0))), Some([Cell::from_f64(12.0)].as_slice())); + } + #[test] + fn input_slots_are_named_lhs_rhs() { + let names: Vec = Mul::builder().schema().inputs.iter().map(|p| p.name.clone()).collect(); + assert_eq!(names, ["lhs", "rhs"]); + } +} +``` + +- [ ] **Step 2: Wire `lib.rs` + run** + +Add `mod mul;` (alphabetical, after `mod longonly;` / before `mod position_management;`) and +`pub use mul::Mul;`. Run: `cargo test -p aura-std mul` Expected: PASS +(`mul_is_product_once_both_inputs_present`, `input_slots_are_named_lhs_rhs`). + +--- + +## Task 2: `Sqrt` primitive (one-input f64 square root) + +**Files:** +- Create: `crates/aura-std/src/sqrt.rs` +- Modify: `crates/aura-std/src/lib.rs` + +- [ ] **Step 1: Write `sqrt.rs` + RED tests** + +```rust +//! `Sqrt` — one-input f64 square root. Turns a variance estimate (price²) back +//! into a standard deviation (price), e.g. the last stage of a rolling-stddev +//! volatility. Negative inputs are clamped to `0.0` before the root (a variance +//! is non-negative; floating rounding can produce a tiny negative). Emits `None` +//! until its input has a value. + +use aura_core::{Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, ScalarKind}; + +/// One-input f64 square root, `sqrt(max(input, 0.0))`. Emits `None` until its +/// input has a value (warm-up filter, C8). +pub struct Sqrt { + out: [Cell; 1], +} + +impl Sqrt { + pub fn new() -> Self { + Self { out: [Cell::from_f64(0.0)] } + } + pub fn builder() -> PrimitiveBuilder { + PrimitiveBuilder::new( + "Sqrt", + NodeSchema { + inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "value".into() }], + output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }], + params: vec![], + }, + |_| Box::new(Sqrt::new()), + ) + } +} + +impl Default for Sqrt { + fn default() -> Self { Self::new() } +} + +impl Node for Sqrt { + fn lookbacks(&self) -> Vec { vec![1] } + fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> { + let w = ctx.f64_in(0); + if w.is_empty() { + return None; + } + self.out[0] = Cell::from_f64(w[0].max(0.0).sqrt()); + Some(&self.out) + } + fn label(&self) -> String { "Sqrt".to_string() } +} + +#[cfg(test)] +mod tests { + use super::*; + use aura_core::{AnyColumn, Scalar, Timestamp}; + + #[test] + fn sqrt_of_nine_is_three_zero_is_zero() { + let mut s = Sqrt::new(); + let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; + inputs[0].push(Scalar::f64(9.0)).unwrap(); + assert_eq!(s.eval(Ctx::new(&inputs, Timestamp(0))), Some([Cell::from_f64(3.0)].as_slice())); + inputs[0].push(Scalar::f64(0.0)).unwrap(); + assert_eq!(s.eval(Ctx::new(&inputs, Timestamp(0))), Some([Cell::from_f64(0.0)].as_slice())); + } + #[test] + fn sqrt_clamps_negative_to_zero() { + let mut s = Sqrt::new(); + let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; + inputs[0].push(Scalar::f64(-1e-12)).unwrap(); + assert_eq!(s.eval(Ctx::new(&inputs, Timestamp(0))), Some([Cell::from_f64(0.0)].as_slice())); + } + #[test] + fn sqrt_is_none_until_input_present() { + let mut s = Sqrt::new(); + let inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; + assert_eq!(s.eval(Ctx::new(&inputs, Timestamp(0))), None); + } +} +``` + +- [ ] **Step 2: Wire `lib.rs` + run** + +Add `mod sqrt;` (alphabetical, after `mod sma;` / before `mod stop_rule;`) and +`pub use sqrt::Sqrt;`. Run: `cargo test -p aura-std sqrt` Expected: PASS (3 tests). + +--- + +## Task 3: Remove the fused `VolStop` node (keep `FixedStop`) + +**Files:** +- Modify: `crates/aura-std/src/stop_rule.rs` +- Modify: `crates/aura-std/src/lib.rs` + +- [ ] **Step 1: Delete `VolStop` from `stop_rule.rs`** + +Remove the entire `pub struct VolStop`, its `impl VolStop`, its `impl Node for VolStop`, +and the two inline tests `vol_stop_is_none_until_warm` and +`vol_stop_tracks_k_times_ema_abs_return`. Keep `FixedStop` (struct, impl, builder, +`Node`, and its test `fixed_stop_is_constant_after_first_price`) and the `feed` test +helper if `FixedStop`'s test still uses it (else remove the now-unused helper). Update +the module doc-comment: the volatility stop is now a composition (see +`crates/aura-engine/tests/vol_stop_composite.rs`), `FixedStop` the only stop-rule +primitive. + +- [ ] **Step 2: Drop the `VolStop` re-export** + +In `crates/aura-std/src/lib.rs`: change `pub use stop_rule::{FixedStop, VolStop};` to +`pub use stop_rule::FixedStop;`. + +- [ ] **Step 3: Build the workspace green-unchanged** + +Run: `cargo build --workspace 2>&1 | grep -E "error|cannot find VolStop" | head` +Expected: empty (no code outside `stop_rule.rs`/`lib.rs` references `VolStop` — the iter-1 +E2E uses `FixedStop`). Then `cargo test -p aura-std stop_rule` Expected: PASS +(`fixed_stop_is_constant_after_first_price`). Then `cargo test --workspace 2>&1 | tail -5` +Expected: all green. + +--- + +## Task 4: The `vol_stop` composite (rolling EWMA stddev) + +**Files:** +- Create: `crates/aura-engine/tests/vol_stop_composite.rs` + +- [ ] **Step 1: Write the composite-builder + a bootstrap RED test** + +The `vol_stop` builder wires the primitives; the test bootstraps it over an +alternating ±1 price series (`Δ² ≡ 1` ⇒ EWMA-variance `1` ⇒ `σ = 1` ⇒ +`stop_distance = k·1 = k`). NOTE: verify each node's exact port names against its +`builder()` (`Delay` input "series"/output "value"/param "lag"; `Sub`/`Mul` +"lhs"/"rhs"→"value"; `Ema` input/output + param "length"; `Sqrt` "value"→"value"; +`LinComb::builder(1)` input "term[0]"→"value", weight param "weights[0]") and adjust the +wiring if a name differs. + +```rust +//! The volatility stop as a COMPOSITION of primitives (the post-correction design): +//! stop_distance = k * Sqrt(Ema(Mul(Δ,Δ), length)), Δ = Sub(price, Delay(price,1)). +//! Proves the decomposition wires + computes the rolling EWMA stddev. +use aura_core::{Scalar, ScalarKind, Timestamp}; +use aura_engine::{GraphBuilder, Harness, VecSource}; +use aura_std::{Delay, Ema, LinComb, Mul, Sqrt, Sub, Recorder}; +use std::sync::mpsc::channel; + +/// Build the volatility-stop composite: price -> k * rolling-EWMA-stddev. +fn vol_stop(length: i64, k: f64) -> aura_engine::Composite { + let mut g = GraphBuilder::new("vol_stop"); + let price = g.input_role("price"); + let delay = g.add(Delay::builder().bind("lag", Scalar::i64(1))); + let sub = g.add(Sub::builder()); + let sq = g.add(Mul::builder()); + let ema = g.add(Ema::builder().bind("length", Scalar::i64(length))); + let sqrt = g.add(Sqrt::builder()); + let scale = g.add(LinComb::builder(1).bind("weights[0]", Scalar::f64(k))); + g.feed(price, [delay.input("series"), sub.input("lhs")]); + g.connect(delay.output("value"), sub.input("rhs")); + g.connect(sub.output("value"), sq.input("lhs")); + g.connect(sub.output("value"), sq.input("rhs")); // square: Δ·Δ + g.connect(sq.output("value"), ema.input("series")); + g.connect(ema.output("value"), sqrt.input("value")); + g.connect(sqrt.output("value"), scale.input("term[0]")); + g.expose(scale.output("value"), "stop_distance"); + g.build().expect("vol_stop composite wires") +} + +#[test] +fn vol_stop_emits_k_times_rolling_stddev() { + let (tx, rx) = channel(); + let mut g = GraphBuilder::new("vol_stop_harness"); + let price = g.source_role("price", ScalarKind::F64); + let vs = g.add(vol_stop(/*length*/ 3, /*k*/ 2.0)); + let rec = g.add(Recorder::builder(vec![ScalarKind::F64], aura_core::Firing::Any, tx)); + g.feed(price, [vs.input("price")]); + g.connect(vs.output("stop_distance"), rec.input("col[0]")); + let composite = g.build().expect("harness wires"); + let mut h: Harness = composite.bootstrap_with_params(vec![]).expect("bootstraps"); + + // alternating +/-1 moves -> Δ² ≡ 1 -> EWMA-variance 1 -> σ = 1 -> stop = k·1 = 2.0 + let prices = [100.0, 101.0, 100.0, 101.0, 100.0, 101.0, 100.0, 101.0]; + let src: Vec<(Timestamp, Scalar)> = prices.iter().enumerate() + .map(|(i, &p)| (Timestamp(i as i64), Scalar::f64(p))).collect(); + h.run(vec![VecSource::new(src)]); + + let rows: Vec<(Timestamp, Vec)> = rx.try_iter().collect(); + let last = rows.last().expect("vol_stop produced output after warm-up"); + assert!((last.1[0].as_f64() - 2.0).abs() < 1e-9, "expected stop_distance = k·σ = 2.0, got {:?}", last.1[0]); +} +``` + +NOTE: the exact `Harness`/`Composite` bootstrap + `VecSource` + source-role wiring +mirrors `crates/aura-engine/tests/stage1_r_e2e.rs` and the `built_sma_cross` bootstrap +test (`crates/aura-engine/src/builder.rs` ~:234). Adjust the source/bootstrap calls and +imports to the real signatures (e.g. `source_role` vs `input_role`, `bootstrap` vs +`bootstrap_with_params`, `VecSource` construction) — the assertion (`stop_distance → 2.0`) +is the contract. + +- [ ] **Step 2: Run the composite test + full suite + lint** + +Run: `cargo test -p aura-engine --test vol_stop_composite` Expected: PASS +(`vol_stop_emits_k_times_rolling_stddev`). +Run: `cargo test --workspace 2>&1 | tail -10` Expected: all green. +Run: `cargo clippy --workspace --all-targets -- -D warnings` Expected: clean. + +--- + +## Self-review + +- **Spec coverage:** the §(b) CORRECTION — add `Mul` (Task 1) + `Sqrt` (Task 2), remove + the fused `VolStop` (Task 3), express the vol stop as a composition (Task 4). `Abs` + deliberately not added (Δ² not |Δ|); `FixedStop` kept. +- **Placeholder scan:** none. +- **Type consistency:** `Mul`, `Sqrt`, `vol_stop`, the node port names referenced in + Task 4 match the builders the implementer verifies. +- **Step granularity:** each step 2-5 min; Tasks 1-2 are patterned mirrors of `sub.rs`. +- **No commit steps:** none. +- **Compile-gate ordering:** Task 3's `VolStop` removal threads its only references + (`stop_rule.rs` + the `lib.rs` re-export) within Task 3; the workspace build gate is + satisfiable because no other code references `VolStop` (iter-1 E2E uses `FixedStop`). +- **Verification filters resolve:** `cargo test -p aura-std mul` / `sqrt` / + `stop_rule` / `-p aura-engine --test vol_stop_composite` match the new/kept named + tests; the removal gate uses `cargo build --workspace` + the unfiltered suite. diff --git a/docs/specs/0065-stage1-r-signal-quality.md b/docs/specs/0065-stage1-r-signal-quality.md index 6d77031..17e4cb9 100644 --- a/docs/specs/0065-stage1-r-signal-quality.md +++ b/docs/specs/0065-stage1-r-signal-quality.md @@ -157,15 +157,31 @@ field is renamed to `bias_sign_flips` **with `#[serde(alias = "exposure_sign_fli historical `runs.jsonl` still deserialises (the planner pins the exact sites; ~311 `exposure` / 82 `Exposure` references across `crates/`). -**(b) `VolStop` + `FixedStop` stop-rule nodes (#119) — new, `aura-std`.** +**(b) Stop-rule (#119).** + +> **CORRECTION (2026-06-24, #117).** The "fused `VolStop` node (no Abs/Mul +> primitive exists, so fuse)" below was a design error. A node is a PRIMITIVE only +> if it is **not** DAG-expressible from other primitives; a missing primitive is +> **added**, not fused away. The vol stop is pure feed-forward arithmetic → a +> **composition**, not a primitive. Corrected design: +> - add primitives `Mul` (two-stream f64 product) + `Sqrt` to `aura-std`; +> - the vol stop is a **rolling stddev (EWMA volatility)**: `stop_distance = +> k · Sqrt(Ema(Mul(Δ,Δ), length))`, `Δ = Sub(price, Delay(price,1))`, scaled +> `k·σ` via the existing `LinComb` (k a param weight). `Sqrt` is mandatory +> (variance is price², `stop_distance` must be price so R is dimensionless); +> `Abs` is **not** added (Δ² not |Δ|); +> - this `vol_stop(length, k) → Composite` lives in `aura-engine` (composites need +> `GraphBuilder`); the fused `VolStop` node is removed; +> - `FixedStop` is **kept** as a legitimate primitive (a param constant gated on +> its price input — a source-less `Const` has no firing trigger in the push model). +> +> The block below is the superseded pre-correction shape, retained for ancestry. ```rust -// VolStop: one fused node (no Abs/Mul primitive exists, so abs+delta+EMA must fuse). +// SUPERSEDED (see CORRECTION above): the fused-node shape, not the shipped design. pub struct VolStop { length: usize, k: f64, prev_price: Option, ema: f64, comp: f64, count: usize, out: [Cell; 1] } // eval: d = |price - prev_price|; ema = EMA_length(d) (Kahan, like Sma); out = k * ema. -// None until warm (count < length). Input "price" (f64, Any). Output "stop_distance" (f64). -// Params length:i64, k:f64. prev_price updated AFTER use (intra-node z⁻¹, C2-clean). -pub struct FixedStop { distance: f64, out: [Cell; 1] } // out = distance, constant; test fixture + axis sibling +pub struct FixedStop { distance: f64, out: [Cell; 1] } // KEPT: out = distance, constant (a triggered-constant primitive) ``` **(c) `PositionManagement` node (#127) — new, `aura-std`. The dense R-record.**