From 4ade475dc3c1f2cd010395d1282d882f732e08e4 Mon Sep 17 00:00:00 2001 From: Brummel Date: Wed, 17 Jun 2026 16:55:19 +0200 Subject: [PATCH] =?UTF-8?q?feat(aura-std):=20Gt=20=E2=80=94=20strict=20f64?= =?UTF-8?q?->bool=20greater-than=20comparator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stateless f64 x f64 -> bool, out = (a > b), STRICT: a == b emits false (a close exactly equal to the previous bar's high is not a breakout). Computes breakout = close15 > prevHigh15 in the session-breakout strategy. First bool-emitting f64 comparator; the operator is topology (relational siblings are separate types, never a swept param). Build-step 2 of milestone 'Strategy node vocabulary I'. closes #85 --- crates/aura-std/src/gt.rs | 127 +++++++++++++++++++++++++++++++++++++ crates/aura-std/src/lib.rs | 2 + 2 files changed, 129 insertions(+) create mode 100644 crates/aura-std/src/gt.rs diff --git a/crates/aura-std/src/gt.rs b/crates/aura-std/src/gt.rs new file mode 100644 index 0000000..de4fc1a --- /dev/null +++ b/crates/aura-std/src/gt.rs @@ -0,0 +1,127 @@ +//! `Gt` — a stateless `f64 × f64 -> bool` comparator: `out = (a > b)`, STRICT +//! greater-than. +//! +//! The first **bool-emitting f64 comparator** in `aura-std` (the `f64` cousin of +//! `EqConst`'s `i64 -> bool` gate). Its purpose in the session-breakout strategy +//! is `breakout = close15 > prevHigh15`: a fresh 15m close strictly above the +//! prior bar's high. **Strict `>` is load-bearing** — a close exactly *equal* to +//! the previous high is **not** a breakout, so `a == b` emits `false`. +//! +//! The **operator is topology** — `Gt` is a concrete node type, not an op +//! selected by a swept param. The relational siblings (`Lt`, `Ge`, `Le`, `Eq`) +//! would each be their own node type, never a param. Two `f64` inputs +//! (`Firing::Any`), one `bool` output, 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 `f64 × f64 -> bool` strict comparator: emits `a > b` each cycle. +/// Emits `None` until **both** inputs have a value (warm-up gate, C8). +pub struct Gt { + out: [Cell; 1], +} + +impl Gt { + /// Build a `Gt` node. + pub fn new() -> Self { + Self { out: [Cell::from_bool(false)] } + } + + /// The param-generic recipe for a blueprint primitive: paramless, builds + /// through `Gt::new`. + pub fn builder() -> PrimitiveBuilder { + PrimitiveBuilder::new( + "Gt", + NodeSchema { + inputs: vec![ + PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "a".into() }, + PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "b".into() }, + ], + output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::Bool }], + params: vec![], + }, + |_| Box::new(Gt::new()), + ) + } +} + +impl Default for Gt { + fn default() -> Self { + Self::new() + } +} + +impl Node for Gt { + fn lookbacks(&self) -> Vec { + vec![1, 1] + } + + fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> { + let a = ctx.f64_in(0); + let b = ctx.f64_in(1); + if a.is_empty() || b.is_empty() { + return None; // not yet warmed up — both legs required (C8 filter) + } + self.out[0] = Cell::from_bool(a[0] > b[0]); // STRICT: a == b -> false + Some(&self.out) + } + + fn label(&self) -> String { + "Gt".to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use aura_core::{AnyColumn, Scalar, Timestamp}; + + #[test] + fn gt_is_strict_greater_than_once_both_inputs_present() { + // The core property: out == (a > b), STRICT. Three regions are covered, + // and the a==b case is the load-bearing one — a close exactly equal to + // the previous high is NOT a breakout, so equality emits `false`. + let mut node = Gt::new(); + let mut inputs = vec![ + AnyColumn::with_capacity(ScalarKind::F64, 1), + AnyColumn::with_capacity(ScalarKind::F64, 1), + ]; + + let a_feed = [1.0_f64, 5.0, 3.0]; + let b_feed = [2.0_f64, 2.0, 3.0]; + // ab a==b + let expect = [Some(false), Some(true), Some(false)]; + + for ((av, bv), want) in a_feed.iter().zip(b_feed.iter()).zip(expect) { + inputs[0].push(Scalar::f64(*av)).unwrap(); + inputs[1].push(Scalar::f64(*bv)).unwrap(); + let got = node.eval(Ctx::new(&inputs, Timestamp(0))); + assert_eq!(got, Some([Cell::from_bool(want.unwrap())].as_slice())); + } + } + + #[test] + fn gt_is_none_until_both_inputs_present() { + // Both-inputs warm-up gate (C8), like `Sub`: only one leg present -> None. + let mut node = Gt::new(); + let mut inputs = vec![ + AnyColumn::with_capacity(ScalarKind::F64, 1), + AnyColumn::with_capacity(ScalarKind::F64, 1), + ]; + + // only input 0 present -> None + inputs[0].push(Scalar::f64(10.0)).unwrap(); + assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(0))), None); + + // both present -> a strict-gt bool (10.0 > 4.0 == true) + inputs[1].push(Scalar::f64(4.0)).unwrap(); + assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(0))), Some([Cell::from_bool(true)].as_slice())); + } + + #[test] + fn input_slots_are_named_a_b() { + let g = Gt::builder(); + let names: Vec<&str> = g.schema().inputs.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, ["a", "b"]); + } +} diff --git a/crates/aura-std/src/lib.rs b/crates/aura-std/src/lib.rs index 0c05500..c0d214d 100644 --- a/crates/aura-std/src/lib.rs +++ b/crates/aura-std/src/lib.rs @@ -19,6 +19,7 @@ mod add; mod ema; mod eqconst; mod exposure; +mod gt; mod lincomb; mod recorder; mod sim_broker; @@ -28,6 +29,7 @@ pub use add::Add; pub use ema::Ema; pub use eqconst::EqConst; pub use exposure::Exposure; +pub use gt::Gt; pub use lincomb::LinComb; pub use recorder::Recorder; pub use sim_broker::SimBroker;