From 831092841e9a3c3091e366bebcf5d1ec5784acc3 Mon Sep 17 00:00:00 2001 From: Brummel Date: Wed, 24 Jun 2026 00:53:28 +0200 Subject: [PATCH] feat(aura-std): add Mul + Sqrt primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two genuinely-missing arithmetic primitives (Mul = two-stream f64 product, Sqrt = one-input f64 root, negatives clamped to 0). They are the building blocks for the volatility stop as a composition (rolling EWMA stddev), replacing the fused VolStop node. Also corrects plan 0066 Task 3 (the VolStop removal must migrate its stage1_r_e2e.rs caller — a false premise the implementer caught). refs #117 #119 --- crates/aura-std/src/lib.rs | 4 + crates/aura-std/src/mul.rs | 73 ++++++++++++++++++ crates/aura-std/src/sqrt.rs | 76 +++++++++++++++++++ .../plans/0066-stage1-r-primitives-volstop.md | 57 +++++++++++--- 4 files changed, 198 insertions(+), 12 deletions(-) create mode 100644 crates/aura-std/src/mul.rs create mode 100644 crates/aura-std/src/sqrt.rs diff --git a/crates/aura-std/src/lib.rs b/crates/aura-std/src/lib.rs index a3c2af1..06bdfd4 100644 --- a/crates/aura-std/src/lib.rs +++ b/crates/aura-std/src/lib.rs @@ -25,12 +25,14 @@ mod gt; mod latch; mod lincomb; mod longonly; +mod mul; mod position_management; mod recorder; mod resample; mod session; mod sim_broker; mod sma; +mod sqrt; mod stop_rule; mod sub; pub use add::Add; @@ -43,6 +45,7 @@ pub use gt::Gt; pub use latch::Latch; pub use lincomb::LinComb; pub use longonly::LongOnly; +pub use mul::Mul; pub use position_management::{ ExitReason, FIELD_NAMES as PM_FIELD_NAMES, PositionManagement, RECORD_KINDS as PM_RECORD_KINDS, WIDTH as PM_WIDTH, @@ -52,5 +55,6 @@ pub use resample::Resample; pub use session::Session; pub use sim_broker::SimBroker; pub use sma::Sma; +pub use sqrt::Sqrt; pub use stop_rule::{FixedStop, VolStop}; pub use sub::Sub; diff --git a/crates/aura-std/src/mul.rs b/crates/aura-std/src/mul.rs new file mode 100644 index 0000000..cac49a2 --- /dev/null +++ b/crates/aura-std/src/mul.rs @@ -0,0 +1,73 @@ +//! `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"]); + } +} diff --git a/crates/aura-std/src/sqrt.rs b/crates/aura-std/src/sqrt.rs new file mode 100644 index 0000000..bec11fd --- /dev/null +++ b/crates/aura-std/src/sqrt.rs @@ -0,0 +1,76 @@ +//! `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); + } +} diff --git a/docs/plans/0066-stage1-r-primitives-volstop.md b/docs/plans/0066-stage1-r-primitives-volstop.md index a4dd8cd..a128c4d 100644 --- a/docs/plans/0066-stage1-r-primitives-volstop.md +++ b/docs/plans/0066-stage1-r-primitives-volstop.md @@ -218,13 +218,46 @@ Add `mod sqrt;` (alphabetical, after `mod sma;` / before `mod stop_rule;`) and --- -## Task 3: Remove the fused `VolStop` node (keep `FixedStop`) +## Task 3: Remove the fused `VolStop` node (keep `FixedStop`) + migrate its one caller + +`VolStop` IS referenced outside `stop_rule.rs`/`lib.rs`: `crates/aura-engine/tests/stage1_r_e2e.rs` +imports it (line 30) and constructs `VolStop::new(1, 1.0)` in +`r_is_stop_defined_two_stops_fold_to_different_expectancy`. That caller must be migrated +in this task or the workspace build breaks. **Files:** - Modify: `crates/aura-std/src/stop_rule.rs` - Modify: `crates/aura-std/src/lib.rs` +- Modify: `crates/aura-engine/tests/stage1_r_e2e.rs` -- [ ] **Step 1: Delete `VolStop` from `stop_rule.rs`** +- [ ] **Step 1: Migrate the `stage1_r_e2e.rs` caller off `VolStop` (FIRST)** + +`run_chain` drives a `&mut dyn Node` directly, so the vol stop (now a bootstrapped +composite, Task 4) cannot slot in there. The test's property — "different stop distances +fold to different R" — holds via two CONSTANT stops (R = (exit−entry)/distance, so a +10×-tighter stop folds to a ~10× deeper R-loss). Replace the `VolStop` arm with a tight +`FixedStop`: +- line 30 import → `use aura_std::{FixedStop, PM_FIELD_NAMES, PM_RECORD_KINDS, PM_WIDTH, PositionManagement};` (drop `VolStop`). +- in `r_is_stop_defined_two_stops_fold_to_different_expectancy`, replace the lines from + `let mut vol = VolStop::new(1, 1.0);` through the final `assert!` with: + +```rust + // A TIGHT FixedStop (distance 1.0) vs the wide one (10.0): R = (exit-entry)/distance, + // so the 10x-tighter stop folds to a ~10x deeper R-loss for a comparable drop. + let mut tight = FixedStop::new(1.0); + let tight_m = run_chain(&mut tight, &long_path(&[100.0, 100.0, 96.0])); + assert!(wide.n_trades >= 1 && tight_m.n_trades >= 1); + assert!( + tight_m.expectancy_r < wide.expectancy_r, + "stop choice must change folded R: tight={:?} wide={:?}", + tight_m.expectancy_r, + wide.expectancy_r, + ); +``` +Update the test's doc-comment "tight VolStop" → "tight FixedStop". (The vol stop's +varying-distance behaviour is covered by `vol_stop_composite.rs`, Task 4.) + +- [ ] **Step 2: 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 @@ -235,18 +268,17 @@ 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** +- [ ] **Step 3: 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** +- [ ] **Step 4: Build the workspace green** -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. +Run: `cargo build --workspace 2>&1 | grep -E "error" | head` +Expected: empty. Then `cargo test --workspace 2>&1 | tail -8` +Expected: all green (incl. the migrated +`r_is_stop_defined_two_stops_fold_to_different_expectancy`). --- @@ -345,9 +377,10 @@ Run: `cargo clippy --workspace --all-targets -- -D warnings` Expected: clean. 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`). +- **Compile-gate ordering:** `VolStop` IS referenced by `stage1_r_e2e.rs` + (`VolStop::new(1, 1.0)`), so Task 3 threads all three references — `stop_rule.rs`, the + `lib.rs` re-export, AND the E2E caller (migrated to a tight `FixedStop` in Step 1, BEFORE + the deletion) — within Task 3; the workspace build gate at Step 4 is then satisfiable. - **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.