From 27f850dc527accf9eb58e61f9206861e8605025f Mon Sep 17 00:00:00 2001 From: Brummel Date: Sun, 21 Jun 2026 11:15:22 +0200 Subject: [PATCH] feat(sweep-key): generic filesystem-portable member key + LongOnly bool node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalises the #105 foot-gun: the family-member trace key was derived from two hardcoded axis names (f{fast}s{slow}), collision-free only over the built-in SMA grid. It is now derived from the axes that actually VARY (SweepBinder::varying_axes) and rendered as a filesystem-conformant directory component: - charset restricted to [A-Za-z0-9._-] (valid on Linux/Windows/macOS, also URL-path- and cloud-sync-safe); any other byte sanitised; - case-less values (i64/timestamp decimal, bool true/false, f64 via Display, no scientific notation) so two members of one family never differ only by letter case -> no silent overwrite on case-insensitive filesystems (NTFS/APFS); - length-capped (MAX_KEY=200) with a version-stable FNV-1a fallback for the unbounded-grid edge. Pinned singleton axes are omitted; the SMA sweep's member dirs change from f2s4 to signals.trend.fast.length-2_signals.trend.slow.length-4 (its --trace integration test + the stale prose updated accordingly). Adds the first aura-std node with a bool *param*, LongOnly (a long-only exposure gate: enabled=true clamps short exposure to >=0, false passes through) — the block the momentum demo strategy (next commit) sweeps to prove the key generic over a bool axis. The engine change is one additive pure read accessor; no existing signature changes, trace state stays out of the engine. Self-verified: cargo build --workspace, cargo test --workspace (all green incl. the LongOnly node tests, the member_key worked-examples corpus, the varying_axes accessor test, and the updated SMA sweep-trace integration test), cargo clippy --workspace --all-targets -D warnings. refs #105 --- crates/aura-cli/src/main.rs | 182 ++++++++++++++++++++++------ crates/aura-cli/tests/cli_run.rs | 12 +- crates/aura-engine/src/blueprint.rs | 21 ++++ crates/aura-std/src/lib.rs | 2 + crates/aura-std/src/longonly.rs | 108 +++++++++++++++++ 5 files changed, 283 insertions(+), 42 deletions(-) create mode 100644 crates/aura-std/src/longonly.rs diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index c84f0ef..aa61a8b 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -27,6 +27,7 @@ use aura_registry::{ }; use aura_std::{Ema, Exposure, LinComb, Recorder, SimBroker, Sma, Sub}; use std::sync::mpsc::{self, Receiver}; +use std::collections::HashSet; /// The pip size the built-in *synthetic* harnesses run at: a 5-decimal FX major /// (`EURUSD`-shaped). The synthetic streams carry no instrument, so there is no @@ -174,25 +175,60 @@ fn persist_traces( } } -/// The content-derived member key for a swept grid point: the two varying axes -/// of the built-in grid (`signals.trend.fast.length`, `signals.trend.slow.length`). -/// Deterministic + collision-free over that grid (distinct points differ in ≥1 -/// of these). `named` is the `zip_params(&space, point)` the manifest already builds. -/// -/// A key-driving axis that is absent from `named` is a grid/key drift, not data: -/// the collision-free promise above would silently break (two points collapsing -/// to a shared `f…s…` dir that overwrite each other's traces). So a missing axis -/// panics, naming the axis — the same caller-bug-is-a-panic idiom as `Scalar::as_i64`. -fn sweep_member_key(named: &[(String, Scalar)]) -> String { - let val = |name: &str| { - named - .iter() - .find(|(n, _)| n.as_str() == name) - .unwrap_or_else(|| panic!("sweep_member_key: grid is missing the key axis '{name}'")) - .1 - .as_i64() - }; - format!("f{}s{}", val("signals.trend.fast.length"), val("signals.trend.slow.length")) +/// 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())) } } /// Build the serve-ready `ChartData` from a run's read-back traces by the spec-§6 @@ -469,14 +505,17 @@ fn sample_blueprint() -> Composite { fn sweep_family(trace: Option<&str>) -> SweepFamily { let bp = sample_blueprint_with_sinks().0; let space = bp.param_space(); - bp.axis("signals.trend.fast.length", [2, 3]) + 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]) + .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 @@ -489,7 +528,7 @@ fn sweep_family(trace: Option<&str>) -> SweepFamily { let eq_rows = rx_eq.try_iter().collect::>(); let ex_rows = rx_ex.try_iter().collect::>(); let named = zip_params(&space, point); - let key = sweep_member_key(&named); + 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); @@ -1404,25 +1443,90 @@ mod tests { assert_eq!(r1.manifest.commit, r2.manifest.commit); } - #[test] - fn sweep_member_key_maps_the_two_named_axes() { - // The happy path the doc comment promises: the two varying axes drive - // the `fNsM` key, so distinct grid points yield distinct keys. - let named = vec![ - ("signals.trend.fast.length".to_string(), Scalar::i64(2)), - ("signals.trend.slow.length".to_string(), Scalar::i64(5)), - ]; - assert_eq!(sweep_member_key(&named), "f2s5"); + fn pair(name: &str, v: Scalar) -> (String, Scalar) { + (name.to_string(), v) } #[test] - #[should_panic(expected = "signals.trend.slow.length")] - fn sweep_member_key_fails_loudly_on_a_missing_axis() { - // Property: the key is collision-free over the grid, so a key-driving - // axis that is absent (a future grid/key drift) must fail loudly, not - // silently collapse to a shared `f…s0` dir that overwrites a sibling's - // trace. The panic names the offending axis. - let drifted = vec![("signals.trend.fast.length".to_string(), Scalar::i64(2))]; - let _ = sweep_member_key(&drifted); + 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:?}"); } } diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index 39fe116..0e65f2b 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -716,7 +716,8 @@ fn mc_trace_persists_a_member_dir_per_seed() { /// Property: `aura sweep --trace ` persists one standalone, round-tripping /// run-dir per grid point. The built-in grid is fast∈{2,3} × slow∈{4,5} = 4 -/// points, content-keyed f2s4/f2s5/f3s4/f3s5; a plain `aura sweep` writes nothing. +/// points, content-keyed by the varying axes (signals.trend.fast.length / +/// signals.trend.slow.length); a plain `aura sweep` writes nothing. #[test] fn sweep_trace_persists_a_member_dir_per_grid_point() { let cwd = temp_cwd("sweep-trace"); @@ -731,7 +732,12 @@ fn sweep_trace_persists_a_member_dir_per_grid_point() { .output() .expect("spawn aura sweep --trace"); assert!(traced.status.success(), "traced sweep exit: {:?}", traced.status); - for key in ["f2s4", "f2s5", "f3s4", "f3s5"] { + 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", + ] { let dir = cwd.join(format!("runs/traces/swp/{key}")); assert!(dir.join("index.json").exists(), "{key} index.json missing"); assert!(dir.join("equity.json").exists(), "{key} equity tap missing"); @@ -791,7 +797,7 @@ fn walkforward_trace_persists_a_member_dir_per_oos_window() { /// grid point's content (not a runtime ordinal counter) is that members run in /// parallel across threads (C1: parallelism *across* sims); a thread-race in the /// keying or the persisted bytes would silently regress determinism. A runtime -/// counter would shuffle which member lands in `f2s4` vs `f3s5` run-to-run; this +/// counter would shuffle which member lands in which content-keyed dir run-to-run; this /// test fails loudly if that drift is ever reintroduced. #[test] fn sweep_trace_is_byte_deterministic_across_runs() { diff --git a/crates/aura-engine/src/blueprint.rs b/crates/aura-engine/src/blueprint.rs index 6e0af9c..a786ebb 100644 --- a/crates/aura-engine/src/blueprint.rs +++ b/crates/aura-engine/src/blueprint.rs @@ -388,6 +388,14 @@ impl SweepBinder { self } + /// 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() + } + /// Resolve the named axes against `param_space()` into a positional grid and /// run the disjoint sweep. pub fn sweep(self, run_one: F) -> Result @@ -1011,6 +1019,19 @@ mod tests { ); } + #[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()]); + } + #[test] fn resolve_axes_empty_axis() { let space = vec![ParamSpec { name: "a".into(), kind: ScalarKind::I64 }]; diff --git a/crates/aura-std/src/lib.rs b/crates/aura-std/src/lib.rs index f1aaf0d..c275fb5 100644 --- a/crates/aura-std/src/lib.rs +++ b/crates/aura-std/src/lib.rs @@ -24,6 +24,7 @@ mod exposure; mod gt; mod latch; mod lincomb; +mod longonly; mod recorder; mod resample; mod session; @@ -39,6 +40,7 @@ pub use exposure::Exposure; pub use gt::Gt; pub use latch::Latch; pub use lincomb::LinComb; +pub use longonly::LongOnly; pub use recorder::Recorder; pub use resample::Resample; pub use session::Session; diff --git a/crates/aura-std/src/longonly.rs b/crates/aura-std/src/longonly.rs new file mode 100644 index 0000000..3adcd5d --- /dev/null +++ b/crates/aura-std/src/longonly.rs @@ -0,0 +1,108 @@ +//! `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 builder = LongOnly::builder(); + let schema = builder.schema(); + assert_eq!(schema.params.len(), 1); + assert_eq!(schema.params[0].name, "enabled"); + assert_eq!(schema.params[0].kind, ScalarKind::Bool); + } +}