From b92aab713c3eedc525abd72b7c400b9b183f46fc Mon Sep 17 00:00:00 2001 From: Brummel Date: Sun, 21 Jun 2026 11:35:59 +0200 Subject: [PATCH] =?UTF-8?q?audit(sweep-key):=20cycle=20close=200059=20?= =?UTF-8?q?=E2=80=94=20drift-clean,=20retire=20spec/plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cycle-close tidy for the generic portable sweep member-key + bool-param demo strategy (spec/plan 0059, reference issue #105, implementations 27f850d + 0b73a75). Architect drift review (range 1d6fafb..HEAD): clean — no drift, no debt. - C1 (determinism): varying_axes + member_key are pure functions of the grid point, no schedule state; the engine carries no trace state (consistent with the #104 design + the C22/#101 ledger note). - C7/C8/C9: LongOnly is a clean one-in/one-out f64 node, None until warmed (C8 filter), the first bool *param* through the already-ratified Cell::bool() path; momentum is an ordinary composite of existing nodes; brokers stay downstream (C10). C18/C21/C22 upheld. - The engine accessor exposes the binder's OWN axis-cardinality knowledge (it holds the value-lists), not a presentation concern — formatting stays in the CLI. The hand-rolled fnv1a64 mirrors the ledger-blessed SplitMix64 precedent (harness.rs): tiny, dependency-free, bit-stable-on-purpose (std DefaultHasher is unstable across releases; on-disk-name stability is the per-case C16 reason). LongOnly is a genuine universal aura-std block (long-only is asset-agnostic). The ledger amendment matches the code. Regression: the project declares no dedicated regression command; the gate is the architect review plus the full suite, verified green at 0b73a75 (cargo test --workspace, clippy --all-targets -D warnings). Spec docs/specs/0059 and plan docs/plans/0059 retired (git rm) per the ephemeral active-cycle artefact lifecycle; the durable rationale (the portable member-key convention) lives in the ledger amendment (docs/design/INDEX.md C22/#101 note) landed in 0b73a75. closes #105 --- docs/plans/0059-generic-portable-sweep-key.md | 875 ------------------ docs/specs/0059-generic-portable-sweep-key.md | 431 --------- 2 files changed, 1306 deletions(-) delete mode 100644 docs/plans/0059-generic-portable-sweep-key.md delete mode 100644 docs/specs/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 deleted file mode 100644 index 49e892e..0000000 --- a/docs/plans/0059-generic-portable-sweep-key.md +++ /dev/null @@ -1,875 +0,0 @@ -# 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. diff --git a/docs/specs/0059-generic-portable-sweep-key.md b/docs/specs/0059-generic-portable-sweep-key.md deleted file mode 100644 index 176cf93..0000000 --- a/docs/specs/0059-generic-portable-sweep-key.md +++ /dev/null @@ -1,431 +0,0 @@ -# Generic, filesystem-portable sweep member-key + a bool-param demo strategy — Design Spec - -**Date:** 2026-06-21 -**Status:** Draft — awaiting user spec review -**Authors:** orchestrator + Claude - -> Reference issue: #105 (Brummel/Aura). Design decision log: the latest comment -> on #105 (forks: key-from-varying-axes, portable charset, demo strategy shape). - -## Goal - -Two coupled deliverables, in this order: - -1. **Generalise the family-member trace key** so it is collision-free for *any* - sweep grid, decoupled from the two hardcoded axis names — and so the on-disk - key is a **filesystem-conformant directory name on every major OS** (Linux, - Windows, macOS), including case-insensitive filesystems. -2. **A new demo strategy** with three params of three kinds — one of them a - `bool` — swept generically. It is the empirical proof that (1) is generic: the - same `aura sweep` machinery drives a strategy whose params have nothing in - common with the SMA-cross demo, and the bool axis shows up, conformant, in the - member directory name. - -The foot-gun being closed (#105): `sweep_member_key` renders `f{fast}s{slow}` -from two hardcoded axes. It is collision-free only over *today's* grid; a grid -that varies any other axis silently maps distinct points to the same directory -and overwrites traces. The fix derives the key from the axes that *actually -vary*, generically. - -## Architecture - -Three changes, smallest-blast-radius first: - -- **Engine (additive, one pure read accessor).** `SweepBinder` already - accumulates its declared axes as `Vec<(String, Vec)>` - (`blueprint.rs:379-382`). Add `SweepBinder::varying_axes(&self) -> Vec` - returning the names of axes with **> 1 value** — the axes that distinguish one - grid point from another. This is the binder's own knowledge (it holds the - value-lists); exposing it is a benign, behaviour-preserving addition. Nothing - else in the engine changes — the `sweep` HOF signature, `GridSpace`, - `SweepFamily`, and the `Fn(&[Cell]) -> RunReport + Sync` closure contract are - untouched (consistent with the #104 design: persistence stays in the CLI - closure, the engine carries no trace state). - -- **CLI (the portable key + the seam).** A new shared, pure key renderer - replaces `sweep_member_key`. The per-member closure captures the binder's - `varying_axes()` set and renders the member key from the *varying* axes of - `zip_params(&space, point)`, in param-space slot order, through a portable - sanitiser. Filesystem-portability (charset, case, length) lives here — it is a - presentation concern of the on-disk trace store, not the engine's. - -- **CLI + aura-std (the demo strategy).** A new `LongOnly` node in `aura-std` - carries the first `bool` *param*; a new `momentum` strategy composite in the - CLI wires `Ema → Sub → Exposure → LongOnly → SimBroker`; and `aura sweep` gains - a `--strategy ` selector so the new strategy is swept and traced - the same way the built-in SMA sweep is. - -### The portable key — the rules that make it conformant - -A member key is **one path component** under `runs/traces///`. It must -be valid on Linux, Windows, and macOS, and must not silently collide on -case-insensitive filesystems (NTFS / APFS default). The rules: - -- **Charset:** the rendered key contains only `[A-Za-z0-9._-]`. Every other byte - (the Windows-forbidden `<>:"/\|?*`, shell/cloud-sync-hostile `=,` `[]`, spaces, - control chars) is mapped to `_` by a single `sanitize_component` pass. This set - is also URL-path-safe (member keys appear as URL segments when served) and - survives cloud-sync clients (OneDrive/Dropbox) that reject more than the bare - filesystem does. -- **Case-less values → no case-insensitive-FS collision.** Within one family the - axis *names* are fixed (they are the grid's axes); two members differ only in - axis *values*. Values are rendered case-lessly — `i64`/`timestamp` as decimal - digits, `bool` as `true`/`false`, `f64` via Rust's `f64 Display` (decimal, with - **no** scientific notation) — so two distinct members can never differ only by - letter case. Therefore they never collapse to one directory on a - case-insensitive filesystem. -- **Collision-free by construction.** Distinct grid points differ in ≥1 varying - axis value at the same slot. Each value renders injectively per kind (Rust's - `f64 Display` is the shortest round-tripping decimal; integers/bools are - trivially injective), and the token structure (same names, same order, same - separators) is identical across a family's members — so distinct points yield - distinct keys. -- **Bounded length.** The key is capped at a fixed limit (`MAX_KEY = 200` bytes, - 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). A key within the cap is used verbatim. A key that would exceed it - degrades to a conformant, collision-resistant fallback: `h-<16-hex>` where the - hex is FNV-1a-64 of the full untruncated readable key (a fixed, version-stable, - non-cryptographic byte hash — chosen so the on-disk name never drifts across - Rust releases the way `DefaultHasher` may). The built-in demo grids stay far - under the cap, so the fallback never fires for them; it exists so the - *generic* promise holds for an arbitrarily large grid. - -### Token grammar - -For each varying axis, in param-space slot order, a token -`sanitize_component(name)` + `-` + `sanitize_component(render_value(value))`; -tokens joined by `_`. Example (the new strategy's grid): -`ema.length-5_exposure.scale-0.5_longonly.enabled-true`. - -## Concrete code shapes - -### (1) The user-facing program — the new strategy, authored and swept - -The new demo strategy a user authors (CLI-local, mirroring the existing -`sma_cross` / `macd` sample builders), and the command that sweeps + traces it: - -```rust -/// EMA-distance momentum: the signal is how far price sits above/below its own -/// EMA (a mean-reversion/trend score), sized by Exposure, then passed through a -/// long-only gate. Three swept knobs of three kinds: `ema.length` (i64), -/// `exposure.scale` (f64), `longonly.enabled` (bool). Nothing in common with the -/// SMA-cross demo — the generic-sweep proof. -fn momentum_blueprint_with_sinks() -> (Composite, Receiver<…>, Receiver<…>) { - let (tx_eq, rx_eq) = mpsc::channel(); - let (tx_ex, rx_ex) = mpsc::channel(); - let mut g = GraphBuilder::new("momentum"); - let ema = g.add(Ema::builder()); // default path segment: ema.length - let dist = g.add(Sub::builder()); // price - ema (momentum score) - let expo = g.add(Exposure::builder()); // exposure.scale - let gate = g.add(LongOnly::builder()); // 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")); // long-only exposure -> broker - g.connect(broker.output("equity"), eq.input("col[0]")); - g.connect(gate.output("exposure"), ex.input("col[0]")); // record the FINAL (gated) exposure - (g.build().expect("momentum wiring resolves"), rx_eq, rx_ex) -} -``` - -Param-space (lowering order): `[ema.length: I64, exposure.scale: F64, -longonly.enabled: Bool]` — the path segments are the nodes' default names -(lowercased builder labels), exactly as `exposure.scale` already arises in the -SMA sample. - -The invocation and its observable result: - -```console -$ aura sweep --strategy momentum --trace mom -{"family_id":"mom-0","report":{…}} # one line per grid point (8 points) -… 8 lines … -$ ls runs/traces/mom/ -ema.length-5_exposure.scale-0.5_longonly.enabled-true -ema.length-5_exposure.scale-0.5_longonly.enabled-false -ema.length-5_exposure.scale-1_longonly.enabled-true -… -ema.length-10_exposure.scale-1_longonly.enabled-false -$ aura chart 'mom/ema.length-5_exposure.scale-0.5_longonly.enabled-true' - … uPlot page … -``` - -The grid: `ema.length ∈ {5,10} × exposure.scale ∈ {0.5,1.0} × longonly.enabled ∈ -{true,false}` = 8 members, **all three axes varying** — so all three appear in -every member key, the bool among them. This is the criterion's evidence: a user -authoring a genuinely different strategy reaches for exactly the same `aura -sweep`, and gets correct, portable, self-describing member directories for free. - -### (2) The portable key renderer (replaces `sweep_member_key`) - -Before — `main.rs:186-196`, two hardcoded axes, panics on drift: - -```rust -fn sweep_member_key(named: &[(String, Scalar)]) -> String { - let val = |name: &str| named.iter().find(|(n,_)| n == name) - .unwrap_or_else(|| panic!("… missing the key axis '{name}'")).1.as_i64(); - format!("f{}s{}", val("signals.trend.fast.length"), val("signals.trend.slow.length")) -} -``` - -After — generic over the *varying* axes, portable, collision-free: - -```rust -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. -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 decimal `Display` (no sci-notation). -fn render_value(v: &Scalar) -> String { - match v { - Scalar::I64(n) => n.to_string(), - Scalar::F64(x) => x.to_string(), // Rust f64 Display: decimal, shortest round-trip - Scalar::Bool(b) => b.to_string(), // "true" / "false" - Scalar::Timestamp(t) => t.to_string(), // epoch i64 digits, never a colon-bearing date - } -} - -/// The portable, collision-free member key for a grid point: a `name-value` token -/// per *varying* axis (slot order), joined by `_`. `varying` is the binder's -/// `varying_axes()` set; pinned singletons carry no information and are omitted. -/// Over the cap, degrade to a conformant FNV-1a fallback so the key stays one -/// valid path component for ANY grid. -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 }; // 1-point grid: no varying axis - if key.len() <= MAX_KEY { key } else { format!("h-{:016x}", fnv1a64(key.as_bytes())) } -} -``` - -Worked examples (the unit-test corpus): - -| varying named pairs | key | -|---|---| -| `[(ema.length, I64 5), (exposure.scale, F64 0.5), (longonly.enabled, Bool true)]` | `ema.length-5_exposure.scale-0.5_longonly.enabled-true` | -| `[(longonly.enabled, Bool false)]` | `longonly.enabled-false` | -| `[(exposure.scale, F64 -0.5)]` | `exposure.scale--0.5` (leading `-` is a value sign; `-` is in the portable set) | -| `[(weird key!, F64 1.0)]` (sanitised name) | `weird_key_-1` | -| `[(t, Timestamp 1725148800000)]` | `t-1725148800000` (epoch digits, no `:`/`T`/`Z`) | - -`fnv1a64` is a fixed FNV-1a-64 over the bytes (offset `0xcbf29ce484222325`, prime -`0x100000001b3`) — a ~5-line, version-stable, non-security disambiguator for the -rare over-cap key. (Per the project dependency policy this is the per-case -judgement: a tiny fixed algorithm whose output must be stable *on disk* across -toolchain versions, where a hand-written FNV is the simplest vetted-equivalent -and `std`'s `DefaultHasher` is explicitly unstable across releases.) - -### (3) The `LongOnly` node (aura-std) — the first bool param - -New file `crates/aura-std/src/longonly.rs`, mirroring `exposure.rs` exactly; the -bool *param* drives `eval`: - -```rust -/// Long-only exposure gate. The bool param `enabled`: true clamps short -/// (negative) exposure to 0 (long-only); false passes exposure through unchanged. -/// One f64 input, one f64 output. Emits None until its input is present (C8). -pub struct LongOnly { enabled: bool, out: [Cell; 1] } - -impl LongOnly { - pub fn new(enabled: bool) -> Self { Self { enabled, out: [Cell::from_f64(0.0)] } } - - 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())), // first use of Cell::bool() in a builder - ) - } -} - -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; } - 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) } -} -``` - -Exported from `aura-std/src/lib.rs` (`mod longonly; pub use longonly::LongOnly;`). - -The end-to-end bool path the cycle *proves* (it is not assumed): the type -machinery is already in place and ratified — `ScalarKind::Bool`, `Scalar::Bool` / -`as_bool`, `Cell::from_bool` / `Cell::bool`, and `GridSpace::new`'s per-value -kind-check that already classifies `Bool` (see the green -`random_space_new_non_numeric_slot`, which shows `Bool` is a known, handled kind -on the *random* side). No node has yet *used* a bool param, so this is the first; -its node test and the momentum bool-axis sweep test are what ratify the -authoring + enumeration + bootstrap path for a bool param. - -### (4) The `--strategy` selector (a pure parse fn, mirroring `parse_real_args`) - -Before — three fixed arms (`main.rs:993-995`): - -```rust -["sweep"] => run_sweep("sweep", false), -["sweep", "--name", n] => run_sweep(n, false), -["sweep", "--trace", n] => run_sweep(n, true), -``` - -After — one arm + a pure, unit-testable grammar; `run_sweep` gains a strategy: - -```rust -#[derive(Clone, Copy, PartialEq, Debug)] -enum Strategy { SmaCross, Momentum } - -/// `sweep [--strategy ] [--name | --trace ]`. Defaults: -/// SMA-cross, name "sweep", no persist (today's behaviour preserved). `--name` -/// and `--trace` are mutually exclusive. Pure (no I/O / exit) so it is testable. -fn parse_sweep_args(rest: &[&str]) -> Result<(Strategy, String, bool), String> { … } - -// dispatch: -["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); } -}, - -fn run_sweep(strategy: Strategy, name: &str, persist: bool) { - let family = match strategy { - Strategy::SmaCross => sweep_family(persist.then_some(name)), - Strategy::Momentum => momentum_sweep_family(persist.then_some(name)), - }; - // … unchanged: append_family + print each point's record line … -} -``` - -`momentum_sweep_family` mirrors `sweep_family`: declare the 3-axis grid via the -`.axis()` chain, capture `binder.varying_axes()`, and in the closure render the -key with `member_key(&zip_params(&space, point), &varying)`. `sweep_family` -(SMA) is refactored the same way — it drops the hardcoded `sweep_member_key` and -uses the generic `member_key` with its own `varying_axes()` (so its member dirs -change from `f2s4` to `signals.trend.fast.length-2_signals.trend.slow.length-4`). - -### Seam: `SweepBinder::varying_axes` (engine, additive) - -```rust -impl SweepBinder { - /// The names of the axes that vary (more than one value) — the axes that - /// distinguish one grid point from another. Declaration order; the caller - /// derives membership, key token order comes from param-space slot order. - pub fn varying_axes(&self) -> Vec { - self.axes.iter().filter(|(_, v)| v.len() > 1).map(|(n, _)| n.clone()).collect() - } -} -``` - -The CLI captures it *before* the consuming `.sweep()`: -`let binder = bp.axis(…)….axis(…); let varying: HashSet = -binder.varying_axes().into_iter().collect(); binder.sweep(move |point| { … })`. - -## Components - -- **`aura-engine` `SweepBinder::varying_axes`** — new pure read accessor - (`blueprint.rs`). Additive; no existing signature changes. -- **`aura-std` `LongOnly`** — new node + module + export. First bool-param node. -- **`aura-cli`** — - - `sanitize_component`, `render_value`, `member_key`, `fnv1a64`, `MAX_KEY` - (replace `sweep_member_key`); - - `momentum_blueprint_with_sinks`, `momentum_sweep_family`, a momentum point - grid + (re-used) synthetic stream; - - `Strategy` enum, `parse_sweep_args`, `run_sweep(strategy, name, persist)`; - - `sweep_family` / `sweep_over` refactored to the generic key via - `varying_axes()`; - - `USAGE` updated to show `aura sweep [--strategy ] [--name |--trace ]`. - -## Data flow - -Unchanged through the sweep engine (enumerate → disjoint parallel runs → -`SweepFamily` in odometer order). The only new flow is in the per-member closure: -`point: &[Cell]` → `zip_params(&space, point)` → filter by the captured -`varying` set → `member_key` → portable dir component → `persist_traces( -"{name}/{key}", …)` → `runs/traces///.json`. The MC (`seed{seed}`) -and walk-forward (`oos{from}`) keys are already charset-conformant; they are left -as-is this cycle (single-axis, trivially portable). `sanitize_component` is the -one shared portability gate the sweep key flows through. - -## Error handling - -- A 1-point grid (no varying axis) → `member_key` returns `"m"` (a fixed valid - component) rather than the empty string. Specified, tested. -- An over-`MAX_KEY` key → FNV-1a fallback (`h-`), conformant and - collision-resistant. Specified, tested with a synthetic many-axis case. -- `parse_sweep_args` rejects an unknown flag, a flag missing its value, an - unknown strategy token, or both `--name` and `--trace` — `Err(usage)`, surfaced - as stderr + `exit(2)` (the CLI's convention), never a panic. -- The old fail-loud-on-missing-axis panic is **removed** with `sweep_member_key`: - it guarded a hardcoded-axis assumption that no longer exists. Its two unit tests - are removed with it. - -## Testing strategy - -- **`member_key` unit tests (aura-cli `--bin aura`)** — the worked-examples table: - charset conformance (`key.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.'|'_'|'-'))`), - the bool case, a negative-float case, a sanitised-name case, the empty→`"m"` - case, and an over-cap case (key length ≤ `MAX_KEY`, still conformant, two - distinct over-cap inputs → distinct keys). -- **Collision-freedom over a different-axis grid (the #105 regression)** — a - grid that varies an axis set *other than* the old `trend.fast`/`trend.slow` - (e.g. the momentum grid, which varies `exposure.scale` and a bool) yields N - distinct member keys for N points. The old `f…s…` key would have collapsed - them; the new key does not. -- **Determinism** — two runs of the momentum sweep produce byte-identical member - keys (the family + its keys are a pure function of the build, C1). -- **The bool axis in the key** — the momentum sweep's member dirs include both - `…_longonly.enabled-true` and `…_longonly.enabled-false`. -- **`SweepBinder::varying_axes` (aura-engine)** — a grid with mixed singleton and - multi-value axes returns exactly the multi-value names. -- **`LongOnly` node (aura-std)** — `enabled=true` clamps a negative input to 0 - and passes a positive through; `enabled=false` passes both through; `None` - until input present. Plus `enabled` is read from the bool param - (`LongOnly::builder()` schema param is `enabled: Bool`). -- **Integration (aura-cli `--test cli_run`, spawn-with-temp-cwd)** — `aura sweep - --strategy momentum --trace mom` persists 8 member dirs whose names match the - portable charset and include the bool; `aura chart 'mom/'` emits a uPlot - page for one. The existing SMA `--trace` integration test is **updated** from - the `f2s4`-style assertion to the new portable key form. -- **Backward-compat** — the existing `["sweep"]` / `--name` / `--trace` forms - still work through `parse_sweep_args` (default SMA), so the existing sweep - integration + determinism tests stay green (with the key-string assertions - updated to the new form). - -## Acceptance criteria - -The project's feature-acceptance criterion (audience reaches for it; improves -correctness; reintroduces no failure the core constraints forbid): - -- **Audience reaches for it.** A researcher authoring a new strategy with new - params (incl. a bool) runs the *same* `aura sweep` and gets correct, - self-describing, portable per-member traces — shown by the worked momentum - example. No per-strategy key code. -- **Improves correctness.** Closes #105: the key is collision-free for any grid, - not just the built-in two-axis one. Removes the latent silent-overwrite. -- **Reintroduces no forbidden failure.** Determinism (C1) preserved — keys are a - pure function of the grid; parallelism across members unchanged. No look-ahead, - no engine merge, no per-eval emission added (C2/C3/C8). The engine change is a - pure read accessor; trace state stays out of the engine (C-consistency with - #104). -- **Concrete, conformant, generic.** Member keys match `^[A-Za-z0-9._-]+$`, are - case-insensitive-FS safe, length-bounded, and carry the bool axis — proven by - the test corpus above over a grid that varies a different axis set than the old - hardcoded two.