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:
2026-06-24 00:37:53 +02:00
parent 2c43296c2c
commit 5de8576fd0
2 changed files with 374 additions and 5 deletions
@@ -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.
+21 -5
View File
@@ -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.**