//! `Bias` — shapes a raw signal score into a bounded, UNSIGNED-magnitude directional //! bias (C10). The strategy's primary output: one f64 input, one f64 output //! `clamp(signal / scale, -1, +1)`. Sign is direction, magnitude is (optional) //! conviction; the output is UNSIZED — sizing leaves the strategy (downstream Sizer). //! `scale` sets which signal magnitude maps to full conviction. use aura_core::{ Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, ScalarKind, }; /// Bounded bias 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 Bias { scale: f64, out: [Cell; 1], } impl Bias { /// Build a bias node with saturation magnitude `scale` (must be > 0). pub fn new(scale: f64) -> Self { assert!(scale > 0.0, "Bias scale must be > 0"); Self { scale, out: [Cell::from_f64(0.0)] } } /// The param-generic recipe for a blueprint primitive: declares `scale` and builds /// through `Bias::new` (the single sizing/validation gate; the slice is /// kind-checked before `build` runs, so the typed read is total). pub fn builder() -> PrimitiveBuilder { PrimitiveBuilder::new( "Bias", NodeSchema { inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "signal".into() }], output: vec![FieldSpec { name: "bias".into(), kind: ScalarKind::F64 }], params: vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }], }, |p| Box::new(Bias::new(p[0].f64())), ) } } impl Node for Bias { 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((w[0] / self.scale).clamp(-1.0, 1.0)); Some(&self.out) } fn label(&self) -> String { format!("Bias({})", self.scale) } } #[cfg(test)] mod tests { use super::*; use aura_core::{AnyColumn, Scalar, Timestamp}; #[test] fn bias_clamps_to_unit_band() { let mut e = Bias::new(0.5); let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; // (raw signal, expected clamped bias) 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([Cell::from_f64(want)].as_slice()) ); } } #[test] fn bias_is_none_until_input_present() { let mut e = Bias::new(0.5); let inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; assert_eq!(e.eval(Ctx::new(&inputs, Timestamp(0))), None); } #[test] fn input_slot_is_named_signal() { assert_eq!(Bias::builder().schema().inputs[0].name, "signal"); } }