//! `Sign` — a stateless `f64 -> f64` sign extractor emitting exactly `+1.0`, //! `-1.0`, or `0.0` by strict comparison. //! //! The `sign0()` semantics already private in //! [`PositionManagement`](crate::PositionManagement) //! (`position_management.rs`), promoted to vocabulary (#281): `> 0 -> +1.0`, //! `< 0 -> -1.0`, else `0.0` — so `NaN` (which compares false on both sides) //! and `-0.0` both fall to `0.0`. The private helper itself stays untouched; //! this cell is the roster-eligible twin, the generic bridge the Bool/F64 //! conditional plane was missing. //! //! One `f64` input (`Firing::Any`), one `f64` output, no params, stateless, //! allocation-free on the hot path (the single-cell output buffer is sized //! once at construction, C7). use aura_core::{Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, ScalarKind}; /// Stateless sign extractor: `+1.0` / `-1.0` / `0.0` by strict comparison /// (`NaN` falls to `0.0`). Emits `None` until the input is warm (C8). pub struct Sign { out: [Cell; 1], } impl Sign { /// Build a `Sign` node. pub fn new() -> Self { Self { out: [Cell::from_f64(0.0)] } } /// The param-generic recipe for a blueprint primitive: paramless, builds /// through `Sign::new`. pub fn builder() -> PrimitiveBuilder { PrimitiveBuilder::new( "Sign", NodeSchema { inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "value".into() }], output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }], params: vec![], doc: "sign of the input: -1, 0 or +1", }, |_| Box::new(Sign::new()), ) } } impl Default for Sign { fn default() -> Self { Self::new() } } impl Node for Sign { fn lookbacks(&self) -> Vec { vec![1] } fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> { let v = ctx.f64_in(0); if v.is_empty() { return None; // warm-up gate (C8) } let x = v[0]; // strict comparisons: NaN and -0.0 both fall to 0.0, as sign0 does let s = if x > 0.0 { 1.0 } else if x < 0.0 { -1.0 } else { 0.0 }; self.out[0] = Cell::from_f64(s); Some(&self.out) } fn label(&self) -> String { "Sign".to_string() } } #[cfg(test)] mod tests { use super::*; use aura_core::{AnyColumn, Scalar, Timestamp}; #[test] fn sign_maps_the_three_regions_and_nan_to_zero() { // PROPERTY: out = +1.0 / -1.0 / 0.0 by strict comparison — the sign0() // semantics promoted to vocabulary. NaN compares false on both sides, // so it falls to 0.0, exactly as sign0 does; -0.0 likewise. let mut node = Sign::new(); let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; let cases = [(2.5, 1.0), (-0.1, -1.0), (0.0, 0.0), (f64::NAN, 0.0), (-0.0, 0.0)]; for (x, want) in cases { inputs[0].push(Scalar::f64(x)).unwrap(); let got = node.eval(Ctx::new(&inputs, Timestamp(0))); assert_eq!(got, Some([Cell::from_f64(want)].as_slice()), "sign({x})"); } } #[test] fn sign_is_none_until_warm() { // Cold input -> None (C8 warm-up gate), like every std cell. let mut node = Sign::new(); let inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(0))), None); } #[test] fn input_slot_is_named_value() { let names: Vec = Sign::builder().schema().inputs.iter().map(|p| p.name.clone()).collect(); assert_eq!(names, ["value"]); } }