feat(aura-std): add Mul + Sqrt primitives

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
This commit is contained in:
2026-06-24 00:53:28 +02:00
parent 5de8576fd0
commit 831092841e
4 changed files with 198 additions and 12 deletions
+4
View File
@@ -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;
+73
View File
@@ -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<usize> { 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<String> = Mul::builder().schema().inputs.iter().map(|p| p.name.clone()).collect();
assert_eq!(names, ["lhs", "rhs"]);
}
}
+76
View File
@@ -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<usize> { 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);
}
}
+45 -12
View File
@@ -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 = (exitentry)/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.