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
This commit is contained in:
@@ -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<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**
|
||||
|
||||
```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<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`)
|
||||
|
||||
**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<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:** 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.
|
||||
@@ -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<f64>, 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.**
|
||||
|
||||
Reference in New Issue
Block a user