Files
Aura/docs/plans/0066-stage1-r-primitives-volstop.md
T
Brummel 831092841e 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
2026-06-24 00:53:28 +02:00

16 KiB
Raw Blame History

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.rsMul (two-input f64 product).
  • Create: crates/aura-std/src/sqrt.rsSqrt (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

//! `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.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:
    // 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 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 VolStop re-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σ = 1stop_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 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: 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.