//! `Exposure` — shapes a raw signal score into a bounded exposure (intent). //! The decision/sizing node of C10's chain `signals -> decision/sizing node -> //! exposure stream`: one f64 input, one f64 output `clamp(signal / scale, -1, +1)`. //! `scale` sets which signal magnitude maps to full exposure (sizing lives here). use aura_core::{Ctx, FieldSpec, Firing, InputSpec, Node, NodeSchema, Scalar, ScalarKind}; /// Bounded exposure from a raw signal score: `clamp(signal / scale, -1.0, +1.0)`. /// Emits `None` until its input is present (warm-up filter, C8). pub struct Exposure { scale: f64, out: [Scalar; 1], } impl Exposure { /// Build an exposure node with saturation magnitude `scale` (must be > 0). pub fn new(scale: f64) -> Self { assert!(scale > 0.0, "Exposure scale must be > 0"); Self { scale, out: [Scalar::F64(0.0)] } } } impl Node for Exposure { fn schema(&self) -> NodeSchema { NodeSchema { inputs: vec![InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }], output: vec![FieldSpec { name: "exposure", kind: ScalarKind::F64 }], } } fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> { let w = ctx.f64_in(0); if w.is_empty() { return None; // not yet warmed up (C8 filter) } self.out[0] = Scalar::F64((w[0] / self.scale).clamp(-1.0, 1.0)); Some(&self.out) } } #[cfg(test)] mod tests { use super::*; use aura_core::{AnyColumn, Timestamp}; #[test] fn exposure_clamps_to_unit_band() { let mut e = Exposure::new(0.5); let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; // (raw signal, expected clamped exposure) for scale 0.5 let cases = [ (0.1_f64, 0.2_f64), // within band (0.5, 1.0), // at the high edge (1.0, 1.0), // saturates high (-0.1, -0.2), // within band, negative (-1.0, -1.0), // saturates low ]; for (sig, want) in cases { inputs[0].push(Scalar::F64(sig)).unwrap(); assert_eq!( e.eval(Ctx::new(&inputs, Timestamp(0))), Some([Scalar::F64(want)].as_slice()) ); } } #[test] fn exposure_is_none_until_input_present() { let mut e = Exposure::new(0.5); let inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; assert_eq!(e.eval(Ctx::new(&inputs, Timestamp(0))), None); } }