From 44e3a1a6786924ab6ba74cb9f7bf07802fc9562a Mon Sep 17 00:00:00 2001 From: Brummel Date: Sun, 21 Jun 2026 10:55:20 +0200 Subject: [PATCH] plan: 0059 generic portable sweep key + bool-param demo strategy Decomposes spec 0059 into 5 tasks: (1) engine SweepBinder::varying_axes accessor; (2) aura-std LongOnly bool-param node; (3) the portable member_key renderer replacing sweep_member_key (+ sweep_family refactor + the SMA sweep-trace assertion update); (4) the momentum strategy + --strategy selector (+ the momentum integration test); (5) ledger amendment + full-suite/lint gate. Each task is RED-first and builds green per task. refs #105 --- docs/plans/0059-generic-portable-sweep-key.md | 875 ++++++++++++++++++ 1 file changed, 875 insertions(+) create mode 100644 docs/plans/0059-generic-portable-sweep-key.md diff --git a/docs/plans/0059-generic-portable-sweep-key.md b/docs/plans/0059-generic-portable-sweep-key.md new file mode 100644 index 0000000..49e892e --- /dev/null +++ b/docs/plans/0059-generic-portable-sweep-key.md @@ -0,0 +1,875 @@ +# Generic portable sweep member-key + bool-param demo strategy — Implementation Plan + +> **Parent spec:** `docs/specs/0059-generic-portable-sweep-key.md` +> +> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run +> this plan. Steps use `- [ ]` checkboxes for tracking. + +**Goal:** Replace the two-hardcoded-axis `sweep_member_key` with a generic, +filesystem-portable, collision-free member key derived from the *varying* axes, +and prove it generic with a new `momentum` demo strategy whose first-ever bool +*param* node (`LongOnly`) is swept via `aura sweep --strategy `. + +**Architecture:** Engine gains one additive read accessor +(`SweepBinder::varying_axes`). aura-std gains the `LongOnly` node. aura-cli +replaces the key with a portable renderer (`sanitize_component` / `render_value` +/ `fnv1a64` / `member_key`), adds the `momentum` strategy + composite, and a +`parse_sweep_args` selector. The ledger's stale sweep-key clause is amended. + +**Tech Stack:** Rust workspace — `aura-engine` (blueprint.rs), `aura-std` +(longonly.rs, lib.rs), `aura-cli` (main.rs, tests/cli_run.rs), `docs/design`. + +--- + +**Files this plan creates or modifies:** + +- Modify: `crates/aura-engine/src/blueprint.rs:384-404` — add `SweepBinder::varying_axes` +- Test: `crates/aura-engine/src/blueprint.rs` (tests mod) — varying-axes accessor +- Create: `crates/aura-std/src/longonly.rs` — the `LongOnly` bool-param node +- Modify: `crates/aura-std/src/lib.rs:18-47` — register `mod longonly` + `pub use` +- Modify: `crates/aura-cli/src/main.rs:29` — add `use std::collections::HashSet;` +- Modify: `crates/aura-cli/src/main.rs:28` — add `LongOnly` to the aura-std import +- Modify: `crates/aura-cli/src/main.rs:177-196` — replace `sweep_member_key` with the portable renderer +- Modify: `crates/aura-cli/src/main.rs:469-502` — refactor `sweep_family` to the generic key +- Modify: `crates/aura-cli/src/main.rs` — add `momentum_blueprint_with_sinks`, `momentum_sweep_family`, `Strategy`, `parse_sweep_args`; change `run_sweep` +- Modify: `crates/aura-cli/src/main.rs:968-969` — USAGE +- Modify: `crates/aura-cli/src/main.rs:993-995` — sweep dispatch arms → one parse arm +- Modify: `crates/aura-cli/src/main.rs:1407-1427` — replace the 2 `sweep_member_key_*` tests with the `member_key` corpus +- Modify: `crates/aura-cli/tests/cli_run.rs:717-747` — update the SMA sweep-key assertion + prose; add the momentum integration test +- Modify: `docs/design/INDEX.md:1033` — amend the sweep-key clause + +--- + +### Task 1: Engine — `SweepBinder::varying_axes` + +**Files:** +- Modify: `crates/aura-engine/src/blueprint.rs:384-404` (inside `impl SweepBinder`) +- Test: `crates/aura-engine/src/blueprint.rs` (tests mod) + +- [ ] **Step 1: Write the failing test** + +Add to the `#[cfg(test)] mod tests` in `crates/aura-engine/src/blueprint.rs`. It +discovers the fixture's param names (so it pins behaviour, not specific names), +varies the first axis and pins the rest, and asserts only the first is reported. +If `composite_sma_cross_harness` is not already imported in that test module, add +`use crate::test_fixtures::composite_sma_cross_harness;` to the test module. + +```rust +#[test] +fn varying_axes_names_only_the_multi_value_axes() { + let bp = composite_sma_cross_harness().0; + let names: Vec = bp.param_space().into_iter().map(|p| p.name).collect(); + assert!(names.len() >= 3, "fixture must have >= 3 param slots: {names:?}"); + // vary the first axis (>1 value), pin the other two (1 value each). + let binder = bp + .axis(names[0].as_str(), vec![Scalar::i64(2), Scalar::i64(3)]) + .axis(names[1].as_str(), vec![Scalar::i64(4)]) + .axis(names[2].as_str(), vec![Scalar::f64(0.5)]); + assert_eq!(binder.varying_axes(), vec![names[0].clone()]); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p aura-engine varying_axes_names_only_the_multi_value_axes` +Expected: FAIL — compile error `no method named \`varying_axes\` found for struct \`SweepBinder\``. + +- [ ] **Step 3: Write minimal implementation** + +In `crates/aura-engine/src/blueprint.rs`, add this method to `impl SweepBinder` +(immediately after the `axis` method, before `sweep`): + +```rust + /// The names of the axes that vary (more than one value) — the axes that + /// distinguish one grid point from another. Declaration order; callers derive + /// set membership (the key's token order comes from param-space slot order, + /// not from this list). A pure read accessor; it runs no sweep. + pub fn varying_axes(&self) -> Vec { + self.axes.iter().filter(|(_, v)| v.len() > 1).map(|(n, _)| n.clone()).collect() + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test -p aura-engine varying_axes_names_only_the_multi_value_axes` +Expected: PASS (1 passed). + +- [ ] **Step 5: Engine suite stays green** + +Run: `cargo test -p aura-engine` +Expected: PASS — all engine tests, no regressions. + +--- + +### Task 2: aura-std — the `LongOnly` bool-param node + +**Files:** +- Create: `crates/aura-std/src/longonly.rs` +- Modify: `crates/aura-std/src/lib.rs:18-47` + +- [ ] **Step 1: Create the node with its tests** + +Create `crates/aura-std/src/longonly.rs` (mirrors `exposure.rs` structurally): + +```rust +//! `LongOnly` — a long-only exposure gate. The bool param `enabled`: when true, +//! short (negative) exposure is clamped to 0 (a long-only constraint); when +//! false, exposure passes through unchanged. One f64 input, one f64 output. +//! Emits `None` until its input is present (warm-up filter, C8). + +use aura_core::{ + Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, + ScalarKind, +}; + +/// Long-only exposure gate driven by a bool param. `enabled=true` → +/// `max(exposure, 0.0)`; `enabled=false` → `exposure` unchanged. +pub struct LongOnly { + enabled: bool, + out: [Cell; 1], +} + +impl LongOnly { + /// Build a long-only gate. `enabled` toggles the short-clamp. + pub fn new(enabled: bool) -> Self { + Self { enabled, out: [Cell::from_f64(0.0)] } + } + + /// The param-generic recipe: declares the bool param `enabled` and builds + /// through `LongOnly::new` (the slice is kind-checked before `build`, so the + /// typed `p[0].bool()` read is total). First aura-std node with a bool param. + pub fn builder() -> PrimitiveBuilder { + PrimitiveBuilder::new( + "LongOnly", + NodeSchema { + inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "exposure".into() }], + output: vec![FieldSpec { name: "exposure".into(), kind: ScalarKind::F64 }], + params: vec![ParamSpec { name: "enabled".into(), kind: ScalarKind::Bool }], + }, + |p| Box::new(LongOnly::new(p[0].bool())), + ) + } +} + +impl Node for LongOnly { + fn lookbacks(&self) -> Vec { + vec![1] + } + + fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> { + let w = ctx.f64_in(0); + if w.is_empty() { + return None; // not yet warmed up (C8 filter) + } + self.out[0] = Cell::from_f64(if self.enabled { w[0].max(0.0) } else { w[0] }); + Some(&self.out) + } + + fn label(&self) -> String { + format!("LongOnly({})", self.enabled) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use aura_core::{AnyColumn, Scalar, Timestamp}; + + #[test] + fn long_only_gates_negatives_when_enabled() { + let mut g = LongOnly::new(true); + let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; + // (input exposure, expected output) for enabled=true: negatives clamp to 0. + let cases = [(0.6_f64, 0.6_f64), (-0.6, 0.0), (1.0, 1.0), (-1.0, 0.0)]; + for (sig, want) in cases { + inputs[0].push(Scalar::f64(sig)).unwrap(); + assert_eq!( + g.eval(Ctx::new(&inputs, Timestamp(0))), + Some([Cell::from_f64(want)].as_slice()) + ); + } + } + + #[test] + fn long_only_passes_through_when_disabled() { + let mut g = LongOnly::new(false); + let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; + let cases = [(0.6_f64, 0.6_f64), (-0.6, -0.6)]; + for (sig, want) in cases { + inputs[0].push(Scalar::f64(sig)).unwrap(); + assert_eq!( + g.eval(Ctx::new(&inputs, Timestamp(0))), + Some([Cell::from_f64(want)].as_slice()) + ); + } + } + + #[test] + fn long_only_is_none_until_input_present() { + let mut g = LongOnly::new(true); + let inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; + assert_eq!(g.eval(Ctx::new(&inputs, Timestamp(0))), None); + } + + #[test] + fn long_only_param_is_a_bool_named_enabled() { + let schema = LongOnly::builder().schema(); + assert_eq!(schema.params.len(), 1); + assert_eq!(schema.params[0].name, "enabled"); + assert_eq!(schema.params[0].kind, ScalarKind::Bool); + } +} +``` + +- [ ] **Step 2: Register the module + export** + +In `crates/aura-std/src/lib.rs`, add `mod longonly;` between `mod latch;` (L25) +and `mod lincomb;` (L26), and `pub use longonly::LongOnly;` between +`pub use latch::Latch;` (L40) and `pub use lincomb::LinComb;` (L41). + +- [ ] **Step 3: Run the node tests to verify they pass** + +Run: `cargo test -p aura-std longonly` +Expected: PASS — 4 tests (`long_only_gates_negatives_when_enabled`, +`long_only_passes_through_when_disabled`, `long_only_is_none_until_input_present`, +`long_only_param_is_a_bool_named_enabled`). + +- [ ] **Step 4: aura-std suite stays green** + +Run: `cargo test -p aura-std` +Expected: PASS — all aura-std tests, no regressions. + +--- + +### Task 3: CLI — the generic portable member key + +**Files:** +- Modify: `crates/aura-cli/src/main.rs:29` (HashSet import), `:177-196` (key fns), + `:469-502` (sweep_family), `:1407-1427` (unit tests) +- Modify: `crates/aura-cli/tests/cli_run.rs:717-747` (SMA key assertion + prose), + `:793-794` (prose) + +- [ ] **Step 1: Write the failing member_key unit tests** + +In `crates/aura-cli/src/main.rs`, replace the two `sweep_member_key_*` unit tests +at lines 1407-1427 (the whole block from the `/// The happy path…`-commented test +through the `#[should_panic…]` test) with this corpus. The helper `pair` and the +`use std::collections::HashSet;` are needed; the tests reference `member_key`, +`MAX_KEY`, `sanitize_component` (all added in Step 3): + +```rust + fn pair(name: &str, v: Scalar) -> (String, Scalar) { + (name.to_string(), v) + } + + #[test] + fn member_key_renders_varying_axes_portably() { + let named = vec![ + pair("ema.length", Scalar::i64(5)), + pair("exposure.scale", Scalar::f64(0.5)), + pair("longonly.enabled", Scalar::bool(true)), + ]; + let varying: std::collections::HashSet = + named.iter().map(|(n, _)| n.clone()).collect(); + let key = member_key(&named, &varying); + assert_eq!(key, "ema.length-5_exposure.scale-0.5_longonly.enabled-true"); + assert!(key.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))); + } + + #[test] + fn member_key_omits_pinned_axes() { + let named = vec![ + pair("ema.length", Scalar::i64(5)), + pair("exposure.scale", Scalar::f64(0.5)), + pair("longonly.enabled", Scalar::bool(false)), + ]; + let mut varying = std::collections::HashSet::new(); + varying.insert("longonly.enabled".to_string()); + assert_eq!(member_key(&named, &varying), "longonly.enabled-false"); + } + + #[test] + fn member_key_handles_negative_float_and_sanitises_names() { + let named = vec![pair("exposure.scale", Scalar::f64(-0.5))]; + let varying: std::collections::HashSet = + ["exposure.scale".to_string()].into_iter().collect(); + assert_eq!(member_key(&named, &varying), "exposure.scale--0.5"); + + let named2 = vec![pair("weird key!", Scalar::f64(1.0))]; + let varying2: std::collections::HashSet = + ["weird key!".to_string()].into_iter().collect(); + assert_eq!(member_key(&named2, &varying2), "weird_key_-1"); + } + + #[test] + fn member_key_is_m_when_no_axis_varies() { + let named = vec![pair("exposure.scale", Scalar::f64(0.5))]; + let varying = std::collections::HashSet::new(); + assert_eq!(member_key(&named, &varying), "m"); + } + + #[test] + fn member_key_caps_length_with_conformant_hash_fallback() { + let named: Vec<(String, Scalar)> = (0..40) + .map(|i| pair(&format!("some.long.axis.path.number.{i}"), Scalar::i64(i))) + .collect(); + let varying: std::collections::HashSet = + named.iter().map(|(n, _)| n.clone()).collect(); + let key = member_key(&named, &varying); + assert!(key.len() <= MAX_KEY, "over-cap key not bounded: {} bytes", key.len()); + assert!(key.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))); + let mut named2 = named.clone(); + named2[0].1 = Scalar::i64(999); + assert_ne!(key, member_key(&named2, &varying), "distinct over-cap inputs must differ"); + } + + #[test] + fn member_key_collision_free_over_a_non_trend_axis_set() { + // #105 regression: vary exposure.scale + a bool (NOT the old hardcoded + // trend.fast/slow). Distinct points -> distinct keys; the old f…s… key + // would have collapsed them all to one dir. + let varying: std::collections::HashSet = + ["exposure.scale".to_string(), "longonly.enabled".to_string()].into_iter().collect(); + let p = |s: f64, b: bool| { + vec![ + pair("ema.length", Scalar::i64(5)), // pinned -> omitted from key + pair("exposure.scale", Scalar::f64(s)), + pair("longonly.enabled", Scalar::bool(b)), + ] + }; + let keys: Vec = [(0.5, true), (0.5, false), (1.0, true), (1.0, false)] + .iter() + .map(|&(s, b)| member_key(&p(s, b), &varying)) + .collect(); + let unique: std::collections::HashSet<&String> = keys.iter().collect(); + assert_eq!(unique.len(), 4, "distinct points must yield distinct keys: {keys:?}"); + } +``` + +- [ ] **Step 2: Run the new tests to verify they fail** + +Run: `cargo test -p aura-cli --bin aura member_key` +Expected: FAIL — compile error `cannot find function \`member_key\`` (and `MAX_KEY`). + +- [ ] **Step 3: Add the portable key renderer; remove `sweep_member_key`** + +In `crates/aura-cli/src/main.rs`, add the import after line 29 +(`use std::sync::mpsc::{self, Receiver};`): + +```rust +use std::collections::HashSet; +``` + +Replace the `sweep_member_key` function and its doc comment (lines 177-196) with: + +```rust +/// The maximum length (bytes) of an on-disk member-key path component. Comfortably +/// under the 255-byte POSIX `NAME_MAX` and inside Windows' 260-char `MAX_PATH` +/// once the `runs/traces//` prefix and `/.json` suffix are added. +const MAX_KEY: usize = 200; + +/// Map any byte outside the portable directory-name charset `[A-Za-z0-9._-]` to +/// `_`. The single source of filesystem-portability for an on-disk path component +/// (valid on Linux / Windows / macOS, also URL-path- and cloud-sync-safe). +fn sanitize_component(s: &str) -> String { + s.chars() + .map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { c } else { '_' }) + .collect() +} + +/// Render a scalar value case-lessly: integers/timestamps as decimal digits, bool +/// as `true`/`false`, f64 via Rust's `Display` (decimal, shortest round-trip, NO +/// scientific notation). Case-less rendering is what keeps two members of one +/// family from ever differing only by letter case (case-insensitive-FS safety). +fn render_value(v: &Scalar) -> String { + match v { + Scalar::I64(n) => n.to_string(), + Scalar::F64(x) => x.to_string(), + Scalar::Bool(b) => b.to_string(), + Scalar::Timestamp(t) => t.0.to_string(), + } +} + +/// FNV-1a-64 over `bytes` — a fixed, version-stable, non-cryptographic hash used +/// only as the over-cap member-key disambiguator (`std`'s `DefaultHasher` is +/// explicitly unstable across releases, so it cannot name an on-disk artefact). +fn fnv1a64(bytes: &[u8]) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for &b in bytes { + h ^= b as u64; + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + h +} + +/// The portable, collision-free member key for a swept grid point: one `name-value` +/// token per *varying* axis (in `named`'s param-space slot order), joined by `_`, +/// every token sanitised to the portable charset. Pinned (singleton) axes carry no +/// information and are omitted. A 1-point grid (no varying axis) keys as `"m"`. A +/// key over `MAX_KEY` degrades to a conformant `h-<16-hex>` FNV fallback so the +/// key is one valid path component for ANY grid (the #105 generalisation). +fn member_key(named: &[(String, Scalar)], varying: &HashSet) -> String { + let key: String = named + .iter() + .filter(|(n, _)| varying.contains(n)) + .map(|(n, v)| format!("{}-{}", sanitize_component(n), sanitize_component(&render_value(v)))) + .collect::>() + .join("_"); + let key = if key.is_empty() { "m".to_string() } else { key }; + if key.len() <= MAX_KEY { key } else { format!("h-{:016x}", fnv1a64(key.as_bytes())) } +} +``` + +- [ ] **Step 4: Refactor `sweep_family` to the generic key** + +In `crates/aura-cli/src/main.rs`, replace the body of `sweep_family` +(lines 469-502) with the version that captures the binder's varying axes and +keys via `member_key` (the `.axis(…)` chain values are unchanged): + +```rust +fn sweep_family(trace: Option<&str>) -> SweepFamily { + let bp = sample_blueprint_with_sinks().0; + let space = bp.param_space(); + let binder = bp + .axis("signals.trend.fast.length", [2, 3]) + .axis("signals.trend.slow.length", [4, 5]) + .axis("signals.momentum.fast.length", [2]) + .axis("signals.momentum.slow.length", [4]) + .axis("signals.momentum.signal.length", [3]) + .axis("signals.blend.weights[0]", [1.0]) + .axis("signals.blend.weights[1]", [1.0]) + .axis("exposure.scale", [0.5]); + let varying: HashSet = binder.varying_axes().into_iter().collect(); + binder + .sweep(|point| { + let (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks(); + let mut h = bp + .bootstrap_with_cells(point) + .expect("grid points are kind-checked against param_space"); + let sources: Vec> = + vec![Box::new(VecSource::new(showcase_prices()))]; + let window = window_of(&sources).expect("non-empty showcase stream"); + h.run(sources); + let eq_rows = rx_eq.try_iter().collect::>(); + let ex_rows = rx_ex.try_iter().collect::>(); + let named = zip_params(&space, point); + let key = member_key(&named, &varying); + let manifest = sim_optimal_manifest(named, window, 0, SYNTHETIC_PIP_SIZE); + if let Some(name) = trace { + persist_traces(&format!("{name}/{key}"), &manifest, &eq_rows, &ex_rows); + } + let equity = f64_field(&eq_rows, 0); + let exposure = f64_field(&ex_rows, 0); + RunReport { manifest, metrics: summarize(&equity, &exposure) } + }) + .expect("the built-in named grid matches the sample param-space") +} +``` + +- [ ] **Step 5: Run the member_key tests to verify they pass** + +Run: `cargo test -p aura-cli --bin aura member_key` +Expected: PASS — 6 tests (`member_key_renders_varying_axes_portably`, +`member_key_omits_pinned_axes`, `member_key_handles_negative_float_and_sanitises_names`, +`member_key_is_m_when_no_axis_varies`, `member_key_caps_length_with_conformant_hash_fallback`, +`member_key_collision_free_over_a_non_trend_axis_set`). + +- [ ] **Step 6: Update the SMA sweep-trace integration assertion + prose** + +In `crates/aura-cli/tests/cli_run.rs`, in `sweep_trace_persists_a_member_dir_per_grid_point`, +replace the literal array on line 734: + +```rust + for key in ["f2s4", "f2s5", "f3s4", "f3s5"] { +``` + +with the new portable keys (the varying axes are `signals.trend.fast.length` ∈ +{2,3} and `signals.trend.slow.length` ∈ {4,5}): + +```rust + for key in [ + "signals.trend.fast.length-2_signals.trend.slow.length-4", + "signals.trend.fast.length-2_signals.trend.slow.length-5", + "signals.trend.fast.length-3_signals.trend.slow.length-4", + "signals.trend.fast.length-3_signals.trend.slow.length-5", + ] { +``` + +Update the stale prose: on line 719 replace `content-keyed f2s4/f2s5/f3s4/f3s5` +with `content-keyed by the varying axes (signals.trend.fast.length / +signals.trend.slow.length)`; on line 794 replace `which member lands in \`f2s4\` +vs \`f3s5\`` with `which member lands in which content-keyed dir`. + +- [ ] **Step 7: Run the affected integration tests** + +Run: `cargo test -p aura-cli --test cli_run sweep_trace` +Expected: PASS — `sweep_trace_persists_a_member_dir_per_grid_point` and +`sweep_trace_is_byte_deterministic_across_runs` both green with the new keys. + +- [ ] **Step 8: Workspace build stays green** + +Run: `cargo build --workspace` +Expected: build succeeds (`sweep_member_key` fully removed, no dangling reference). + +--- + +### Task 4: CLI — the `momentum` strategy + `--strategy` selector + +**Files:** +- Modify: `crates/aura-cli/src/main.rs:28` (import), add `momentum_*`, `Strategy`, + `parse_sweep_args`, change `run_sweep` (`:527-540`), USAGE (`:968-969`), + dispatch (`:993-995`), add unit tests +- Modify: `crates/aura-cli/tests/cli_run.rs` — add the momentum integration test + +- [ ] **Step 1: Write the failing momentum + parse unit tests** + +In `crates/aura-cli/src/main.rs` tests mod, add: + +```rust + #[test] + fn momentum_param_space_is_ema_exposure_longonly() { + // pins the default node-name path segments (ema / exposure / longonly) and + // the param order/kinds the member key + sweep depend on. + let names: Vec = + momentum_blueprint_with_sinks().0.param_space().into_iter().map(|p| p.name).collect(); + assert_eq!( + names, + vec![ + "ema.length".to_string(), + "exposure.scale".to_string(), + "longonly.enabled".to_string(), + ], + ); + } + + #[test] + fn momentum_sweep_is_deterministic_and_has_eight_points() { + let a = momentum_sweep_family(None); + let b = momentum_sweep_family(None); + assert_eq!(a.points.len(), 8, "2x2x2 grid = 8 points"); + assert_eq!(a, b, "C1: the momentum family is a pure function of the build"); + } + + #[test] + fn parse_sweep_args_defaults_selects_and_rejects() { + assert_eq!(parse_sweep_args(&[]), Ok((Strategy::SmaCross, "sweep".to_string(), false))); + assert_eq!(parse_sweep_args(&["--trace", "swp"]), Ok((Strategy::SmaCross, "swp".to_string(), true))); + assert_eq!(parse_sweep_args(&["--name", "s"]), Ok((Strategy::SmaCross, "s".to_string(), false))); + assert_eq!( + parse_sweep_args(&["--strategy", "momentum", "--trace", "mom"]), + Ok((Strategy::Momentum, "mom".to_string(), true)) + ); + assert!(parse_sweep_args(&["--strategy", "bogus"]).is_err()); + assert!(parse_sweep_args(&["--name", "a", "--trace", "b"]).is_err()); // mutually exclusive + assert!(parse_sweep_args(&["--trace"]).is_err()); // flag missing its value + } +``` + +- [ ] **Step 2: Run the new tests to verify they fail** + +Run: `cargo test -p aura-cli --bin aura momentum_param_space_is_ema_exposure_longonly` +Expected: FAIL — compile error `cannot find function \`momentum_blueprint_with_sinks\``. + +- [ ] **Step 3: Add `LongOnly` to the aura-std import** + +In `crates/aura-cli/src/main.rs` line 28, change: + +```rust +use aura_std::{Ema, Exposure, LinComb, Recorder, SimBroker, Sma, Sub}; +``` +to: +```rust +use aura_std::{Ema, Exposure, LinComb, LongOnly, Recorder, SimBroker, Sma, Sub}; +``` + +- [ ] **Step 4: Add the momentum blueprint + sweep family** + +In `crates/aura-cli/src/main.rs`, add (e.g. after `sweep_family`, before +`sweep_report`): + +```rust +/// The EMA-distance momentum demo strategy with its two recording sinks reachable. +/// The signal is how far price sits above/below its own EMA (`price - ema`), sized +/// by `Exposure`, then passed through the long-only `LongOnly` gate. Three swept +/// knobs of three kinds — `ema.length` (i64), `exposure.scale` (f64), +/// `longonly.enabled` (bool) — with nothing in common with the SMA-cross demo: the +/// generic-sweep proof. The exposure sink taps the FINAL (gated) exposure so the +/// bool's effect is visible in the trace. +#[allow(clippy::type_complexity)] +fn momentum_blueprint_with_sinks() -> ( + Composite, + Receiver<(Timestamp, Vec)>, + Receiver<(Timestamp, Vec)>, +) { + let (tx_eq, rx_eq) = mpsc::channel(); + let (tx_ex, rx_ex) = mpsc::channel(); + let mut g = GraphBuilder::new("momentum"); + // Name the param-bearing nodes explicitly so the swept paths are guaranteed + // ema.length / exposure.scale / longonly.enabled regardless of default-name + // derivation (the member-key examples + the param-space test depend on these). + let ema = g.add(Ema::builder().named("ema")); // ema.length + let dist = g.add(Sub::builder()); // momentum = price - ema + let expo = g.add(Exposure::builder().named("exposure")); // exposure.scale + let gate = g.add(LongOnly::builder().named("longonly")); // longonly.enabled (the bool param) + let broker = g.add(SimBroker::builder(SYNTHETIC_PIP_SIZE)); + let eq = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq)); + let ex = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex)); + let price = g.source_role("price", ScalarKind::F64); + g.feed(price, [ema.input("series"), dist.input("lhs"), broker.input("price")]); + g.connect(ema.output("value"), dist.input("rhs")); // momentum = price - ema + g.connect(dist.output("value"), expo.input("signal")); + g.connect(expo.output("exposure"), gate.input("exposure")); + g.connect(gate.output("exposure"), broker.input("exposure")); // gated exposure -> broker + g.connect(broker.output("equity"), eq.input("col[0]")); // equity -> sink + g.connect(gate.output("exposure"), ex.input("col[0]")); // gated exposure -> sink + let bp = g.build().expect("momentum blueprint wiring resolves"); + (bp, rx_eq, rx_ex) +} + +/// Run the momentum strategy over its built-in grid — `ema.length ∈ {5,10}` × +/// `exposure.scale ∈ {0.5,1.0}` × `longonly.enabled ∈ {true,false}` = 8 points, +/// all three axes varying. Mirrors `sweep_family`: capture the binder's varying +/// axes, key each member via the generic portable `member_key`. With `--trace`, +/// persist each member under `runs/traces///`. +fn momentum_sweep_family(trace: Option<&str>) -> SweepFamily { + let bp = momentum_blueprint_with_sinks().0; + let space = bp.param_space(); + let binder = bp + .axis("ema.length", [5, 10]) + .axis("exposure.scale", [0.5, 1.0]) + .axis("longonly.enabled", [true, false]); + let varying: HashSet = binder.varying_axes().into_iter().collect(); + binder + .sweep(|point| { + let (bp, rx_eq, rx_ex) = momentum_blueprint_with_sinks(); + let mut h = bp + .bootstrap_with_cells(point) + .expect("grid points are kind-checked against param_space"); + let sources: Vec> = + vec![Box::new(VecSource::new(showcase_prices()))]; + let window = window_of(&sources).expect("non-empty showcase stream"); + h.run(sources); + let eq_rows = rx_eq.try_iter().collect::>(); + let ex_rows = rx_ex.try_iter().collect::>(); + let named = zip_params(&space, point); + let key = member_key(&named, &varying); + let manifest = sim_optimal_manifest(named, window, 0, SYNTHETIC_PIP_SIZE); + if let Some(name) = trace { + persist_traces(&format!("{name}/{key}"), &manifest, &eq_rows, &ex_rows); + } + let equity = f64_field(&eq_rows, 0); + let exposure = f64_field(&ex_rows, 0); + RunReport { manifest, metrics: summarize(&equity, &exposure) } + }) + .expect("the momentum named grid matches the momentum param-space") +} +``` + +- [ ] **Step 5: Add the `Strategy` enum + `parse_sweep_args`; change `run_sweep`** + +In `crates/aura-cli/src/main.rs`, add near `run_sweep` (before it): + +```rust +/// Which built-in strategy `aura sweep` runs. Default (today's behaviour) is the +/// SMA-cross sample; `momentum` is the bool-param demo. +#[derive(Clone, Copy, PartialEq, Debug)] +enum Strategy { + SmaCross, + Momentum, +} + +/// Parse the `sweep` tail: `[--strategy ] [--name | --trace ]`. +/// Defaults: SMA-cross, name "sweep", no persist (today's bare `aura sweep`). +/// `--name` and `--trace` are mutually exclusive. Pure (no I/O / exit) so the +/// grammar is unit-testable; `main` does the side effects. An unknown token, a +/// flag without its value, an unknown strategy, or both name flags rejects. +fn parse_sweep_args(rest: &[&str]) -> Result<(Strategy, String, bool), String> { + let usage = || "sweep [--strategy ] [--name | --trace ]".to_string(); + let mut strategy = Strategy::SmaCross; + let mut name: Option<(String, bool)> = None; // (name, persist) + let mut tail = rest; + while let Some((flag, t)) = tail.split_first() { + let (value, t) = t.split_first().ok_or_else(usage)?; + match *flag { + "--strategy" => { + strategy = match *value { + "sma" => Strategy::SmaCross, + "momentum" => Strategy::Momentum, + _ => return Err(usage()), + }; + } + "--name" if name.is_none() => name = Some(((*value).to_string(), false)), + "--trace" if name.is_none() => name = Some(((*value).to_string(), true)), + _ => return Err(usage()), + } + tail = t; + } + let (name, persist) = name.unwrap_or_else(|| ("sweep".to_string(), false)); + Ok((strategy, name, persist)) +} +``` + +Then change `run_sweep` (lines 527-540) to dispatch on the strategy. Replace its +signature and the family-construction line: + +```rust +fn run_sweep(strategy: Strategy, name: &str, persist: bool) { + let reg = default_registry(); + let family = match strategy { + Strategy::SmaCross => sweep_family(persist.then_some(name)), + Strategy::Momentum => momentum_sweep_family(persist.then_some(name)), + }; + let id = match reg.append_family(name, FamilyKind::Sweep, &sweep_member_reports(&family)) { + Ok(id) => id, + Err(e) => { + eprintln!("aura: {e}"); + std::process::exit(2); + } + }; + for pt in &family.points { + println!("{}", serde_json::json!({ "family_id": id, "report": pt.report })); + } +} +``` + +- [ ] **Step 6: Update USAGE and the dispatch arms** + +In `crates/aura-cli/src/main.rs`, in the `USAGE` const (lines 968-969), replace +the token `aura sweep [--name |--trace ]` with +`aura sweep [--strategy ] [--name |--trace ]`. + +Replace the three `["sweep", …]` dispatch arms (lines 993-995): + +```rust + ["sweep"] => run_sweep("sweep", false), + ["sweep", "--name", n] => run_sweep(n, false), + ["sweep", "--trace", n] => run_sweep(n, true), +``` +with the single parse arm: +```rust + ["sweep", rest @ ..] => match parse_sweep_args(rest) { + Ok((strategy, name, persist)) => run_sweep(strategy, &name, persist), + Err(msg) => { + eprintln!("aura: {msg}"); + std::process::exit(2); + } + }, +``` + +- [ ] **Step 7: Run the unit tests to verify they pass** + +Run: `cargo test -p aura-cli --bin aura parse_sweep_args_defaults_selects_and_rejects` +Expected: PASS. + +Run: `cargo test -p aura-cli --bin aura momentum_param_space_is_ema_exposure_longonly` +Expected: PASS (confirms the default node names resolve to ema.length / +exposure.scale / longonly.enabled). + +Run: `cargo test -p aura-cli --bin aura momentum_sweep_is_deterministic_and_has_eight_points` +Expected: PASS. + +- [ ] **Step 8: Add the momentum integration test** + +In `crates/aura-cli/tests/cli_run.rs`, add after +`sweep_trace_is_byte_deterministic_across_runs` (after line 834): + +```rust +/// Property (#105 generic key, the bool-param proof): `aura sweep --strategy +/// momentum --trace ` sweeps the momentum strategy — a totally different +/// param surface incl. a bool — and persists one portable member dir per grid +/// point. Every member-dir name is filesystem-conformant (`[A-Za-z0-9._-]`), the +/// bool axis shows up (both `longonly.enabled-true` and `-false`), and one member +/// is chartable. +#[test] +fn momentum_sweep_trace_persists_portable_member_dirs_with_the_bool() { + let cwd = temp_cwd("momentum-trace"); + + let traced = Command::new(BIN) + .args(["sweep", "--strategy", "momentum", "--trace", "mom"]) + .current_dir(&cwd) + .output() + .expect("spawn aura sweep --strategy momentum --trace"); + assert!( + traced.status.success(), + "momentum sweep exit: {:?}; stderr: {}", + traced.status, + String::from_utf8_lossy(&traced.stderr) + ); + + let base = cwd.join("runs/traces/mom"); + let members: Vec = std::fs::read_dir(&base) + .expect("read mom trace dir") + .map(|e| e.expect("dir entry").file_name().to_string_lossy().into_owned()) + .collect(); + assert_eq!(members.len(), 8, "2x2x2 momentum grid = 8 member dirs; got {members:?}"); + for m in &members { + assert!( + m.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')), + "non-portable member dir name: {m}" + ); + } + assert!(members.iter().any(|m| m.contains("longonly.enabled-true")), "missing bool=true: {members:?}"); + assert!(members.iter().any(|m| m.contains("longonly.enabled-false")), "missing bool=false: {members:?}"); + + // one member is chartable (the bool sits inside the dir name passed through). + let one = &members[0]; + let chart = Command::new(BIN) + .args(["chart", &format!("mom/{one}")]) + .current_dir(&cwd) + .output() + .expect("spawn aura chart mom/"); + assert!(chart.status.success(), "chart exit: {:?}", chart.status); + assert!( + String::from_utf8_lossy(&chart.stdout).contains("uPlot"), + "expected a self-contained uPlot page" + ); + + let _ = std::fs::remove_dir_all(&cwd); +} +``` + +- [ ] **Step 9: Run the momentum integration test** + +Run: `cargo test -p aura-cli --test cli_run momentum_sweep_trace` +Expected: PASS — 8 conformant member dirs incl. both bool values, chart renders. + +- [ ] **Step 10: Workspace build + lint stay green** + +Run: `cargo build --workspace` +Expected: build succeeds. + +Run: `cargo clippy --workspace --all-targets -- -D warnings` +Expected: no warnings. + +--- + +### Task 5: Ledger amendment + full-suite gate + +**Files:** +- Modify: `docs/design/INDEX.md:1033` + +- [ ] **Step 1: Amend the stale sweep-key clause in the ledger** + +In `docs/design/INDEX.md` line 1033 (the C22/#101 realization note recording the +member-key convention), the sweep clause `sweep \`f{fast}s{slow}\`` is now stale. +Update only the sweep clause to record the generalised form — the key is derived +from the *varying* axes, rendered as a filesystem-portable directory component +(`-` tokens joined by `_`, charset `[A-Za-z0-9._-]`, +case-less values, length-capped with an FNV fallback). Leave the MC (`seed{N}`) +and walk-forward (`oos{ns}`) clauses unchanged (single-axis, already portable). +The surrounding note text (the `runs/traces///` layout) is +unchanged. Make the edit a minimal clause replacement that reads naturally in +context; do not restructure the note. + +- [ ] **Step 2: Full workspace test suite** + +Run: `cargo test --workspace` +Expected: PASS — every test across aura-core / aura-engine / aura-std / aura-cli +(bin + cli_run integration), no regressions. Confirms the SMA sweep, MC, and +walk-forward families still round-trip; the new momentum family + member_key +corpus + LongOnly node + varying_axes accessor all green. + +- [ ] **Step 3: Final lint gate** + +Run: `cargo clippy --workspace --all-targets -- -D warnings` +Expected: no warnings.