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
16 KiB
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
implementskill 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— addmod mul; mod sqrt;+pub use; dropVolStopfrom thestop_rulere-export. - Modify:
crates/aura-std/src/stop_rule.rs— remove the fusedVolStopstruct/impl/tests; keepFixedStop. - Create:
crates/aura-engine/tests/vol_stop_composite.rs— thevol_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(mirrorsub.rs, with*) + RED tests
//! `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"]);
}
}
- 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
//! `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);
}
}
- 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) + 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: Migrate the
stage1_r_e2e.rscaller offVolStop(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};(dropVolStop). - in
r_is_stop_defined_two_stops_fold_to_different_expectancy, replace the lines fromlet mut vol = VolStop::new(1, 1.0);through the finalassert!with:
// 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
VolStopfromstop_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 3: Drop the
VolStopre-export
In crates/aura-std/src/lib.rs: change pub use stop_rule::{FixedStop, VolStop}; to
pub use stop_rule::FixedStop;.
- Step 4: Build the workspace 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).
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.
//! 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<Scalar>)> = 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 fusedVolStop(Task 3), express the vol stop as a composition (Task 4).Absdeliberately not added (Δ² not |Δ|);FixedStopkept. - 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:
VolStopIS referenced bystage1_r_e2e.rs(VolStop::new(1, 1.0)), so Task 3 threads all three references —stop_rule.rs, thelib.rsre-export, AND the E2E caller (migrated to a tightFixedStopin 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_compositematch the new/kept named tests; the removal gate usescargo build --workspace+ the unfiltered suite.