feat(sweep-key): generic filesystem-portable member key + LongOnly bool node

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
This commit is contained in:
2026-06-21 11:15:22 +02:00
parent 44e3a1a678
commit 27f850dc52
5 changed files with 283 additions and 42 deletions
+143 -39
View File
@@ -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/<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())) }
}
/// 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<String> = 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::<Vec<_>>();
let ex_rows = rx_ex.try_iter().collect::<Vec<_>>();
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<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:?}");
}
}
+9 -3
View File
@@ -716,7 +716,8 @@ fn mc_trace_persists_a_member_dir_per_seed() {
/// Property: `aura sweep --trace <name>` 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() {
+21
View File
@@ -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<String> {
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<F>(self, run_one: F) -> Result<SweepFamily, BindError>
@@ -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<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()]);
}
#[test]
fn resolve_axes_empty_axis() {
let space = vec![ParamSpec { name: "a".into(), kind: ScalarKind::I64 }];
+2
View File
@@ -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;
+108
View File
@@ -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<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 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);
}
}