Files
Aura/docs/plans/0059-generic-portable-sweep-key.md
T
Brummel 44e3a1a678 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
2026-06-21 10:55:20 +02:00

36 KiB
Raw Blame History

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 <sma|momentum>.

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.

#[test]
fn varying_axes_names_only_the_multi_value_axes() {
    let bp = composite_sma_cross_harness().0;
    let names: Vec<String> = 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):

    /// 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<String> {
        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):

//! `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<usize> {
        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):

    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<String> =
            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<String> =
            ["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<String> =
            ["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<String> =
            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<String> =
            ["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<String> = [(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`(andMAX_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};):

use std::collections::HashSet;

Replace the sweep_member_key function and its doc comment (lines 177-196) with:

/// 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/<name>/` prefix and `/<tap>.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>) -> String {
    let key: String = named
        .iter()
        .filter(|(n, _)| varying.contains(n))
        .map(|(n, v)| format!("{}-{}", sanitize_component(n), sanitize_component(&render_value(v))))
        .collect::<Vec<_>>()
        .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):

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<String> = 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<Box<dyn aura_engine::Source>> =
                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::<Vec<_>>();
            let ex_rows = rx_ex.try_iter().collect::<Vec<_>>();
            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:

    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}):

    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`withwhich 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:

    #[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<String> =
            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:

use aura_std::{Ema, Exposure, LinComb, Recorder, SimBroker, Sma, Sub};

to:

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):

/// 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<Scalar>)>,
    Receiver<(Timestamp, Vec<Scalar>)>,
) {
    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/<name>/<member_key>/`.
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<String> = 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<Box<dyn aura_engine::Source>> =
                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::<Vec<_>>();
            let ex_rows = rx_ex.try_iter().collect::<Vec<_>>();
            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):

/// 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 <sma|momentum>] [--name <n> | --trace <n>]`.
/// 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 <sma|momentum>] [--name <n> | --trace <n>]".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:

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 <n>|--trace <n>] with aura sweep [--strategy <sma|momentum>] [--name <n>|--trace <n>].

Replace the three ["sweep", …] dispatch arms (lines 993-995):

        ["sweep"] => run_sweep("sweep", false),
        ["sweep", "--name", n] => run_sweep(n, false),
        ["sweep", "--trace", n] => run_sweep(n, true),

with the single parse arm:

        ["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):

/// Property (#105 generic key, the bool-param proof): `aura sweep --strategy
/// momentum --trace <name>` 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<String> = 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/<member>");
    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//<member_key>/` 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.