feat(std): fill the by-chance vocabulary gaps — Const, Div, Abs, Max, Min

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
This commit is contained in:
2026-07-10 19:59:10 +02:00
parent a0098fc8c1
commit 99237e1d0a
8 changed files with 477 additions and 7 deletions
+2 -2
View File
@@ -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(); let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
assert_eq!( assert_eq!(
lines.len(), lines.len(),
23, 28,
"the std-only (no project) vocabulary has exactly the roster's 23 entries: {stdout}" "the std-only (no project) vocabulary has exactly the roster's 28 entries: {stdout}"
); );
} }
+78
View File
@@ -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<usize> {
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);
}
}
+86
View File
@@ -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<usize> {
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);
}
}
+114
View File
@@ -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<usize> {
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<String> = Div::builder().schema().inputs.iter().map(|p| p.name.clone()).collect();
assert_eq!(names, ["lhs", "rhs"]);
}
}
+10
View File
@@ -17,14 +17,17 @@
//! The first block lands with the walking skeleton: [`Sma`], the simple moving //! The first block lands with the walking skeleton: [`Sma`], the simple moving
//! average — a worked producer node proving the `aura-core` `Node` contract. //! average — a worked producer node proving the `aura-core` `Node` contract.
mod abs;
mod add; mod add;
mod and; mod and;
mod bias; mod bias;
mod carry_cost; mod carry_cost;
mod const_node;
mod constant_cost; mod constant_cost;
mod cost; mod cost;
mod cost_sum; mod cost_sum;
mod delay; mod delay;
mod div;
mod ema; mod ema;
mod eqconst; mod eqconst;
mod gated_recorder; mod gated_recorder;
@@ -32,6 +35,8 @@ mod gt;
mod latch; mod latch;
mod lincomb; mod lincomb;
mod longonly; mod longonly;
mod max;
mod min;
mod mul; mod mul;
mod position_management; mod position_management;
mod recorder; mod recorder;
@@ -49,10 +54,12 @@ mod stop_rule;
mod sub; mod sub;
mod vocabulary; mod vocabulary;
mod vol_slippage_cost; mod vol_slippage_cost;
pub use abs::Abs;
pub use add::Add; pub use add::Add;
pub use and::And; pub use and::And;
pub use bias::Bias; pub use bias::Bias;
pub use carry_cost::CarryCost; pub use carry_cost::CarryCost;
pub use const_node::Const;
pub use constant_cost::ConstantCost; pub use constant_cost::ConstantCost;
pub use cost::{ pub use cost::{
cost_node_builder, ChargeMode, CostNode, CostRunner, COST_FIELD_NAMES, COST_WIDTH, 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 cost_sum::CostSum;
pub use delay::Delay; pub use delay::Delay;
pub use div::Div;
pub use ema::Ema; pub use ema::Ema;
pub use eqconst::EqConst; pub use eqconst::EqConst;
pub use gated_recorder::GatedRecorder; pub use gated_recorder::GatedRecorder;
@@ -67,6 +75,8 @@ pub use gt::Gt;
pub use latch::Latch; pub use latch::Latch;
pub use lincomb::LinComb; pub use lincomb::LinComb;
pub use longonly::LongOnly; pub use longonly::LongOnly;
pub use max::Max;
pub use min::Min;
pub use mul::Mul; pub use mul::Mul;
pub use position_management::{ pub use position_management::{
ExitReason, FIELD_NAMES as PM_FIELD_NAMES, PositionManagement, RECORD_KINDS as PM_RECORD_KINDS, ExitReason, FIELD_NAMES as PM_FIELD_NAMES, PositionManagement, RECORD_KINDS as PM_RECORD_KINDS,
+88
View File
@@ -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<usize> {
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<String> = Max::builder().schema().inputs.iter().map(|p| p.name.clone()).collect();
assert_eq!(names, ["lhs", "rhs"]);
}
}
+89
View File
@@ -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<usize> {
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<String> = Min::builder().schema().inputs.iter().map(|p| p.name.clone()).collect();
assert_eq!(names, ["lhs", "rhs"]);
}
}
+10 -5
View File
@@ -18,9 +18,9 @@
//! later, additive extension (#156/C20). //! later, additive extension (#156/C20).
use crate::{ use crate::{
Add, And, Bias, CarryCost, ConstantCost, Delay, Ema, EqConst, FixedStop, Gt, Latch, LongOnly, Abs, Add, And, Bias, CarryCost, Const, ConstantCost, Delay, Div, Ema, EqConst, FixedStop, Gt,
Mul, PositionManagement, Resample, RollingMax, RollingMin, Scale, Sizer, Sma, Sqrt, Sub, Latch, LongOnly, Max, Min, Mul, PositionManagement, Resample, RollingMax, RollingMin, Scale,
VolSlippageCost, Sizer, Sma, Sqrt, Sub, VolSlippageCost,
}; };
use aura_core::PrimitiveBuilder; use aura_core::PrimitiveBuilder;
@@ -58,18 +58,23 @@ macro_rules! std_vocabulary_roster {
} }
std_vocabulary_roster! { std_vocabulary_roster! {
"Abs" => Abs,
"Add" => Add, "Add" => Add,
"And" => And, "And" => And,
"Bias" => Bias, "Bias" => Bias,
"CarryCost" => CarryCost, "CarryCost" => CarryCost,
"Const" => Const,
"ConstantCost" => ConstantCost, "ConstantCost" => ConstantCost,
"Delay" => Delay, "Delay" => Delay,
"Div" => Div,
"EMA" => Ema, "EMA" => Ema,
"EqConst" => EqConst, "EqConst" => EqConst,
"FixedStop" => FixedStop, "FixedStop" => FixedStop,
"Gt" => Gt, "Gt" => Gt,
"Latch" => Latch, "Latch" => Latch,
"LongOnly" => LongOnly, "LongOnly" => LongOnly,
"Max" => Max,
"Min" => Min,
"Mul" => Mul, "Mul" => Mul,
"PositionManagement" => PositionManagement, "PositionManagement" => PositionManagement,
"Resample" => Resample, "Resample" => Resample,
@@ -124,7 +129,7 @@ mod tests {
assert!(!std_vocabulary_types().contains(&"LinComb")); // construction-arg node assert!(!std_vocabulary_types().contains(&"LinComb")); // construction-arg node
assert!(!std_vocabulary_types().contains(&"Recorder")); // sink assert!(!std_vocabulary_types().contains(&"Recorder")); // sink
assert!(!std_vocabulary_types().contains(&"nope")); assert!(!std_vocabulary_types().contains(&"nope"));
// count guard: pins the roster at exactly 23 entries // count guard: pins the roster at exactly 28 entries
assert_eq!(std_vocabulary_types().len(), 23); assert_eq!(std_vocabulary_types().len(), 28);
} }
} }