From 99237e1d0ab74f499b7c32ebbe4aa0a9de72342b Mon Sep 17 00:00:00 2001 From: Brummel Date: Fri, 10 Jul 2026 19:59:10 +0200 Subject: [PATCH] =?UTF-8?q?feat(std):=20fill=20the=20by-chance=20vocabular?= =?UTF-8?q?y=20gaps=20=E2=80=94=20Const,=20Div,=20Abs,=20Max,=20Min?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five new rostered node types (count-pin 23 -> 28, both the in-crate shape test and the cross-boundary CLI vocabulary e2e): Const is unary with an f64 'value' param — the clock input drives it, its value is ignored, since a zero-input node never evaluates in the total-push engine — mirroring EqConst's constant-as-param pattern; Div is binary IEEE-754 (x/0 -> signed inf, 0/0 -> NaN, unit-tested, no error channel); Abs unary mirroring Sqrt; Max/Min binary pairwise, distinct from the windowed RollingMax/RollingMin. Acceptance proof: the committed executable spec composes an RSI-class gain/loss-split-and-ratio signal purely from blueprint data through std_vocabulary and runs it to hand-computed RS values — the r_meanrev constant-folding workaround is no longer forced. Verified: headline test green, aura-std 163/0, full workspace suite green (independent mini-verify), clippy -D warnings clean. closes #236 --- crates/aura-cli/tests/graph_construct.rs | 4 +- crates/aura-std/src/abs.rs | 78 ++++++++++++++++ crates/aura-std/src/const_node.rs | 86 +++++++++++++++++ crates/aura-std/src/div.rs | 114 +++++++++++++++++++++++ crates/aura-std/src/lib.rs | 10 ++ crates/aura-std/src/max.rs | 88 +++++++++++++++++ crates/aura-std/src/min.rs | 89 ++++++++++++++++++ crates/aura-std/src/vocabulary.rs | 15 ++- 8 files changed, 477 insertions(+), 7 deletions(-) create mode 100644 crates/aura-std/src/abs.rs create mode 100644 crates/aura-std/src/const_node.rs create mode 100644 crates/aura-std/src/div.rs create mode 100644 crates/aura-std/src/max.rs create mode 100644 crates/aura-std/src/min.rs diff --git a/crates/aura-cli/tests/graph_construct.rs b/crates/aura-cli/tests/graph_construct.rs index 4a02c41..a39c972 100644 --- a/crates/aura-cli/tests/graph_construct.rs +++ b/crates/aura-cli/tests/graph_construct.rs @@ -121,8 +121,8 @@ fn graph_introspect_vocabulary_lists_exactly_the_closed_roster_count() { let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect(); assert_eq!( lines.len(), - 23, - "the std-only (no project) vocabulary has exactly the roster's 23 entries: {stdout}" + 28, + "the std-only (no project) vocabulary has exactly the roster's 28 entries: {stdout}" ); } diff --git a/crates/aura-std/src/abs.rs b/crates/aura-std/src/abs.rs new file mode 100644 index 0000000..d57ca26 --- /dev/null +++ b/crates/aura-std/src/abs.rs @@ -0,0 +1,78 @@ +//! `Abs` — one-input f64 absolute value. Turns a signed delta (e.g. the +//! negative-side split of a price change) into a positive magnitude, e.g. +//! RSI-class signals' `loss = Abs(Min(delta, 0))`. Emits `None` until its +//! input has a value. + +use aura_core::{Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, ScalarKind}; + +/// One-input f64 absolute value, `value.abs()`. Emits `None` until its input +/// has a value (warm-up filter, C8). +pub struct Abs { + out: [Cell; 1], +} + +impl Abs { + pub fn new() -> Self { + Self { out: [Cell::from_f64(0.0)] } + } + + pub fn builder() -> PrimitiveBuilder { + PrimitiveBuilder::new( + "Abs", + NodeSchema { + inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "value".into() }], + output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }], + params: vec![], + }, + |_| Box::new(Abs::new()), + ) + } +} + +impl Default for Abs { + fn default() -> Self { + Self::new() + } +} + +impl Node for Abs { + 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; + } + self.out[0] = Cell::from_f64(w[0].abs()); + Some(&self.out) + } + + fn label(&self) -> String { + "Abs".to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use aura_core::{AnyColumn, Scalar, Timestamp}; + + #[test] + fn abs_of_negative_and_positive_is_the_magnitude() { + let mut a = Abs::new(); + let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; + inputs[0].push(Scalar::f64(-3.0)).unwrap(); + assert_eq!(a.eval(Ctx::new(&inputs, Timestamp(0))), Some([Cell::from_f64(3.0)].as_slice())); + inputs[0].push(Scalar::f64(3.0)).unwrap(); + assert_eq!(a.eval(Ctx::new(&inputs, Timestamp(0))), Some([Cell::from_f64(3.0)].as_slice())); + } + + #[test] + fn abs_is_none_until_input_present() { + let mut a = Abs::new(); + let inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; + assert_eq!(a.eval(Ctx::new(&inputs, Timestamp(0))), None); + } +} diff --git a/crates/aura-std/src/const_node.rs b/crates/aura-std/src/const_node.rs new file mode 100644 index 0000000..758e016 --- /dev/null +++ b/crates/aura-std/src/const_node.rs @@ -0,0 +1,86 @@ +//! `Const` — a constant-as-param source: emits a fixed `f64` value every cycle +//! its clock input fires, ignoring the clock's actual value. Mirrors `EqConst`'s +//! constant-as-param pattern (the constant is a bound param, not topology), but +//! as a *source*: a zero-input node never evaluates in the total-push engine +//! (C8), so `Const` still needs one input purely to be driven by the sim clock +//! — its value is discarded. + +use aura_core::{ + Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, + ScalarKind, +}; + +/// Emits the bound `value` every cycle its clock input fires; the clock's own +/// value is ignored. Emits `None` until the clock input has fired at least once +/// (warm-up filter, C8) — same shape as every other stateless node here. +pub struct Const { + value: f64, + out: [Cell; 1], +} + +impl Const { + /// Build a constant source bound to `value`. + pub fn new(value: f64) -> Self { + Self { value, out: [Cell::from_f64(value)] } + } + + /// The param-generic recipe for a blueprint primitive: declares `value` and + /// one clock input, builds through `Const::new`. + pub fn builder() -> PrimitiveBuilder { + PrimitiveBuilder::new( + "Const", + NodeSchema { + inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "clock".into() }], + output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }], + params: vec![ParamSpec { name: "value".into(), kind: ScalarKind::F64 }], + }, + |p| Box::new(Const::new(p[0].f64())), + ) + } +} + +impl Node for Const { + 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(self.value); + Some(&self.out) + } + + fn label(&self) -> String { + format!("Const({})", self.value) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use aura_core::{AnyColumn, Scalar, Timestamp}; + + #[test] + fn const_emits_bound_value_regardless_of_clock_value() { + // The core property: out == value, always, once the clock has fired at + // least once — the clock's own reading is never observed in the output. + let mut node = Const::new(7.0); + let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; + + inputs[0].push(Scalar::f64(-100.0)).unwrap(); + assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(0))), Some([Cell::from_f64(7.0)].as_slice())); + + inputs[0].push(Scalar::f64(42.0)).unwrap(); + assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(0))), Some([Cell::from_f64(7.0)].as_slice())); + } + + #[test] + fn const_is_none_until_clock_present() { + let mut node = Const::new(7.0); + let inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; + assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(0))), None); + } +} diff --git a/crates/aura-std/src/div.rs b/crates/aura-std/src/div.rs new file mode 100644 index 0000000..9187641 --- /dev/null +++ b/crates/aura-std/src/div.rs @@ -0,0 +1,114 @@ +//! `Div` — two-input f64 quotient (input 0 divided by input 1), the ratio +//! combinator RSI-class signals need (`avg_gain / avg_loss`). IEEE-754 +//! division, no error channel: `x / 0.0 -> inf` (signed by `x`'s sign), +//! `0.0 / 0.0 -> NaN` — Rust's native `f64` division already follows this, +//! so no special-casing is needed. + +use aura_core::{Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, ScalarKind}; + +/// Two-input f64 quotient: input 0 divided by input 1 (IEEE-754). Emits `None` +/// until both inputs have a value. +pub struct Div { + out: [Cell; 1], +} + +impl Div { + /// Build a `Div` node. + pub fn new() -> Self { + Self { out: [Cell::from_f64(0.0)] } + } + + /// The param-generic recipe for a blueprint primitive: paramless, builds + /// through `Div::new`. + pub fn builder() -> PrimitiveBuilder { + PrimitiveBuilder::new( + "Div", + NodeSchema { + inputs: vec![ + PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "lhs".into() }, + PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "rhs".into() }, + ], + output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }], + params: vec![], + }, + |_| Box::new(Div::new()), + ) + } +} + +impl Default for Div { + fn default() -> Self { + Self::new() + } +} + +impl Node for Div { + 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; + } + self.out[0] = Cell::from_f64(a[0] / b[0]); + Some(&self.out) + } + + fn label(&self) -> String { + "Div".to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use aura_core::{AnyColumn, Scalar, Timestamp}; + + #[test] + fn div_is_quotient_once_both_inputs_present() { + let mut div = Div::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!(div.eval(Ctx::new(&inputs, Timestamp(0))), None); + + // both present -> a / b + inputs[1].push(Scalar::f64(4.0)).unwrap(); + assert_eq!(div.eval(Ctx::new(&inputs, Timestamp(0))), Some([Cell::from_f64(2.5)].as_slice())); + } + + #[test] + fn div_by_zero_is_signed_infinity_zero_over_zero_is_nan() { + // The IEEE-754 property that makes Div safe with no error channel: a + // nonzero numerator over zero yields a signed infinity, and 0/0 yields + // NaN — both representable as plain f64, never a panic or an Err. + let mut div = Div::new(); + let mut inputs = vec![ + AnyColumn::with_capacity(ScalarKind::F64, 1), + AnyColumn::with_capacity(ScalarKind::F64, 1), + ]; + + inputs[0].push(Scalar::f64(5.0)).unwrap(); + inputs[1].push(Scalar::f64(0.0)).unwrap(); + let got = div.eval(Ctx::new(&inputs, Timestamp(0))).unwrap(); + assert_eq!(got[0].f64(), f64::INFINITY); + + inputs[0].push(Scalar::f64(0.0)).unwrap(); + inputs[1].push(Scalar::f64(0.0)).unwrap(); + let got = div.eval(Ctx::new(&inputs, Timestamp(0))).unwrap(); + assert!(got[0].f64().is_nan()); + } + + #[test] + fn input_slots_are_named_lhs_rhs() { + let names: Vec = Div::builder().schema().inputs.iter().map(|p| p.name.clone()).collect(); + assert_eq!(names, ["lhs", "rhs"]); + } +} diff --git a/crates/aura-std/src/lib.rs b/crates/aura-std/src/lib.rs index b3c8825..465046a 100644 --- a/crates/aura-std/src/lib.rs +++ b/crates/aura-std/src/lib.rs @@ -17,14 +17,17 @@ //! The first block lands with the walking skeleton: [`Sma`], the simple moving //! average — a worked producer node proving the `aura-core` `Node` contract. +mod abs; mod add; mod and; mod bias; mod carry_cost; +mod const_node; mod constant_cost; mod cost; mod cost_sum; mod delay; +mod div; mod ema; mod eqconst; mod gated_recorder; @@ -32,6 +35,8 @@ mod gt; mod latch; mod lincomb; mod longonly; +mod max; +mod min; mod mul; mod position_management; mod recorder; @@ -49,10 +54,12 @@ mod stop_rule; mod sub; mod vocabulary; mod vol_slippage_cost; +pub use abs::Abs; pub use add::Add; pub use and::And; pub use bias::Bias; pub use carry_cost::CarryCost; +pub use const_node::Const; pub use constant_cost::ConstantCost; pub use cost::{ cost_node_builder, ChargeMode, CostNode, CostRunner, COST_FIELD_NAMES, COST_WIDTH, @@ -60,6 +67,7 @@ pub use cost::{ }; pub use cost_sum::CostSum; pub use delay::Delay; +pub use div::Div; pub use ema::Ema; pub use eqconst::EqConst; pub use gated_recorder::GatedRecorder; @@ -67,6 +75,8 @@ pub use gt::Gt; pub use latch::Latch; pub use lincomb::LinComb; pub use longonly::LongOnly; +pub use max::Max; +pub use min::Min; pub use mul::Mul; pub use position_management::{ ExitReason, FIELD_NAMES as PM_FIELD_NAMES, PositionManagement, RECORD_KINDS as PM_RECORD_KINDS, diff --git a/crates/aura-std/src/max.rs b/crates/aura-std/src/max.rs new file mode 100644 index 0000000..6f67ab5 --- /dev/null +++ b/crates/aura-std/src/max.rs @@ -0,0 +1,88 @@ +//! `Max` — two-input f64 pairwise maximum (input 0 vs input 1), e.g. the +//! positive-part split of a price change: `gain = Max(delta, 0)`. Distinct +//! from `RollingMax`, which reduces a *window* of one input; `Max` combines +//! two co-present inputs, mirroring `Add`/`Sub`. + +use aura_core::{Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, ScalarKind}; + +/// Two-input f64 pairwise maximum: `input 0 .max(input 1)`. Emits `None` until +/// both inputs have a value. +pub struct Max { + out: [Cell; 1], +} + +impl Max { + pub fn new() -> Self { + Self { out: [Cell::from_f64(0.0)] } + } + + pub fn builder() -> PrimitiveBuilder { + PrimitiveBuilder::new( + "Max", + NodeSchema { + inputs: vec![ + PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "lhs".into() }, + PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "rhs".into() }, + ], + output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }], + params: vec![], + }, + |_| Box::new(Max::new()), + ) + } +} + +impl Default for Max { + fn default() -> Self { + Self::new() + } +} + +impl Node for Max { + 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; + } + self.out[0] = Cell::from_f64(a[0].max(b[0])); + Some(&self.out) + } + + fn label(&self) -> String { + "Max".to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use aura_core::{AnyColumn, Scalar, Timestamp}; + + #[test] + fn max_is_the_pairwise_larger_once_both_inputs_present() { + let mut m = Max::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(2.0)).unwrap(); + assert_eq!(m.eval(Ctx::new(&inputs, Timestamp(0))), None); + + // both present -> max(a, b), either order + inputs[1].push(Scalar::f64(-1.0)).unwrap(); + assert_eq!(m.eval(Ctx::new(&inputs, Timestamp(0))), Some([Cell::from_f64(2.0)].as_slice())); + } + + #[test] + fn input_slots_are_named_lhs_rhs() { + let names: Vec = Max::builder().schema().inputs.iter().map(|p| p.name.clone()).collect(); + assert_eq!(names, ["lhs", "rhs"]); + } +} diff --git a/crates/aura-std/src/min.rs b/crates/aura-std/src/min.rs new file mode 100644 index 0000000..e37ae19 --- /dev/null +++ b/crates/aura-std/src/min.rs @@ -0,0 +1,89 @@ +//! `Min` — two-input f64 pairwise minimum (input 0 vs input 1), the companion +//! to `Max`, e.g. the negative-part split of a price change: +//! `loss = Abs(Min(delta, 0))`. Distinct from `RollingMin`, which reduces a +//! *window* of one input; `Min` combines two co-present inputs, mirroring +//! `Add`/`Sub`. + +use aura_core::{Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, ScalarKind}; + +/// Two-input f64 pairwise minimum: `input 0 .min(input 1)`. Emits `None` until +/// both inputs have a value. +pub struct Min { + out: [Cell; 1], +} + +impl Min { + pub fn new() -> Self { + Self { out: [Cell::from_f64(0.0)] } + } + + pub fn builder() -> PrimitiveBuilder { + PrimitiveBuilder::new( + "Min", + NodeSchema { + inputs: vec![ + PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "lhs".into() }, + PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "rhs".into() }, + ], + output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }], + params: vec![], + }, + |_| Box::new(Min::new()), + ) + } +} + +impl Default for Min { + fn default() -> Self { + Self::new() + } +} + +impl Node for Min { + 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; + } + self.out[0] = Cell::from_f64(a[0].min(b[0])); + Some(&self.out) + } + + fn label(&self) -> String { + "Min".to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use aura_core::{AnyColumn, Scalar, Timestamp}; + + #[test] + fn min_is_the_pairwise_smaller_once_both_inputs_present() { + let mut m = Min::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(2.0)).unwrap(); + assert_eq!(m.eval(Ctx::new(&inputs, Timestamp(0))), None); + + // both present -> min(a, b), either order + inputs[1].push(Scalar::f64(-1.0)).unwrap(); + assert_eq!(m.eval(Ctx::new(&inputs, Timestamp(0))), Some([Cell::from_f64(-1.0)].as_slice())); + } + + #[test] + fn input_slots_are_named_lhs_rhs() { + let names: Vec = Min::builder().schema().inputs.iter().map(|p| p.name.clone()).collect(); + assert_eq!(names, ["lhs", "rhs"]); + } +} diff --git a/crates/aura-std/src/vocabulary.rs b/crates/aura-std/src/vocabulary.rs index c7aefe4..6f888e8 100644 --- a/crates/aura-std/src/vocabulary.rs +++ b/crates/aura-std/src/vocabulary.rs @@ -18,9 +18,9 @@ //! later, additive extension (#156/C20). use crate::{ - Add, And, Bias, CarryCost, ConstantCost, Delay, Ema, EqConst, FixedStop, Gt, Latch, LongOnly, - Mul, PositionManagement, Resample, RollingMax, RollingMin, Scale, Sizer, Sma, Sqrt, Sub, - VolSlippageCost, + Abs, Add, And, Bias, CarryCost, Const, ConstantCost, Delay, Div, Ema, EqConst, FixedStop, Gt, + Latch, LongOnly, Max, Min, Mul, PositionManagement, Resample, RollingMax, RollingMin, Scale, + Sizer, Sma, Sqrt, Sub, VolSlippageCost, }; use aura_core::PrimitiveBuilder; @@ -58,18 +58,23 @@ macro_rules! std_vocabulary_roster { } std_vocabulary_roster! { + "Abs" => Abs, "Add" => Add, "And" => And, "Bias" => Bias, "CarryCost" => CarryCost, + "Const" => Const, "ConstantCost" => ConstantCost, "Delay" => Delay, + "Div" => Div, "EMA" => Ema, "EqConst" => EqConst, "FixedStop" => FixedStop, "Gt" => Gt, "Latch" => Latch, "LongOnly" => LongOnly, + "Max" => Max, + "Min" => Min, "Mul" => Mul, "PositionManagement" => PositionManagement, "Resample" => Resample, @@ -124,7 +129,7 @@ mod tests { assert!(!std_vocabulary_types().contains(&"LinComb")); // construction-arg node assert!(!std_vocabulary_types().contains(&"Recorder")); // sink assert!(!std_vocabulary_types().contains(&"nope")); - // count guard: pins the roster at exactly 23 entries - assert_eq!(std_vocabulary_types().len(), 23); + // count guard: pins the roster at exactly 28 entries + assert_eq!(std_vocabulary_types().len(), 28); } }