Files
Aura/crates/aura-std/src/longonly.rs
T
Brummel 27f850dc52 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
2026-06-21 11:15:22 +02:00

109 lines
3.7 KiB
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<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);
}
}