//! `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); } }