From bc3e4bd877aea3b26e64552fe30c53f8939f596f Mon Sep 17 00:00:00 2001 From: MathisWellmann Date: Sat, 10 Oct 2026 19:22:50 +0200 Subject: [PATCH 01/17] View for Box: assemble chains at runtime Every combinator takes its input as `V: View`, so a chain whose shape is only known at runtime (parsed from a spec) needs boxed views to be views. --- src/lib.rs | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 618c5b0..06b689c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,9 +23,45 @@ pub trait View { fn last(&self) -> Option; } +/// A boxed view is a view, so a chain can be assembled at runtime: every +/// combinator takes its input by value as `V: View`, and +/// `Box>` is the only way to nest views whose types are not +/// known at compile time. +impl + ?Sized> View for Box { + #[inline(always)] + fn update(&mut self, val: T) { + (**self).update(val); + } + + #[inline(always)] + fn last(&self) -> Option { + (**self).last() + } +} + #[cfg(test)] mod tests { // Used in benchmarks. #[allow(unused_imports)] use criterion::*; + + use super::*; + use crate::{ + pure_functions::Echo, + sliding_windows::Sma, + }; + + #[test] + fn boxed_views_chain_like_concrete_ones() { + let window = std::num::NonZeroUsize::new(3).expect("non zero"); + let inner: Box> = Box::new(Sma::new(Echo::new(), window)); + let mut boxed = Sma::new(inner, window); + let mut concrete = Sma::new(Sma::new(Echo::new(), window), window); + for v in [1.0, 4.0, 2.0, 8.0, 5.0, 7.0] { + boxed.update(v); + concrete.update(v); + assert_eq!(boxed.last(), concrete.last()); + } + assert!(boxed.last().is_some()); + } } From d4271cc9c82126f801f59322e8574349f7f44f11 Mon Sep 17 00:00:00 2001 From: MathisWellmann Date: Sat, 10 Oct 2026 19:23:13 +0200 Subject: [PATCH 02/17] pure_functions: add Abs and Ln Building blocks for return-magnitude and log-price features (e.g. the mean absolute return as a volatility estimate). Ln yields None for non-positive input instead of a non-finite value that would poison downstream windows. --- src/pure_functions/abs.rs | 59 ++++++++++++++++++++++++++++++++ src/pure_functions/ln.rs | 71 +++++++++++++++++++++++++++++++++++++++ src/pure_functions/mod.rs | 4 +++ 3 files changed, 134 insertions(+) create mode 100644 src/pure_functions/abs.rs create mode 100644 src/pure_functions/ln.rs diff --git a/src/pure_functions/abs.rs b/src/pure_functions/abs.rs new file mode 100644 index 0000000..978807c --- /dev/null +++ b/src/pure_functions/abs.rs @@ -0,0 +1,59 @@ +use num::Float; + +use crate::View; + +/// The absolute value of the output of its View component, e.g. the size of +/// a return regardless of its direction. +#[derive(Debug, Clone)] +pub struct Abs { + view: V, + _marker: std::marker::PhantomData, +} + +impl Abs +where + V: View, + T: Float, +{ + /// Create a new instance with a chained View + pub fn new(view: V) -> Self { + Self { + view, + _marker: std::marker::PhantomData, + } + } +} + +impl View for Abs +where + V: View, + T: Float, +{ + fn update(&mut self, val: T) { + debug_assert!(val.is_finite(), "value must be finite"); + self.view.update(val); + } + + fn last(&self) -> Option { + self.view.last().map(|v| { + debug_assert!(v.is_finite(), "value must be finite"); + v.abs() + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pure_functions::Echo; + + #[test] + fn abs() { + let mut abs = Abs::new(Echo::new()); + assert_eq!(abs.last(), None); + abs.update(-2.5); + assert_eq!(abs.last(), Some(2.5)); + abs.update(3.0); + assert_eq!(abs.last(), Some(3.0)); + } +} diff --git a/src/pure_functions/ln.rs b/src/pure_functions/ln.rs new file mode 100644 index 0000000..00b989d --- /dev/null +++ b/src/pure_functions/ln.rs @@ -0,0 +1,71 @@ +use num::Float; + +use crate::View; + +/// The natural logarithm of the output of its View component, e.g. log +/// prices, whose differences are returns. +/// +/// The logarithm is only defined for positive values: for zero or negative +/// input, `last` is `None` rather than a non-finite value that would poison +/// every window downstream. +#[derive(Debug, Clone)] +pub struct Ln { + view: V, + _marker: std::marker::PhantomData, +} + +impl Ln +where + V: View, + T: Float, +{ + /// Create a new instance with a chained View + pub fn new(view: V) -> Self { + Self { + view, + _marker: std::marker::PhantomData, + } + } +} + +impl View for Ln +where + V: View, + T: Float, +{ + fn update(&mut self, val: T) { + debug_assert!(val.is_finite(), "value must be finite"); + self.view.update(val); + } + + fn last(&self) -> Option { + let v = self.view.last()?; + debug_assert!(v.is_finite(), "value must be finite"); + (v > T::zero()).then(|| v.ln()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pure_functions::Echo; + + #[test] + fn ln_of_positive_values() { + let mut ln = Ln::new(Echo::new()); + assert_eq!(ln.last(), None); + ln.update(1.0); + assert_eq!(ln.last(), Some(0.0)); + ln.update(std::f64::consts::E); + assert!((ln.last().unwrap() - 1.0).abs() < 1e-12); + } + + #[test] + fn ln_of_non_positive_values_is_none() { + let mut ln = Ln::new(Echo::new()); + ln.update(0.0); + assert_eq!(ln.last(), None); + ln.update(-1.0); + assert_eq!(ln.last(), None); + } +} diff --git a/src/pure_functions/mod.rs b/src/pure_functions/mod.rs index 4a8a4cc..dd69a54 100644 --- a/src/pure_functions/mod.rs +++ b/src/pure_functions/mod.rs @@ -1,21 +1,25 @@ //! This module contains `View` implementations that act like pure functions. //! They don't really rely on an internal state, but rather process value from other `View`s +mod abs; mod add; mod constant; mod divide; mod echo; mod gte; +mod ln; mod lte; mod multiply; mod subtract; mod tanh; +pub use abs::Abs; pub use add::Add; pub use constant::Constant; pub use divide::Divide; pub use echo::Echo; pub use gte::GTE; +pub use ln::Ln; pub use lte::LTE; pub use multiply::Multiply; pub use subtract::Subtract; From fb854ebfadb4feea2a48dcb38b3e10537b658827 Mon Sep 17 00:00:00 2001 From: MathisWellmann Date: Sat, 10 Oct 2026 19:28:24 +0200 Subject: [PATCH 03/17] spec: parse feature specs and build their view chains at runtime A small expression language over the crate's views: `x`, + - * /, unary minus and calls `name(expr, params...)` for 35 functions, e.g. `zscore(ema(x, 10) - ema(x, 50), 200)`. Features become data that can be stored, deduplicated (the Display form is canonical) and written by a search or an LLM without compiling code. Built chains are safe for untrusted specs: Limits bound windows, nodes and depth; every node passes only finite values on; division by zero and logs of non-positive values are "not ready"; per-function minimum windows keep the library's index arithmetic in bounds (cyber_cycle needs 3, pfe 3). An adversarial test runs every function on trends, constants, zeros, alternating signs and huge values with debug assertions on. --- README.md | 22 +++ src/lib.rs | 1 + src/spec/build.rs | 343 +++++++++++++++++++++++++++++++++++++++++++ src/spec/error.rs | 48 ++++++ src/spec/expr.rs | 319 ++++++++++++++++++++++++++++++++++++++++ src/spec/function.rs | 159 ++++++++++++++++++++ src/spec/mod.rs | 52 +++++++ src/spec/parse.rs | 319 ++++++++++++++++++++++++++++++++++++++++ 8 files changed, 1263 insertions(+) create mode 100644 src/spec/build.rs create mode 100644 src/spec/error.rs create mode 100644 src/spec/expr.rs create mode 100644 src/spec/function.rs create mode 100644 src/spec/mod.rs create mode 100644 src/spec/parse.rs diff --git a/README.md b/README.md index 5bc2d91..f67a9eb 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,26 @@ for v in &test_values { Each View will first call it's chained View to get it's last value, which will then be used to update the state of the View. Some Views have additional parameters such as ALMA. +### Features as text: the `spec` module +A chain can also be written as a spec string, parsed and built at runtime, +so features can be stored, compared and generated (e.g. by a search or an +LLM agent) as data, without compiling code: + +```rust +use sliding_features::{View, spec::{Expr, Limits}}; + +let spec: Expr = "zscore(ema(x, 10) - ema(x, 50), 200)".parse().unwrap(); +let mut view = spec.build(&Limits::default()).unwrap(); +view.update(100.0); +``` + +`x` is the input, `+ - * /` combine views fed the same input, and every +function takes an expression and then numeric parameters, e.g. `pfe(x, 20, 5)`. +`Function::reference()` renders the table of all functions. `Limits` bounds +the window lengths and tree size, and a built view only outputs finite values +(a division by zero is "not ready" instead of infinite). The `Display` form of +an `Expr` is canonical, so equal strings mean equal features. + ### Examples See examples folder for some code ideas ```shell @@ -92,6 +112,8 @@ A View defines the function which processes value updates. They currently includ * Multiply * Divide * Math functions + * Abs + * Ln * Tanh * GTE - Greater Than or Equal clipping function * LTE - Lower Than or Equal clipping function diff --git a/src/lib.rs b/src/lib.rs index 06b689c..e64f48f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ pub mod pure_functions; pub mod rolling; pub mod sliding_windows; +pub mod spec; #[cfg(test)] mod plot; diff --git a/src/spec/build.rs b/src/spec/build.rs new file mode 100644 index 0000000..c3191af --- /dev/null +++ b/src/spec/build.rs @@ -0,0 +1,343 @@ +//! Turns a validated [`Expr`] into a runtime chain of this crate's views. + +use std::num::NonZeroUsize; + +use super::{ + BinaryOp, + Expr, + Function, + SpecError, +}; +use crate::{ + View, + pure_functions::{ + Abs, + Add, + Constant, + Echo, + GTE, + LTE, + Ln, + Multiply, + Subtract, + Tanh, + }, + rolling::{ + Drawdown, + LnReturn, + }, + sliding_windows::{ + Alma, + BinaryEntropy, + CenterOfGravity, + CorrelationTrendIndicator, + Cumulative, + CyberCycle, + EhlersFisherTransform, + Ema, + Lag, + LaguerreFilter, + LaguerreRSI, + MadScaler, + Max, + Min, + MinMaxNormalizer, + MyRSI, + NoiseEliminationTechnology, + PolarizedFractalEfficiency, + ReFlex, + Roc, + RoofingFilter, + Rsi, + Sma, + SuperSmoother, + TrendFlex, + Vst, + WelfordOnline, + ZScoreStandardization, + }, +}; + +/// A view assembled at runtime. +pub type DynView = Box + Send>; + +/// Resource bounds of a spec, so a spec from an untrusted author (e.g. an +/// LLM) cannot make a feature arbitrarily slow or large. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Limits { + /// The largest window parameter. + pub max_window: usize, + /// The most nodes in the tree. + pub max_nodes: usize, + /// The longest root-to-leaf path. + pub max_depth: usize, +} + +impl Default for Limits { + fn default() -> Self { + Self { + max_window: 100_000, + max_nodes: 64, + max_depth: 16, + } + } +} + +impl Limits { + /// No bounds besides the signatures: what parsing checks. + pub fn unbounded() -> Self { + Self { + max_window: usize::MAX, + max_nodes: usize::MAX, + max_depth: usize::MAX, + } + } +} + +/// Passes only finite values on: one non-finite value (e.g. an overflow) +/// would otherwise stay in every window downstream for its whole length. +struct Finite(V); + +impl> View for Finite { + fn update(&mut self, val: f64) { + self.0.update(val); + } + + fn last(&self) -> Option { + self.0.last().filter(|v| v.is_finite()) + } +} + +/// Passes only positive values on, for functions defined on them. +struct Positive(V); + +impl> View for Positive { + fn update(&mut self, val: f64) { + self.0.update(val); + } + + fn last(&self) -> Option { + self.0.last().filter(|v| *v > 0.0) + } +} + +/// `a / b`, not ready while `b` is zero (the library's `Divide` asserts a +/// non-zero divisor, which a spec cannot guarantee). +struct SafeDiv { + a: A, + b: B, +} + +impl, B: View> View for SafeDiv { + fn update(&mut self, val: f64) { + self.a.update(val); + self.b.update(val); + } + + fn last(&self) -> Option { + let (a, b) = (self.a.last()?, self.b.last()?); + (b != 0.0).then(|| a / b).filter(|v| v.is_finite()) + } +} + +impl Expr { + /// Check the spec against `limits` and assemble its view chain. + /// + /// Feed the view finite values only. Its output is always finite: a + /// division by zero, a logarithm of a non-positive value or an overflow + /// makes that node not ready (`last()` is `None`) instead of passing a + /// non-finite value on. + pub fn build(&self, limits: &Limits) -> Result { + self.validate(limits)?; + Ok(self.assemble()) + } + + fn assemble(&self) -> DynView { + let view: DynView = match self { + Expr::Input => Box::new(Echo::new()), + Expr::Const(c) => Box::new(Constant::new(*c)), + Expr::Neg(e) => Box::new(Multiply::new(Constant::new(-1.0), e.assemble())), + Expr::Binary { op, lhs, rhs } => { + let (a, b) = (lhs.assemble(), rhs.assemble()); + match op { + BinaryOp::Add => Box::new(Add::new(a, b)), + BinaryOp::Sub => Box::new(Subtract::new(a, b)), + BinaryOp::Mul => Box::new(Multiply::new(a, b)), + BinaryOp::Div => Box::new(SafeDiv { a, b }), + } + } + Expr::Call { + function, + arg, + params, + } => call(*function, arg.assemble(), params), + }; + Box::new(Finite(view)) + } +} + +/// The view of one validated call. +fn call(function: Function, a: DynView, params: &[f64]) -> DynView { + // Validated: positive integers far below 2^53, exact as f64. + let w = |i: usize| NonZeroUsize::new(params[i] as usize).expect("validated window"); + let ema = |i: usize| Ema::new(Echo::new(), w(i)); + use Function as F; + match function { + F::Abs => Box::new(Abs::new(a)), + F::Ln => Box::new(Ln::new(a)), + F::Tanh => Box::new(Tanh::new(a)), + F::LnReturn => Box::new(LnReturn::new(Positive(a))), + F::Drawdown => Box::new(Drawdown::new(Positive(a))), + F::Gte => Box::new(GTE::new(a, params[0])), + F::Lte => Box::new(LTE::new(a, params[0])), + F::Sma => Box::new(Sma::new(a, w(0))), + F::Ema => Box::new(Ema::new(a, w(0))), + F::Alma => Box::new(Alma::new(a, w(0))), + F::Roc => Box::new(Roc::new(a, w(0))), + F::Lag => Box::new(Lag::new(a, w(0))), + F::Min => Box::new(Min::new(a, w(0))), + F::Max => Box::new(Max::new(a, w(0))), + F::MinMax => Box::new(MinMaxNormalizer::new(a, w(0))), + F::Mad => Box::new(MadScaler::new(a, w(0))), + F::ZScore => Box::new(ZScoreStandardization::new(a, w(0))), + F::Std => Box::new(WelfordOnline::new(a, w(0))), + F::Cumulative => Box::new(Cumulative::new(a, w(0))), + F::Rsi => Box::new(Rsi::new(a, w(0))), + F::MyRsi => Box::new(MyRSI::new(a, w(0))), + F::LaguerreRsi => Box::new(LaguerreRSI::new(a, w(0))), + F::LaguerreFilter => Box::new(LaguerreFilter::new(a, params[0])), + F::SuperSmoother => Box::new(SuperSmoother::new(a, w(0))), + F::RoofingFilter => Box::new(RoofingFilter::new(a, w(0), w(1))), + F::ReFlex => Box::new(ReFlex::new(a, w(0))), + F::TrendFlex => Box::new(TrendFlex::new(a, w(0))), + F::CyberCycle => Box::new(CyberCycle::new(a, w(0))), + F::CenterOfGravity => Box::new(CenterOfGravity::new(a, w(0))), + F::Cti => Box::new(CorrelationTrendIndicator::new(a, w(0))), + F::Pfe => Box::new(PolarizedFractalEfficiency::new(a, ema(1), w(0))), + F::Fisher => Box::new(EhlersFisherTransform::new(a, ema(1), w(0))), + F::BinaryEntropy => Box::new(BinaryEntropy::new(a, w(0))), + F::Vst => Box::new(Vst::new(a, w(0))), + F::Net => Box::new(NoiseEliminationTechnology::new(a, w(0))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::spec::Param; + + fn build(spec: &str) -> DynView { + Expr::parse(spec) + .expect("parses") + .build(&Limits::default()) + .expect("builds") + } + + /// A built spec computes exactly what the hand-written chain computes. + #[test] + fn matches_the_hand_written_chain() { + let window = |n| NonZeroUsize::new(n).expect("non zero"); + let mut built = build("zscore(ema(x, 5) - sma(x, 10), 20)"); + let mut manual = ZScoreStandardization::new( + Subtract::new( + Ema::new(Echo::new(), window(5)), + Sma::new(Echo::new(), window(10)), + ), + window(20), + ); + let rng = romu::Rng::from_seed_with_64bit(1); + let mut price = 100.0; + for _ in 0..500 { + price += rng.f64() - 0.5; + built.update(price); + manual.update(price); + assert_eq!(built.last(), manual.last()); + } + assert!(built.last().is_some()); + } + + #[test] + fn division_by_zero_is_not_ready_instead_of_infinite() { + let mut v = build("x / (x - x)"); + v.update(3.0); + assert_eq!(v.last(), None); + let mut v = build("1 / x"); + v.update(4.0); + assert_eq!(v.last(), Some(0.25)); + } + + #[test] + fn ln_return_ignores_non_positive_input() { + let mut v = build("ln_return(x - 1)"); + for x in [2.0, 0.5, 3.0] { + v.update(x); + } + // 0.5 - 1 < 0 never reached the log return. + assert!((v.last().expect("ready") - 2.0_f64.ln()).abs() < 1e-12); + } + + #[test] + fn limits_are_enforced() { + let e = Expr::parse("sma(x, 500)").expect("parses"); + let tight = Limits { + max_window: 100, + ..Limits::default() + }; + assert!(e.build(&tight).is_err()); + assert!(e.build(&Limits::default()).is_ok()); + } + + /// Inputs that break naive indicators: a trend, a constant, zeros, + /// alternating signs, huge values. + fn adversarial_inputs() -> Vec> { + let rng = romu::Rng::from_seed_with_64bit(7); + let mut walk = 100.0; + vec![ + (0..300) + .map(|_| { + walk += rng.f64() - 0.5; + walk + }) + .collect(), + vec![5.0; 300], + vec![0.0; 300], + (0..300) + .map(|i| if i % 2 == 0 { 1.0 } else { -1.0 }) + .collect(), + (0..300).map(|i| 1e12 * f64::from(i % 7)).collect(), + ] + } + + /// Every function, at its minimum window, on every adversarial input + /// and composed with a sign-changing argument, never panics (debug + /// assertions on) and only ever outputs finite values. + #[test] + fn every_function_survives_adversarial_input() { + let mut failures = Vec::new(); + for function in Function::ALL { + let params = Vec::from_iter(function.params().iter().map(|p| match p { + Param::Window => function.min_window().to_string(), + Param::Real => "0.5".to_string(), + })); + let tail = params.iter().map(|p| format!(", {p}")).collect::(); + for arg in ["x", "roc(x, 1)", "x - x"] { + let spec = format!("{}({arg}{tail})", function.name()); + for input in adversarial_inputs() { + let run = std::panic::catch_unwind(|| { + let mut v = build(&spec); + input.iter().all(|x| { + v.update(*x); + v.last().is_none_or(f64::is_finite) + }) + }); + match run { + Ok(true) => {} + Ok(false) => failures.push(format!("{spec}: non-finite output")), + Err(_) => failures.push(format!("{spec}: panicked on {:?}", &input[..3])), + } + } + } + } + assert!(failures.is_empty(), "{failures:#?}"); + } +} diff --git a/src/spec/error.rs b/src/spec/error.rs new file mode 100644 index 0000000..b6b0fd3 --- /dev/null +++ b/src/spec/error.rs @@ -0,0 +1,48 @@ +//! The error of parsing or validating a spec. + +use std::fmt; + +/// Why a spec was rejected. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SpecError { + /// The byte offset in the spec text, for syntax errors. + position: Option, + message: String, +} + +impl SpecError { + pub(super) fn parse(position: usize, message: String) -> Self { + Self { + position: Some(position), + message, + } + } + + pub(super) fn invalid(message: String) -> Self { + Self { + position: None, + message, + } + } + + /// The byte offset in the spec text the error points at, if any. + pub fn position(&self) -> Option { + self.position + } + + /// What is wrong, without the position. + pub fn message(&self) -> &str { + &self.message + } +} + +impl fmt::Display for SpecError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.position { + Some(at) => write!(f, "at byte {at}: {}", self.message), + None => f.write_str(&self.message), + } + } +} + +impl std::error::Error for SpecError {} diff --git a/src/spec/expr.rs b/src/spec/expr.rs new file mode 100644 index 0000000..f2652f9 --- /dev/null +++ b/src/spec/expr.rs @@ -0,0 +1,319 @@ +//! The expression tree of a spec, its canonical text form and its checks. + +use std::fmt; + +use super::{ + Function, + Limits, + Param, + SpecError, +}; + +/// An arithmetic operator between two expressions. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum BinaryOp { + /// `a + b` + Add, + /// `a - b` + Sub, + /// `a * b` + Mul, + /// `a / b`, not ready while `b` is zero. + Div, +} + +impl BinaryOp { + fn symbol(self) -> char { + match self { + BinaryOp::Add => '+', + BinaryOp::Sub => '-', + BinaryOp::Mul => '*', + BinaryOp::Div => '/', + } + } +} + +/// A feature as an expression over one input series `x`. +/// +/// Its [`fmt::Display`] form is canonical: parsing it yields the same tree, +/// so two specs that print the same compute the same feature. +#[derive(Debug, Clone, PartialEq)] +pub enum Expr { + /// The input series, `x`. + Input, + /// A constant. + Const(f64), + /// The negated expression, `-a`. + Neg(Box), + /// Arithmetic between two expressions fed the same input. + Binary { + /// The operator. + op: BinaryOp, + /// The left operand. + lhs: Box, + /// The right operand. + rhs: Box, + }, + /// A function applied to an expression, with its parameters. + Call { + /// The function. + function: Function, + /// The expression the function is applied to. + arg: Box, + /// The parameters after the argument, see [`Function::params`]. + params: Vec, + }, +} + +impl fmt::Display for Expr { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Expr::Input => f.write_str("x"), + Expr::Const(c) => write!(f, "{c}"), + Expr::Neg(e) => write!(f, "-{e}"), + Expr::Binary { op, lhs, rhs } => write!(f, "({lhs} {} {rhs})", op.symbol()), + Expr::Call { + function, + arg, + params, + } => { + write!(f, "{}({arg}", function.name())?; + for p in params { + write!(f, ", {p}")?; + } + f.write_str(")") + } + } + } +} + +impl std::str::FromStr for Expr { + type Err = SpecError; + + fn from_str(s: &str) -> Result { + super::parse::parse(s) + } +} + +impl Expr { + /// Parse a spec, e.g. `zscore(ema(x, 10) - ema(x, 50), 200)`. + /// + /// Checks the syntax and every call's signature; [`Expr::validate`] + /// checks the resource limits. + pub fn parse(s: &str) -> Result { + super::parse::parse(s) + } + + /// The number of nodes in the tree. + pub fn node_count(&self) -> usize { + 1 + self.children().map(Expr::node_count).sum::() + } + + /// The length of the longest path from the root to a leaf. + pub fn depth(&self) -> usize { + 1 + self.children().map(Expr::depth).max().unwrap_or(0) + } + + /// The largest window parameter anywhere in the tree (0 without one). + pub fn max_window(&self) -> usize { + let own = match self { + Expr::Call { + function, params, .. + } => window_params(*function, params).max().unwrap_or(0), + _ => 0, + }; + self.children().map(Expr::max_window).fold(own, usize::max) + } + + /// An estimate of the input updates needed before the feature is ready: + /// the window lengths summed along the longest chain. Use it to size the + /// history replayed before the feature is read. + pub fn warm_up(&self) -> usize { + match self { + Expr::Input | Expr::Const(_) => 0, + Expr::Neg(e) => e.warm_up(), + Expr::Binary { lhs, rhs, .. } => lhs.warm_up().max(rhs.warm_up()), + Expr::Call { + function, + arg, + params, + } => { + let own: usize = window_params(*function, params).sum(); + arg.warm_up() + own + usize::from(*function == Function::LnReturn) + } + } + } + + fn children(&self) -> impl Iterator { + let (a, b) = match self { + Expr::Input | Expr::Const(_) => (None, None), + Expr::Neg(e) | Expr::Call { arg: e, .. } => (Some(e.as_ref()), None), + Expr::Binary { lhs, rhs, .. } => (Some(lhs.as_ref()), Some(rhs.as_ref())), + }; + a.into_iter().chain(b) + } + + /// Check the tree against `limits` and every call against its + /// signature, so that [`Expr::build`] cannot fail or panic. + pub fn validate(&self, limits: &Limits) -> Result<(), SpecError> { + if self.node_count() > limits.max_nodes { + return Err(SpecError::invalid(format!( + "{} nodes exceed the limit of {}", + self.node_count(), + limits.max_nodes + ))); + } + if self.depth() > limits.max_depth { + return Err(SpecError::invalid(format!( + "depth {} exceeds the limit of {}", + self.depth(), + limits.max_depth + ))); + } + self.validate_nodes(limits) + } + + fn validate_nodes(&self, limits: &Limits) -> Result<(), SpecError> { + match self { + Expr::Const(c) if !c.is_finite() => { + return Err(SpecError::invalid(format!("constant {c} is not finite"))); + } + Expr::Call { + function, params, .. + } => validate_call(*function, params, limits)?, + _ => {} + } + self.children().try_for_each(|c| c.validate_nodes(limits)) + } +} + +/// The window parameters of a call, as lengths. +fn window_params(function: Function, params: &[f64]) -> impl Iterator + '_ { + function + .params() + .iter() + .zip(params) + .filter(|(kind, _)| **kind == Param::Window) + // Validated windows are small positive integers, exact as f64. + .map(|(_, value)| *value as usize) +} + +/// Check one call's parameters against its signature and `limits`. +pub(super) fn validate_call( + function: Function, + params: &[f64], + limits: &Limits, +) -> Result<(), SpecError> { + let expected = function.params(); + if params.len() != expected.len() { + return Err(SpecError::invalid(format!( + "{} takes {} parameter(s) after its argument, got {}", + function.signature(), + expected.len(), + params.len() + ))); + } + for (kind, value) in expected.iter().zip(params) { + if !value.is_finite() { + return Err(SpecError::invalid(format!( + "{}: parameter {value} is not finite", + function.name() + ))); + } + if *kind == Param::Window { + let valid = value.fract() == 0.0 + && *value >= function.min_window() as f64 + && *value <= limits.max_window as f64; + if !valid { + return Err(SpecError::invalid(format!( + "{}: window {value} must be an integer in [{}, {}]", + function.name(), + function.min_window(), + limits.max_window + ))); + } + } + } + if function == Function::LaguerreFilter && !(params[0] > 0.0 && params[0] < 1.0) { + return Err(SpecError::invalid(format!( + "laguerre_filter: gamma {} must lie in (0, 1)", + params[0] + ))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn call(function: Function, arg: Expr, params: &[f64]) -> Expr { + Expr::Call { + function, + arg: Box::new(arg), + params: params.to_vec(), + } + } + + #[test] + fn display_is_canonical() { + let e = Expr::Binary { + op: BinaryOp::Sub, + lhs: Box::new(call(Function::Ema, Expr::Input, &[10.0])), + rhs: Box::new(Expr::Neg(Box::new(Expr::Const(0.5)))), + }; + assert_eq!(e.to_string(), "(ema(x, 10) - -0.5)"); + let pfe = call(Function::Pfe, Expr::Input, &[20.0, 5.0]); + assert_eq!(pfe.to_string(), "pfe(x, 20, 5)"); + } + + #[test] + fn size_and_warm_up() { + let inner = call(Function::Sma, Expr::Input, &[10.0]); + let e = call(Function::ZScore, inner, &[50.0]); + assert_eq!(e.node_count(), 3); + assert_eq!(e.depth(), 3); + assert_eq!(e.max_window(), 50); + assert_eq!(e.warm_up(), 60); + let both = Expr::Binary { + op: BinaryOp::Add, + lhs: Box::new(e.clone()), + rhs: Box::new(call(Function::Lag, Expr::Input, &[100.0])), + }; + assert_eq!(both.warm_up(), 100); + } + + #[test] + fn validation_rejects_what_build_cannot_handle() { + let limits = Limits::default(); + let ok = call(Function::Pfe, Expr::Input, &[3.0, 3.0]); + assert!(ok.validate(&limits).is_ok()); + for bad in [ + call(Function::Sma, Expr::Input, &[0.0]), + call(Function::Sma, Expr::Input, &[2.5]), + call(Function::Sma, Expr::Input, &[]), + call(Function::Pfe, Expr::Input, &[2.0, 3.0]), + call(Function::Sma, Expr::Input, &[1e9]), + call(Function::LaguerreFilter, Expr::Input, &[1.0]), + call(Function::Gte, Expr::Input, &[f64::NAN]), + Expr::Const(f64::INFINITY), + ] { + assert!(bad.validate(&limits).is_err(), "{bad:?}"); + } + } + + #[test] + fn validation_bounds_the_tree() { + let mut e = Expr::Input; + for _ in 0..20 { + e = Expr::Neg(Box::new(e)); + } + assert!(e.validate(&Limits::default()).is_err(), "too deep"); + let limits = Limits { + max_depth: 100, + max_nodes: 5, + ..Limits::default() + }; + assert!(e.validate(&limits).is_err(), "too many nodes"); + } +} diff --git a/src/spec/function.rs b/src/spec/function.rs new file mode 100644 index 0000000..c690cc2 --- /dev/null +++ b/src/spec/function.rs @@ -0,0 +1,159 @@ +//! The functions of the spec language and their signatures. + +use std::fmt::Write as _; + +/// The kind of a parameter that follows the (expression) first argument. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Param { + /// A window length, counted in updates of the argument: a positive + /// integer. + Window, + /// A finite real number. + Real, +} + +impl Param { + fn name(self) -> &'static str { + match self { + Param::Window => "window", + Param::Real => "real", + } + } +} + +/// Declares [`Function`] with its name, parameters, minimum window and +/// description from one table, so they cannot drift apart. +macro_rules! functions { + ($($variant:ident => $name:literal, [$($param:ident),*], $min:literal, $doc:literal;)*) => { + /// A function of the spec language, wrapping one `View` of this crate. + /// + /// Every function takes an expression as its first argument and then + /// its [`Function::params`]. + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub enum Function { + $(#[doc = $doc] $variant,)* + } + + impl Function { + /// Every function, in declaration order. + pub const ALL: &'static [Function] = &[$(Function::$variant,)*]; + + /// The name the function is called by in a spec. + pub fn name(self) -> &'static str { + match self { $(Function::$variant => $name,)* } + } + + /// The parameters after the expression argument. + pub fn params(self) -> &'static [Param] { + match self { $(Function::$variant => &[$(Param::$param),*],)* } + } + + /// The smallest window length the function accepts: shorter + /// windows are degenerate (e.g. an RSI over one value). + pub fn min_window(self) -> usize { + match self { $(Function::$variant => $min,)* } + } + + /// What the function computes, one line. + pub fn description(self) -> &'static str { + match self { $(Function::$variant => $doc,)* } + } + } + }; +} + +functions! { + Abs => "abs", [], 1, "Absolute value."; + Ln => "ln", [], 1, "Natural logarithm; not ready for non-positive values."; + Tanh => "tanh", [], 1, "Hyperbolic tangent, squashes into (-1, 1)."; + LnReturn => "ln_return", [], 1, "Log return between consecutive updates of a positive argument."; + Drawdown => "drawdown", [], 1, "Largest relative peak-to-valley decline of a positive argument so far."; + Gte => "gte", [Real], 1, "Clips values below the real to it: max(a, real)."; + Lte => "lte", [Real], 1, "Clips values above the real to it: min(a, real)."; + Sma => "sma", [Window], 1, "Simple moving average."; + Ema => "ema", [Window], 1, "Exponential moving average, alpha = 2 / (window + 1)."; + Alma => "alma", [Window], 2, "Arnaud Legoux moving average (sigma 6, offset 0.85)."; + Roc => "roc", [Window], 1, "Rate of change in percent against the value `window` updates ago."; + Lag => "lag", [Window], 1, "The value `window` updates ago."; + Min => "min", [Window], 1, "Minimum over the window."; + Max => "max", [Window], 1, "Maximum over the window."; + MinMax => "min_max", [Window], 2, "Position in the [min, max] range of the previous window, scaled to [-1, 1]."; + Mad => "mad", [Window], 2, "Robust z-score: (a - median) / (1.4826 MAD) of the previous window."; + ZScore => "zscore", [Window], 2, "Z-score against the mean and standard deviation of the previous window."; + Std => "std", [Window], 2, "Standard deviation over the window (Welford)."; + Cumulative => "cumulative", [Window], 1, "Sum over the window."; + Rsi => "rsi", [Window], 2, "Relative strength index."; + MyRsi => "my_rsi", [Window], 2, "John Ehlers' MyRSI, in [-1, 1]."; + LaguerreRsi => "laguerre_rsi", [Window], 2, "John Ehlers' Laguerre RSI."; + LaguerreFilter => "laguerre_filter", [Real], 1, "John Ehlers' Laguerre filter with damping gamma in (0, 1)."; + SuperSmoother => "super_smoother", [Window], 2, "John Ehlers' two-pole super smoother low-pass filter."; + RoofingFilter => "roofing_filter", [Window, Window], 2, "John Ehlers' roofing filter (low-pass window, high-pass window): a band-pass."; + ReFlex => "re_flex", [Window], 2, "John Ehlers' ReFlex zero-lag cycle indicator."; + TrendFlex => "trend_flex", [Window], 2, "John Ehlers' TrendFlex zero-lag trend indicator."; + CyberCycle => "cyber_cycle", [Window], 3, "John Ehlers' cyber cycle."; + CenterOfGravity => "center_of_gravity", [Window], 2, "John Ehlers' center of gravity oscillator."; + Cti => "cti", [Window], 2, "John Ehlers' correlation trend indicator: correlation with a straight line, in [-1, 1]."; + Pfe => "pfe", [Window, Window], 3, "Polarized fractal efficiency (window, EMA smoothing window), in [-1, 1]."; + Fisher => "fisher", [Window, Window], 2, "John Ehlers' Fisher transform (window, EMA smoothing window)."; + BinaryEntropy => "binary_entropy", [Window], 2, "Shannon entropy of the signs over the window."; + Vst => "vst", [Window], 2, "Variance stabilizing transform: change scaled by the rolling standard deviation."; + Net => "net", [Window], 2, "John Ehlers' noise elimination technology (Kendall correlation)."; +} + +impl Function { + /// The function called `name`, if any. + pub fn from_name(name: &str) -> Option { + Self::ALL.iter().copied().find(|f| f.name() == name) + } + + /// The call signature, e.g. `sma(a, window)`. + pub fn signature(self) -> String { + let mut out = format!("{}(a", self.name()); + for param in self.params() { + let _ = write!(out, ", {}", param.name()); + } + out.push(')'); + out + } + + /// A markdown table of every function: its signature and description. + /// Meant for documentation and for prompting an agent that writes specs. + pub fn reference() -> String { + let mut out = String::from("| function | description |\n|---|---|\n"); + for function in Self::ALL { + let _ = writeln!( + out, + "| `{}` | {} |", + function.signature(), + function.description() + ); + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn names_are_unique_and_round_trip() { + for function in Function::ALL { + assert_eq!(Function::from_name(function.name()), Some(*function)); + assert!(function.min_window() >= 1); + } + let mut names = Vec::from_iter(Function::ALL.iter().map(|f| f.name())); + names.sort_unstable(); + names.dedup(); + assert_eq!(names.len(), Function::ALL.len()); + assert_eq!(Function::from_name("nope"), None); + } + + #[test] + fn the_reference_lists_every_signature() { + let reference = Function::reference(); + assert!(reference.contains("| `pfe(a, window, window)` |")); + assert!(reference.contains("| `abs(a)` |")); + assert_eq!(reference.lines().count(), Function::ALL.len() + 2); + } +} diff --git a/src/spec/mod.rs b/src/spec/mod.rs new file mode 100644 index 0000000..ff36f8a --- /dev/null +++ b/src/spec/mod.rs @@ -0,0 +1,52 @@ +//! Features as text: a small expression language over this crate's views. +//! +//! A spec describes a chain of views fed one input series `x`. It is parsed +//! into an [`Expr`], checked against resource [`Limits`] and built into a +//! [`DynView`] at runtime, so features can be written, stored, compared and +//! shared as data, for example by a search or an LLM agent, without +//! compiling code. +//! +//! ```text +//! zscore(ema(x, 10) - ema(x, 50), 200) // a normalized EMA crossover +//! std(ln_return(x), 60) / std(ln_return(x), 600) // short vs long volatility +//! ``` +//! +//! Grammar: numbers, `x`, `+ - * /` with the usual precedence, unary `-`, +//! parentheses and calls `name(expr, param, ...)` whose parameters are +//! number literals; see [`Function::reference`] for every function. Binary +//! operators feed both sides the same input. +//! +//! A built view only ever outputs finite values: a division by zero or a +//! logarithm of a non-positive value makes the node not ready instead. +//! +//! ``` +//! use sliding_features::{View, spec::{Expr, Limits}}; +//! +//! let spec: Expr = "x - sma(x,2)".parse().unwrap(); +//! assert_eq!(spec.to_string(), "(x - sma(x, 2))"); +//! let mut view = spec.build(&Limits::default()).unwrap(); +//! view.update(100.0); +//! assert_eq!(view.last(), None, "the SMA needs two values"); +//! view.update(104.0); +//! assert_eq!(view.last(), Some(2.0)); +//! ``` + +mod build; +mod error; +mod expr; +mod function; +mod parse; + +pub use build::{ + DynView, + Limits, +}; +pub use error::SpecError; +pub use expr::{ + BinaryOp, + Expr, +}; +pub use function::{ + Function, + Param, +}; diff --git a/src/spec/parse.rs b/src/spec/parse.rs new file mode 100644 index 0000000..e616adb --- /dev/null +++ b/src/spec/parse.rs @@ -0,0 +1,319 @@ +//! Recursive-descent parser of the spec language. +//! +//! ```text +//! expr := term (('+' | '-') term)* +//! term := unary (('*' | '/') unary)* +//! unary := '-' unary | atom +//! atom := number | 'x' | name '(' expr (',' number)* ')' | '(' expr ')' +//! ``` + +use super::{ + BinaryOp, + Expr, + Function, + SpecError, +}; + +#[derive(Debug, Clone, PartialEq)] +enum Token { + Number(f64), + Name(String), + Open, + Close, + Comma, + Op(char), + End, +} + +/// A token and the byte offset it starts at. +type Spanned = (Token, usize); + +fn lex(s: &str) -> Result, SpecError> { + let bytes = s.as_bytes(); + let mut tokens = Vec::new(); + let mut i = 0; + while i < bytes.len() { + let c = bytes[i]; + let start = i; + match c { + b' ' | b'\t' | b'\n' | b'\r' => i += 1, + b'(' | b')' | b',' | b'+' | b'-' | b'*' | b'/' => { + tokens.push(( + match c { + b'(' => Token::Open, + b')' => Token::Close, + b',' => Token::Comma, + _ => Token::Op(char::from(c)), + }, + start, + )); + i += 1; + } + b'0'..=b'9' | b'.' => { + i = number_end(bytes, i); + let text = &s[start..i]; + let value = text + .parse::() + .map_err(|_| SpecError::parse(start, format!("invalid number `{text}`")))?; + tokens.push((Token::Number(value), start)); + } + b'a'..=b'z' | b'A'..=b'Z' | b'_' => { + while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') { + i += 1; + } + tokens.push((Token::Name(s[start..i].to_string()), start)); + } + _ => { + let ch = s[start..].chars().next().expect("in bounds"); + return Err(SpecError::parse(start, format!("unexpected `{ch}`"))); + } + } + } + tokens.push((Token::End, s.len())); + Ok(tokens) +} + +/// The end of the number starting at `i`: digits, a point, an exponent. +fn number_end(bytes: &[u8], mut i: usize) -> usize { + while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') { + i += 1; + } + if i < bytes.len() && (bytes[i] == b'e' || bytes[i] == b'E') { + let mut j = i + 1; + if j < bytes.len() && (bytes[j] == b'+' || bytes[j] == b'-') { + j += 1; + } + if j < bytes.len() && bytes[j].is_ascii_digit() { + i = j; + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + } + } + i +} + +struct Parser { + tokens: Vec, + pos: usize, +} + +impl Parser { + fn peek(&self) -> &Spanned { + &self.tokens[self.pos] + } + + fn next(&mut self) -> Spanned { + let token = self.tokens[self.pos].clone(); + if token.0 != Token::End { + self.pos += 1; + } + token + } + + fn expect(&mut self, expected: &Token, what: &str) -> Result<(), SpecError> { + let (token, at) = self.next(); + if &token == expected { + Ok(()) + } else { + Err(SpecError::parse( + at, + format!("expected {what}, found {}", describe(&token)), + )) + } + } + + fn expr(&mut self) -> Result { + let mut lhs = self.term()?; + while let Token::Op(c @ ('+' | '-')) = self.peek().0 { + self.next(); + let op = if c == '+' { + BinaryOp::Add + } else { + BinaryOp::Sub + }; + let rhs = self.term()?; + lhs = binary(op, lhs, rhs); + } + Ok(lhs) + } + + fn term(&mut self) -> Result { + let mut lhs = self.unary()?; + while let Token::Op(c @ ('*' | '/')) = self.peek().0 { + self.next(); + let op = if c == '*' { + BinaryOp::Mul + } else { + BinaryOp::Div + }; + let rhs = self.unary()?; + lhs = binary(op, lhs, rhs); + } + Ok(lhs) + } + + fn unary(&mut self) -> Result { + if self.peek().0 == Token::Op('-') { + self.next(); + // A negated literal is a negative constant, so `-0.5` prints and + // parses back as the same tree. + return Ok(match self.unary()? { + Expr::Const(c) => Expr::Const(-c), + e => Expr::Neg(Box::new(e)), + }); + } + self.atom() + } + + fn atom(&mut self) -> Result { + let (token, at) = self.next(); + match token { + Token::Number(value) => Ok(Expr::Const(value)), + Token::Name(name) if name == "x" => Ok(Expr::Input), + Token::Name(name) => self.call(&name, at), + Token::Open => { + let inner = self.expr()?; + self.expect(&Token::Close, "`)`")?; + Ok(inner) + } + other => Err(SpecError::parse( + at, + format!("expected an expression, found {}", describe(&other)), + )), + } + } + + fn call(&mut self, name: &str, at: usize) -> Result { + let function = Function::from_name(name) + .ok_or_else(|| SpecError::parse(at, format!("unknown function `{name}`")))?; + self.expect(&Token::Open, &format!("`(` after `{name}`"))?; + let arg = self.expr()?; + let mut params = Vec::new(); + while self.peek().0 == Token::Comma { + self.next(); + let param_at = self.peek().1; + match self.unary()? { + Expr::Const(value) => params.push(value), + _ => { + return Err(SpecError::parse( + param_at, + format!("parameters of `{name}` must be numbers"), + )); + } + } + } + self.expect(&Token::Close, "`)` or `,`")?; + super::expr::validate_call(function, ¶ms, &super::Limits::unbounded()) + .map_err(|e| SpecError::parse(at, e.message().to_string()))?; + Ok(Expr::Call { + function, + arg: Box::new(arg), + params, + }) + } +} + +fn binary(op: BinaryOp, lhs: Expr, rhs: Expr) -> Expr { + Expr::Binary { + op, + lhs: Box::new(lhs), + rhs: Box::new(rhs), + } +} + +fn describe(token: &Token) -> String { + match token { + Token::Number(v) => format!("number `{v}`"), + Token::Name(n) => format!("`{n}`"), + Token::Open => "`(`".to_string(), + Token::Close => "`)`".to_string(), + Token::Comma => "`,`".to_string(), + Token::Op(c) => format!("`{c}`"), + Token::End => "the end".to_string(), + } +} + +/// Parse a whole spec. +pub(super) fn parse(s: &str) -> Result { + let mut parser = Parser { + tokens: lex(s)?, + pos: 0, + }; + let expr = parser.expr()?; + parser.expect(&Token::End, "the end of the spec")?; + Ok(expr) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn roundtrip(s: &str) -> Expr { + let e = parse(s).unwrap_or_else(|err| panic!("{s}: {err}")); + let printed = e.to_string(); + assert_eq!( + parse(&printed).expect("canonical form parses"), + e, + "{printed}" + ); + e + } + + #[test] + fn precedence_and_associativity() { + assert_eq!(roundtrip("x + x * 2").to_string(), "(x + (x * 2))"); + assert_eq!(roundtrip("(x + x) * 2").to_string(), "((x + x) * 2)"); + assert_eq!(roundtrip("x - x - 1").to_string(), "((x - x) - 1)"); + assert_eq!(roundtrip("x / 2 / 4").to_string(), "((x / 2) / 4)"); + } + + #[test] + fn calls_unary_minus_and_numbers() { + let e = roundtrip("zscore(ema(x, 10) - ema(x,50), 200)"); + assert_eq!(e.to_string(), "zscore((ema(x, 10) - ema(x, 50)), 200)"); + assert_eq!(roundtrip("-0.5"), Expr::Const(-0.5)); + assert_eq!(roundtrip("gte(x, -1.5e-3)").to_string(), "gte(x, -0.0015)"); + assert_eq!(roundtrip("--x").to_string(), "--x"); + assert_eq!(roundtrip("-(x + 1)").to_string(), "-(x + 1)"); + roundtrip("pfe(roc(x, 1), 20, 5)"); + roundtrip(" laguerre_filter( x ,0.8 ) "); + } + + #[test] + fn every_function_round_trips() { + for function in Function::ALL { + let params = Vec::from_iter(function.params().iter().map(|p| match p { + super::super::Param::Window => function.min_window().to_string(), + super::super::Param::Real => "0.5".to_string(), + })); + let mut s = format!("{}(x", function.name()); + for p in params { + s.push_str(", "); + s.push_str(&p); + } + s.push(')'); + roundtrip(&s); + } + } + + #[test] + fn errors_point_at_the_problem() { + for (spec, position, fragment) in [ + ("sma(x)", 0, "takes 1 parameter"), + ("smaa(x, 3)", 0, "unknown function"), + ("sma(x, y)", 7, "`y`"), + ("sma(x, sma(x, 2))", 7, "must be numbers"), + ("x +", 3, "expected an expression"), + ("(x", 2, "expected `)`"), + ("x x", 2, "end of the spec"), + ("x # 2", 2, "unexpected `#`"), + ("sma(x, 2.5)", 0, "must be an integer"), + ("1..2", 0, "invalid number"), + ] { + let err = parse(spec).expect_err(spec); + assert_eq!(err.position(), Some(position), "{spec}: {err}"); + assert!(err.to_string().contains(fragment), "{spec}: {err}"); + } + } +} From 17056748dfdb2fd797560183c558ce2a63acc904 Mon Sep 17 00:00:00 2001 From: MathisWellmann Date: Sat, 10 Oct 2026 19:28:35 +0200 Subject: [PATCH 04/17] bump version to 10.1.0 (spec module, Abs, Ln, View for Box) --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 85b7f1c..86e6e3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1207,7 +1207,7 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "sliding_features" -version = "10.0.1" +version = "10.1.0" dependencies = [ "ballpark", "criterion", diff --git a/Cargo.toml b/Cargo.toml index 9fd8de0..78c441e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sliding_features" -version = "10.0.1" +version = "10.1.0" authors = ["MathisWellmann "] edition = "2024" license-file = "LICENSE" From 4f302faac58c97c539f6fe4f6035f3a4bfa7ee49 Mon Sep 17 00:00:00 2001 From: MathisWellmann Date: Sun, 11 Oct 2026 22:44:01 +0200 Subject: [PATCH 05/17] mad_scaler: derive Clone The flat pipeline keeps every stateful view in one enum and derives Clone on it, so a warmed-up pipeline can be copied. MadScaler was the only sliding window without Clone. --- src/sliding_windows/mad_scaler.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/sliding_windows/mad_scaler.rs b/src/sliding_windows/mad_scaler.rs index 9b0d7ca..2b65f71 100644 --- a/src/sliding_windows/mad_scaler.rs +++ b/src/sliding_windows/mad_scaler.rs @@ -38,6 +38,7 @@ use crate::View; /// An update costs `O(log w)` comparisons plus moving at most `w / 2` /// values of the sorted window (none for monotone input such as /// timestamps). +#[derive(Clone)] pub struct MadScaler { view: V, /// Sliding window length (for warm-up tracking). @@ -510,4 +511,22 @@ mod tests { } } } + + /// A clone of a warmed-up scaler continues exactly like the original. + #[test] + fn mad_scaler_clone_continues_identically() { + let mut original = MadScaler::new(Echo::new(), NonZeroUsize::new(16).unwrap()); + let (_, vals) = reference_streams(0, 200).remove(0); + let (head, tail) = vals.split_at(100); + for v in head { + original.update(*v); + } + let mut clone = original.clone(); + for v in tail { + original.update(*v); + clone.update(*v); + assert_eq!(original.last(), clone.last()); + } + assert!(clone.last().is_some()); + } } From d6c4aa6b653c0ffb5cc0a79677fa00c3d71ee0ae Mon Sep 17 00:00:00 2001 From: MathisWellmann Date: Sun, 11 Oct 2026 22:49:04 +0200 Subject: [PATCH 06/17] pipeline: catalogue of operators and functions with their domains First piece of the flat pipeline that replaces the spec module. One table declares each function's name, parameters, argument requirement, output range and description, so they cannot drift apart (esr-core repeats a match per property across 900 lines). Unlike esr-core, which only knows a window length, parameters are typed: windows, reals and fractions. Domain is an interval with open, closed or infinite ends, as in esr-core's domain analysis, but derived per node from the inputs instead of fixed per operator. It lets the compiler report calls that are never ready and constant outputs before any data is seen. A randomized test checks that every operation contains all results of its members. The revision is larger than 500 lines because the interval operations are only used by the function table; split apart, either half would be dead code. --- src/lib.rs | 1 + src/pipeline/domain.rs | 534 +++++++++++++++++++++++++++++++++ src/pipeline/function.rs | 626 +++++++++++++++++++++++++++++++++++++++ src/pipeline/mod.rs | 18 ++ 4 files changed, 1179 insertions(+) create mode 100644 src/pipeline/domain.rs create mode 100644 src/pipeline/function.rs create mode 100644 src/pipeline/mod.rs diff --git a/src/lib.rs b/src/lib.rs index e64f48f..61447ee 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ //! The sliding_features crate provides modular, chainable sliding windows //! for various signal processing function and technical indicators +pub mod pipeline; pub mod pure_functions; pub mod rolling; pub mod sliding_windows; diff --git a/src/pipeline/domain.rs b/src/pipeline/domain.rs new file mode 100644 index 0000000..c377483 --- /dev/null +++ b/src/pipeline/domain.rs @@ -0,0 +1,534 @@ +//! The value range of a node, tracked by the compiler to find problems +//! before a pipeline sees any data. + +use std::fmt; + +/// One end of a [`Domain`]. +#[derive(Debug, Clone, Copy, PartialEq)] +struct Bound { + value: f64, + /// Whether `value` itself belongs to the domain. Infinite bounds are + /// always open, because a node only ever outputs finite values. + closed: bool, +} + +impl Bound { + fn new(value: f64, closed: bool) -> Self { + assert!(!value.is_nan(), "a bound is a number"); + Self { + // Adding zero turns -0 into 0, which prints as `0`. + value: value + 0.0, + closed: closed && value.is_finite(), + } + } +} + +/// A set of real numbers: an interval whose ends are open, closed or +/// infinite, e.g. `(0, inf)` or `[0, 100]`. +/// +/// The compiler derives the domain of every node from the domains of the +/// inputs, to report a function that can never be ready (e.g. `ln` of a +/// negative argument) or an output that is constant before any data is +/// seen, and to tell the range of each output. Domains over-approximate: +/// a node's values always lie inside its domain, but may not cover it. +/// +/// Arithmetic on domains follows real arithmetic, so rounding can step +/// past an open end (a product of two tiny positives can underflow to 0). +/// Functions that saturate in floating point, like `tanh` or `exp`, reach +/// their limits, so their domains include them. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Domain { + lo: Bound, + hi: Bound, +} + +impl Domain { + /// All finite real numbers, `(-inf, inf)`. + pub const REAL: Domain = Domain { + lo: Bound { + value: f64::NEG_INFINITY, + closed: false, + }, + hi: Bound { + value: f64::INFINITY, + closed: false, + }, + }; + + /// The domain between two bounds, `None` if it is empty. + fn new(lo: Bound, hi: Bound) -> Option { + let non_empty = lo.value < hi.value || (lo.value == hi.value && lo.closed && hi.closed); + non_empty.then_some(Self { lo, hi }) + } + + /// The domain between two bounds that rounding may have collapsed + /// onto one value: then that value, which over-approximates, or all + /// numbers if the value overflowed. + fn new_or_point(lo: Bound, hi: Bound) -> Self { + Self::new(lo, hi).unwrap_or_else(|| { + assert_eq!(lo.value, hi.value, "bounds are ordered"); + if lo.value.is_finite() { + Self::point(lo.value) + } else { + Self::REAL + } + }) + } + + /// The positive numbers, `(0, inf)`, e.g. of prices. + pub fn positive() -> Self { + Self { + lo: Bound::new(0.0, false), + hi: Self::REAL.hi, + } + } + + /// The non-negative numbers, `[0, inf)`, e.g. of volumes. + pub fn non_negative() -> Self { + Self { + lo: Bound::new(0.0, true), + hi: Self::REAL.hi, + } + } + + /// The numbers from `lo` to `hi`, both included (infinite ends are + /// open). + /// + /// # Panics + /// + /// If a bound is NaN or `lo > hi`. + pub fn closed(lo: f64, hi: f64) -> Self { + Self::new(Bound::new(lo, true), Bound::new(hi, true)) + .expect("the lower bound must not exceed the upper bound") + } + + /// The numbers strictly between `lo` and `hi`. + /// + /// # Panics + /// + /// If a bound is NaN or `lo >= hi`. + pub fn open(lo: f64, hi: f64) -> Self { + Self::new(Bound::new(lo, false), Bound::new(hi, false)) + .expect("the lower bound must be below the upper bound") + } + + /// The single number `value`. + /// + /// # Panics + /// + /// If `value` is not finite. + pub fn point(value: f64) -> Self { + assert!(value.is_finite(), "a point domain holds a finite value"); + Self::closed(value, value) + } + + /// The lower end, `-inf` when unbounded below. + pub fn lo(&self) -> f64 { + self.lo.value + } + + /// The upper end, `inf` when unbounded above. + pub fn hi(&self) -> f64 { + self.hi.value + } + + /// Whether the domain holds `value`. + pub fn contains(&self, value: f64) -> bool { + let above_lo = value > self.lo.value || (value == self.lo.value && self.lo.closed); + let below_hi = value < self.hi.value || (value == self.hi.value && self.hi.closed); + above_lo && below_hi + } + + /// The only value of a single-value domain, e.g. of a constant. + pub fn as_point(&self) -> Option { + (self.lo.value == self.hi.value).then_some(self.lo.value) + } + + /// The values in both domains, `None` if there are none. + pub fn intersect(self, other: Self) -> Option { + let lo = match self.lo.value.total_cmp(&other.lo.value) { + std::cmp::Ordering::Less => other.lo, + std::cmp::Ordering::Greater => self.lo, + std::cmp::Ordering::Equal => { + Bound::new(self.lo.value, self.lo.closed && other.lo.closed) + } + }; + let hi = match self.hi.value.total_cmp(&other.hi.value) { + std::cmp::Ordering::Less => self.hi, + std::cmp::Ordering::Greater => other.hi, + std::cmp::Ordering::Equal => { + Bound::new(self.hi.value, self.hi.closed && other.hi.closed) + } + }; + Self::new(lo, hi) + } + + /// `-a` for `a` in the domain. + pub(super) fn neg(self) -> Self { + Self { + lo: Bound::new(-self.hi.value, self.hi.closed), + hi: Bound::new(-self.lo.value, self.lo.closed), + } + } + + /// `a + b` for `a` in `self` and `b` in `other`. + pub(super) fn add(self, other: Self) -> Self { + // Lower ends are below `inf` and upper ends above `-inf`, so the + // sums are never `inf - inf`; an overflow becomes an open end. + let lo = Bound::new( + self.lo.value + other.lo.value, + self.lo.closed && other.lo.closed, + ); + let hi = Bound::new( + self.hi.value + other.hi.value, + self.hi.closed && other.hi.closed, + ); + Self::new_or_point(lo, hi) + } + + /// `a - b` for `a` in `self` and `b` in `other`. + pub(super) fn sub(self, other: Self) -> Self { + self.add(other.neg()) + } + + /// `a * b` for `a` in `self` and `b` in `other`. + /// + /// The extremes of a product lie at the corners, with `0 * inf = 0`. + pub(super) fn mul(self, other: Self) -> Self { + let corners = [ + product(self.lo, other.lo), + product(self.lo, other.hi), + product(self.hi, other.lo), + product(self.hi, other.hi), + ]; + let lo = extreme(corners, std::cmp::Ordering::Less); + let hi = extreme(corners, std::cmp::Ordering::Greater); + Self::new_or_point(lo, hi) + } + + /// `a / b` for `a` in `self` and `b` in `other`, or `None` when `b` can + /// be zero (the quotient is then unbounded). + pub(super) fn div(self, other: Self) -> Option { + if other.contains(0.0) { + return None; + } + // Zero is outside, so `other` lies on one side of it; a zero end is + // open and its reciprocal is the infinity on that side. + let positive = other.lo.value >= 0.0; + let recip = |b: Bound| { + let value = if b.value == 0.0 { + if positive { + f64::INFINITY + } else { + f64::NEG_INFINITY + } + } else { + 1.0 / b.value + }; + Bound::new(value, b.closed) + }; + let reciprocal = Self::new_or_point(recip(other.hi), recip(other.lo)); + Some(self.mul(reciprocal)) + } + + /// `|a|` for `a` in the domain. + pub(super) fn abs(self) -> Self { + if self.lo.value >= 0.0 { + return self; + } + if self.hi.value <= 0.0 { + return self.neg(); + } + let (neg, pos) = (-self.lo.value, self.hi.value); + let hi = match neg.total_cmp(&pos) { + std::cmp::Ordering::Less => self.hi, + std::cmp::Ordering::Greater => Bound::new(neg, self.lo.closed), + std::cmp::Ordering::Equal => Bound::new(pos, self.lo.closed || self.hi.closed), + }; + Self::new_or_point(Bound::new(0.0, true), hi) + } + + /// `f(a)` for `a` in the domain and a non-decreasing `f` that is + /// defined on all of it. + /// + /// Finite ends of the result are closed: in floating point `f` reaches + /// its limits (`tanh(20) == 1`, `exp(-800) == 0`). + pub(super) fn map_increasing(self, f: impl Fn(f64) -> f64) -> Self { + let (lo, hi) = (f(self.lo.value), f(self.hi.value)); + assert!(!lo.is_nan(), "f is defined on the domain"); + assert!(!hi.is_nan(), "f is defined on the domain"); + Self::new_or_point(Bound::new(lo, true), Bound::new(hi, true)) + } + + /// The sign of `a` for `a` in the domain: values in `{-1, 0, 1}`. + pub(super) fn sign(self) -> Self { + let sign = |b: Bound, open_zero: f64| { + if b.value > 0.0 { + 1.0 + } else if b.value < 0.0 { + -1.0 + } else if b.closed { + 0.0 + } else { + open_zero + } + }; + Self::closed(sign(self.lo, 1.0), sign(self.hi, -1.0)) + } + + /// `max(a, c)` for `a` in the domain. + pub(super) fn max_with(self, c: f64) -> Self { + assert!(c.is_finite(), "a clipping bound is finite"); + let lo = if c > self.lo.value { + Bound::new(c, true) + } else { + self.lo + }; + let hi = if c >= self.hi.value { + Bound::new(c, true) + } else { + self.hi + }; + Self::new_or_point(lo, hi) + } + + /// `min(a, c)` for `a` in the domain. + pub(super) fn min_with(self, c: f64) -> Self { + self.neg().max_with(-c).neg() + } +} + +/// A corner of a product of two domains, with `0 * inf = 0`: a zero end +/// that belongs to its domain makes the product zero whatever the other +/// factor is. +fn product(a: Bound, b: Bound) -> Bound { + let a_zero = a.value == 0.0; + let b_zero = b.value == 0.0; + let value = if a_zero || b_zero { + 0.0 + } else { + a.value * b.value + }; + let closed = (a.closed && b.closed) || (a_zero && a.closed) || (b_zero && b.closed); + Bound::new(value, closed) +} + +/// The smallest (`Less`) or largest (`Greater`) corner; it belongs to the +/// domain if any corner of that value does. +fn extreme(corners: [Bound; 4], order: std::cmp::Ordering) -> Bound { + corners + .into_iter() + .reduce(|best, c| match c.value.total_cmp(&best.value) { + std::cmp::Ordering::Equal => Bound::new(c.value, c.closed || best.closed), + o if o == order => c, + _ => best, + }) + .expect("four corners") +} + +impl fmt::Display for Domain { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let open = if self.lo.closed { '[' } else { '(' }; + let close = if self.hi.closed { ']' } else { ')' }; + write!(f, "{open}{}, {}{close}", self.lo.value, self.hi.value) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display_and_constructors() { + assert_eq!(Domain::REAL.to_string(), "(-inf, inf)"); + assert_eq!(Domain::positive().to_string(), "(0, inf)"); + assert_eq!(Domain::non_negative().to_string(), "[0, inf)"); + assert_eq!(Domain::closed(0.0, 100.0).to_string(), "[0, 100]"); + assert_eq!(Domain::open(-1.0, 1.0).to_string(), "(-1, 1)"); + assert_eq!(Domain::point(2.5).as_point(), Some(2.5)); + assert_eq!(Domain::closed(1.0, f64::INFINITY).to_string(), "[1, inf)"); + assert_eq!(Domain::REAL.as_point(), None); + } + + #[test] + #[should_panic(expected = "must not exceed")] + fn reversed_bounds_panic() { + let _ = Domain::closed(1.0, 0.0); + } + + #[test] + #[should_panic(expected = "must be below")] + fn empty_open_domain_panics() { + let _ = Domain::open(1.0, 1.0); + } + + #[test] + fn contains_respects_open_and_closed_ends() { + let d = Domain::positive(); + assert!(!d.contains(0.0)); + assert!(d.contains(f64::MIN_POSITIVE)); + assert!(!d.contains(-1.0)); + assert!(Domain::non_negative().contains(0.0)); + assert!(!Domain::REAL.contains(f64::INFINITY)); + assert!(Domain::closed(1.0, 2.0).contains(2.0)); + } + + #[test] + fn intersection() { + let r = Domain::REAL.intersect(Domain::positive()); + assert_eq!(r, Some(Domain::positive())); + let neg = Domain::closed(f64::NEG_INFINITY, 0.0); + assert_eq!(neg.intersect(Domain::positive()), None); + assert_eq!( + neg.intersect(Domain::non_negative()), + Some(Domain::point(0.0)) + ); + assert_eq!( + Domain::closed(-1.0, 5.0).intersect(Domain::open(0.0, 10.0)), + Some(Domain::new(Bound::new(0.0, false), Bound::new(5.0, true)).unwrap()) + ); + } + + #[test] + fn arithmetic_examples() { + let pos = Domain::positive(); + assert_eq!(pos.mul(pos), pos); + assert_eq!(pos.add(pos), pos); + assert_eq!(pos.sub(pos), Domain::REAL); + assert_eq!(pos.neg().to_string(), "(-inf, 0)"); + assert_eq!( + Domain::closed(0.0, 5.0).mul(pos), + Domain::non_negative(), + "0 * inf is 0, and 0 is reached" + ); + assert_eq!( + Domain::closed(1.0, 2.0).mul(Domain::closed(-3.0, 4.0)), + Domain::closed(-6.0, 8.0) + ); + assert_eq!( + Domain::closed(1.0, 2.0).div(Domain::closed(-1.0, 1.0)), + None + ); + assert_eq!( + Domain::closed(1.0, 2.0).div(Domain::closed(2.0, 4.0)), + Some(Domain::closed(0.25, 1.0)) + ); + assert_eq!(Domain::point(1.0).div(pos), Some(pos)); + assert_eq!(Domain::point(1.0).div(pos.neg()), Some(pos.neg())); + assert_eq!(Domain::closed(-3.0, 2.0).abs(), Domain::closed(0.0, 3.0)); + assert_eq!(Domain::REAL.abs(), Domain::non_negative()); + assert_eq!(Domain::REAL.sign(), Domain::closed(-1.0, 1.0)); + assert_eq!(pos.sign(), Domain::point(1.0)); + assert_eq!( + Domain::REAL.map_increasing(f64::tanh), + Domain::closed(-1.0, 1.0) + ); + assert_eq!( + Domain::REAL.map_increasing(f64::exp), + Domain::non_negative(), + "exp underflows to 0" + ); + assert_eq!( + Domain::REAL.max_with(-1.0), + Domain::closed(-1.0, f64::INFINITY) + ); + assert_eq!(Domain::closed(0.0, 1.0).min_with(-2.0), Domain::point(-2.0)); + } + + #[test] + fn rounding_collapse_yields_a_point_not_an_empty_domain() { + let far = Domain::open(1000.0, 2000.0); + assert_eq!(far.map_increasing(f64::tanh), Domain::point(1.0)); + let huge = Domain::point(f64::MAX); + assert_eq!(huge.add(huge), Domain::REAL, "an overflow is unbounded"); + } + + /// Bounds that exercise zeros, infinities and open ends. + fn random_domain(rng: &romu::Rng) -> Domain { + const VALUES: [f64; 9] = [ + f64::NEG_INFINITY, + -10.0, + -1.0, + -0.5, + 0.0, + 0.5, + 1.0, + 10.0, + f64::INFINITY, + ]; + loop { + let pick = || VALUES[rng.range_usize(0..VALUES.len())]; + let (a, b) = (pick(), pick()); + let lo = Bound::new(a.min(b), rng.bool()); + let hi = Bound::new(a.max(b), rng.bool()); + if let Some(d) = Domain::new(lo, hi) { + return d; + } + } + } + + /// A value of the domain, often one of its closed ends. + fn random_member(d: Domain, rng: &romu::Rng) -> f64 { + loop { + let v = match rng.range_usize(0..4) { + 0 => d.lo(), + 1 => d.hi(), + _ => { + let lo = d.lo().max(-20.0); + let hi = d.hi().min(20.0); + lo + (hi - lo) * rng.f64() + } + }; + if d.contains(v) { + return v; + } + } + } + + /// Soundness: every result of an operation on members of domains is a + /// member of the operation's domain. + #[test] + fn operations_contain_every_result() { + let rng = romu::Rng::from_seed_with_64bit(3); + for _ in 0..20_000 { + let (da, db) = (random_domain(&rng), random_domain(&rng)); + let (a, b) = (random_member(da, &rng), random_member(db, &rng)); + let c = random_member(Domain::closed(-2.0, 2.0), &rng); + let check = |name: &str, d: Domain, v: f64| { + assert!( + d.contains(v), + "{name}: {v} not in {d} for {a} in {da}, {b} in {db}" + ); + }; + check("neg", da.neg(), -a); + check("add", da.add(db), a + b); + check("sub", da.sub(db), a - b); + check("mul", da.mul(db), a * b); + if let Some(q) = da.div(db) { + check("div", q, a / b); + } else { + assert!(db.contains(0.0)); + } + check("abs", da.abs(), a.abs()); + let sign = if a > 0.0 { + 1.0 + } else if a < 0.0 { + -1.0 + } else { + 0.0 + }; + check("sign", da.sign(), sign); + check("tanh", da.map_increasing(f64::tanh), a.tanh()); + check("exp", da.map_increasing(f64::exp), a.exp()); + check("max_with", da.max_with(c), a.max(c)); + check("min_with", da.min_with(c), a.min(c)); + if let Some(i) = da.intersect(db) { + if da.contains(b) { + check("intersect", i, b); + } + } else { + assert!(!da.contains(b), "{b} in {da} and {db}"); + } + } + } +} diff --git a/src/pipeline/function.rs b/src/pipeline/function.rs new file mode 100644 index 0000000..fc7d0b1 --- /dev/null +++ b/src/pipeline/function.rs @@ -0,0 +1,626 @@ +//! The operators and functions of a pipeline: one table holds each +//! function's name, parameters, argument requirement, output range and +//! description, so they cannot drift apart. + +use std::{ + fmt::Write as _, + num::NonZeroUsize, +}; + +use super::Domain; + +/// An arithmetic operator between two expressions. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum BinaryOp { + /// `a + b` + Add, + /// `a - b` + Sub, + /// `a * b` + Mul, + /// `a / b`, not ready while `b` is zero. + Div, +} + +impl BinaryOp { + /// The operator's symbol in the text form. + pub fn symbol(self) -> char { + match self { + BinaryOp::Add => '+', + BinaryOp::Sub => '-', + BinaryOp::Mul => '*', + BinaryOp::Div => '/', + } + } + + /// The domain of `a op b` for `a` in `lhs` and `b` in `rhs`, `None` + /// when the result is never finite (a division by a zero constant). + pub fn output_domain(self, lhs: Domain, rhs: Domain) -> Option { + Some(match self { + BinaryOp::Add => lhs.add(rhs), + BinaryOp::Sub => lhs.sub(rhs), + BinaryOp::Mul => lhs.mul(rhs), + BinaryOp::Div => match lhs.div(rhs) { + Some(d) => d, + // A divisor that is always zero is never valid; one that + // can be zero leaves the quotient unbounded. + None if rhs.as_point() == Some(0.0) => return None, + None => Domain::REAL, + }, + }) + } +} + +/// The kind of a parameter that follows the expression argument of a call. +/// Parameters are number literals, so they can be checked before the +/// pipeline runs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Param { + /// A window length, counted in updates of the argument: an integer of + /// at least [`Function::min_window`]. + Window, + /// A finite real number. + Real, + /// A real number strictly between 0 and 1, e.g. a damping factor. + Fraction, +} + +impl Param { + /// The name of the parameter kind in signatures. + pub fn name(self) -> &'static str { + match self { + Param::Window => "window", + Param::Real => "real", + Param::Fraction => "fraction", + } + } +} + +/// What a function needs of its argument to update. While the argument +/// does not meet it, the call is not ready (or holds its last value). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Requirement { + /// Any finite value. + Any, + /// Values above zero, e.g. for logarithms. + Positive, + /// Values of at least zero, e.g. for square roots. + NonNegative, + /// Any finite value, but an argument that never changes sign makes the + /// output constant, e.g. the entropy of signs. + BothSigns, +} + +impl Requirement { + /// The values the requirement admits. + pub fn domain(self) -> Domain { + match self { + Requirement::Any | Requirement::BothSigns => Domain::REAL, + Requirement::Positive => Domain::positive(), + Requirement::NonNegative => Domain::non_negative(), + } + } + + fn describe(self) -> &'static str { + match self { + Requirement::Any => "any", + Requirement::Positive => "positive", + Requirement::NonNegative => "non-negative", + Requirement::BothSigns => "changes sign", + } + } +} + +/// The checked parameters of a call. +#[derive(Debug, Clone, Copy)] +pub(super) struct Params<'a>(&'a [f64]); + +impl<'a> Params<'a> { + /// Wrap parameters that passed [`Function::check_params`]. + pub(super) fn new(function: Function, params: &'a [f64]) -> Self { + assert_eq!(params.len(), function.params().len(), "checked parameters"); + Self(params) + } + + /// The `i`-th parameter as a window length. + pub(super) fn window(self, i: usize) -> NonZeroUsize { + window_len(self.0[i]).expect("checked window") + } + + /// The `i`-th parameter as a real number. + pub(super) fn real(self, i: usize) -> f64 { + let value = self.0[i]; + assert!(value.is_finite(), "checked parameter"); + value + } +} + +/// The window length `value` denotes: an integer from 1 to 2^53, where +/// every integer is exact as `f64`. +pub(super) fn window_len(value: f64) -> Option { + const MAX_EXACT: f64 = 9_007_199_254_740_992.0; + if value.fract() != 0.0 || !(1.0..=MAX_EXACT).contains(&value) { + return None; + } + #[allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "an integer in [1, 2^53] is exact as usize on 64-bit targets" + )] + let len = value as usize; + NonZeroUsize::new(len) +} + +/// The output domain of a function given its (admitted) argument domain. +type RangeFn = fn(Domain, Params<'_>) -> Domain; + +/// Declares [`Function`] and its metadata from one table. +macro_rules! functions { + ($($variant:ident { + name: $name:literal, + params: [$($param:ident),*], + min_window: $min:literal, + requires: $requires:ident, + range: $range:expr, + doc: $doc:literal $(,)? + })*) => { + /// A function of a pipeline. Every call takes an expression as its + /// first argument, then its [`Function::params`] as number literals. + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub enum Function { + $(#[doc = $doc] $variant,)* + } + + impl Function { + /// Every function, in declaration order. + pub const ALL: &'static [Function] = &[$(Function::$variant,)*]; + + /// The name the function is called by. + pub fn name(self) -> &'static str { + match self { $(Function::$variant => $name,)* } + } + + /// The parameters after the expression argument. + pub fn params(self) -> &'static [Param] { + match self { $(Function::$variant => &[$(Param::$param),*],)* } + } + + /// The smallest window length the function accepts: shorter + /// windows are degenerate (e.g. an RSI over one value). + pub fn min_window(self) -> usize { + match self { $(Function::$variant => $min,)* } + } + + /// What the function needs of its argument. + pub fn requires(self) -> Requirement { + match self { $(Function::$variant => Requirement::$requires,)* } + } + + /// What the function computes, one line. + pub fn description(self) -> &'static str { + match self { $(Function::$variant => $doc,)* } + } + + fn range(self, arg: Domain, params: Params<'_>) -> Domain { + let range: RangeFn = match self { $(Function::$variant => $range,)* }; + range(arg, params) + } + } + }; +} + +functions! { + Abs { + name: "abs", params: [], min_window: 1, requires: Any, + range: |a, _| a.abs(), + doc: "Absolute value.", + } + Sign { + name: "sign", params: [], min_window: 1, requires: Any, + range: |a, _| a.sign(), + doc: "Sign: -1, 0 or 1.", + } + Sqrt { + name: "sqrt", params: [], min_window: 1, requires: NonNegative, + range: |a, _| a.map_increasing(f64::sqrt), + doc: "Square root.", + } + Square { + name: "square", params: [], min_window: 1, requires: Any, + range: |a, _| a.abs().map_increasing(|v| v * v), + doc: "The argument times itself.", + } + Exp { + name: "exp", params: [], min_window: 1, requires: Any, + range: |a, _| a.map_increasing(f64::exp), + doc: "Exponential function; not ready where it overflows.", + } + Ln { + name: "ln", params: [], min_window: 1, requires: Positive, + range: |a, _| a.map_increasing(f64::ln), + doc: "Natural logarithm.", + } + Tanh { + name: "tanh", params: [], min_window: 1, requires: Any, + range: |a, _| a.map_increasing(f64::tanh), + doc: "Hyperbolic tangent, squashes into (-1, 1).", + } + Sigmoid { + name: "sigmoid", params: [], min_window: 1, requires: Any, + range: |a, _| a.map_increasing(sigmoid), + doc: "Logistic function 1 / (1 + exp(-a)), squashes into (0, 1).", + } + Asinh { + name: "asinh", params: [], min_window: 1, requires: Any, + range: |a, _| a.map_increasing(f64::asinh), + doc: "Inverse hyperbolic sine: like a signed logarithm, compresses large magnitudes.", + } + Gte { + name: "gte", params: [Real], min_window: 1, requires: Any, + range: |a, p| a.max_with(p.real(0)), + doc: "Clips values below the real to it: max(a, real).", + } + Lte { + name: "lte", params: [Real], min_window: 1, requires: Any, + range: |a, p| a.min_with(p.real(0)), + doc: "Clips values above the real to it: min(a, real).", + } + LnReturn { + name: "ln_return", params: [], min_window: 1, requires: Positive, + range: |_, _| Domain::REAL, + doc: "Log return between consecutive updates.", + } + Drawdown { + name: "drawdown", params: [], min_window: 1, requires: Positive, + range: |_, _| Domain::closed(0.0, 1.0), + doc: "Largest relative peak-to-valley decline so far, in [0, 1].", + } + Sma { + name: "sma", params: [Window], min_window: 1, requires: Any, + range: |a, _| a, + doc: "Simple moving average.", + } + Ema { + name: "ema", params: [Window], min_window: 1, requires: Any, + range: |a, _| a, + doc: "Exponential moving average, alpha = 2 / (window + 1).", + } + Alma { + name: "alma", params: [Window], min_window: 2, requires: Any, + range: |a, _| a, + doc: "Arnaud Legoux moving average (sigma 6, offset 0.85).", + } + Roc { + name: "roc", params: [Window], min_window: 1, requires: Any, + range: |_, _| Domain::REAL, + doc: "Rate of change in percent against the value `window` updates ago.", + } + Lag { + name: "lag", params: [Window], min_window: 1, requires: Any, + range: |a, _| a, + doc: "The value `window` updates ago.", + } + Min { + name: "min", params: [Window], min_window: 1, requires: Any, + range: |a, _| a, + doc: "Minimum over the window.", + } + Max { + name: "max", params: [Window], min_window: 1, requires: Any, + range: |a, _| a, + doc: "Maximum over the window.", + } + MinMax { + name: "min_max", params: [Window], min_window: 2, requires: Any, + range: |_, _| Domain::REAL, + doc: "Position in the [min, max] range of the previous window, scaled to [-1, 1] (beyond it on new extremes).", + } + Mad { + name: "mad", params: [Window], min_window: 2, requires: Any, + range: |_, _| Domain::REAL, + doc: "Robust z-score: (a - median) / (1.4826 MAD) of the previous window.", + } + ZScore { + name: "zscore", params: [Window], min_window: 2, requires: Any, + range: |_, _| Domain::REAL, + doc: "Z-score against the mean and standard deviation of the previous window.", + } + Std { + name: "std", params: [Window], min_window: 2, requires: Any, + range: |_, _| Domain::non_negative(), + doc: "Standard deviation over the window (Welford).", + } + Cumulative { + name: "cumulative", params: [Window], min_window: 1, requires: Any, + range: |a, p| a.mul(Domain::closed(1.0, window_f64(p.window(0)))), + doc: "Sum over the window.", + } + Rsi { + name: "rsi", params: [Window], min_window: 2, requires: Any, + range: |_, _| Domain::closed(0.0, 100.0), + doc: "Relative strength index, in [0, 100].", + } + MyRsi { + name: "my_rsi", params: [Window], min_window: 2, requires: Any, + range: |_, _| Domain::closed(-1.0, 1.0), + doc: "John Ehlers' MyRSI, in [-1, 1].", + } + LaguerreRsi { + name: "laguerre_rsi", params: [Window], min_window: 2, requires: Any, + range: |_, _| Domain::closed(0.0, 1.0), + doc: "John Ehlers' Laguerre RSI, in [0, 1].", + } + LaguerreFilter { + name: "laguerre_filter", params: [Fraction], min_window: 1, requires: Any, + range: |_, _| Domain::REAL, + doc: "John Ehlers' Laguerre filter with damping gamma (the fraction).", + } + SuperSmoother { + name: "super_smoother", params: [Window], min_window: 2, requires: Any, + range: |_, _| Domain::REAL, + doc: "John Ehlers' two-pole super smoother low-pass filter.", + } + RoofingFilter { + name: "roofing_filter", params: [Window, Window], min_window: 2, requires: Any, + range: |_, _| Domain::REAL, + doc: "John Ehlers' roofing filter (low-pass window, high-pass window): a band-pass.", + } + ReFlex { + name: "re_flex", params: [Window], min_window: 2, requires: Any, + range: |_, _| Domain::REAL, + doc: "John Ehlers' ReFlex zero-lag cycle indicator.", + } + TrendFlex { + name: "trend_flex", params: [Window], min_window: 2, requires: Any, + range: |_, _| Domain::REAL, + doc: "John Ehlers' TrendFlex zero-lag trend indicator.", + } + CyberCycle { + name: "cyber_cycle", params: [Window], min_window: 3, requires: Any, + range: |_, _| Domain::REAL, + doc: "John Ehlers' cyber cycle.", + } + CenterOfGravity { + name: "center_of_gravity", params: [Window], min_window: 2, requires: Any, + range: |_, _| Domain::REAL, + doc: "John Ehlers' center of gravity oscillator.", + } + Cti { + name: "cti", params: [Window], min_window: 2, requires: Any, + range: |_, _| Domain::closed(-1.0, 1.0), + doc: "John Ehlers' correlation trend indicator: correlation with a straight line, in [-1, 1].", + } + Pfe { + name: "pfe", params: [Window, Window], min_window: 3, requires: Any, + range: |_, _| Domain::closed(-1.0, 1.0), + doc: "Polarized fractal efficiency (window, EMA smoothing window), in [-1, 1].", + } + Fisher { + name: "fisher", params: [Window, Window], min_window: 2, requires: Any, + range: |_, _| Domain::REAL, + doc: "John Ehlers' Fisher transform (window, EMA smoothing window).", + } + BinaryEntropy { + name: "binary_entropy", params: [Window], min_window: 2, requires: BothSigns, + range: |_, _| Domain::closed(0.0, 1.0), + doc: "Shannon entropy of the signs over the window, in [0, 1].", + } + Vst { + name: "vst", params: [Window], min_window: 2, requires: Any, + range: |_, _| Domain::REAL, + doc: "Variance stabilizing transform: change scaled by the rolling standard deviation.", + } + Net { + name: "net", params: [Window], min_window: 2, requires: Any, + range: |_, _| Domain::closed(-1.0, 1.0), + doc: "John Ehlers' noise elimination technology (Kendall correlation), in [-1, 1].", + } +} + +/// The logistic function. +pub(super) fn sigmoid(v: f64) -> f64 { + 1.0 / (1.0 + (-v).exp()) +} + +/// A window length as `f64`, exact for checked windows (at most 2^53). +fn window_f64(window: NonZeroUsize) -> f64 { + #[allow( + clippy::cast_precision_loss, + reason = "checked windows are at most 2^53, exact as f64" + )] + let value = window.get() as f64; + value +} + +impl Function { + /// The function called `name`, if any. + pub fn from_name(name: &str) -> Option { + Self::ALL.iter().copied().find(|f| f.name() == name) + } + + /// The call signature, e.g. `sma(a, window)`. + pub fn signature(self) -> String { + let mut out = format!("{}(a", self.name()); + for param in self.params() { + let _ = write!(out, ", {}", param.name()); + } + out.push(')'); + out + } + + /// Check parameters against the signature and `max_window`; the error + /// says what is wrong. + pub fn check_params(self, params: &[f64], max_window: usize) -> Result<(), String> { + let expected = self.params(); + if params.len() != expected.len() { + return Err(format!( + "{} takes {} parameter(s) after its argument, got {}", + self.signature(), + expected.len(), + params.len() + )); + } + for (kind, value) in expected.iter().zip(params) { + let valid = match kind { + Param::Window => window_len(*value) + .is_some_and(|w| w.get() >= self.min_window() && w.get() <= max_window), + Param::Real => value.is_finite(), + Param::Fraction => *value > 0.0 && *value < 1.0, + }; + if !valid { + let expected = match kind { + Param::Window => { + format!("an integer in [{}, {max_window}]", self.min_window()) + } + Param::Real => "a finite number".to_string(), + Param::Fraction => "a number strictly between 0 and 1".to_string(), + }; + return Err(format!( + "{}: the {} parameter {value} must be {expected}", + self.signature(), + kind.name() + )); + } + } + Ok(()) + } + + /// The domain of the call's output for an argument in `arg`, `None` if + /// no value of `arg` meets [`Function::requires`] (the call is never + /// ready). + /// + /// # Panics + /// + /// If `params` do not pass [`Function::check_params`]. + pub fn output_domain(self, arg: Domain, params: &[f64]) -> Option { + let admitted = arg.intersect(self.requires().domain())?; + Some(self.range(admitted, Params::new(self, params))) + } + + /// A markdown table of every function: signature, what the argument + /// must be, and description. Meant for documentation and for prompting + /// an agent that writes pipelines. + pub fn reference() -> String { + let mut out = String::from("| function | argument | description |\n|---|---|---|\n"); + for function in Self::ALL { + let _ = writeln!( + out, + "| `{}` | {} | {} |", + function.signature(), + function.requires().describe(), + function.description() + ); + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn names_are_unique_and_round_trip() { + for function in Function::ALL { + assert_eq!(Function::from_name(function.name()), Some(*function)); + assert!(function.min_window() >= 1); + } + let mut names = Vec::from_iter(Function::ALL.iter().map(|f| f.name())); + names.sort_unstable(); + names.dedup(); + assert_eq!(names.len(), Function::ALL.len()); + assert_eq!(Function::from_name("nope"), None); + } + + #[test] + fn the_reference_lists_every_signature() { + let reference = Function::reference(); + assert!(reference.contains("| `pfe(a, window, window)` | any |")); + assert!(reference.contains("| `ln(a)` | positive |")); + assert!(reference.contains("| `laguerre_filter(a, fraction)` |")); + assert_eq!(reference.lines().count(), Function::ALL.len() + 2); + } + + #[test] + fn window_lengths() { + assert_eq!(window_len(3.0).map(NonZeroUsize::get), Some(3)); + for bad in [0.0, -1.0, 2.5, f64::NAN, f64::INFINITY, 1e300] { + assert_eq!(window_len(bad), None, "{bad}"); + } + } + + #[test] + fn parameter_checks() { + assert!(Function::Pfe.check_params(&[3.0, 3.0], 100).is_ok()); + for (function, params) in [ + (Function::Sma, &[0.0][..]), + (Function::Sma, &[2.5]), + (Function::Sma, &[]), + (Function::Sma, &[101.0]), + (Function::Pfe, &[2.0, 3.0]), + (Function::LaguerreFilter, &[1.0]), + (Function::LaguerreFilter, &[0.0]), + (Function::Gte, &[f64::NAN]), + (Function::Abs, &[1.0]), + ] { + let err = function.check_params(params, 100).expect_err("invalid"); + assert!(err.starts_with(&function.signature()), "{err}"); + } + let err = Function::Sma.check_params(&[0.0], 100).unwrap_err(); + assert_eq!( + err, + "sma(a, window): the window parameter 0 must be an integer in [1, 100]" + ); + } + + #[test] + fn output_domains() { + let real = Domain::REAL; + let pos = Domain::positive(); + assert_eq!(Function::Ln.output_domain(pos, &[]), Some(real)); + assert_eq!(Function::Ln.output_domain(real, &[]), Some(real)); + assert_eq!(Function::Ln.output_domain(pos.neg(), &[]), None); + assert_eq!( + Function::Sqrt.output_domain(real, &[]), + Some(Domain::non_negative()) + ); + assert_eq!( + Function::Sma.output_domain(Domain::closed(1.0, 2.0), &[5.0]), + Some(Domain::closed(1.0, 2.0)) + ); + assert_eq!( + Function::Cumulative.output_domain(Domain::closed(1.0, 2.0), &[5.0]), + Some(Domain::closed(1.0, 10.0)) + ); + assert_eq!( + Function::Gte.output_domain(real, &[0.0]), + Some(Domain::non_negative()) + ); + assert_eq!( + Function::Sigmoid.output_domain(real, &[]), + Some(Domain::closed(0.0, 1.0)), + "saturates in floating point" + ); + assert_eq!( + Function::Rsi.output_domain(real, &[14.0]), + Some(Domain::closed(0.0, 100.0)) + ); + } + + #[test] + fn binary_op_domains() { + let pos = Domain::positive(); + assert_eq!(BinaryOp::Add.output_domain(pos, pos), Some(pos)); + assert_eq!(BinaryOp::Div.output_domain(pos, pos), Some(pos)); + assert_eq!( + BinaryOp::Div.output_domain(pos, Domain::REAL), + Some(Domain::REAL) + ); + assert_eq!( + BinaryOp::Div.output_domain(pos, Domain::point(0.0)), + None, + "dividing by zero is never finite" + ); + assert_eq!(BinaryOp::Sub.symbol(), '-'); + } +} diff --git a/src/pipeline/mod.rs b/src/pipeline/mod.rs new file mode 100644 index 0000000..f32959d --- /dev/null +++ b/src/pipeline/mod.rs @@ -0,0 +1,18 @@ +//! Feature pipelines as data: many features over several input series, +//! compiled into one flat program at runtime. +//! +//! This module holds the catalogue of operators and [`Function`]s that +//! pipelines are made of, with what each needs of its argument +//! ([`Requirement`]) and the [`Domain`] of its output, so a pipeline can be +//! checked before it sees any data. + +mod domain; +mod function; + +pub use domain::Domain; +pub use function::{ + BinaryOp, + Function, + Param, + Requirement, +}; From 2ce35d635e575eea1f1aa026d74fe122b5209ead Mon Sep 17 00:00:00 2001 From: MathisWellmann Date: Sun, 11 Oct 2026 22:57:52 +0200 Subject: [PATCH 07/17] cumulative: do not count the first value twice The first update set the sum to the value and then added it again, and eviction removed it only once, so every later output carried the first value: a window of 1 over a constant 5 gave 10. Found by the pipeline's check that outputs stay in their declared domain. --- img/cumulative.png | Bin 64561 -> 64529 bytes src/sliding_windows/cumulative.rs | 24 +++++++++++++++++------- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/img/cumulative.png b/img/cumulative.png index 70b8ca4706ddd958b8377c658f3a629cbcc5e01a..b56068e219753aabaafe9f5c46bab2706b60778a 100644 GIT binary patch delta 14411 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 delta 13974 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 diff --git a/src/sliding_windows/cumulative.rs b/src/sliding_windows/cumulative.rs index 0ae30cf..6c5f22b 100644 --- a/src/sliding_windows/cumulative.rs +++ b/src/sliding_windows/cumulative.rs @@ -48,19 +48,15 @@ where let Some(val) = self.view.last() else { return }; debug_assert!(val.is_finite(), "value must be finite"); - if self.out.is_none() { - self.out = Some(val); - } - + let out = self.out.get_or_insert(T::zero()); if self.q_vals.len() >= self.window_len.get() { - let old = self.q_vals.pop_front().unwrap(); - let out = self.out.as_mut().expect("Is some at this point"); + let old = self.q_vals.pop_front().expect("the window is full"); *out = *out - old; } self.q_vals.push_back(val); - let out = self.out.as_mut().expect("Is some at this point"); *out = *out + val; debug_assert!(out.is_finite(), "value must be finite"); + debug_assert!(self.q_vals.len() <= self.window_len.get()); } #[inline(always)] @@ -89,4 +85,18 @@ mod tests { let filename = "img/cumulative.png"; plot_values(out, filename).unwrap(); } + + /// The output is the sum of the last `window_len` values; the first + /// value used to be counted twice, forever. + #[test] + fn cumulative_sums_the_window() { + let mut cum = Cumulative::new(Echo::new(), NonZeroUsize::new(2).unwrap()); + assert_eq!(cum.last(), None); + let mut sums = Vec::new(); + for v in [1.0, 2.0, 4.0, 8.0] { + cum.update(v); + sums.push(cum.last().unwrap()); + } + assert_eq!(sums, [1.0, 3.0, 6.0, 12.0]); + } } From f2c5b15e9107e3c0364c8afc8146e3a940af2bfd Mon Sep 17 00:00:00 2001 From: MathisWellmann Date: Sun, 11 Oct 2026 22:57:52 +0200 Subject: [PATCH 08/17] sma: average exactly window_len values Eviction compared the queue length with `>` before pushing, so a full window held window_len + 1 values and sma(x, 10) averaged 11 values. Found by comparing a pipeline against hand-computed values. --- img/sma.png | Bin 60129 -> 60322 bytes src/sliding_windows/sma.rs | 18 ++++++++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/img/sma.png b/img/sma.png index 377bb1fe5376be9ea6e08fe62092f8f32a0baa62..d061caba36bdf7da96e579c25114399a6e679e76 100644 GIT binary patch delta 20848 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 self.window_len.get() { - let old_val = self.q_vals.pop_front().unwrap(); + if self.q_vals.len() >= self.window_len.get() { + let old_val = self.q_vals.pop_front().expect("the window is full"); self.sum = self.sum - old_val; } self.q_vals.push_back(val); + debug_assert!(self.q_vals.len() <= self.window_len.get()); self.sum = self.sum + val; } @@ -98,6 +99,19 @@ mod tests { } } + /// Exactly `window_len` values are averaged; one more used to stay in + /// the window once it was full. + #[test] + fn sma_averages_the_window() { + let mut sma = Sma::new(Echo::new(), NonZeroUsize::new(2).unwrap()); + let mut out = Vec::new(); + for v in [1.0, 3.0, 5.0, 7.0] { + sma.update(v); + out.push(sma.last()); + } + assert_eq!(out, [None, Some(2.0), Some(4.0), Some(6.0)]); + } + #[test] fn sma_plot() { let mut sma = Sma::new(Echo::new(), NonZeroUsize::new(16).unwrap()); From ac952a3f77700d9c65a9cb3c29ed486acf9fe7fc Mon Sep 17 00:00:00 2001 From: MathisWellmann Date: Sun, 11 Oct 2026 22:57:52 +0200 Subject: [PATCH 09/17] pipeline: build and run pipelines as flat programs A Builder assembles a Pipeline from inputs, constants, binary operators and function calls, and names its outputs. It hash-conses nodes, so features that share ema(x, 10) compute it once, which a Box tree cannot do, and folds stateless calls on constants. Limits bound the distinct node count, the depth and the windows, now for whole multi-input, multi-output pipelines instead of one expression. The pipeline lowers the nodes its outputs read into a flat list of small operations over a value buffer, with the sliding views in one State enum fed by Echo (as in esr-core) and kept apart from the operations, so a large view does not widen every slot. NaN marks a value that is not ready, so stateless operations need no branch; a view only sees values that meet its requirement and holds its last output otherwise, which matches the semantics of chained views. Pipelines are Clone and can be reset, and a single-input single-output pipeline is a View (Feature). Unlike esr-core, evaluation is a loop instead of recursion over a prefix order, stateless functions are not-ready outside their domain instead of silently clamped, and every value is f64. The revision is large because builder, graph, lowering and runtime only make sense together; about 600 of its lines are tests, including adversarial input for every function and a check that outputs stay in their declared domains, which found the Cumulative and Sma bugs and that PFE is not bounded to [-1, 1] as documented. --- src/pipeline/builder.rs | 436 +++++++++++++++++++++++++++++++++++++++ src/pipeline/compiled.rs | 149 +++++++++++++ src/pipeline/error.rs | 107 ++++++++++ src/pipeline/function.rs | 123 ++++++++++- src/pipeline/graph.rs | 183 ++++++++++++++++ src/pipeline/mod.rs | 55 ++++- src/pipeline/program.rs | 200 ++++++++++++++++++ src/pipeline/state.rs | 212 +++++++++++++++++++ src/pipeline/tests.rs | 313 ++++++++++++++++++++++++++++ 9 files changed, 1769 insertions(+), 9 deletions(-) create mode 100644 src/pipeline/builder.rs create mode 100644 src/pipeline/compiled.rs create mode 100644 src/pipeline/error.rs create mode 100644 src/pipeline/graph.rs create mode 100644 src/pipeline/program.rs create mode 100644 src/pipeline/state.rs create mode 100644 src/pipeline/tests.rs diff --git a/src/pipeline/builder.rs b/src/pipeline/builder.rs new file mode 100644 index 0000000..750b1c4 --- /dev/null +++ b/src/pipeline/builder.rs @@ -0,0 +1,436 @@ +//! Assembling a pipeline node by node: the programmatic way to build one, +//! e.g. for a search, and the target of the text form. + +use super::{ + BinaryOp, + CompileError, + Domain, + Function, + Pipeline, + function::Params, + graph::{ + Graph, + Node, + NodeId, + }, + state::Kind, +}; + +/// Resource bounds of a pipeline, so one from an untrusted author (e.g. an +/// LLM) cannot make it arbitrarily slow or large. Plain data: any values +/// are valid, small ones just reject more pipelines. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Limits { + /// The largest window parameter. + pub max_window: usize, + /// The most distinct nodes, inputs and constants included; a shared + /// subexpression counts once. + pub max_nodes: usize, + /// The longest chain of nodes from an input or constant to any node, + /// and the deepest nesting of parentheses and calls in the text form. + pub max_depth: usize, +} + +impl Default for Limits { + fn default() -> Self { + Self { + max_window: 100_000, + max_nodes: 256, + max_depth: 32, + } + } +} + +impl Limits { + /// No bounds besides the function signatures. + pub fn unbounded() -> Self { + Self { + max_window: usize::MAX, + max_nodes: usize::MAX, + max_depth: usize::MAX, + } + } +} + +/// An input series of a pipeline: its name and the values it takes. The +/// domain lets the compiler check functions with requirements, e.g. that +/// `ln(price)` is defined when prices are declared positive. +#[derive(Debug, Clone, PartialEq)] +pub struct Input { + name: String, + domain: Domain, +} + +impl Input { + /// An input called `name` whose values lie in `domain`. + pub fn new(name: impl Into, domain: Domain) -> Self { + Self { + name: name.into(), + domain, + } + } + + /// An input called `name` that takes any finite value. + pub fn real(name: impl Into) -> Self { + Self::new(name, Domain::REAL) + } + + /// The name expressions refer to the input by. + pub fn name(&self) -> &str { + &self.name + } + + /// The values the input takes. + pub fn domain(&self) -> Domain { + self.domain + } +} + +/// A named node: an output of the pipeline, or a helper that only names a +/// node for the text form (`let name = ...`). +#[derive(Debug, Clone, PartialEq)] +pub(super) struct Binding { + name: String, + node: NodeId, + is_output: bool, +} + +impl Binding { + pub(super) fn name(&self) -> &str { + &self.name + } + + pub(super) fn node(&self) -> NodeId { + self.node + } + + pub(super) fn is_output(&self) -> bool { + self.is_output + } +} + +/// Assembles a [`Pipeline`] node by node. +/// +/// Equal computations are shared: asking twice for `ema(x, 10)` returns +/// the same node, so it is computed once per update. Calls on constants +/// are folded into constants. +/// +/// ``` +/// use sliding_features::pipeline::{BinaryOp, Builder, Function, Input, Limits}; +/// +/// let mut b = Builder::new(vec![Input::real("x")], Limits::default()).unwrap(); +/// let x = b.lookup("x").unwrap(); +/// let fast = b.call(Function::Ema, x, &[10.0]).unwrap(); +/// let slow = b.call(Function::Ema, x, &[50.0]).unwrap(); +/// let cross = b.binary(BinaryOp::Sub, fast, slow).unwrap(); +/// b.output("cross", cross).unwrap(); +/// let mut pipeline = b.build().unwrap(); +/// pipeline.update(&[100.0]); +/// assert_eq!(pipeline.output(0), None, "the EMAs warm up"); +/// for _ in 0..50 { +/// pipeline.update(&[100.0]); +/// } +/// assert_eq!(pipeline.output(0), Some(0.0)); +/// ``` +#[derive(Debug, Clone)] +pub struct Builder { + graph: Graph, + /// The depth of every node, see [`Limits::max_depth`]. + depths: Vec, + inputs: Vec, + /// The node of each input. + input_nodes: Vec, + bindings: Vec, + limits: Limits, +} + +impl Builder { + /// A builder over `inputs`, which are fed in this order. + pub fn new(inputs: Vec, limits: Limits) -> Result { + if inputs.is_empty() { + return Err(CompileError::single("a pipeline needs at least one input")); + } + let mut builder = Self { + graph: Graph::default(), + depths: Vec::new(), + inputs: Vec::new(), + input_nodes: Vec::new(), + bindings: Vec::new(), + limits, + }; + for (i, input) in inputs.into_iter().enumerate() { + builder.check_name(input.name())?; + let id = builder.add(Node::Input(i))?; + assert_eq!(id.index(), i, "inputs are the first nodes"); + builder.inputs.push(input); + builder.input_nodes.push(id); + } + Ok(builder) + } + + /// The input or binding called `name`. + pub fn lookup(&self, name: &str) -> Option { + if let Some(i) = self.inputs.iter().position(|input| input.name() == name) { + return Some(self.input_nodes[i]); + } + self.bindings + .iter() + .find(|binding| binding.name == name) + .map(|binding| binding.node) + } + + /// The constant `value`. + pub fn constant(&mut self, value: f64) -> Result { + if !value.is_finite() { + return Err(CompileError::single(format!( + "the constant {value} is not finite" + ))); + } + // Adding zero turns -0 into 0, so both are one node. + self.add(Node::Const(value + 0.0)) + } + + /// `-arg`. + pub fn neg(&mut self, arg: NodeId) -> Result { + match *self.graph.get(arg) { + Node::Const(c) => self.constant(-c), + _ => self.add(Node::Neg(arg)), + } + } + + /// `lhs op rhs`. + pub fn binary( + &mut self, + op: BinaryOp, + lhs: NodeId, + rhs: NodeId, + ) -> Result { + if let (Node::Const(a), Node::Const(b)) = (self.graph.get(lhs), self.graph.get(rhs)) { + let (a, b) = (*a, *b); + let value = op.eval(a, b); + if !value.is_finite() { + return Err(CompileError::single(format!( + "`{a} {} {b}` is not finite", + op.symbol() + ))); + } + return self.constant(value); + } + self.add(Node::Binary(op, lhs, rhs)) + } + + /// `function(arg, params...)`, with parameters checked against the + /// signature and [`Limits::max_window`]. + pub fn call( + &mut self, + function: Function, + arg: NodeId, + params: &[f64], + ) -> Result { + function + .check_params(params, self.limits.max_window) + .map_err(CompileError::single)?; + if let Node::Const(c) = *self.graph.get(arg) + && let Kind::Pure(pure) = function.instantiate(Params::new(function, params)) + { + let value = pure.eval(c); + if !value.is_finite() { + return Err(CompileError::single(format!( + "`{}` of the constant {c} is not finite", + function.name() + ))); + } + return self.constant(value); + } + self.add(Node::Call(function, arg, params.into())) + } + + /// Make `node` an output called `name`. Outputs are read in the order + /// they are added. + pub fn output(&mut self, name: &str, node: NodeId) -> Result<(), CompileError> { + self.push_binding(name, node, true) + } + + /// Name `node` without making it an output, so the text form refers to + /// it by `name` (`let name = ...`). + pub fn bind(&mut self, name: &str, node: NodeId) -> Result<(), CompileError> { + self.push_binding(name, node, false) + } + + /// The pipeline with fresh state. + pub fn build(self) -> Result { + if !self.bindings.iter().any(Binding::is_output) { + return Err(CompileError::single("a pipeline needs at least one output")); + } + Ok(Pipeline::new(self.graph, self.inputs, self.bindings)) + } + + fn push_binding( + &mut self, + name: &str, + node: NodeId, + is_output: bool, + ) -> Result<(), CompileError> { + assert!(node.index() < self.graph.len(), "a node of this builder"); + self.check_name(name)?; + self.bindings.push(Binding { + name: name.to_string(), + node, + is_output, + }); + Ok(()) + } + + /// Names are identifiers, so the text form can refer to them, and + /// unique among inputs and bindings. A name may equal a function's: + /// calls are followed by `(`, so `rsi = rsi(x, 14)` is unambiguous. + fn check_name(&self, name: &str) -> Result<(), CompileError> { + let mut chars = name.chars(); + let identifier = chars + .next() + .is_some_and(|c| c.is_ascii_alphabetic() || c == '_') + && chars.all(|c| c.is_ascii_alphanumeric() || c == '_'); + let problem = if !identifier { + "is not an identifier (a letter or `_`, then letters, digits or `_`)" + } else if name == "let" { + "is a keyword" + } else if self.lookup(name).is_some() { + "is already defined" + } else { + return Ok(()); + }; + Err(CompileError::single(format!("the name `{name}` {problem}"))) + } + + /// Intern `node` within the limits. + fn add(&mut self, node: Node) -> Result { + let depth = 1 + node + .children() + .map(|c| self.depths[c.index()]) + .max() + .unwrap_or(0); + if depth > self.limits.max_depth { + return Err(CompileError::single(format!( + "the expression is nested {depth} deep, the limit is {}", + self.limits.max_depth + ))); + } + let id = self + .graph + .intern(node, self.limits.max_nodes) + .ok_or_else(|| { + CompileError::single(format!( + "the pipeline needs more than {} distinct nodes", + self.limits.max_nodes + )) + })?; + if id.index() == self.depths.len() { + self.depths.push(depth); + } + assert_eq!(self.depths.len(), self.graph.len(), "a depth per node"); + Ok(id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn builder() -> Builder { + Builder::new(vec![Input::real("x")], Limits::default()).unwrap() + } + + #[test] + fn equal_calls_share_one_node() { + let mut b = builder(); + let x = b.lookup("x").unwrap(); + let a = b.call(Function::Ema, x, &[10.0]).unwrap(); + let again = b.call(Function::Ema, x, &[10.0]).unwrap(); + assert_eq!(a, again); + let other = b.call(Function::Ema, x, &[11.0]).unwrap(); + assert_ne!(a, other); + } + + #[test] + fn constants_are_folded() { + let mut b = builder(); + let two = b.constant(2.0).unwrap(); + let three = b.constant(3.0).unwrap(); + let six = b.binary(BinaryOp::Mul, two, three).unwrap(); + assert_eq!(six, b.constant(6.0).unwrap()); + let neg = b.neg(six).unwrap(); + assert_eq!(neg, b.constant(-6.0).unwrap()); + let abs = b.call(Function::Abs, neg, &[]).unwrap(); + assert_eq!(abs, six); + assert_eq!(b.constant(-0.0).unwrap(), b.constant(0.0).unwrap()); + let zero = b.constant(0.0).unwrap(); + let err = b.binary(BinaryOp::Div, two, zero).unwrap_err(); + assert_eq!(err.to_string(), "error: `2 / 0` is not finite"); + let err = b.call(Function::Ln, neg, &[]).unwrap_err(); + assert_eq!( + err.to_string(), + "error: `ln` of the constant -6 is not finite" + ); + // Stateful calls on constants are not folded: they are not ready + // until their window fills. + let sma = b.call(Function::Sma, two, &[3.0]).unwrap(); + assert_ne!(sma, two); + } + + #[test] + fn names_are_checked() { + let mut b = builder(); + let x = b.lookup("x").unwrap(); + for (name, problem) in [ + ("x", "is already defined"), + ("let", "is a keyword"), + ("1a", "is not an identifier"), + ("a-b", "is not an identifier"), + ("", "is not an identifier"), + ] { + let err = b.output(name, x).unwrap_err().to_string(); + assert!(err.contains(problem), "{name}: {err}"); + } + b.bind("helper", x).unwrap(); + assert_eq!(b.lookup("helper"), Some(x)); + b.output("sma", x).unwrap(); + assert!(b.output("helper", x).is_err()); + assert!(Builder::new(vec![Input::real("x"), Input::real("x")], Limits::default()).is_err()); + assert!(Builder::new(Vec::new(), Limits::default()).is_err()); + } + + #[test] + fn limits_are_enforced() { + let limits = Limits { + max_window: 10, + max_nodes: 3, + max_depth: 3, + }; + let mut b = Builder::new(vec![Input::real("x")], limits).unwrap(); + let x = b.lookup("x").unwrap(); + assert!(b.call(Function::Sma, x, &[11.0]).is_err()); + let a = b.call(Function::Sma, x, &[10.0]).unwrap(); + let c = b.call(Function::Ema, a, &[10.0]).unwrap(); + let err = b.call(Function::Ema, c, &[10.0]).unwrap_err().to_string(); + assert!(err.contains("nested 4 deep"), "{err}"); + let err = b.call(Function::Ema, x, &[10.0]).unwrap_err().to_string(); + assert!(err.contains("more than 3 distinct nodes"), "{err}"); + } + + #[test] + fn a_pipeline_needs_an_output() { + let mut b = builder(); + let x = b.lookup("x").unwrap(); + b.bind("helper", x).unwrap(); + assert!(b.build().is_err()); + } + + #[test] + #[should_panic(expected = "a node of this builder")] + fn foreign_nodes_are_rejected() { + let mut other = builder(); + let x = other.lookup("x").unwrap(); + let foreign = other.call(Function::Sma, x, &[2.0]).unwrap(); + let _ = builder().output("a", foreign); + } +} diff --git a/src/pipeline/compiled.rs b/src/pipeline/compiled.rs new file mode 100644 index 0000000..a9df177 --- /dev/null +++ b/src/pipeline/compiled.rs @@ -0,0 +1,149 @@ +//! A compiled pipeline and its use as a single view. + +use super::{ + CompileError, + Input, + builder::Binding, + graph::{ + Graph, + NodeId, + }, + program::Program, +}; +use crate::View; + +/// Features computed from input series by one flat program. +/// +/// Every update feeds one value per input and runs each distinct node +/// once, in order, with no allocation, recursion or dynamic dispatch. An +/// output is `None` while not ready: until its windows fill, or while an +/// argument is outside what a function accepts (a division by zero, the +/// logarithm of a non-positive value). Ready outputs are always finite. +/// +/// Clone a warmed-up pipeline to branch it; [`Pipeline::reset`] starts it +/// over. +#[derive(Debug, Clone)] +pub struct Pipeline { + graph: Graph, + inputs: Vec, + bindings: Vec, + program: Program, +} + +impl Pipeline { + pub(super) fn new(graph: Graph, inputs: Vec, bindings: Vec) -> Self { + assert!(!inputs.is_empty(), "a pipeline has an input"); + let outputs = output_nodes(&bindings); + let program = Program::lower(&graph, &outputs); + Self { + graph, + inputs, + bindings, + program, + } + } + + /// Feed one value per input, in the order of [`Pipeline::inputs`]. A + /// non-finite value counts as not ready. + /// + /// # Panics + /// + /// If the number of values differs from the number of inputs. + #[inline] + pub fn update(&mut self, inputs: &[f64]) { + assert_eq!(inputs.len(), self.inputs.len(), "one value per input"); + self.program.update(inputs); + } + + /// The value of the `i`-th output, `None` while not ready. + /// + /// # Panics + /// + /// If there is no `i`-th output. + #[inline] + pub fn output(&self, i: usize) -> Option { + self.program.output(i) + } + + /// The value of every output, in order. + pub fn outputs(&self) -> impl ExactSizeIterator> + '_ { + (0..self.output_count()).map(|i| self.output(i)) + } + + /// The number of outputs. + pub fn output_count(&self) -> usize { + self.output_bindings().count() + } + + /// The name of every output, in order. + pub fn output_names(&self) -> impl Iterator { + self.output_bindings().map(Binding::name) + } + + /// The index of the output called `name`. + pub fn output_index(&self, name: &str) -> Option { + self.output_names().position(|n| n == name) + } + + /// The inputs, in the order values are fed. + pub fn inputs(&self) -> &[Input] { + &self.inputs + } + + /// The number of operations an update runs: the distinct nodes the + /// outputs read. + pub fn op_count(&self) -> usize { + self.program.len() + } + + /// Start over: every output is not ready until fed again. + pub fn reset(&mut self) { + self.program = Program::lower(&self.graph, &output_nodes(&self.bindings)); + } + + fn output_bindings(&self) -> impl Iterator { + self.bindings.iter().filter(|b| b.is_output()) + } +} + +fn output_nodes(bindings: &[Binding]) -> Vec { + Vec::from_iter(bindings.iter().filter(|b| b.is_output()).map(Binding::node)) +} + +/// A pipeline with one input and one output, used as a [`View`], e.g. to +/// chain it with other views or to replace the spec module's `DynView`. +#[derive(Debug, Clone)] +pub struct Feature(Pipeline); + +impl Feature { + /// The pipeline behind the view. + pub fn pipeline(&self) -> &Pipeline { + &self.0 + } +} + +impl TryFrom for Feature { + type Error = CompileError; + + fn try_from(pipeline: Pipeline) -> Result { + let (inputs, outputs) = (pipeline.inputs().len(), pipeline.output_count()); + if inputs != 1 || outputs != 1 { + return Err(CompileError::single(format!( + "a feature has one input and one output, the pipeline has {inputs} and {outputs}" + ))); + } + Ok(Self(pipeline)) + } +} + +impl View for Feature { + #[inline] + fn update(&mut self, val: f64) { + self.0.update(&[val]); + } + + #[inline] + fn last(&self) -> Option { + self.0.output(0) + } +} diff --git a/src/pipeline/error.rs b/src/pipeline/error.rs new file mode 100644 index 0000000..cd4ea05 --- /dev/null +++ b/src/pipeline/error.rs @@ -0,0 +1,107 @@ +//! What the compiler reports about a pipeline. + +use std::fmt; + +/// How bad a [`Diagnostic`] is. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Severity { + /// The pipeline is rejected. + Error, + /// The pipeline compiles, but likely does not do what was meant. + Warning, +} + +/// One problem the compiler found, worded to be fixed by whoever wrote the +/// pipeline, a person or an LLM. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Diagnostic { + severity: Severity, + message: String, +} + +impl Diagnostic { + pub(super) fn error(message: impl Into) -> Self { + Self { + severity: Severity::Error, + message: message.into(), + } + } + + /// How bad the problem is. + pub fn severity(&self) -> Severity { + self.severity + } + + /// What is wrong. + pub fn message(&self) -> &str { + &self.message + } +} + +impl fmt::Display for Diagnostic { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let severity = match self.severity { + Severity::Error => "error", + Severity::Warning => "warning", + }; + write!(f, "{severity}: {}", self.message) + } +} + +/// Why a pipeline was rejected: at least one error, and every warning +/// found on the way, one per line in the [`fmt::Display`] form. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CompileError { + diagnostics: Vec, +} + +impl CompileError { + pub(super) fn new(diagnostics: Vec) -> Self { + assert!( + diagnostics.iter().any(|d| d.severity == Severity::Error), + "a compile error holds an error" + ); + Self { diagnostics } + } + + pub(super) fn single(message: impl Into) -> Self { + Self::new(vec![Diagnostic::error(message)]) + } + + /// Every problem found, errors and warnings, in the order found. + pub fn diagnostics(&self) -> &[Diagnostic] { + &self.diagnostics + } +} + +impl fmt::Display for CompileError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (i, diagnostic) in self.diagnostics.iter().enumerate() { + if i > 0 { + f.write_str("\n")?; + } + write!(f, "{diagnostic}")?; + } + Ok(()) + } +} + +impl std::error::Error for CompileError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display_lists_every_diagnostic() { + let err = CompileError::new(vec![Diagnostic::error("a"), Diagnostic::error("b")]); + assert_eq!(err.to_string(), "error: a\nerror: b"); + assert_eq!(err.diagnostics().len(), 2); + } + + #[test] + #[should_panic(expected = "holds an error")] + fn a_compile_error_needs_an_error() { + let _ = CompileError::new(Vec::new()); + } +} diff --git a/src/pipeline/function.rs b/src/pipeline/function.rs index fc7d0b1..efa01e0 100644 --- a/src/pipeline/function.rs +++ b/src/pipeline/function.rs @@ -1,13 +1,56 @@ //! The operators and functions of a pipeline: one table holds each -//! function's name, parameters, argument requirement, output range and -//! description, so they cannot drift apart. +//! function's name, parameters, argument requirement, output range, runtime +//! form and description, so they cannot drift apart. use std::{ fmt::Write as _, num::NonZeroUsize, }; -use super::Domain; +use super::{ + Domain, + state::{ + Kind, + Pure, + }, +}; +use crate::{ + pure_functions::Echo, + rolling::{ + Drawdown, + LnReturn, + }, + sliding_windows::{ + Alma, + BinaryEntropy, + CenterOfGravity, + CorrelationTrendIndicator, + Cumulative, + CyberCycle, + EhlersFisherTransform, + Ema, + Lag, + LaguerreFilter, + LaguerreRSI, + MadScaler, + Max, + Min, + MinMaxNormalizer, + MyRSI, + NoiseEliminationTechnology, + PolarizedFractalEfficiency, + ReFlex, + Roc, + RoofingFilter, + Rsi, + Sma, + SuperSmoother, + TrendFlex, + Vst, + WelfordOnline, + ZScoreStandardization, + }, +}; /// An arithmetic operator between two expressions. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -33,6 +76,18 @@ impl BinaryOp { } } + /// `a op b`. NaN (not ready) operands give NaN, and so does a division + /// by zero once the caller filters the infinity. + #[inline] + pub(super) fn eval(self, a: f64, b: f64) -> f64 { + match self { + BinaryOp::Add => a + b, + BinaryOp::Sub => a - b, + BinaryOp::Mul => a * b, + BinaryOp::Div => a / b, + } + } + /// The domain of `a op b` for `a` in `lhs` and `b` in `rhs`, `None` /// when the result is never finite (a division by a zero constant). pub fn output_domain(self, lhs: Domain, rhs: Domain) -> Option { @@ -154,6 +209,9 @@ pub(super) fn window_len(value: f64) -> Option { /// The output domain of a function given its (admitted) argument domain. type RangeFn = fn(Domain, Params<'_>) -> Domain; +/// What a call becomes at runtime given its checked parameters. +type BuildFn = fn(Params<'_>) -> Kind; + /// Declares [`Function`] and its metadata from one table. macro_rules! functions { ($($variant:ident { @@ -162,6 +220,7 @@ macro_rules! functions { min_window: $min:literal, requires: $requires:ident, range: $range:expr, + build: $build:expr, doc: $doc:literal $(,)? })*) => { /// A function of a pipeline. Every call takes an expression as its @@ -205,6 +264,12 @@ macro_rules! functions { let range: RangeFn = match self { $(Function::$variant => $range,)* }; range(arg, params) } + + /// What a call with checked `params` becomes at runtime. + pub(super) fn instantiate(self, params: Params<'_>) -> Kind { + let build: BuildFn = match self { $(Function::$variant => $build,)* }; + build(params) + } } }; } @@ -213,210 +278,258 @@ functions! { Abs { name: "abs", params: [], min_window: 1, requires: Any, range: |a, _| a.abs(), + build: |_| Kind::Pure(Pure::Abs), doc: "Absolute value.", } Sign { name: "sign", params: [], min_window: 1, requires: Any, range: |a, _| a.sign(), + build: |_| Kind::Pure(Pure::Sign), doc: "Sign: -1, 0 or 1.", } Sqrt { name: "sqrt", params: [], min_window: 1, requires: NonNegative, range: |a, _| a.map_increasing(f64::sqrt), + build: |_| Kind::Pure(Pure::Sqrt), doc: "Square root.", } Square { name: "square", params: [], min_window: 1, requires: Any, range: |a, _| a.abs().map_increasing(|v| v * v), + build: |_| Kind::Pure(Pure::Square), doc: "The argument times itself.", } Exp { name: "exp", params: [], min_window: 1, requires: Any, range: |a, _| a.map_increasing(f64::exp), + build: |_| Kind::Pure(Pure::Exp), doc: "Exponential function; not ready where it overflows.", } Ln { name: "ln", params: [], min_window: 1, requires: Positive, range: |a, _| a.map_increasing(f64::ln), + build: |_| Kind::Pure(Pure::Ln), doc: "Natural logarithm.", } Tanh { name: "tanh", params: [], min_window: 1, requires: Any, range: |a, _| a.map_increasing(f64::tanh), + build: |_| Kind::Pure(Pure::Tanh), doc: "Hyperbolic tangent, squashes into (-1, 1).", } Sigmoid { name: "sigmoid", params: [], min_window: 1, requires: Any, range: |a, _| a.map_increasing(sigmoid), + build: |_| Kind::Pure(Pure::Sigmoid), doc: "Logistic function 1 / (1 + exp(-a)), squashes into (0, 1).", } Asinh { name: "asinh", params: [], min_window: 1, requires: Any, range: |a, _| a.map_increasing(f64::asinh), + build: |_| Kind::Pure(Pure::Asinh), doc: "Inverse hyperbolic sine: like a signed logarithm, compresses large magnitudes.", } Gte { name: "gte", params: [Real], min_window: 1, requires: Any, range: |a, p| a.max_with(p.real(0)), + build: |p| Kind::Pure(Pure::Gte(p.real(0))), doc: "Clips values below the real to it: max(a, real).", } Lte { name: "lte", params: [Real], min_window: 1, requires: Any, range: |a, p| a.min_with(p.real(0)), + build: |p| Kind::Pure(Pure::Lte(p.real(0))), doc: "Clips values above the real to it: min(a, real).", } LnReturn { name: "ln_return", params: [], min_window: 1, requires: Positive, range: |_, _| Domain::REAL, + build: |_| LnReturn::new(Echo::new()).into(), doc: "Log return between consecutive updates.", } Drawdown { name: "drawdown", params: [], min_window: 1, requires: Positive, range: |_, _| Domain::closed(0.0, 1.0), + build: |_| Drawdown::new(Echo::new()).into(), doc: "Largest relative peak-to-valley decline so far, in [0, 1].", } Sma { name: "sma", params: [Window], min_window: 1, requires: Any, range: |a, _| a, + build: |p| Sma::new(Echo::new(), p.window(0)).into(), doc: "Simple moving average.", } Ema { name: "ema", params: [Window], min_window: 1, requires: Any, range: |a, _| a, + build: |p| Ema::new(Echo::new(), p.window(0)).into(), doc: "Exponential moving average, alpha = 2 / (window + 1).", } Alma { name: "alma", params: [Window], min_window: 2, requires: Any, range: |a, _| a, + build: |p| Alma::new(Echo::new(), p.window(0)).into(), doc: "Arnaud Legoux moving average (sigma 6, offset 0.85).", } Roc { name: "roc", params: [Window], min_window: 1, requires: Any, range: |_, _| Domain::REAL, + build: |p| Roc::new(Echo::new(), p.window(0)).into(), doc: "Rate of change in percent against the value `window` updates ago.", } Lag { name: "lag", params: [Window], min_window: 1, requires: Any, range: |a, _| a, + build: |p| Lag::new(Echo::new(), p.window(0)).into(), doc: "The value `window` updates ago.", } Min { name: "min", params: [Window], min_window: 1, requires: Any, range: |a, _| a, + build: |p| Min::new(Echo::new(), p.window(0)).into(), doc: "Minimum over the window.", } Max { name: "max", params: [Window], min_window: 1, requires: Any, range: |a, _| a, + build: |p| Max::new(Echo::new(), p.window(0)).into(), doc: "Maximum over the window.", } MinMax { name: "min_max", params: [Window], min_window: 2, requires: Any, range: |_, _| Domain::REAL, + build: |p| MinMaxNormalizer::new(Echo::new(), p.window(0)).into(), doc: "Position in the [min, max] range of the previous window, scaled to [-1, 1] (beyond it on new extremes).", } Mad { name: "mad", params: [Window], min_window: 2, requires: Any, range: |_, _| Domain::REAL, + build: |p| MadScaler::new(Echo::new(), p.window(0)).into(), doc: "Robust z-score: (a - median) / (1.4826 MAD) of the previous window.", } ZScore { name: "zscore", params: [Window], min_window: 2, requires: Any, range: |_, _| Domain::REAL, + build: |p| ZScoreStandardization::new(Echo::new(), p.window(0)).into(), doc: "Z-score against the mean and standard deviation of the previous window.", } Std { name: "std", params: [Window], min_window: 2, requires: Any, range: |_, _| Domain::non_negative(), + build: |p| WelfordOnline::new(Echo::new(), p.window(0)).into(), doc: "Standard deviation over the window (Welford).", } Cumulative { name: "cumulative", params: [Window], min_window: 1, requires: Any, range: |a, p| a.mul(Domain::closed(1.0, window_f64(p.window(0)))), + build: |p| Cumulative::new(Echo::new(), p.window(0)).into(), doc: "Sum over the window.", } Rsi { name: "rsi", params: [Window], min_window: 2, requires: Any, range: |_, _| Domain::closed(0.0, 100.0), + build: |p| Rsi::new(Echo::new(), p.window(0)).into(), doc: "Relative strength index, in [0, 100].", } MyRsi { name: "my_rsi", params: [Window], min_window: 2, requires: Any, range: |_, _| Domain::closed(-1.0, 1.0), + build: |p| MyRSI::new(Echo::new(), p.window(0)).into(), doc: "John Ehlers' MyRSI, in [-1, 1].", } LaguerreRsi { name: "laguerre_rsi", params: [Window], min_window: 2, requires: Any, range: |_, _| Domain::closed(0.0, 1.0), + build: |p| LaguerreRSI::new(Echo::new(), p.window(0)).into(), doc: "John Ehlers' Laguerre RSI, in [0, 1].", } LaguerreFilter { name: "laguerre_filter", params: [Fraction], min_window: 1, requires: Any, range: |_, _| Domain::REAL, + build: |p| LaguerreFilter::new(Echo::new(), p.real(0)).into(), doc: "John Ehlers' Laguerre filter with damping gamma (the fraction).", } SuperSmoother { name: "super_smoother", params: [Window], min_window: 2, requires: Any, range: |_, _| Domain::REAL, + build: |p| SuperSmoother::new(Echo::new(), p.window(0)).into(), doc: "John Ehlers' two-pole super smoother low-pass filter.", } RoofingFilter { name: "roofing_filter", params: [Window, Window], min_window: 2, requires: Any, range: |_, _| Domain::REAL, + build: |p| RoofingFilter::new(Echo::new(), p.window(0), p.window(1)).into(), doc: "John Ehlers' roofing filter (low-pass window, high-pass window): a band-pass.", } ReFlex { name: "re_flex", params: [Window], min_window: 2, requires: Any, range: |_, _| Domain::REAL, + build: |p| ReFlex::new(Echo::new(), p.window(0)).into(), doc: "John Ehlers' ReFlex zero-lag cycle indicator.", } TrendFlex { name: "trend_flex", params: [Window], min_window: 2, requires: Any, range: |_, _| Domain::REAL, + build: |p| TrendFlex::new(Echo::new(), p.window(0)).into(), doc: "John Ehlers' TrendFlex zero-lag trend indicator.", } CyberCycle { name: "cyber_cycle", params: [Window], min_window: 3, requires: Any, range: |_, _| Domain::REAL, + build: |p| CyberCycle::new(Echo::new(), p.window(0)).into(), doc: "John Ehlers' cyber cycle.", } CenterOfGravity { name: "center_of_gravity", params: [Window], min_window: 2, requires: Any, range: |_, _| Domain::REAL, + build: |p| CenterOfGravity::new(Echo::new(), p.window(0)).into(), doc: "John Ehlers' center of gravity oscillator.", } Cti { name: "cti", params: [Window], min_window: 2, requires: Any, range: |_, _| Domain::closed(-1.0, 1.0), + build: |p| CorrelationTrendIndicator::new(Echo::new(), p.window(0)).into(), doc: "John Ehlers' correlation trend indicator: correlation with a straight line, in [-1, 1].", } Pfe { name: "pfe", params: [Window, Window], min_window: 3, requires: Any, - range: |_, _| Domain::closed(-1.0, 1.0), - doc: "Polarized fractal efficiency (window, EMA smoothing window), in [-1, 1].", + // Not [-1, 1] as documented: the path length sums one segment too + // few, so the efficiency exceeds 1. + range: |_, _| Domain::REAL, + build: |p| PolarizedFractalEfficiency::new(Echo::new(), ema(p.window(1)), p.window(0)).into(), + doc: "Polarized fractal efficiency (window, EMA smoothing window): near 1 in a straight up-trend, near -1 down.", } Fisher { name: "fisher", params: [Window, Window], min_window: 2, requires: Any, range: |_, _| Domain::REAL, + build: |p| EhlersFisherTransform::new(Echo::new(), ema(p.window(1)), p.window(0)).into(), doc: "John Ehlers' Fisher transform (window, EMA smoothing window).", } BinaryEntropy { name: "binary_entropy", params: [Window], min_window: 2, requires: BothSigns, range: |_, _| Domain::closed(0.0, 1.0), + build: |p| BinaryEntropy::new(Echo::new(), p.window(0)).into(), doc: "Shannon entropy of the signs over the window, in [0, 1].", } Vst { name: "vst", params: [Window], min_window: 2, requires: Any, range: |_, _| Domain::REAL, + build: |p| Vst::new(Echo::new(), p.window(0)).into(), doc: "Variance stabilizing transform: change scaled by the rolling standard deviation.", } Net { name: "net", params: [Window], min_window: 2, requires: Any, range: |_, _| Domain::closed(-1.0, 1.0), + build: |p| NoiseEliminationTechnology::new(Echo::new(), p.window(0)).into(), doc: "John Ehlers' noise elimination technology (Kendall correlation), in [-1, 1].", } } +/// The EMA that smooths `pfe` and `fisher`. +fn ema(window: NonZeroUsize) -> Ema> { + Ema::new(Echo::new(), window) +} + /// The logistic function. pub(super) fn sigmoid(v: f64) -> f64 { 1.0 / (1.0 + (-v).exp()) diff --git a/src/pipeline/graph.rs b/src/pipeline/graph.rs new file mode 100644 index 0000000..829c097 --- /dev/null +++ b/src/pipeline/graph.rs @@ -0,0 +1,183 @@ +//! A pipeline as a graph: nodes in creation order, so children come before +//! their parents, and structurally equal nodes shared. + +use std::collections::HashMap; + +use super::{ + BinaryOp, + Function, +}; + +/// A node of a pipeline under construction, see +/// [`Builder`](super::Builder). Only valid with the builder that made it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct NodeId(u32); + +impl NodeId { + pub(super) fn index(self) -> usize { + usize::try_from(self.0).expect("u32 fits in usize on supported targets") + } +} + +/// One computation of a pipeline. +#[derive(Debug, Clone, PartialEq)] +pub(super) enum Node { + /// The input series with this index. + Input(usize), + /// A finite constant. + Const(f64), + /// `-a` + Neg(NodeId), + /// `a op b` + Binary(BinaryOp, NodeId, NodeId), + /// A function applied to a node, with checked parameters. + Call(Function, NodeId, Box<[f64]>), +} + +impl Node { + /// The nodes this one reads, left to right. + pub(super) fn children(&self) -> impl Iterator + use<> { + let (a, b) = match self { + Node::Input(_) | Node::Const(_) => (None, None), + Node::Neg(a) | Node::Call(_, a, _) => (Some(*a), None), + Node::Binary(_, a, b) => (Some(*a), Some(*b)), + }; + a.into_iter().chain(b) + } + + fn key(&self) -> Key { + match self { + Node::Input(i) => Key::Input(*i), + Node::Const(c) => Key::Const(c.to_bits()), + Node::Neg(a) => Key::Neg(*a), + Node::Binary(op, a, b) => Key::Binary(*op, *a, *b), + Node::Call(function, a, params) => { + Key::Call(*function, *a, params.iter().map(|p| p.to_bits()).collect()) + } + } + } +} + +/// A [`Node`] with its numbers as bits, so it can be hashed: equal keys +/// compute the same values, so one node serves both. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +enum Key { + Input(usize), + Const(u64), + Neg(NodeId), + Binary(BinaryOp, NodeId, NodeId), + Call(Function, NodeId, Box<[u64]>), +} + +/// The nodes of a pipeline, each computation once. +#[derive(Debug, Clone, Default)] +pub(super) struct Graph { + nodes: Vec, + index: HashMap, +} + +impl Graph { + /// The id of `node`: the existing equal node, or `node` added. `None` + /// if adding it would exceed `max_nodes`. + pub(super) fn intern(&mut self, node: Node, max_nodes: usize) -> Option { + for child in node.children() { + assert!(child.index() < self.nodes.len(), "a node of this graph"); + } + if let Node::Const(c) = node { + assert!(c.is_finite(), "constants are finite"); + } + let key = node.key(); + if let Some(id) = self.index.get(&key) { + return Some(*id); + } + if self.nodes.len() >= max_nodes { + return None; + } + let id = NodeId(u32::try_from(self.nodes.len()).ok()?); + self.nodes.push(node); + self.index.insert(key, id); + assert_eq!(self.nodes.len(), self.index.len(), "every node is indexed"); + Some(id) + } + + /// The node `id`. + pub(super) fn get(&self, id: NodeId) -> &Node { + &self.nodes[id.index()] + } + + /// The number of nodes. + pub(super) fn len(&self) -> usize { + self.nodes.len() + } + + /// Every node with its id, children before parents. + pub(super) fn iter(&self) -> impl Iterator { + self.nodes + .iter() + .enumerate() + .map(|(i, node)| (NodeId(u32::try_from(i).expect("ids fit u32")), node)) + } + + /// Which nodes the `roots` read, directly or not, roots included. + pub(super) fn reachable(&self, roots: impl IntoIterator) -> Vec { + let mut reachable = vec![false; self.nodes.len()]; + for root in roots { + reachable[root.index()] = true; + } + // Parents come after their children, so one backward pass suffices. + for i in (0..self.nodes.len()).rev() { + if reachable[i] { + for child in self.nodes[i].children() { + assert!(child.index() < i, "children come first"); + reachable[child.index()] = true; + } + } + } + reachable + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn equal_nodes_are_shared() { + let mut g = Graph::default(); + let x = g.intern(Node::Input(0), 10).unwrap(); + let a = g.intern(Node::Call(Function::Sma, x, [3.0].into()), 10); + let b = g.intern(Node::Call(Function::Sma, x, [3.0].into()), 10); + let c = g.intern(Node::Call(Function::Sma, x, [4.0].into()), 10); + assert_eq!(a, b); + assert_ne!(a, c); + assert_eq!(g.len(), 3); + } + + #[test] + fn the_node_limit_counts_distinct_nodes() { + let mut g = Graph::default(); + let x = g.intern(Node::Input(0), 2).unwrap(); + let neg = g.intern(Node::Neg(x), 2).unwrap(); + assert_eq!(g.intern(Node::Neg(x), 2), Some(neg), "shared: no new node"); + assert_eq!(g.intern(Node::Neg(neg), 2), None); + } + + #[test] + fn reachability() { + let mut g = Graph::default(); + let x = g.intern(Node::Input(0), 10).unwrap(); + let y = g.intern(Node::Input(1), 10).unwrap(); + let neg = g.intern(Node::Neg(x), 10).unwrap(); + let sum = g.intern(Node::Binary(BinaryOp::Add, neg, x), 10).unwrap(); + assert_eq!(g.reachable([sum]), vec![true, false, true, true]); + assert_eq!(g.reachable([y]), vec![false, true, false, false]); + assert_eq!(Vec::from_iter(g.get(sum).children()), vec![neg, x]); + } + + #[test] + #[should_panic(expected = "a node of this graph")] + fn foreign_nodes_are_rejected() { + let mut g = Graph::default(); + let _ = g.intern(Node::Neg(NodeId(5)), 10); + } +} diff --git a/src/pipeline/mod.rs b/src/pipeline/mod.rs index f32959d..9e4be93 100644 --- a/src/pipeline/mod.rs +++ b/src/pipeline/mod.rs @@ -1,18 +1,65 @@ //! Feature pipelines as data: many features over several input series, //! compiled into one flat program at runtime. //! -//! This module holds the catalogue of operators and [`Function`]s that -//! pipelines are made of, with what each needs of its argument -//! ([`Requirement`]) and the [`Domain`] of its output, so a pipeline can be -//! checked before it sees any data. +//! A [`Builder`] assembles a [`Pipeline`] from inputs, constants, the +//! arithmetic [`BinaryOp`]s and calls of [`Function`]s, and names its +//! outputs. Equal computations are shared, so features that use the same +//! `ema(x, 10)` compute it once. The pipeline runs as a flat list of +//! operations over a value buffer: one pass per update, no allocation, no +//! recursion and no dynamic dispatch. +//! +//! Each function declares what it needs of its argument ([`Requirement`]) +//! and the [`Domain`] of its output, so a pipeline can be checked before it +//! sees any data. +//! +//! ``` +//! use sliding_features::pipeline::{BinaryOp, Builder, Domain, Function, Input, Limits}; +//! +//! let inputs = vec![Input::new("price", Domain::positive())]; +//! let mut b = Builder::new(inputs, Limits::default()).unwrap(); +//! let price = b.lookup("price").unwrap(); +//! let r = b.call(Function::LnReturn, price, &[]).unwrap(); +//! let fast = b.call(Function::Std, r, &[2.0]).unwrap(); +//! let slow = b.call(Function::Std, r, &[4.0]).unwrap(); +//! let ratio = b.binary(BinaryOp::Div, fast, slow).unwrap(); +//! b.output("vol_ratio", ratio).unwrap(); +//! let mut pipeline = b.build().unwrap(); +//! for p in [100.0, 101.0, 99.0, 102.0, 100.0] { +//! pipeline.update(&[p]); +//! } +//! assert!(pipeline.output(0).is_some()); +//! ``` +mod builder; +mod compiled; mod domain; +mod error; mod function; +mod graph; +mod program; +mod state; +#[cfg(test)] +mod tests; +pub use builder::{ + Builder, + Input, + Limits, +}; +pub use compiled::{ + Feature, + Pipeline, +}; pub use domain::Domain; +pub use error::{ + CompileError, + Diagnostic, + Severity, +}; pub use function::{ BinaryOp, Function, Param, Requirement, }; +pub use graph::NodeId; diff --git a/src/pipeline/program.rs b/src/pipeline/program.rs new file mode 100644 index 0000000..38d18a4 --- /dev/null +++ b/src/pipeline/program.rs @@ -0,0 +1,200 @@ +//! The runtime form of a pipeline: a flat list of operations evaluated in +//! one pass, each writing one slot of a value buffer. + +use super::{ + BinaryOp, + Requirement, + function::Params, + graph::{ + Graph, + Node, + NodeId, + }, + state::{ + Kind, + Pure, + State, + }, +}; + +/// One operation. Operands index the value buffer, always below the +/// operation's own slot. +#[derive(Debug, Clone, Copy)] +enum Op { + Input(u32), + Const(f64), + Pure(Pure, u32), + Binary(BinaryOp, u32, u32), + Stateful { + state: u32, + arg: u32, + requires: Requirement, + }, +} + +/// The operations of the nodes the outputs read, their states and their +/// last values; NaN marks a value that is not ready. +#[derive(Debug, Clone)] +pub(super) struct Program { + ops: Vec, + /// Kept apart from `ops`, so the loop over the small operations stays + /// dense and a large view does not widen every slot. + states: Vec, + values: Vec, + outputs: Vec, +} + +impl Program { + /// Lower the nodes that `outputs` read into operations, with fresh + /// states. + pub(super) fn lower(graph: &Graph, outputs: &[NodeId]) -> Self { + assert!(!outputs.is_empty(), "a pipeline has an output"); + let reachable = graph.reachable(outputs.iter().copied()); + let mut slot_of = vec![u32::MAX; graph.len()]; + let mut ops = Vec::new(); + let mut states = Vec::new(); + for (id, node) in graph.iter() { + if !reachable[id.index()] { + continue; + } + let slot = |child: &NodeId| { + let s = slot_of[child.index()]; + assert_ne!(s, u32::MAX, "children are lowered first"); + s + }; + let op = match node { + Node::Input(i) => Op::Input(to_u32(*i)), + Node::Const(c) => Op::Const(*c), + Node::Neg(a) => Op::Pure(Pure::Neg, slot(a)), + Node::Binary(op, a, b) => Op::Binary(*op, slot(a), slot(b)), + Node::Call(function, a, params) => { + match function.instantiate(Params::new(*function, params)) { + Kind::Pure(pure) => Op::Pure(pure, slot(a)), + Kind::Stateful(state) => { + states.push(state); + Op::Stateful { + state: to_u32(states.len() - 1), + arg: slot(a), + requires: function.requires(), + } + } + } + } + }; + slot_of[id.index()] = to_u32(ops.len()); + ops.push(op); + } + let outputs = Vec::from_iter(outputs.iter().map(|o| slot_of[o.index()])); + assert!(outputs.iter().all(|o| (*o as usize) < ops.len())); + Self { + values: vec![f64::NAN; ops.len()], + ops, + states, + outputs, + } + } + + /// Feed one value per input series. + #[inline] + pub(super) fn update(&mut self, inputs: &[f64]) { + run(&self.ops, &mut self.states, &mut self.values, inputs); + } + + /// The `i`-th output, `None` while not ready. + #[inline] + pub(super) fn output(&self, i: usize) -> Option { + let v = self.values[self.outputs[i] as usize]; + (!v.is_nan()).then_some(v) + } + + /// The number of operations run per update. + pub(super) fn len(&self) -> usize { + self.ops.len() + } +} + +fn to_u32(i: usize) -> u32 { + u32::try_from(i).expect("node counts fit u32") +} + +/// Evaluate every operation once, in order. Free of `self`, so the slices +/// stay in registers. +#[inline] +fn run(ops: &[Op], states: &mut [State], values: &mut [f64], inputs: &[f64]) { + assert_eq!(ops.len(), values.len(), "one value per operation"); + for (i, op) in ops.iter().enumerate() { + let v = match *op { + Op::Input(j) => inputs[j as usize], + Op::Const(c) => c, + Op::Pure(f, a) => f.eval(values[a as usize]), + Op::Binary(op, a, b) => op.eval(values[a as usize], values[b as usize]), + Op::Stateful { + state, + arg, + requires, + } => { + let a = values[arg as usize]; + let state = &mut states[state as usize]; + // A view only sees admitted values and otherwise holds its + // last output, like a chained view whose input is not ready. + if admits(requires, a) { + state.update(a) + } else { + state.last() + } + } + }; + // Not ready instead of an infinity: one non-finite value would stay + // in every window downstream for its whole length. + values[i] = if v.is_finite() { v } else { f64::NAN }; + } +} + +/// Whether a value meets the requirement; NaN (not ready) never does. +#[inline] +fn admits(requires: Requirement, v: f64) -> bool { + match requires { + Requirement::Any | Requirement::BothSigns => !v.is_nan(), + Requirement::Positive => v > 0.0, + Requirement::NonNegative => v >= 0.0, + } +} + +#[cfg(test)] +mod tests { + use super::{ + super::Function, + *, + }; + + #[test] + fn admits_rejects_not_ready_values() { + for requires in [ + Requirement::Any, + Requirement::BothSigns, + Requirement::Positive, + Requirement::NonNegative, + ] { + assert!(!admits(requires, f64::NAN)); + } + assert!(admits(Requirement::NonNegative, 0.0)); + assert!(!admits(Requirement::Positive, 0.0)); + assert!(admits(Requirement::Any, -1.0)); + } + + #[test] + fn only_reachable_nodes_are_lowered() { + let mut g = Graph::default(); + let x = g.intern(Node::Input(0), 10).unwrap(); + let unused = g + .intern(Node::Call(Function::Sma, x, [3.0].into()), 10) + .unwrap(); + let ema = g + .intern(Node::Call(Function::Ema, x, [3.0].into()), 10) + .unwrap(); + let program = Program::lower(&g, &[ema]); + assert_eq!(program.len(), 2); + assert_eq!(program.states.len(), 1); + assert_ne!(unused, ema); + } +} diff --git a/src/pipeline/state.rs b/src/pipeline/state.rs new file mode 100644 index 0000000..ebea878 --- /dev/null +++ b/src/pipeline/state.rs @@ -0,0 +1,212 @@ +//! What a call becomes at runtime: a stateless function evaluated inline, +//! or a sliding view of this crate held in one enum. + +use super::function::sigmoid; +use crate::{ + View, + pure_functions::Echo, + rolling::{ + Drawdown, + LnReturn, + }, + sliding_windows::{ + Alma, + BinaryEntropy, + CenterOfGravity, + CorrelationTrendIndicator, + Cumulative, + CyberCycle, + EhlersFisherTransform, + Ema, + Lag, + LaguerreFilter, + LaguerreRSI, + MadScaler, + Max, + Min, + MinMaxNormalizer, + MyRSI, + NoiseEliminationTechnology, + PolarizedFractalEfficiency, + ReFlex, + Roc, + RoofingFilter, + Rsi, + Sma, + SuperSmoother, + TrendFlex, + Vst, + WelfordOnline, + ZScoreStandardization, + }, +}; + +/// A stateless function of one value. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(super) enum Pure { + Neg, + Abs, + Sign, + Sqrt, + Square, + Exp, + Ln, + Tanh, + Sigmoid, + Asinh, + Gte(f64), + Lte(f64), +} + +impl Pure { + /// The function of `v`. A NaN (not ready) argument gives NaN; outside + /// its domain a function gives NaN or an infinity, which the caller + /// turns into "not ready". + #[inline] + pub(super) fn eval(self, v: f64) -> f64 { + // `f64::max` and `f64::min` would replace a NaN by the bound. + if v.is_nan() { + return v; + } + match self { + Pure::Neg => -v, + Pure::Abs => v.abs(), + // Not `f64::signum`, which is 1 for 0. + Pure::Sign => { + if v > 0.0 { + 1.0 + } else if v < 0.0 { + -1.0 + } else { + 0.0 + } + } + Pure::Sqrt => v.sqrt(), + Pure::Square => v * v, + Pure::Exp => v.exp(), + Pure::Ln => v.ln(), + Pure::Tanh => v.tanh(), + Pure::Sigmoid => sigmoid(v), + Pure::Asinh => v.asinh(), + Pure::Gte(c) => v.max(c), + Pure::Lte(c) => v.min(c), + } + } +} + +/// What a call becomes at runtime. +#[derive(Debug, Clone)] +pub(super) enum Kind { + Pure(Pure), + Stateful(State), +} + +/// Declares [`State`] over the views of this crate, each fed by an [`Echo`] +/// so the pipeline pushes values into it, with uniform `update` and `last`. +macro_rules! states { + ($($variant:ident($ty:ty),)*) => { + /// The state of a call to a sliding view. + #[derive(Debug, Clone)] + pub(super) enum State { + $($variant($ty),)* + } + + impl State { + /// Push `v` and return the output, NaN while not ready. + #[inline] + pub(super) fn update(&mut self, v: f64) -> f64 { + match self { + $(State::$variant(s) => { + s.update(v); + s.last().unwrap_or(f64::NAN) + })* + } + } + + /// The last output, NaN while not ready. + #[inline] + pub(super) fn last(&self) -> f64 { + match self { + $(State::$variant(s) => s.last().unwrap_or(f64::NAN),)* + } + } + } + + $(impl From<$ty> for Kind { + fn from(view: $ty) -> Self { + Kind::Stateful(State::$variant(view)) + } + })* + }; +} + +type E = Echo; + +states! { + LnReturn(LnReturn), + Drawdown(Drawdown), + Sma(Sma), + Ema(Ema), + Alma(Alma), + Roc(Roc), + Lag(Lag), + Min(Min), + Max(Max), + MinMax(MinMaxNormalizer), + Mad(MadScaler), + ZScore(ZScoreStandardization), + Std(WelfordOnline), + Cumulative(Cumulative), + Rsi(Rsi), + MyRsi(MyRSI), + LaguerreRsi(LaguerreRSI), + LaguerreFilter(LaguerreFilter), + SuperSmoother(SuperSmoother), + RoofingFilter(RoofingFilter), + ReFlex(ReFlex), + TrendFlex(TrendFlex), + CyberCycle(CyberCycle), + CenterOfGravity(CenterOfGravity), + Cti(CorrelationTrendIndicator), + Pfe(PolarizedFractalEfficiency>), + Fisher(EhlersFisherTransform>), + BinaryEntropy(BinaryEntropy), + Vst(Vst), + Net(NoiseEliminationTechnology), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pure_functions_propagate_not_ready() { + for f in [ + Pure::Neg, + Pure::Abs, + Pure::Sign, + Pure::Sqrt, + Pure::Square, + Pure::Exp, + Pure::Ln, + Pure::Tanh, + Pure::Sigmoid, + Pure::Asinh, + Pure::Gte(0.0), + Pure::Lte(0.0), + ] { + assert!(f.eval(f64::NAN).is_nan(), "{f:?}"); + } + } + + #[test] + fn pure_function_values() { + assert_eq!(Pure::Sign.eval(0.0), 0.0); + assert_eq!(Pure::Sign.eval(-3.0), -1.0); + assert_eq!(Pure::Gte(1.0).eval(0.5), 1.0); + assert_eq!(Pure::Lte(1.0).eval(0.5), 0.5); + assert!(Pure::Ln.eval(-1.0).is_nan()); + assert_eq!(Pure::Ln.eval(0.0), f64::NEG_INFINITY); + assert_eq!(Pure::Square.eval(-3.0), 9.0); + } +} diff --git a/src/pipeline/tests.rs b/src/pipeline/tests.rs new file mode 100644 index 0000000..f7451d2 --- /dev/null +++ b/src/pipeline/tests.rs @@ -0,0 +1,313 @@ +//! Behaviour of compiled pipelines: they compute what hand-written view +//! chains compute, survive adversarial input and stay in their domains. + +use std::num::NonZeroUsize; + +use super::*; +use crate::{ + View, + pure_functions::{ + Echo, + Subtract, + }, + sliding_windows::{ + Ema, + Sma, + ZScoreStandardization, + }, +}; + +fn window(n: usize) -> NonZeroUsize { + NonZeroUsize::new(n).unwrap() +} + +fn single_input() -> (Builder, NodeId) { + let b = Builder::new(vec![Input::real("x")], Limits::default()).unwrap(); + let x = b.lookup("x").unwrap(); + (b, x) +} + +fn random_walk(seed: u64, len: usize) -> Vec { + let rng = romu::Rng::from_seed_with_64bit(seed); + let mut price = 100.0; + Vec::from_iter((0..len).map(|_| { + price += rng.f64() - 0.5; + price + })) +} + +/// Parameters at the smallest valid values. +fn min_params(function: Function) -> Vec { + Vec::from_iter(function.params().iter().map(|p| match p { + Param::Window => f64::from(u32::try_from(function.min_window()).unwrap()), + Param::Real | Param::Fraction => 0.5, + })) +} + +#[test] +fn matches_the_hand_written_chain() { + let (mut b, x) = single_input(); + let fast = b.call(Function::Ema, x, &[5.0]).unwrap(); + let slow = b.call(Function::Sma, x, &[10.0]).unwrap(); + let diff = b.binary(BinaryOp::Sub, fast, slow).unwrap(); + let z = b.call(Function::ZScore, diff, &[20.0]).unwrap(); + b.output("z", z).unwrap(); + let mut pipeline = b.build().unwrap(); + let mut manual = ZScoreStandardization::new( + Subtract::new( + Ema::new(Echo::new(), window(5)), + Sma::new(Echo::new(), window(10)), + ), + window(20), + ); + for price in random_walk(1, 500) { + pipeline.update(&[price]); + manual.update(price); + assert_eq!(pipeline.output(0), manual.last()); + } + assert!(pipeline.output(0).is_some()); +} + +#[test] +fn shared_nodes_are_computed_once() { + let (mut b, x) = single_input(); + let r = b.call(Function::LnReturn, x, &[]).unwrap(); + let fast = b.call(Function::Std, r, &[10.0]).unwrap(); + let slow = b.call(Function::Std, r, &[60.0]).unwrap(); + let ratio = b.binary(BinaryOp::Div, fast, slow).unwrap(); + b.output("ratio", ratio).unwrap(); + let again = b.call(Function::Std, r, &[10.0]).unwrap(); + b.output("fast", again).unwrap(); + let pipeline = b.build().unwrap(); + // x, ln_return, two stds and the division. + assert_eq!(pipeline.op_count(), 5); + assert_eq!(Vec::from_iter(pipeline.output_names()), ["ratio", "fast"]); + assert_eq!(pipeline.output_index("fast"), Some(1)); +} + +#[test] +fn unused_helpers_are_not_run() { + let (mut b, x) = single_input(); + let helper = b.call(Function::Sma, x, &[10.0]).unwrap(); + b.bind("helper", helper).unwrap(); + let out = b.call(Function::Ema, x, &[10.0]).unwrap(); + b.output("out", out).unwrap(); + assert_eq!(b.build().unwrap().op_count(), 2); +} + +#[test] +fn a_view_holds_its_value_while_its_argument_is_not_ready() { + let (mut b, x) = single_input(); + let one = b.constant(1.0).unwrap(); + let inv = b.binary(BinaryOp::Div, one, x).unwrap(); + let sma = b.call(Function::Sma, inv, &[2.0]).unwrap(); + b.output("inv", inv).unwrap(); + b.output("sma", sma).unwrap(); + let mut p = b.build().unwrap(); + p.update(&[1.0]); + p.update(&[2.0]); + assert_eq!(Vec::from_iter(p.outputs()), [Some(0.5), Some(0.75)]); + p.update(&[0.0]); + assert_eq!(Vec::from_iter(p.outputs()), [None, Some(0.75)]); + p.update(&[4.0]); + assert_eq!(Vec::from_iter(p.outputs()), [Some(0.25), Some(0.375)]); +} + +#[test] +fn requirements_guard_the_views() { + let (mut b, x) = single_input(); + let one = b.constant(1.0).unwrap(); + let shifted = b.binary(BinaryOp::Sub, x, one).unwrap(); + let r = b.call(Function::LnReturn, shifted, &[]).unwrap(); + b.output("r", r).unwrap(); + let mut p = b.build().unwrap(); + for x in [2.0, 0.5, 3.0] { + p.update(&[x]); + } + // 0.5 - 1 < 0 never reached the log return. + assert!((p.output(0).unwrap() - 2.0_f64.ln()).abs() < 1e-12); +} + +#[test] +fn non_finite_input_is_not_ready() { + let (mut b, x) = single_input(); + let sma = b.call(Function::Sma, x, &[1.0]).unwrap(); + b.output("raw", x).unwrap(); + b.output("sma", sma).unwrap(); + let mut p = b.build().unwrap(); + p.update(&[f64::INFINITY]); + assert_eq!(Vec::from_iter(p.outputs()), [None, None]); + p.update(&[2.0]); + assert_eq!(Vec::from_iter(p.outputs()), [Some(2.0), Some(2.0)]); +} + +#[test] +fn several_inputs() { + let inputs = vec![ + Input::new("price", Domain::positive()), + Input::new("volume", Domain::non_negative()), + ]; + let mut b = Builder::new(inputs, Limits::default()).unwrap(); + let (price, volume) = (b.lookup("price").unwrap(), b.lookup("volume").unwrap()); + let turnover = b.binary(BinaryOp::Mul, price, volume).unwrap(); + let num = b.call(Function::Cumulative, turnover, &[2.0]).unwrap(); + let den = b.call(Function::Cumulative, volume, &[2.0]).unwrap(); + let vwap = b.binary(BinaryOp::Div, num, den).unwrap(); + b.output("vwap", vwap).unwrap(); + let mut p = b.build().unwrap(); + p.update(&[10.0, 1.0]); + p.update(&[20.0, 3.0]); + assert_eq!(p.output(0), Some(17.5)); + assert_eq!(p.inputs()[1].name(), "volume"); +} + +#[test] +#[should_panic(expected = "one value per input")] +fn update_checks_the_input_count() { + let (mut b, x) = single_input(); + b.output("raw", x).unwrap(); + b.build().unwrap().update(&[1.0, 2.0]); +} + +#[test] +fn clone_branches_and_reset_starts_over() { + let (mut b, x) = single_input(); + let rsi = b.call(Function::Rsi, x, &[14.0]).unwrap(); + b.output("rsi", rsi).unwrap(); + let mut p = b.build().unwrap(); + let fresh = p.clone(); + let walk = random_walk(2, 200); + for v in &walk[..100] { + p.update(&[*v]); + } + let mut branch = p.clone(); + for v in &walk[100..] { + p.update(&[*v]); + branch.update(&[*v]); + assert_eq!(p.output(0), branch.output(0)); + } + p.reset(); + let mut fresh = fresh; + for v in &walk { + p.update(&[*v]); + fresh.update(&[*v]); + assert_eq!(p.output(0), fresh.output(0)); + } +} + +#[test] +fn a_single_output_pipeline_is_a_view() { + let (mut b, x) = single_input(); + let ema = b.call(Function::Ema, x, &[3.0]).unwrap(); + b.output("ema", ema).unwrap(); + let feature = Feature::try_from(b.build().unwrap()).unwrap(); + let mut chained = Sma::new(feature, window(2)); + let mut manual = Sma::new(Ema::new(Echo::new(), window(3)), window(2)); + for v in random_walk(3, 50) { + chained.update(v); + manual.update(v); + assert_eq!(chained.last(), manual.last()); + } + + let (mut b, x) = single_input(); + b.output("a", x).unwrap(); + b.output("b", x).unwrap(); + let err = Feature::try_from(b.build().unwrap()).unwrap_err(); + assert!(err.to_string().contains("has 1 and 2"), "{err}"); +} + +/// Inputs that break naive indicators: a trend, a constant, zeros, +/// alternating signs, huge values. +fn adversarial_inputs() -> Vec> { + vec![ + random_walk(7, 300), + vec![5.0; 300], + vec![0.0; 300], + Vec::from_iter((0..300).map(|i| if i % 2 == 0 { 1.0 } else { -1.0 })), + Vec::from_iter((0..300).map(|i| 1e12 * f64::from(i % 7))), + ] +} + +/// The arguments every function is tried on: the input, a series of both +/// signs, and an exact zero. +fn arguments(b: &mut Builder, x: NodeId) -> Vec { + let roc = b.call(Function::Roc, x, &[1.0]).unwrap(); + let zero = b.binary(BinaryOp::Sub, x, x).unwrap(); + vec![x, roc, zero] +} + +/// Every function, at its minimum parameters, on every adversarial input +/// and composed with arguments of both signs, never panics (debug +/// assertions on) and only ever outputs finite values. +#[test] +fn every_function_survives_adversarial_input() { + let mut failures = Vec::new(); + for function in Function::ALL { + for input in adversarial_inputs() { + let run = std::panic::catch_unwind(|| { + let (mut b, x) = single_input(); + for (i, arg) in arguments(&mut b, x).into_iter().enumerate() { + let call = b.call(*function, arg, &min_params(*function)).unwrap(); + b.output(&format!("out{i}"), call).unwrap(); + } + let mut p = b.build().unwrap(); + input.iter().all(|v| { + p.update(&[*v]); + p.outputs().all(|o| o.is_none_or(f64::is_finite)) + }) + }); + match run { + Ok(true) => {} + Ok(false) => failures.push(format!("{}: non-finite output", function.name())), + Err(_) => failures.push(format!( + "{}: panicked on {:?}", + function.name(), + &input[..3] + )), + } + } + } + assert!(failures.is_empty(), "{failures:#?}"); +} + +/// Every ready output lies in the domain the function declares for an +/// argument in the hull of the input, up to rounding. +#[test] +fn outputs_stay_in_their_declared_domain() { + let mut failures = Vec::new(); + for function in Function::ALL { + let params = min_params(*function); + for input in adversarial_inputs() { + let lo = input.iter().copied().fold(f64::INFINITY, f64::min); + let hi = input.iter().copied().fold(f64::NEG_INFINITY, f64::max); + let (mut b, x) = single_input(); + let call = b.call(*function, x, ¶ms).unwrap(); + b.output("out", call).unwrap(); + let mut p = b.build().unwrap(); + let domain = function.output_domain(Domain::closed(lo, hi), ¶ms); + for v in &input { + p.update(&[*v]); + let Some(out) = p.output(0) else { continue }; + let Some(domain) = domain else { + // A drawdown that never updated reports 0. + if *function != Function::Drawdown { + failures.push(format!("{}: ready but never admitted", function.name())); + } + break; + }; + // Indicators round, e.g. a correlation of 1 + 1e-8. + let tolerance = 1e-6 * out.abs().max(1.0); + let inside = domain.contains(out) + || (out >= domain.lo() - tolerance && out <= domain.hi() + tolerance); + if !inside { + failures.push(format!( + "{}: {out} outside {domain} on [{lo}, {hi}]", + function.name() + )); + break; + } + } + } + } + assert!(failures.is_empty(), "{failures:#?}"); +} From bc5ae41e56dcc57b9f0638998ed8cebee53bb6a6 Mon Sep 17 00:00:00 2001 From: MathisWellmann Date: Sun, 11 Oct 2026 23:01:39 +0200 Subject: [PATCH 10/17] pipeline: check pipelines before they run Analysis derives the domain and warm-up of every node in one pass from the declared input domains, the functions' requirements and output ranges, the way esr-core's domain checks guide tree generation, but per node instead of per operator, so a pipeline gets feedback instead of being generated around it. build() now rejects calls that are never ready (ln of -abs(x)), divisions by zero and constant outputs (x - x is recognised because equal subexpressions are one node), listing every error at once. It warns about calls that are not always ready, with a hint to declare the input's domain, entropy of an argument that never changes sign, duplicate outputs and unused helpers. Messages quote the expressions, so an LLM can act on them. Each output reports its domain and warm-up estimate; a test checks the estimate suffices for every function. --- src/pipeline/analysis.rs | 192 +++++++++++++++++++++++++++++++++++++++ src/pipeline/builder.rs | 22 ++++- src/pipeline/compiled.rs | 47 +++++++++- src/pipeline/display.rs | 96 ++++++++++++++++++++ src/pipeline/error.rs | 7 ++ src/pipeline/function.rs | 19 ++++ src/pipeline/mod.rs | 9 +- src/pipeline/tests.rs | 130 +++++++++++++++++++++++++- 8 files changed, 513 insertions(+), 9 deletions(-) create mode 100644 src/pipeline/analysis.rs create mode 100644 src/pipeline/display.rs diff --git a/src/pipeline/analysis.rs b/src/pipeline/analysis.rs new file mode 100644 index 0000000..aad91a6 --- /dev/null +++ b/src/pipeline/analysis.rs @@ -0,0 +1,192 @@ +//! Checks before a pipeline runs: the domain and warm-up of every node, +//! and what is likely a mistake, worded so the author can fix it. + +use super::{ + BinaryOp, + Diagnostic, + Domain, + Input, + Requirement, + builder::Binding, + display::Printer, + graph::{ + Graph, + Node, + NodeId, + }, +}; + +/// What the compiler knows about every node before any data is seen. +#[derive(Debug, Clone)] +pub(super) struct Analysis { + domains: Vec, + warm_ups: Vec, + diagnostics: Vec, +} + +impl Analysis { + /// Derive domains and warm-ups in one pass over the nodes (children + /// come first), then check the bindings. + pub(super) fn run(graph: &Graph, inputs: &[Input], bindings: &[Binding]) -> Self { + let name_of = |id: NodeId| bindings.iter().find(|b| b.node() == id).map(Binding::name); + let printer = Printer::new(graph, inputs, name_of); + let mut analysis = Self { + domains: Vec::with_capacity(graph.len()), + warm_ups: Vec::with_capacity(graph.len()), + diagnostics: Vec::new(), + }; + for (id, node) in graph.iter() { + let (domain, warm_up, diagnostic) = analysis.node(&printer, id, node, inputs); + analysis.domains.push(domain); + analysis.warm_ups.push(warm_up); + analysis.diagnostics.extend(diagnostic); + } + assert_eq!(analysis.domains.len(), graph.len(), "a domain per node"); + let findings = analysis.check_bindings(graph, bindings); + analysis.diagnostics.extend(findings); + analysis + } + + /// The values node `id` can take. + pub(super) fn domain(&self, id: NodeId) -> Domain { + self.domains[id.index()] + } + + /// An estimate of the updates node `id` needs before it is ready. + pub(super) fn warm_up(&self, id: NodeId) -> usize { + self.warm_ups[id.index()] + } + + /// Every problem found, in node order, then the bindings'. + pub(super) fn diagnostics(&self) -> &[Diagnostic] { + &self.diagnostics + } + + /// The domain and warm-up of one node, and a problem with it. + fn node<'a>( + &self, + printer: &Printer<'a, impl Fn(NodeId) -> Option<&'a str>>, + id: NodeId, + node: &Node, + inputs: &[Input], + ) -> (Domain, usize, Option) { + let d = |n: &NodeId| self.domains[n.index()]; + let w = |n: &NodeId| self.warm_ups[n.index()]; + match node { + Node::Input(i) => (inputs[*i].domain(), 0, None), + Node::Const(c) => (Domain::point(*c), 0, None), + Node::Neg(a) => (d(a).neg(), w(a), None), + Node::Binary(op, a, b) => { + let warm_up = w(a).max(w(b)); + match binary_domain(*op, a == b, d(a), d(b)) { + Some(domain) => (domain, warm_up, None), + None => { + let message = format!( + "`{}` divides by zero, so it is never ready", + printer.expr(id) + ); + (Domain::REAL, warm_up, Some(Diagnostic::error(message))) + } + } + } + Node::Call(function, a, params) => { + let warm_up = w(a) + function.warm_up(params); + let (arg, requires) = (d(a), function.requires()); + let Some(domain) = function.output_domain(arg, params) else { + let message = format!( + "`{}` is never ready: `{}` needs {} values, but `{}` lies in {arg}", + printer.expr(id), + function.name(), + requirement_words(requires), + printer.name_or_expr(*a), + ); + return (Domain::REAL, warm_up, Some(Diagnostic::error(message))); + }; + let partly = arg.intersect(requires.domain()) != Some(arg); + // The entropy of signs counts values of at least zero. + let one_sign = requires == Requirement::BothSigns + && (arg.lo() >= 0.0 || arg.intersect(Domain::non_negative()).is_none()); + let warning = if partly { + let hint = match printer.input_name(*a) { + Some(name) => format!( + "; declare the domain of input `{name}` if it is always {}", + requirement_words(requires) + ), + None => String::new(), + }; + Some(format!( + "`{}` is not ready while `{}` is not {} (it lies in {arg}){hint}", + printer.expr(id), + printer.name_or_expr(*a), + requirement_words(requires), + )) + } else if one_sign { + Some(format!( + "`{}` is constant: `{}` never changes sign (it lies in {arg})", + printer.expr(id), + printer.name_or_expr(*a), + )) + } else { + None + }; + (domain, warm_up, warning.map(Diagnostic::warning)) + } + } + } + + /// Constant and duplicate outputs, and helpers no output uses. + fn check_bindings(&self, graph: &Graph, bindings: &[Binding]) -> Vec { + let outputs = Vec::from_iter(bindings.iter().filter(|b| b.is_output())); + let reachable = graph.reachable(outputs.iter().map(|b| b.node())); + let mut found = Vec::new(); + for (i, binding) in bindings.iter().enumerate() { + let name = binding.name(); + if !binding.is_output() { + if !reachable[binding.node().index()] { + found.push(Diagnostic::warning(format!( + "`{name}` is not used by any output" + ))); + } + continue; + } + if let Some(c) = self.domain(binding.node()).as_point() { + found.push(Diagnostic::error(format!( + "output `{name}` is constant: it is always {c}" + ))); + } + let earlier = bindings[..i] + .iter() + .find(|b| b.is_output() && b.node() == binding.node()); + if let Some(earlier) = earlier { + found.push(Diagnostic::warning(format!( + "output `{name}` computes the same as output `{}`", + earlier.name() + ))); + } + } + found + } +} + +/// The domain of `a op b`, `None` if it is never finite. `same` says +/// that both operands are one node, so `x - x` is exactly zero. +fn binary_domain(op: BinaryOp, same: bool, lhs: Domain, rhs: Domain) -> Option { + if same { + match op { + BinaryOp::Sub => return Some(Domain::point(0.0)), + // Where `a` is zero the quotient is not ready, elsewhere 1. + BinaryOp::Div if lhs.as_point() != Some(0.0) => return Some(Domain::point(1.0)), + BinaryOp::Mul => return Some(lhs.abs().map_increasing(|v| v * v)), + BinaryOp::Add | BinaryOp::Div => {} + } + } + op.output_domain(lhs, rhs) +} + +fn requirement_words(requires: Requirement) -> &'static str { + match requires { + Requirement::Positive => "positive", + Requirement::NonNegative => "non-negative", + Requirement::Any | Requirement::BothSigns => "finite", + } +} diff --git a/src/pipeline/builder.rs b/src/pipeline/builder.rs index 750b1c4..49ff942 100644 --- a/src/pipeline/builder.rs +++ b/src/pipeline/builder.rs @@ -7,6 +7,8 @@ use super::{ Domain, Function, Pipeline, + Severity, + analysis::Analysis, function::Params, graph::{ Graph, @@ -257,12 +259,28 @@ impl Builder { self.push_binding(name, node, false) } - /// The pipeline with fresh state. + /// Check the pipeline and return it with fresh state. + /// + /// The check derives the [`Domain`] of every node from the inputs'. + /// It rejects calls that are never ready (e.g. `ln` of a negative + /// argument), divisions by zero and constant outputs, and warns about + /// calls that are not always ready, duplicate outputs and unused + /// helpers, see [`Pipeline::warnings`]. The error lists every problem. pub fn build(self) -> Result { if !self.bindings.iter().any(Binding::is_output) { return Err(CompileError::single("a pipeline needs at least one output")); } - Ok(Pipeline::new(self.graph, self.inputs, self.bindings)) + let analysis = Analysis::run(&self.graph, &self.inputs, &self.bindings); + let diagnostics = analysis.diagnostics(); + if diagnostics.iter().any(|d| d.severity() == Severity::Error) { + return Err(CompileError::new(diagnostics.to_vec())); + } + Ok(Pipeline::new( + self.graph, + self.inputs, + self.bindings, + &analysis, + )) } fn push_binding( diff --git a/src/pipeline/compiled.rs b/src/pipeline/compiled.rs index a9df177..230bc4c 100644 --- a/src/pipeline/compiled.rs +++ b/src/pipeline/compiled.rs @@ -2,7 +2,11 @@ use super::{ CompileError, + Diagnostic, + Domain, Input, + Severity, + analysis::Analysis, builder::Binding, graph::{ Graph, @@ -27,15 +31,32 @@ pub struct Pipeline { graph: Graph, inputs: Vec, bindings: Vec, + /// The domain and warm-up of each output. + output_domains: Vec, + output_warm_ups: Vec, + warnings: Vec, program: Program, } impl Pipeline { - pub(super) fn new(graph: Graph, inputs: Vec, bindings: Vec) -> Self { + pub(super) fn new( + graph: Graph, + inputs: Vec, + bindings: Vec, + analysis: &Analysis, + ) -> Self { assert!(!inputs.is_empty(), "a pipeline has an input"); let outputs = output_nodes(&bindings); let program = Program::lower(&graph, &outputs); + let warnings = analysis.diagnostics().to_vec(); + assert!( + warnings.iter().all(|d| d.severity() == Severity::Warning), + "a pipeline with errors is rejected" + ); Self { + output_domains: Vec::from_iter(outputs.iter().map(|o| analysis.domain(*o))), + output_warm_ups: Vec::from_iter(outputs.iter().map(|o| analysis.warm_up(*o))), + warnings, graph, inputs, bindings, @@ -85,6 +106,30 @@ impl Pipeline { self.output_names().position(|n| n == name) } + /// The values the `i`-th output can take, derived from the inputs' + /// domains before any data is seen. + pub fn output_domain(&self, i: usize) -> Domain { + self.output_domains[i] + } + + /// An estimate of the updates the `i`-th output needs before it is + /// ready: its window lengths summed along its longest chain. Use it to + /// size the history replayed before the output is read. + pub fn output_warm_up(&self, i: usize) -> usize { + self.output_warm_ups[i] + } + + /// The largest warm-up of any output, see [`Pipeline::output_warm_up`]. + pub fn warm_up(&self) -> usize { + self.output_warm_ups.iter().copied().max().unwrap_or(0) + } + + /// What the compiler found suspicious but allowed, e.g. a logarithm + /// of an argument that can be negative. + pub fn warnings(&self) -> &[Diagnostic] { + &self.warnings + } + /// The inputs, in the order values are fed. pub fn inputs(&self) -> &[Input] { &self.inputs diff --git a/src/pipeline/display.rs b/src/pipeline/display.rs new file mode 100644 index 0000000..5d6d4ee --- /dev/null +++ b/src/pipeline/display.rs @@ -0,0 +1,96 @@ +//! The text of a node: canonical, so equal texts mean equal computations. + +use std::fmt::Write as _; + +use super::{ + Input, + graph::{ + Graph, + Node, + NodeId, + }, +}; + +/// Writes the expression of nodes, referring to named nodes by name. +pub(super) struct Printer<'a, F> { + graph: &'a Graph, + inputs: &'a [Input], + /// The name to print instead of a node's expression, if any. + name_of: F, +} + +impl<'a, F> Printer<'a, F> +where + F: Fn(NodeId) -> Option<&'a str>, +{ + pub(super) fn new(graph: &'a Graph, inputs: &'a [Input], name_of: F) -> Self { + Self { + graph, + inputs, + name_of, + } + } + + /// The expression of `id`, with its children by name where they have + /// one. + pub(super) fn expr(&self, id: NodeId) -> String { + let mut out = String::new(); + self.write(&mut out, id); + out + } + + /// The name of `id` if it has one, else its expression. + pub(super) fn name_or_expr(&self, id: NodeId) -> String { + let mut out = String::new(); + self.write_child(&mut out, id); + out + } + + /// The name of `id` if it is an input. + pub(super) fn input_name(&self, id: NodeId) -> Option<&'a str> { + match self.graph.get(id) { + Node::Input(i) => Some(self.inputs[*i].name()), + _ => None, + } + } + + fn write_child(&self, out: &mut String, id: NodeId) { + // An input keeps its own name even if a binding aliases it. + if matches!(self.graph.get(id), Node::Input(_)) { + return self.write(out, id); + } + match (self.name_of)(id) { + Some(name) => out.push_str(name), + None => self.write(out, id), + } + } + + fn write(&self, out: &mut String, id: NodeId) { + match self.graph.get(id) { + Node::Input(i) => out.push_str(self.inputs[*i].name()), + Node::Const(c) => { + let _ = write!(out, "{c}"); + } + Node::Neg(a) => { + out.push('-'); + self.write_child(out, *a); + } + Node::Binary(op, a, b) => { + out.push('('); + self.write_child(out, *a); + let _ = write!(out, " {} ", op.symbol()); + self.write_child(out, *b); + out.push(')'); + } + Node::Call(function, a, params) => { + out.push_str(function.name()); + out.push('('); + self.write_child(out, *a); + for p in params { + let _ = write!(out, ", {p}"); + } + out.push(')'); + } + } + } +} diff --git a/src/pipeline/error.rs b/src/pipeline/error.rs index cd4ea05..74f5a4a 100644 --- a/src/pipeline/error.rs +++ b/src/pipeline/error.rs @@ -27,6 +27,13 @@ impl Diagnostic { } } + pub(super) fn warning(message: impl Into) -> Self { + Self { + severity: Severity::Warning, + message: message.into(), + } + } + /// How bad the problem is. pub fn severity(&self) -> Severity { self.severity diff --git a/src/pipeline/function.rs b/src/pipeline/function.rs index efa01e0..927afa8 100644 --- a/src/pipeline/function.rs +++ b/src/pipeline/function.rs @@ -610,6 +610,25 @@ impl Function { Some(self.range(admitted, Params::new(self, params))) } + /// An estimate of the admitted updates of its argument a call needs + /// before it is ready: the sum of its windows (a log return needs one + /// more value). Pipelines sum it along their longest chain. + /// + /// # Panics + /// + /// If `params` do not pass [`Function::check_params`]. + pub fn warm_up(self, params: &[f64]) -> usize { + let params = Params::new(self, params); + let windows: usize = self + .params() + .iter() + .enumerate() + .filter(|(_, kind)| **kind == Param::Window) + .map(|(i, _)| params.window(i).get()) + .sum(); + windows + usize::from(self == Function::LnReturn) + } + /// A markdown table of every function: signature, what the argument /// must be, and description. Meant for documentation and for prompting /// an agent that writes pipelines. diff --git a/src/pipeline/mod.rs b/src/pipeline/mod.rs index 9e4be93..592ce16 100644 --- a/src/pipeline/mod.rs +++ b/src/pipeline/mod.rs @@ -9,8 +9,11 @@ //! recursion and no dynamic dispatch. //! //! Each function declares what it needs of its argument ([`Requirement`]) -//! and the [`Domain`] of its output, so a pipeline can be checked before it -//! sees any data. +//! and the [`Domain`] of its output. From the domains of the inputs the +//! compiler derives every node's domain and warm-up before any data is +//! seen: it rejects calls that are never ready and constant outputs, and +//! warns about calls that are not always ready, each with a message that +//! says how to fix it. //! //! ``` //! use sliding_features::pipeline::{BinaryOp, Builder, Domain, Function, Input, Limits}; @@ -30,8 +33,10 @@ //! assert!(pipeline.output(0).is_some()); //! ``` +mod analysis; mod builder; mod compiled; +mod display; mod domain; mod error; mod function; diff --git a/src/pipeline/tests.rs b/src/pipeline/tests.rs index f7451d2..93dce7d 100644 --- a/src/pipeline/tests.rs +++ b/src/pipeline/tests.rs @@ -228,12 +228,14 @@ fn adversarial_inputs() -> Vec> { ] } -/// The arguments every function is tried on: the input, a series of both -/// signs, and an exact zero. +/// The arguments every function is tried on: the input, and series of +/// both signs that are exactly zero on constant input. (`x - x` would be +/// rejected as constant.) fn arguments(b: &mut Builder, x: NodeId) -> Vec { let roc = b.call(Function::Roc, x, &[1.0]).unwrap(); - let zero = b.binary(BinaryOp::Sub, x, x).unwrap(); - vec![x, roc, zero] + let lag = b.call(Function::Lag, x, &[1.0]).unwrap(); + let diff = b.binary(BinaryOp::Sub, x, lag).unwrap(); + vec![x, roc, diff] } /// Every function, at its minimum parameters, on every adversarial input @@ -311,3 +313,123 @@ fn outputs_stay_in_their_declared_domain() { } assert!(failures.is_empty(), "{failures:#?}"); } + +/// After its estimated warm-up on a positive random walk, every function +/// is ready, at its minimum parameters and at larger ones. +#[test] +fn the_warm_up_estimate_suffices() { + let mut failures = Vec::new(); + for function in Function::ALL { + let larger = Vec::from_iter(function.params().iter().map(|p| match p { + Param::Window => 10.0, + Param::Real | Param::Fraction => 0.5, + })); + for params in [min_params(*function), larger] { + let (mut b, x) = single_input(); + let inner = b.call(Function::Ema, x, &[3.0]).unwrap(); + let call = b.call(*function, inner, ¶ms).unwrap(); + b.output("out", call).unwrap(); + let mut p = b.build().unwrap(); + let walk = random_walk(4, p.warm_up()); + for v in &walk { + p.update(&[*v]); + } + if p.output(0).is_none() { + failures.push(format!( + "{}{params:?}: not ready after {}", + function.name(), + p.warm_up() + )); + } + } + } + assert!(failures.is_empty(), "{failures:#?}"); +} + +fn build_errors(b: Builder) -> Vec { + let err = b.build().unwrap_err(); + Vec::from_iter(err.diagnostics().iter().map(ToString::to_string)) +} + +#[test] +fn never_ready_calls_and_constant_outputs_are_errors() { + let (mut b, x) = single_input(); + let abs = b.call(Function::Abs, x, &[]).unwrap(); + let neg = b.neg(abs).unwrap(); + let ln = b.call(Function::Ln, neg, &[]).unwrap(); + b.output("ln", ln).unwrap(); + let zero = b.binary(BinaryOp::Sub, x, x).unwrap(); + let div = b.binary(BinaryOp::Div, x, zero).unwrap(); + b.output("div", div).unwrap(); + b.output("zero", zero).unwrap(); + let sma = b.call(Function::Sma, zero, &[5.0]).unwrap(); + b.output("flat", sma).unwrap(); + assert_eq!( + build_errors(b), + [ + "error: `ln(-abs(x))` is never ready: `ln` needs positive values, but `-abs(x)` lies in (-inf, 0]", + "error: `(x / zero)` divides by zero, so it is never ready", + "error: output `zero` is constant: it is always 0", + "error: output `flat` is constant: it is always 0", + ] + ); +} + +#[test] +fn suspicious_pipelines_compile_with_warnings() { + let (mut b, x) = single_input(); + let ln = b.call(Function::Ln, x, &[]).unwrap(); + b.output("ln", ln).unwrap(); + let abs = b.call(Function::Abs, x, &[]).unwrap(); + let entropy = b.call(Function::BinaryEntropy, abs, &[10.0]).unwrap(); + b.output("entropy", entropy).unwrap(); + b.output("again", ln).unwrap(); + let unused = b.call(Function::Ema, x, &[3.0]).unwrap(); + b.bind("unused", unused).unwrap(); + let p = b.build().unwrap(); + let warnings = Vec::from_iter(p.warnings().iter().map(ToString::to_string)); + assert_eq!( + warnings, + [ + "warning: `ln(x)` is not ready while `x` is not positive (it lies in (-inf, inf)); \ + declare the domain of input `x` if it is always positive", + "warning: `binary_entropy(abs(x), 10)` is constant: `abs(x)` never changes sign (it lies in [0, inf))", + "warning: output `again` computes the same as output `ln`", + "warning: `unused` is not used by any output", + ] + ); +} + +#[test] +fn declared_input_domains_silence_warnings() { + let inputs = vec![Input::new("price", Domain::positive())]; + let mut b = Builder::new(inputs, Limits::default()).unwrap(); + let price = b.lookup("price").unwrap(); + let ln = b.call(Function::Ln, price, &[]).unwrap(); + let r = b.call(Function::LnReturn, price, &[]).unwrap(); + b.output("ln", ln).unwrap(); + b.output("r", r).unwrap(); + let p = b.build().unwrap(); + assert!(p.warnings().is_empty(), "{:?}", p.warnings()); +} + +#[test] +fn output_domains_and_warm_ups() { + let (mut b, x) = single_input(); + let rsi = b.call(Function::Rsi, x, &[14.0]).unwrap(); + let smooth = b.call(Function::Ema, rsi, &[5.0]).unwrap(); + let tanh = b.call(Function::Tanh, x, &[]).unwrap(); + let lag = b.call(Function::Lag, x, &[100.0]).unwrap(); + let sum = b.binary(BinaryOp::Add, smooth, lag).unwrap(); + b.output("smooth", smooth).unwrap(); + b.output("tanh", tanh).unwrap(); + b.output("sum", sum).unwrap(); + let p = b.build().unwrap(); + assert_eq!(p.output_domain(0), Domain::closed(0.0, 100.0)); + assert_eq!(p.output_domain(1), Domain::closed(-1.0, 1.0)); + assert_eq!(p.output_domain(2), Domain::REAL); + assert_eq!(p.output_warm_up(0), 19); + assert_eq!(p.output_warm_up(1), 0); + assert_eq!(p.output_warm_up(2), 100); + assert_eq!(p.warm_up(), 100); +} From 7fb5911df789eeed84f50da89ed674b3aced91ee Mon Sep 17 00:00:00 2001 From: MathisWellmann Date: Sun, 11 Oct 2026 23:08:49 +0200 Subject: [PATCH 11/17] pipeline: text form for writing pipelines, e.g. by an LLM Pipeline::compile parses statements into a Builder: `name = expr` for outputs, `let name = expr` for helpers and bare expressions as outputs named by their text, over named inputs with declared domains. The expression grammar is the spec module's, refactored into a lexer and a parser that drive the builder, so text and code build the same graphs and share all checks. Line breaks end statements only after a complete expression, so long expressions can span lines, and `#` starts a comment. Every diagnostic now carries a line and column: syntax errors, builder errors and the analysis findings, which point at the expression they are about. Unknown names and functions suggest the closest match (smaa -> sma), a value called like a function and a function used like a value say so, and nesting is bounded so a malicious text cannot overflow the stack (the spec parser had no such bound). Display prints the canonical text: helpers by name, parentheses only around nested operations. A randomized test checks that random pipelines print to text that compiles to the same text and computes the same values. reference() renders the language guide and function table for a prompt. About 400 of the lines are tests. --- src/pipeline/analysis.rs | 42 ++- src/pipeline/builder.rs | 140 +++++++-- src/pipeline/compiled.rs | 74 ++++- src/pipeline/display.rs | 40 ++- src/pipeline/error.rs | 77 ++++- src/pipeline/lex.rs | 236 +++++++++++++++ src/pipeline/mod.rs | 93 ++++-- src/pipeline/parse.rs | 610 +++++++++++++++++++++++++++++++++++++++ src/pipeline/tests.rs | 2 +- 9 files changed, 1230 insertions(+), 84 deletions(-) create mode 100644 src/pipeline/lex.rs create mode 100644 src/pipeline/parse.rs diff --git a/src/pipeline/analysis.rs b/src/pipeline/analysis.rs index aad91a6..e6a3c8d 100644 --- a/src/pipeline/analysis.rs +++ b/src/pipeline/analysis.rs @@ -6,6 +6,7 @@ use super::{ Diagnostic, Domain, Input, + Location, Requirement, builder::Binding, display::Printer, @@ -26,9 +27,21 @@ pub(super) struct Analysis { impl Analysis { /// Derive domains and warm-ups in one pass over the nodes (children - /// come first), then check the bindings. - pub(super) fn run(graph: &Graph, inputs: &[Input], bindings: &[Binding]) -> Self { - let name_of = |id: NodeId| bindings.iter().find(|b| b.node() == id).map(Binding::name); + /// come first), then check the bindings. Diagnostics point at the + /// node's location in the text form, if any. + pub(super) fn run( + graph: &Graph, + inputs: &[Input], + bindings: &[Binding], + locations: &[Option], + ) -> Self { + assert_eq!(locations.len(), graph.len(), "a location per node"); + let name_of = |id: NodeId| { + bindings + .iter() + .find(|b| b.is_named() && b.node() == id) + .map(Binding::name) + }; let printer = Printer::new(graph, inputs, name_of); let mut analysis = Self { domains: Vec::with_capacity(graph.len()), @@ -39,7 +52,8 @@ impl Analysis { let (domain, warm_up, diagnostic) = analysis.node(&printer, id, node, inputs); analysis.domains.push(domain); analysis.warm_ups.push(warm_up); - analysis.diagnostics.extend(diagnostic); + let at = locations[id.index()]; + analysis.diagnostics.extend(diagnostic.map(|d| d.or_at(at))); } assert_eq!(analysis.domains.len(), graph.len(), "a domain per node"); let findings = analysis.check_bindings(graph, bindings); @@ -141,27 +155,31 @@ impl Analysis { let mut found = Vec::new(); for (i, binding) in bindings.iter().enumerate() { let name = binding.name(); + let at = binding.location(); if !binding.is_output() { if !reachable[binding.node().index()] { - found.push(Diagnostic::warning(format!( - "`{name}` is not used by any output" - ))); + found.push( + Diagnostic::warning(format!("`{name}` is not used by any output")) + .or_at(at), + ); } continue; } if let Some(c) = self.domain(binding.node()).as_point() { - found.push(Diagnostic::error(format!( - "output `{name}` is constant: it is always {c}" - ))); + found.push( + Diagnostic::error(format!("output `{name}` is constant: it is always {c}")) + .or_at(at), + ); } let earlier = bindings[..i] .iter() .find(|b| b.is_output() && b.node() == binding.node()); if let Some(earlier) = earlier { - found.push(Diagnostic::warning(format!( + let message = format!( "output `{name}` computes the same as output `{}`", earlier.name() - ))); + ); + found.push(Diagnostic::warning(message).or_at(at)); } } found diff --git a/src/pipeline/builder.rs b/src/pipeline/builder.rs index 49ff942..0b6dbab 100644 --- a/src/pipeline/builder.rs +++ b/src/pipeline/builder.rs @@ -6,9 +6,11 @@ use super::{ CompileError, Domain, Function, + Location, Pipeline, Severity, analysis::Analysis, + display::Printer, function::Params, graph::{ Graph, @@ -88,13 +90,24 @@ impl Input { } } -/// A named node: an output of the pipeline, or a helper that only names a -/// node for the text form (`let name = ...`). +/// What a binding names. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum BindingKind { + /// A node later statements refer to by name (`let name = ...`). + Helper, + /// An output (`name = ...`). + Output, + /// An output written as a bare expression, named by its text. + Anonymous, +} + +/// A named node, see [`BindingKind`]. #[derive(Debug, Clone, PartialEq)] pub(super) struct Binding { name: String, node: NodeId, - is_output: bool, + kind: BindingKind, + location: Option, } impl Binding { @@ -106,8 +119,22 @@ impl Binding { self.node } + pub(super) fn kind(&self) -> BindingKind { + self.kind + } + pub(super) fn is_output(&self) -> bool { - self.is_output + self.kind != BindingKind::Helper + } + + /// Whether expressions can refer to the node by the binding's name. + pub(super) fn is_named(&self) -> bool { + self.kind != BindingKind::Anonymous + } + + /// Where the binding is in the text form. + pub(super) fn location(&self) -> Option { + self.location } } @@ -144,6 +171,10 @@ pub struct Builder { input_nodes: Vec, bindings: Vec, limits: Limits, + /// Where in the text form each node first appears. + locations: Vec>, + /// Where the text form is, for new nodes, bindings and errors. + location: Option, } impl Builder { @@ -159,6 +190,8 @@ impl Builder { input_nodes: Vec::new(), bindings: Vec::new(), limits, + locations: Vec::new(), + location: None, }; for (i, input) in inputs.into_iter().enumerate() { builder.check_name(input.name())?; @@ -177,16 +210,36 @@ impl Builder { } self.bindings .iter() - .find(|binding| binding.name == name) + .find(|binding| binding.is_named() && binding.name == name) .map(|binding| binding.node) } + /// Every name expressions can refer to: inputs, then bindings. + pub(super) fn names(&self) -> impl Iterator { + let inputs = self.inputs.iter().map(Input::name); + let bindings = self + .bindings + .iter() + .filter(|b| b.is_named()) + .map(Binding::name); + inputs.chain(bindings) + } + + /// The limits the builder enforces. + pub(super) fn limits(&self) -> Limits { + self.limits + } + + /// Attribute new nodes, bindings and errors to `location` in the text + /// form. + pub(super) fn set_location(&mut self, location: Location) { + self.location = Some(location); + } + /// The constant `value`. pub fn constant(&mut self, value: f64) -> Result { if !value.is_finite() { - return Err(CompileError::single(format!( - "the constant {value} is not finite" - ))); + return Err(self.error(format!("the constant {value} is not finite"))); } // Adding zero turns -0 into 0, so both are one node. self.add(Node::Const(value + 0.0)) @@ -211,10 +264,7 @@ impl Builder { let (a, b) = (*a, *b); let value = op.eval(a, b); if !value.is_finite() { - return Err(CompileError::single(format!( - "`{a} {} {b}` is not finite", - op.symbol() - ))); + return Err(self.error(format!("`{a} {} {b}` is not finite", op.symbol()))); } return self.constant(value); } @@ -231,13 +281,13 @@ impl Builder { ) -> Result { function .check_params(params, self.limits.max_window) - .map_err(CompileError::single)?; + .map_err(|message| self.error(message))?; if let Node::Const(c) = *self.graph.get(arg) && let Kind::Pure(pure) = function.instantiate(Params::new(function, params)) { let value = pure.eval(c); if !value.is_finite() { - return Err(CompileError::single(format!( + return Err(self.error(format!( "`{}` of the constant {c} is not finite", function.name() ))); @@ -250,13 +300,43 @@ impl Builder { /// Make `node` an output called `name`. Outputs are read in the order /// they are added. pub fn output(&mut self, name: &str, node: NodeId) -> Result<(), CompileError> { - self.push_binding(name, node, true) + self.check_name(name)?; + self.push_binding(name.to_string(), node, BindingKind::Output); + Ok(()) } /// Name `node` without making it an output, so the text form refers to /// it by `name` (`let name = ...`). pub fn bind(&mut self, name: &str, node: NodeId) -> Result<(), CompileError> { - self.push_binding(name, node, false) + self.check_name(name)?; + self.push_binding(name.to_string(), node, BindingKind::Helper); + Ok(()) + } + + /// Make `node` an output named by its expression, as written by a bare + /// expression in the text form. + pub(super) fn anonymous_output(&mut self, node: NodeId) -> Result<(), CompileError> { + let name = self.expr_text(node); + let taken = self + .bindings + .iter() + .any(|b| b.is_output() && b.name() == name); + if taken { + return Err(self.error(format!("the output `{name}` is already defined"))); + } + self.push_binding(name, node, BindingKind::Anonymous); + Ok(()) + } + + /// The text of `node`, referring to named nodes by name. + fn expr_text(&self, node: NodeId) -> String { + let name_of = |id: NodeId| { + self.bindings + .iter() + .find(|b| b.is_named() && b.node() == id) + .map(Binding::name) + }; + Printer::new(&self.graph, &self.inputs, name_of).expr(node) } /// Check the pipeline and return it with fresh state. @@ -270,7 +350,7 @@ impl Builder { if !self.bindings.iter().any(Binding::is_output) { return Err(CompileError::single("a pipeline needs at least one output")); } - let analysis = Analysis::run(&self.graph, &self.inputs, &self.bindings); + let analysis = Analysis::run(&self.graph, &self.inputs, &self.bindings, &self.locations); let diagnostics = analysis.diagnostics(); if diagnostics.iter().any(|d| d.severity() == Severity::Error) { return Err(CompileError::new(diagnostics.to_vec())); @@ -283,20 +363,19 @@ impl Builder { )) } - fn push_binding( - &mut self, - name: &str, - node: NodeId, - is_output: bool, - ) -> Result<(), CompileError> { + fn push_binding(&mut self, name: String, node: NodeId, kind: BindingKind) { assert!(node.index() < self.graph.len(), "a node of this builder"); - self.check_name(name)?; self.bindings.push(Binding { - name: name.to_string(), + name, node, - is_output, + kind, + location: self.location, }); - Ok(()) + } + + /// An error at the current location. + fn error(&self, message: impl Into) -> CompileError { + CompileError::single(message).or_at(self.location) } /// Names are identifiers, so the text form can refer to them, and @@ -317,7 +396,7 @@ impl Builder { } else { return Ok(()); }; - Err(CompileError::single(format!("the name `{name}` {problem}"))) + Err(self.error(format!("the name `{name}` {problem}"))) } /// Intern `node` within the limits. @@ -328,7 +407,7 @@ impl Builder { .max() .unwrap_or(0); if depth > self.limits.max_depth { - return Err(CompileError::single(format!( + return Err(self.error(format!( "the expression is nested {depth} deep, the limit is {}", self.limits.max_depth ))); @@ -337,13 +416,14 @@ impl Builder { .graph .intern(node, self.limits.max_nodes) .ok_or_else(|| { - CompileError::single(format!( + self.error(format!( "the pipeline needs more than {} distinct nodes", self.limits.max_nodes )) })?; if id.index() == self.depths.len() { self.depths.push(depth); + self.locations.push(self.location); } assert_eq!(self.depths.len(), self.graph.len(), "a depth per node"); Ok(id) diff --git a/src/pipeline/compiled.rs b/src/pipeline/compiled.rs index 230bc4c..0e46863 100644 --- a/src/pipeline/compiled.rs +++ b/src/pipeline/compiled.rs @@ -1,17 +1,30 @@ -//! A compiled pipeline and its use as a single view. +//! A compiled pipeline, its canonical text form and its use as a single +//! view. + +use std::{ + fmt, + str::FromStr, +}; use super::{ + Builder, CompileError, Diagnostic, Domain, Input, + Limits, Severity, analysis::Analysis, - builder::Binding, + builder::{ + Binding, + BindingKind, + }, + display::Printer, graph::{ Graph, NodeId, }, + parse::parse, program::Program, }; use crate::View; @@ -26,6 +39,18 @@ use crate::View; /// /// Clone a warmed-up pipeline to branch it; [`Pipeline::reset`] starts it /// over. +/// +/// The [`fmt::Display`] form is canonical: compiling it yields the same +/// pipeline, so two pipelines that print the same compute the same. +/// +/// ``` +/// use sliding_features::pipeline::Pipeline; +/// +/// let mut p: Pipeline = "zscore(ema(x, 10) - ema(x, 50), 200)".parse().unwrap(); +/// assert_eq!(p.to_string(), "zscore(ema(x, 10) - ema(x, 50), 200)"); +/// p.update(&[100.0]); +/// assert_eq!(p.output(0), None, "warming up"); +/// ``` #[derive(Debug, Clone)] pub struct Pipeline { graph: Graph, @@ -64,6 +89,16 @@ impl Pipeline { } } + /// Compile the text form of a pipeline over `inputs`, see + /// [`reference()`](super::reference()) for the language. + /// + /// The error lists every problem with its line and column, worded to + /// be fixed by the author, e.g. an LLM asked to retry. + pub fn compile(source: &str, inputs: Vec, limits: Limits) -> Result { + let builder = Builder::new(inputs, limits)?; + parse(source, builder)?.build() + } + /// Feed one value per input, in the order of [`Pipeline::inputs`]. A /// non-finite value counts as not ready. /// @@ -151,6 +186,41 @@ impl Pipeline { } } +impl FromStr for Pipeline { + type Err = CompileError; + + /// Compile a pipeline over one input `x` that takes any finite value, + /// within the default [`Limits`]. + fn from_str(source: &str) -> Result { + Self::compile(source, vec![Input::real("x")], Limits::default()) + } +} + +impl fmt::Display for Pipeline { + /// One statement per line, in the order written. A node named by an + /// earlier statement is referred to by that name. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (k, binding) in self.bindings.iter().enumerate() { + let name_of = |id: NodeId| { + self.bindings[..k] + .iter() + .find(|b| b.is_named() && b.node() == id) + .map(Binding::name) + }; + let expr = Printer::new(&self.graph, &self.inputs, name_of).expr(binding.node()); + if k > 0 { + f.write_str("\n")?; + } + match binding.kind() { + BindingKind::Helper => write!(f, "let {} = {expr}", binding.name())?, + BindingKind::Output => write!(f, "{} = {expr}", binding.name())?, + BindingKind::Anonymous => f.write_str(&expr)?, + } + } + Ok(()) + } +} + fn output_nodes(bindings: &[Binding]) -> Vec { Vec::from_iter(bindings.iter().filter(|b| b.is_output()).map(Binding::node)) } diff --git a/src/pipeline/display.rs b/src/pipeline/display.rs index 5d6d4ee..7af592e 100644 --- a/src/pipeline/display.rs +++ b/src/pipeline/display.rs @@ -35,14 +35,14 @@ where /// one. pub(super) fn expr(&self, id: NodeId) -> String { let mut out = String::new(); - self.write(&mut out, id); + self.write(&mut out, id, Context::Whole); out } /// The name of `id` if it has one, else its expression. pub(super) fn name_or_expr(&self, id: NodeId) -> String { let mut out = String::new(); - self.write_child(&mut out, id); + self.write_child(&mut out, id, Context::Whole); out } @@ -54,18 +54,18 @@ where } } - fn write_child(&self, out: &mut String, id: NodeId) { + fn write_child(&self, out: &mut String, id: NodeId, context: Context) { // An input keeps its own name even if a binding aliases it. if matches!(self.graph.get(id), Node::Input(_)) { - return self.write(out, id); + return self.write(out, id, context); } match (self.name_of)(id) { Some(name) => out.push_str(name), - None => self.write(out, id), + None => self.write(out, id, context), } } - fn write(&self, out: &mut String, id: NodeId) { + fn write(&self, out: &mut String, id: NodeId, context: Context) { match self.graph.get(id) { Node::Input(i) => out.push_str(self.inputs[*i].name()), Node::Const(c) => { @@ -73,19 +73,26 @@ where } Node::Neg(a) => { out.push('-'); - self.write_child(out, *a); + self.write_child(out, *a, Context::Operand); } Node::Binary(op, a, b) => { - out.push('('); - self.write_child(out, *a); + // Every nested operation is parenthesized, so reading the + // text needs no precedence rules. + let parens = context == Context::Operand; + if parens { + out.push('('); + } + self.write_child(out, *a, Context::Operand); let _ = write!(out, " {} ", op.symbol()); - self.write_child(out, *b); - out.push(')'); + self.write_child(out, *b, Context::Operand); + if parens { + out.push(')'); + } } Node::Call(function, a, params) => { out.push_str(function.name()); out.push('('); - self.write_child(out, *a); + self.write_child(out, *a, Context::Whole); for p in params { let _ = write!(out, ", {p}"); } @@ -94,3 +101,12 @@ where } } } + +/// Where an expression is written. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Context { + /// On its own: a statement or a call argument. + Whole, + /// As the operand of an operator. + Operand, +} diff --git a/src/pipeline/error.rs b/src/pipeline/error.rs index 74f5a4a..b670e11 100644 --- a/src/pipeline/error.rs +++ b/src/pipeline/error.rs @@ -11,11 +11,47 @@ pub enum Severity { Warning, } +/// Where in the text form a problem is. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Location { + offset: usize, + line: usize, + column: usize, +} + +impl Location { + pub(super) fn new(offset: usize, line: usize, column: usize) -> Self { + assert!(line >= 1, "lines count from 1"); + assert!(column >= 1, "columns count from 1"); + Self { + offset, + line, + column, + } + } + + /// The byte offset in the text. + pub fn offset(&self) -> usize { + self.offset + } + + /// The line, counted from 1. + pub fn line(&self) -> usize { + self.line + } + + /// The column in characters, counted from 1. + pub fn column(&self) -> usize { + self.column + } +} + /// One problem the compiler found, worded to be fixed by whoever wrote the /// pipeline, a person or an LLM. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Diagnostic { severity: Severity, + location: Option, message: String, } @@ -23,6 +59,7 @@ impl Diagnostic { pub(super) fn error(message: impl Into) -> Self { Self { severity: Severity::Error, + location: None, message: message.into(), } } @@ -30,15 +67,28 @@ impl Diagnostic { pub(super) fn warning(message: impl Into) -> Self { Self { severity: Severity::Warning, + location: None, message: message.into(), } } + /// The diagnostic at `location`, unless it already has one. + pub(super) fn or_at(mut self, location: Option) -> Self { + self.location = self.location.or(location); + self + } + /// How bad the problem is. pub fn severity(&self) -> Severity { self.severity } + /// Where the problem is in the text form, if the pipeline was written + /// as text. + pub fn location(&self) -> Option { + self.location + } + /// What is wrong. pub fn message(&self) -> &str { &self.message @@ -51,7 +101,14 @@ impl fmt::Display for Diagnostic { Severity::Error => "error", Severity::Warning => "warning", }; - write!(f, "{severity}: {}", self.message) + match self.location { + Some(at) => write!( + f, + "{severity} at {}:{}: {}", + at.line, at.column, self.message + ), + None => write!(f, "{severity}: {}", self.message), + } } } @@ -75,6 +132,17 @@ impl CompileError { Self::new(vec![Diagnostic::error(message)]) } + pub(super) fn at(location: Location, message: impl Into) -> Self { + Self::new(vec![Diagnostic::error(message).or_at(Some(location))]) + } + + /// The error with `location` on every diagnostic that has none. + pub(super) fn or_at(self, location: Option) -> Self { + Self { + diagnostics: Vec::from_iter(self.diagnostics.into_iter().map(|d| d.or_at(location))), + } + } + /// Every problem found, errors and warnings, in the order found. pub fn diagnostics(&self) -> &[Diagnostic] { &self.diagnostics @@ -104,6 +172,13 @@ mod tests { let err = CompileError::new(vec![Diagnostic::error("a"), Diagnostic::error("b")]); assert_eq!(err.to_string(), "error: a\nerror: b"); assert_eq!(err.diagnostics().len(), 2); + let located = err.or_at(Some(Location::new(4, 2, 3))); + assert_eq!(located.to_string(), "error at 2:3: a\nerror at 2:3: b"); + let kept = located.or_at(Some(Location::new(0, 1, 1))); + assert_eq!( + kept.diagnostics()[0].location().map(|l| l.offset()), + Some(4) + ); } #[test] diff --git a/src/pipeline/lex.rs b/src/pipeline/lex.rs new file mode 100644 index 0000000..dcfdf2c --- /dev/null +++ b/src/pipeline/lex.rs @@ -0,0 +1,236 @@ +//! Splits the text form of a pipeline into tokens with their locations. + +use super::{ + CompileError, + Location, +}; + +#[derive(Debug, Clone, PartialEq)] +pub(super) enum Token { + Number(f64), + Name(String), + Open, + Close, + Comma, + /// `+ - * /` + Op(char), + Assign, + /// The end of a statement: `;` or a line break. + Sep, + End, +} + +impl Token { + /// The token as an error message names it. + pub(super) fn describe(&self) -> String { + match self { + Token::Number(v) => format!("number `{v}`"), + Token::Name(n) => format!("`{n}`"), + Token::Open => "`(`".to_string(), + Token::Close => "`)`".to_string(), + Token::Comma => "`,`".to_string(), + Token::Op(c) => format!("`{c}`"), + Token::Assign => "`=`".to_string(), + Token::Sep => "the end of the line".to_string(), + Token::End => "the end".to_string(), + } + } + + /// Whether a line break after the token continues the statement, as + /// after an operator or inside a call. + fn continues(&self) -> bool { + matches!( + self, + Token::Op(_) | Token::Comma | Token::Assign | Token::Open | Token::Sep + ) + } +} + +/// A token and where it starts. +pub(super) type Spanned = (Token, Location); + +/// Tracks the location while walking the source. +struct Cursor<'a> { + source: &'a str, + offset: usize, + line: usize, + column: usize, +} + +impl Cursor<'_> { + fn location(&self) -> Location { + Location::new(self.offset, self.line, self.column) + } + + fn peek(&self) -> Option { + self.source[self.offset..].chars().next() + } + + fn bump(&mut self) { + let c = self.peek().expect("not at the end"); + self.offset += c.len_utf8(); + if c == '\n' { + self.line += 1; + self.column = 1; + } else { + self.column += 1; + } + } + + fn bump_while(&mut self, pred: impl Fn(char) -> bool) -> &str { + let start = self.offset; + while self.peek().is_some_and(&pred) { + self.bump(); + } + &self.source[start..self.offset] + } +} + +/// The tokens of `source`. Line breaks end a statement only outside +/// parentheses and after a complete expression, so long expressions can +/// span lines. +pub(super) fn lex(source: &str) -> Result, CompileError> { + let mut cursor = Cursor { + source, + offset: 0, + line: 1, + column: 1, + }; + let mut tokens: Vec = Vec::new(); + let mut depth = 0_usize; + while let Some(c) = cursor.peek() { + let at = cursor.location(); + let token = match c { + '#' => { + cursor.bump_while(|c| c != '\n'); + continue; + } + '\n' | ';' => { + cursor.bump(); + let continued = tokens.last().is_none_or(|(t, _)| t.continues()); + if depth > 0 || (c == '\n' && continued) { + continue; + } + Token::Sep + } + c if c.is_whitespace() => { + cursor.bump(); + continue; + } + '(' | ')' | ',' | '+' | '-' | '*' | '/' | '=' => { + cursor.bump(); + match c { + '(' => { + depth += 1; + Token::Open + } + ')' => { + depth = depth.saturating_sub(1); + Token::Close + } + ',' => Token::Comma, + '=' => Token::Assign, + op => Token::Op(op), + } + } + '0'..='9' | '.' => number(&mut cursor, at)?, + c if c.is_ascii_alphabetic() || c == '_' => { + let name = cursor.bump_while(|c| c.is_ascii_alphanumeric() || c == '_'); + Token::Name(name.to_string()) + } + other => { + return Err(CompileError::at(at, format!("unexpected `{other}`"))); + } + }; + tokens.push((token, at)); + } + tokens.push((Token::End, cursor.location())); + Ok(tokens) +} + +/// A number: digits with an optional point, then an optional exponent. +fn number(cursor: &mut Cursor<'_>, at: Location) -> Result { + let start = cursor.offset; + cursor.bump_while(|c| c.is_ascii_digit() || c == '.'); + let rest = &cursor.source[cursor.offset..]; + let mut exponent = rest.chars(); + if matches!(exponent.next(), Some('e' | 'E')) { + let sign = usize::from(matches!(rest[1..].chars().next(), Some('+' | '-'))); + if rest[1 + sign..].starts_with(|c: char| c.is_ascii_digit()) { + for _ in 0..=sign { + cursor.bump(); + } + cursor.bump_while(|c| c.is_ascii_digit()); + } + } + let text = &cursor.source[start..cursor.offset]; + text.parse::() + .map(Token::Number) + .map_err(|_| CompileError::at(at, format!("invalid number `{text}`"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tokens(source: &str) -> Vec { + Vec::from_iter(lex(source).unwrap().into_iter().map(|(t, _)| t)) + } + + #[test] + fn numbers() { + assert_eq!( + tokens("1 2.5 .5 1e3 1.5E-2 3e"), + [ + Token::Number(1.0), + Token::Number(2.5), + Token::Number(0.5), + Token::Number(1000.0), + Token::Number(0.015), + Token::Number(3.0), + Token::Name("e".to_string()), + Token::End + ] + ); + let err = lex("1..2").unwrap_err(); + assert_eq!(err.to_string(), "error at 1:1: invalid number `1..2`"); + } + + #[test] + fn line_breaks_end_statements_only_after_complete_expressions() { + use Token::*; + let name = |n: &str| Name(n.to_string()); + assert_eq!( + tokens("a = x +\n 1\nb = sma(x,\n 2) # comment\n\n"), + [ + name("a"), + Assign, + name("x"), + Op('+'), + Number(1.0), + Sep, + name("b"), + Assign, + name("sma"), + Open, + name("x"), + Comma, + Number(2.0), + Close, + Sep, + End + ] + ); + assert_eq!(tokens("x; y"), [name("x"), Sep, name("y"), End]); + } + + #[test] + fn locations_count_lines_and_columns() { + let spanned = lex("a = 1\n b = 2").unwrap(); + let b = &spanned[4]; + assert_eq!(b.0, Token::Name("b".to_string())); + assert_eq!((b.1.line(), b.1.column(), b.1.offset()), (2, 3, 8)); + let err = lex("x\n $").unwrap_err(); + assert_eq!(err.to_string(), "error at 2:3: unexpected `$`"); + } +} diff --git a/src/pipeline/mod.rs b/src/pipeline/mod.rs index 592ce16..2f1c98d 100644 --- a/src/pipeline/mod.rs +++ b/src/pipeline/mod.rs @@ -1,37 +1,39 @@ //! Feature pipelines as data: many features over several input series, +//! written as text (e.g. by an LLM) or assembled by code, checked, and //! compiled into one flat program at runtime. //! -//! A [`Builder`] assembles a [`Pipeline`] from inputs, constants, the -//! arithmetic [`BinaryOp`]s and calls of [`Function`]s, and names its -//! outputs. Equal computations are shared, so features that use the same -//! `ema(x, 10)` compute it once. The pipeline runs as a flat list of -//! operations over a value buffer: one pass per update, no allocation, no -//! recursion and no dynamic dispatch. -//! -//! Each function declares what it needs of its argument ([`Requirement`]) -//! and the [`Domain`] of its output. From the domains of the inputs the -//! compiler derives every node's domain and warm-up before any data is -//! seen: it rejects calls that are never ready and constant outputs, and -//! warns about calls that are not always ready, each with a message that -//! says how to fix it. -//! //! ``` -//! use sliding_features::pipeline::{BinaryOp, Builder, Domain, Function, Input, Limits}; +//! use sliding_features::pipeline::{Domain, Input, Limits, Pipeline}; //! +//! let source = " +//! let r = ln_return(price) # a helper, not an output +//! vol_ratio = std(r, 20) / std(r, 100) +//! trend = zscore(ema(price, 10) - ema(price, 50), 200) +//! "; //! let inputs = vec![Input::new("price", Domain::positive())]; -//! let mut b = Builder::new(inputs, Limits::default()).unwrap(); -//! let price = b.lookup("price").unwrap(); -//! let r = b.call(Function::LnReturn, price, &[]).unwrap(); -//! let fast = b.call(Function::Std, r, &[2.0]).unwrap(); -//! let slow = b.call(Function::Std, r, &[4.0]).unwrap(); -//! let ratio = b.binary(BinaryOp::Div, fast, slow).unwrap(); -//! b.output("vol_ratio", ratio).unwrap(); -//! let mut pipeline = b.build().unwrap(); -//! for p in [100.0, 101.0, 99.0, 102.0, 100.0] { -//! pipeline.update(&[p]); +//! let mut pipeline = Pipeline::compile(source, inputs, Limits::default()).unwrap(); +//! assert_eq!(Vec::from_iter(pipeline.output_names()), ["vol_ratio", "trend"]); +//! for i in 0..300 { +//! pipeline.update(&[100.0 + (f64::from(i) * 0.1).sin()]); //! } -//! assert!(pipeline.output(0).is_some()); +//! assert!(pipeline.outputs().all(|v| v.is_some())); //! ``` +//! +//! [`reference()`] describes the language and every function, to put into an +//! LLM's prompt. A [`Builder`] assembles the same pipelines node by node, +//! e.g. for a search. +//! +//! Equal computations are shared, so features that use the same +//! `ema(x, 10)` compute it once. The pipeline runs as a flat list of +//! operations over a value buffer: one pass per update, no allocation, no +//! recursion and no dynamic dispatch. +//! +//! Each [`Function`] declares what it needs of its argument +//! ([`Requirement`]) and the [`Domain`] of its output. From the domains of +//! the inputs the compiler derives every node's domain and warm-up before +//! any data is seen: it rejects calls that are never ready and constant +//! outputs, and warns about calls that are not always ready, each with a +//! [`Location`] and a message that says how to fix it. mod analysis; mod builder; @@ -41,6 +43,8 @@ mod domain; mod error; mod function; mod graph; +mod lex; +mod parse; mod program; mod state; #[cfg(test)] @@ -59,6 +63,7 @@ pub use domain::Domain; pub use error::{ CompileError, Diagnostic, + Location, Severity, }; pub use function::{ @@ -68,3 +73,39 @@ pub use function::{ Requirement, }; pub use graph::NodeId; + +/// The language guide and the table of every function, in markdown: what +/// an LLM needs in its prompt to write pipelines. +pub fn reference() -> String { + let mut out = String::from(LANGUAGE); + out.push_str(&Function::reference()); + out +} + +const LANGUAGE: &str = "\ +# Feature pipelines + +A pipeline computes features (its outputs) from input series, one update at a time. + +- One statement per line (or separated by `;`); `#` starts a comment. +- `name = expr` defines an output. `let name = expr` defines a helper that later \ +statements can use by name. A bare `expr` is an output named by its text. +- An expression is a number, an input or helper name, `a + b`, `a - b`, `a * b`, `a / b` \ +(usual precedence), `-a`, `(a)`, or a call `function(a, param, ...)`: the first argument \ +is an expression, the parameters after it are number literals. +- Operators combine the values of the same update. Equal subexpressions are computed once. +- An output is not ready (no value) until its windows fill, or while an argument is outside \ +what a function accepts (a division by zero, a logarithm of a non-positive value). Ready \ +outputs are always finite. A function whose argument is not ready keeps its last value. +- The compiler rejects calls that can never be ready and constant outputs, and warns about \ +calls that are not always ready. + +```text +let r = ln_return(price) +vol_ratio = std(r, 20) / std(r, 100) +trend = zscore(ema(price, 10) - ema(price, 50), 200) +``` + +## Functions + +"; diff --git a/src/pipeline/parse.rs b/src/pipeline/parse.rs new file mode 100644 index 0000000..ee383bf --- /dev/null +++ b/src/pipeline/parse.rs @@ -0,0 +1,610 @@ +//! Recursive-descent parser of the text form, driving a [`Builder`]. +//! +//! ```text +//! program := (statement (sep statement)*)? +//! statement := 'let' name '=' expr | name '=' expr | expr +//! expr := term (('+' | '-') term)* +//! term := unary (('*' | '/') unary)* +//! unary := '-' unary | atom +//! atom := number | name | name '(' expr (',' param)* ')' | '(' expr ')' +//! param := '-'? number +//! ``` + +use super::{ + BinaryOp, + Builder, + CompileError, + Function, + Location, + graph::NodeId, + lex::{ + Spanned, + Token, + lex, + }, +}; + +/// Parse `source` into `builder`, statement by statement. +pub(super) fn parse(source: &str, builder: Builder) -> Result { + let max_depth = builder.limits().max_depth; + let mut parser = Parser { + tokens: lex(source)?, + pos: 0, + builder, + depth: 0, + max_depth, + }; + parser.program()?; + assert_eq!(parser.depth, 0, "nesting is balanced"); + Ok(parser.builder) +} + +struct Parser { + tokens: Vec, + pos: usize, + builder: Builder, + /// The current nesting of parentheses, calls and negations: bounded + /// so a malicious text cannot overflow the stack. + depth: usize, + max_depth: usize, +} + +impl Parser { + fn peek(&self) -> &Spanned { + &self.tokens[self.pos] + } + + fn peek_second(&self) -> &Token { + &self.tokens[(self.pos + 1).min(self.tokens.len() - 1)].0 + } + + fn next(&mut self) -> Spanned { + let spanned = self.tokens[self.pos].clone(); + if spanned.0 != Token::End { + self.pos += 1; + } + spanned + } + + fn expect(&mut self, expected: &Token, what: &str) -> Result<(), CompileError> { + let (token, at) = self.next(); + if &token == expected { + return Ok(()); + } + Err(CompileError::at( + at, + format!("expected {what}, found {}", token.describe()), + )) + } + + fn program(&mut self) -> Result<(), CompileError> { + loop { + while self.peek().0 == Token::Sep { + self.next(); + } + if self.peek().0 == Token::End { + return Ok(()); + } + self.statement()?; + let (token, at) = self.next(); + match token { + Token::Sep => {} + Token::End => return Ok(()), + other => { + return Err(CompileError::at( + at, + format!( + "expected a new line or `;` after the statement, found {}", + other.describe() + ), + )); + } + } + } + } + + fn statement(&mut self) -> Result<(), CompileError> { + let (first, at) = self.peek().clone(); + match (first, self.peek_second()) { + (Token::Name(keyword), Token::Name(_)) if keyword == "let" => { + self.next(); + let (Token::Name(name), name_at) = self.next() else { + unreachable!("peeked a name"); + }; + self.expect(&Token::Assign, &format!("`=` after `let {name}`"))?; + let node = self.expr()?; + self.builder.set_location(name_at); + self.builder.bind(&name, node) + } + (Token::Name(name), Token::Assign) => { + self.next(); + self.next(); + let node = self.expr()?; + self.builder.set_location(at); + self.builder.output(&name, node) + } + _ => { + let node = self.expr()?; + self.builder.set_location(at); + self.builder.anonymous_output(node) + } + } + } + + fn expr(&mut self) -> Result { + let mut lhs = self.term()?; + while let (Token::Op(c @ ('+' | '-')), at) = self.peek().clone() { + self.next(); + let rhs = self.term()?; + let op = if c == '+' { + BinaryOp::Add + } else { + BinaryOp::Sub + }; + self.builder.set_location(at); + lhs = self.builder.binary(op, lhs, rhs)?; + } + Ok(lhs) + } + + fn term(&mut self) -> Result { + let mut lhs = self.unary()?; + while let (Token::Op(c @ ('*' | '/')), at) = self.peek().clone() { + self.next(); + let rhs = self.unary()?; + let op = if c == '*' { + BinaryOp::Mul + } else { + BinaryOp::Div + }; + self.builder.set_location(at); + lhs = self.builder.binary(op, lhs, rhs)?; + } + Ok(lhs) + } + + fn unary(&mut self) -> Result { + let (token, at) = self.peek().clone(); + if token != Token::Op('-') { + return self.atom(); + } + self.next(); + let inner = self.nested(at, Self::unary)?; + self.builder.set_location(at); + // A negated literal folds into a negative constant. + self.builder.neg(inner) + } + + fn atom(&mut self) -> Result { + let (token, at) = self.next(); + self.builder.set_location(at); + match token { + Token::Number(value) => self.builder.constant(value), + Token::Name(name) if self.peek().0 == Token::Open => { + self.nested(at, |p| p.call(&name, at)) + } + Token::Name(name) => self + .builder + .lookup(&name) + .ok_or_else(|| self.unknown_name(&name, at)), + Token::Open => { + let inner = self.nested(at, Self::expr)?; + self.expect(&Token::Close, "`)`")?; + Ok(inner) + } + other => Err(CompileError::at( + at, + format!("expected an expression, found {}", other.describe()), + )), + } + } + + fn call(&mut self, name: &str, at: Location) -> Result { + let function = Function::from_name(name).ok_or_else(|| self.unknown_function(name, at))?; + self.expect(&Token::Open, &format!("`(` after `{name}`"))?; + let arg = self.expr()?; + let mut params = Vec::new(); + while self.peek().0 == Token::Comma { + self.next(); + params.push(self.param(name)?); + } + self.expect(&Token::Close, "`)` or `,`")?; + self.builder.set_location(at); + self.builder.call(function, arg, ¶ms) + } + + fn param(&mut self, function: &str) -> Result { + let (token, at) = self.next(); + let negative = token == Token::Op('-'); + let token = if negative { self.next().0 } else { token }; + match token { + Token::Number(value) if negative => Ok(-value), + Token::Number(value) => Ok(value), + other => Err(CompileError::at( + at, + format!( + "the parameters of `{function}` after its argument must be numbers, found {}", + other.describe() + ), + )), + } + } + + /// Run `f` one level deeper, failing past the depth limit. + fn nested( + &mut self, + at: Location, + f: impl FnOnce(&mut Self) -> Result, + ) -> Result { + if self.depth >= self.max_depth { + return Err(CompileError::at( + at, + format!( + "the expression is nested deeper than the limit of {}", + self.max_depth + ), + )); + } + self.depth += 1; + let result = f(self); + self.depth -= 1; + result + } + + fn unknown_name(&self, name: &str, at: Location) -> CompileError { + let message = if let Some(function) = Function::from_name(name) { + format!( + "`{name}` is a function, call it as `{}`", + function.signature() + ) + } else { + let known = Vec::from_iter(self.builder.names()); + let hint = match closest(name, known.iter().copied()) { + Some(close) => format!("did you mean `{close}`?"), + None => format!("defined so far: {}", known.join(", ")), + }; + format!("unknown name `{name}`; {hint}") + }; + CompileError::at(at, message) + } + + fn unknown_function(&self, name: &str, at: Location) -> CompileError { + let hint = if self.builder.lookup(name).is_some() { + format!("`{name}` is a value, not a function") + } else if let Some(close) = closest(name, Function::ALL.iter().map(|f| f.name())) { + format!("did you mean `{close}`?") + } else { + "see the function reference".to_string() + }; + CompileError::at(at, format!("unknown function `{name}`; {hint}")) + } +} + +/// The candidate closest to `name` by edit distance, if close enough to be +/// a likely typo: a third of the name may change, but not all of it (`y` +/// is no typo of `x`). +fn closest<'a>(name: &str, candidates: impl Iterator) -> Option<&'a str> { + let len = name.chars().count(); + let limit = (len / 3).max(1); + candidates + .map(|c| (edit_distance(name, c), c)) + .filter(|(d, _)| *d <= limit && *d < len) + .min_by_key(|(d, _)| *d) + .map(|(_, c)| c) +} + +/// The Levenshtein distance between two strings, in characters. +fn edit_distance(a: &str, b: &str) -> usize { + let b: Vec = b.chars().collect(); + let mut row: Vec = (0..=b.len()).collect(); + for (i, ca) in a.chars().enumerate() { + let mut diagonal = row[0]; + row[0] = i + 1; + for (j, cb) in b.iter().enumerate() { + let substitution = diagonal + usize::from(ca != *cb); + diagonal = row[j + 1]; + row[j + 1] = substitution.min(row[j] + 1).min(row[j + 1] + 1); + } + } + row[b.len()] +} + +#[cfg(test)] +mod tests { + use super::{ + super::{ + Domain, + Input, + Limits, + Param, + Pipeline, + }, + *, + }; + + /// Compile over `x`, check that the canonical form compiles to the + /// same canonical form, and return it. + fn canonical(source: &str) -> String { + let pipeline: Pipeline = source.parse().unwrap_or_else(|e| panic!("{source}: {e}")); + let printed = pipeline.to_string(); + let again: Pipeline = printed.parse().unwrap_or_else(|e| panic!("{printed}: {e}")); + assert_eq!(again.to_string(), printed, "{source}"); + printed + } + + fn error(source: &str) -> String { + source.parse::().expect_err(source).to_string() + } + + #[test] + fn precedence_and_associativity() { + assert_eq!(canonical("x + x * 2"), "x + (x * 2)"); + assert_eq!(canonical("(x + x) * 2"), "(x + x) * 2"); + assert_eq!(canonical("x - 1 - x"), "(x - 1) - x"); + assert_eq!(canonical("x / 2 / 4"), "(x / 2) / 4"); + assert_eq!(canonical("-x * 2 + -(x - 1)"), "(-x * 2) + -(x - 1)"); + } + + #[test] + fn calls_unary_minus_and_numbers() { + assert_eq!( + canonical("zscore(ema(x, 10) - ema(x,50), 200)"), + "zscore(ema(x, 10) - ema(x, 50), 200)" + ); + assert_eq!(canonical("x * -0.5"), "x * -0.5"); + assert_eq!(canonical("x * -(2 * 3)"), "x * -6", "constants fold"); + assert_eq!(canonical("gte(x, -1.5e-3)"), "gte(x, -0.0015)"); + assert_eq!(canonical("--x"), "--x"); + assert_eq!(canonical("-(x + 1)"), "-(x + 1)"); + assert_eq!(canonical("pfe(roc(x, 1), 20, 5)"), "pfe(roc(x, 1), 20, 5)"); + assert_eq!( + canonical(" laguerre_filter( x ,0.8 ) "), + "laguerre_filter(x, 0.8)" + ); + } + + #[test] + fn every_function_round_trips() { + for function in Function::ALL { + let mut source = format!("{}(x", function.name()); + for param in function.params() { + let value = match param { + Param::Window => function.min_window().to_string(), + Param::Real | Param::Fraction => "0.5".to_string(), + }; + source.push_str(", "); + source.push_str(&value); + } + source.push(')'); + assert_eq!(canonical(&source), source); + } + } + + #[test] + fn statements_bindings_and_sharing() { + let source = " + # helpers are named in the canonical form + let r = ln_return(x) + a = std(r, 10) / std(r, 20); b = std(ln_return(x), 10) + ema(x, 3) - + x + rsi = rsi(x, 14) + c = ema(rsi, 2) + "; + assert_eq!( + canonical(source), + "let r = ln_return(x)\n\ + a = std(r, 10) / std(r, 20)\n\ + b = std(r, 10)\n\ + ema(x, 3) - x\n\ + rsi = rsi(x, 14)\n\ + c = ema(rsi, 2)" + ); + let p: Pipeline = source.parse().unwrap(); + assert_eq!( + Vec::from_iter(p.output_names()), + ["a", "b", "ema(x, 3) - x", "rsi", "c"] + ); + // x, r, two stds, a, ema, the difference, rsi and its ema. + assert_eq!(p.op_count(), 9); + } + + #[test] + fn several_inputs() { + let inputs = vec![ + Input::new("price", Domain::positive()), + Input::new("volume", Domain::non_negative()), + ]; + let source = "vwap = cumulative(price * volume, 2) / cumulative(volume, 2)"; + let mut p = Pipeline::compile(source, inputs, Limits::default()).unwrap(); + p.update(&[10.0, 1.0]); + p.update(&[20.0, 3.0]); + assert_eq!(p.output(0), Some(17.5)); + assert_eq!( + p.to_string(), + "vwap = cumulative(price * volume, 2) / cumulative(volume, 2)" + ); + } + + #[test] + fn errors_point_at_the_problem() { + for (source, expected) in [ + ( + "sma(x)", + "error at 1:1: sma(a, window) takes 1 parameter(s) after its argument, got 0", + ), + ( + "smaa(x, 3)", + "error at 1:1: unknown function `smaa`; did you mean `sma`?", + ), + ( + "sma(x, y)", + "error at 1:8: the parameters of `sma` after its argument must be numbers, found `y`", + ), + ( + "sma(x, sma(x, 2))", + "error at 1:8: the parameters of `sma` after its argument must be numbers, found `sma`", + ), + ("x +", "error at 1:4: expected an expression, found the end"), + ("(x", "error at 1:3: expected `)`, found the end"), + ( + "x x", + "error at 1:3: expected a new line or `;` after the statement, found `x`", + ), + ("x $ 2", "error at 1:3: unexpected `$`"), + ( + "sma(x, 2.5)", + "error at 1:1: sma(a, window): the window parameter 2.5 must be an integer in [1, 100000]", + ), + ("1..2", "error at 1:1: invalid number `1..2`"), + ( + "a = x\nb = y", + "error at 2:5: unknown name `y`; defined so far: x, a", + ), + ( + "a = x\nb = aa", + "error at 2:5: unknown name `aa`; did you mean `a`?", + ), + ( + "a = sma", + "error at 1:5: `sma` is a function, call it as `sma(a, window)`", + ), + ( + "let a = x\na(x)", + "error at 2:1: unknown function `a`; `a` is a value, not a function", + ), + ( + "let a = x\na = x", + "error at 2:1: the name `a` is already defined", + ), + ("let = 3", "error at 1:1: the name `let` is a keyword"), + ( + "ema(x, 3); ema(x, 3)", + "error at 1:12: the output `ema(x, 3)` is already defined", + ), + ("a = 1 / (2 - 2)", "error at 1:7: `1 / 0` is not finite"), + ("", "error: a pipeline needs at least one output"), + ] { + assert_eq!(error(source), expected, "{source}"); + } + } + + #[test] + fn analysis_findings_point_at_their_expression() { + assert_eq!( + error("a = x\nb = ln(-abs(x) - 1)\nx - x"), + "error at 2:5: `ln(-abs(x) - 1)` is never ready: `ln` needs positive values, \ + but `-abs(x) - 1` lies in (-inf, -1]\n\ + error at 3:1: output `x - x` is constant: it is always 0" + ); + let p: Pipeline = "a = ema(x, 2)\nb = 1 + ln(x)".parse().unwrap(); + let warning = &p.warnings()[0]; + assert_eq!( + warning.location().map(|l| (l.line(), l.column())), + Some((2, 9)) + ); + } + + #[test] + fn deep_nesting_is_an_error_not_a_stack_overflow() { + for source in [ + format!("{}x{}", "(".repeat(100_000), ")".repeat(100_000)), + format!("{}x", "-".repeat(100_000)), + format!("{}x{}", "abs(".repeat(100_000), ")".repeat(100_000)), + ] { + let err = error(&source); + assert!(err.contains("nested deeper than the limit of 32"), "{err}"); + } + } + + /// A random expression over the builder's inputs, `None` if the + /// builder rejects it (e.g. a constant folds to infinity). + fn random_expr(b: &mut super::Builder, rng: &romu::Rng, depth: usize) -> Option { + let leaf = depth == 0 || rng.range_usize(0..4) == 0; + if leaf { + return match rng.range_usize(0..3) { + 0 => b.lookup("y"), + 1 => b.constant(f64::from(rng.range_u8(1..=40)) / 8.0 - 2.5).ok(), + _ => b.lookup("x"), + }; + } + let a = random_expr(b, rng, depth - 1)?; + match rng.range_usize(0..5) { + 0 => b.neg(a).ok(), + 1 | 2 => { + let ops = [BinaryOp::Add, BinaryOp::Sub, BinaryOp::Mul, BinaryOp::Div]; + let rhs = random_expr(b, rng, depth - 1)?; + b.binary(ops[rng.range_usize(0..ops.len())], a, rhs).ok() + } + _ => { + let function = Function::ALL[rng.range_usize(0..Function::ALL.len())]; + let params = Vec::from_iter(function.params().iter().map(|p| match p { + Param::Window => f64::from(rng.range_u8(3..=12)), + Param::Real => f64::from(rng.range_u8(0..=8)) / 4.0 - 1.0, + Param::Fraction => 0.5, + })); + b.call(function, a, ¶ms).ok() + } + } + } + + /// Random pipelines print to text that compiles to the same text and + /// computes the same values. + #[test] + fn random_pipelines_round_trip_through_text() { + let rng = romu::Rng::from_seed_with_64bit(11); + let inputs = || vec![Input::new("x", Domain::positive()), Input::real("y")]; + let mut compared = 0; + for _ in 0..500 { + let mut b = super::Builder::new(inputs(), Limits::default()).unwrap(); + let Some(helper) = random_expr(&mut b, &rng, 3) else { + continue; + }; + if b.bind("h", helper).is_err() { + continue; + } + let Some(out) = random_expr(&mut b, &rng, 4) else { + continue; + }; + let both = b + .lookup("h") + .and_then(|h| b.binary(BinaryOp::Add, h, out).ok()); + let Some(both) = both else { continue }; + if b.output("out", out).is_err() || b.output("both", both).is_err() { + continue; + } + let Ok(mut original) = b.build() else { + continue; + }; + let text = original.to_string(); + let mut parsed = Pipeline::compile(&text, inputs(), Limits::default()) + .unwrap_or_else(|e| panic!("{text}\n{e}")); + assert_eq!(parsed.to_string(), text); + assert_eq!(parsed.op_count(), original.op_count(), "{text}"); + for i in 0..50 { + let v = [100.0 + f64::from(i % 7), f64::from(i % 5) - 2.0]; + original.update(&v); + parsed.update(&v); + let same = original.outputs().zip(parsed.outputs()).all(|(a, b)| { + a == b || a.zip(b).is_some_and(|(a, b)| a.is_nan() && b.is_nan()) + }); + assert!(same, "{text}"); + } + compared += 1; + } + assert!(compared > 100, "only {compared} pipelines compiled"); + } + + #[test] + fn edit_distances() { + assert_eq!(edit_distance("sma", "sma"), 0); + assert_eq!(edit_distance("smaa", "sma"), 1); + assert_eq!(edit_distance("zscroe", "zscore"), 2); + assert_eq!(edit_distance("", "abc"), 3); + assert_eq!( + closest("emaa", ["ema", "sma", "rsi"].into_iter()), + Some("ema") + ); + assert_eq!(closest("foo", ["ema", "sma"].into_iter()), None); + } +} diff --git a/src/pipeline/tests.rs b/src/pipeline/tests.rs index 93dce7d..703982f 100644 --- a/src/pipeline/tests.rs +++ b/src/pipeline/tests.rs @@ -368,7 +368,7 @@ fn never_ready_calls_and_constant_outputs_are_errors() { build_errors(b), [ "error: `ln(-abs(x))` is never ready: `ln` needs positive values, but `-abs(x)` lies in (-inf, 0]", - "error: `(x / zero)` divides by zero, so it is never ready", + "error: `x / zero` divides by zero, so it is never ready", "error: output `zero` is constant: it is always 0", "error: output `flat` is constant: it is always 0", ] From 4b4ac80b4067b4bed6098bff95ec48e330c31617 Mon Sep 17 00:00:00 2001 From: MathisWellmann Date: Sun, 11 Oct 2026 23:12:04 +0200 Subject: [PATCH 12/17] pipeline: compare against spec views and hand-written chains A test compiles every spec function (on three arguments) and composite specs as pipelines and checks they compute bit-identical values to the spec module's boxed trees on adversarial input, so moving to pipelines changes no feature. The bench measures per-update cost on 100k values (this machine): one feature, zscore(ema - sma): static chain 10.4 ns, spec 26.4 ns, pipeline 18.5 ns mostly stateless nodes: spec 57.0 ns, pipeline 39.6 ns 8 features sharing nodes: spec 256 ns, pipeline 217 ns The flat program removes about a third of the boxed tree's cost. The gain from sharing is smaller than hoped because the windows themselves (mad over 100 values, zscore over 200) dominate the feature set. --- Cargo.toml | 4 ++ benches/pipeline.rs | 150 ++++++++++++++++++++++++++++++++++++++++++ src/pipeline/tests.rs | 38 +++++++++++ 3 files changed, 192 insertions(+) create mode 100644 benches/pipeline.rs diff --git a/Cargo.toml b/Cargo.toml index 78c441e..4730a59 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -82,6 +82,10 @@ harness = false name = "noise_elimination_technology" harness = false +[[bench]] +name = "pipeline" +harness = false + [[bench]] name = "polarized_fractal_efficiency" harness = false diff --git a/benches/pipeline.rs b/benches/pipeline.rs new file mode 100644 index 0000000..5ce5154 --- /dev/null +++ b/benches/pipeline.rs @@ -0,0 +1,150 @@ +//! A pipeline against the same features as a hand-written generic chain +//! and as the spec module's boxed view trees. + +use std::{ + hint::black_box, + num::NonZeroUsize, +}; + +use criterion::{ + Criterion, + criterion_group, + criterion_main, +}; +use sliding_features::{ + View, + pipeline::{ + Feature, + Pipeline, + }, + pure_functions::{ + Echo, + Subtract, + }, + sliding_windows::{ + Ema, + Sma, + ZScoreStandardization, + }, + spec::{ + DynView, + Expr, + Limits, + }, +}; + +const N: usize = 100_000; + +fn random_walk() -> Vec { + let rng = romu::Rng::from_seed_with_64bit(1); + let mut price = 100.0; + Vec::from_iter((0..N).map(|_| { + price += rng.f64() - 0.5; + price + })) +} + +fn spec(source: &str) -> DynView { + Expr::parse(source) + .expect("parses") + .build(&Limits::default()) + .expect("builds") +} + +fn feature(source: &str) -> Feature { + let pipeline: Pipeline = source.parse().expect("compiles"); + Feature::try_from(pipeline).expect("one input and one output") +} + +/// Push every value through a view and read it, as a feature loop does. +fn drive(view: &mut impl View, vals: &[f64]) { + for v in vals { + view.update(*v); + let _ = black_box(view.last()); + } +} + +/// One feature: the overhead of dispatch per node. +fn one_feature(c: &mut Criterion) { + let vals = random_walk(); + let source = "zscore(ema(x, 10) - sma(x, 50), 200)"; + let window = |n| NonZeroUsize::new(n).expect("non zero"); + let mut group = c.benchmark_group("pipeline_one_feature_100k"); + group.bench_function("static_chain", |b| { + b.iter(|| { + let mut view = ZScoreStandardization::new( + Subtract::new( + Ema::new(Echo::new(), window(10)), + Sma::new(Echo::new(), window(50)), + ), + window(200), + ); + drive(&mut view, &vals); + }) + }); + group.bench_function("spec_dyn_view", |b| { + b.iter(|| drive(&mut spec(source), &vals)) + }); + group.bench_function("pipeline", |b| { + b.iter(|| drive(&mut feature(source), &vals)) + }); + group.finish(); +} + +/// Mostly stateless nodes: where a boxed tree pays most per node. +fn stateless_heavy(c: &mut Criterion) { + let vals = random_walk(); + let source = "tanh(abs(-(-(-(-(x * 2 + 1)))) - ema(x, 5)) * 0.01)"; + let mut group = c.benchmark_group("pipeline_stateless_heavy_100k"); + group.bench_function("spec_dyn_view", |b| { + b.iter(|| drive(&mut spec(source), &vals)) + }); + group.bench_function("pipeline", |b| { + b.iter(|| drive(&mut feature(source), &vals)) + }); + group.finish(); +} + +/// A feature set with shared subexpressions: one pipeline computes each +/// of them once, separate trees compute them per feature. +fn feature_set(c: &mut Criterion) { + let vals = random_walk(); + let sources = [ + "std(ln_return(x), 20) / std(ln_return(x), 100)", + "std(ln_return(x), 20)", + "zscore(ema(x, 10) - ema(x, 50), 200)", + "mad(ema(x, 10) - ema(x, 50), 100)", + "roc(ema(x, 10), 10)", + "rsi(x, 14)", + "ema(rsi(x, 14), 5)", + "tanh(zscore(ln_return(x), 100))", + ]; + let mut group = c.benchmark_group("pipeline_feature_set_100k"); + group.bench_function("spec_dyn_views", |b| { + b.iter(|| { + let mut views = Vec::from_iter(sources.iter().map(|s| spec(s))); + for v in &vals { + for view in &mut views { + view.update(*v); + let _ = black_box(view.last()); + } + } + }) + }); + group.bench_function("pipeline", |b| { + let source = sources.join("\n"); + b.iter(|| { + let mut pipeline: Pipeline = source.parse().expect("compiles"); + for v in &vals { + pipeline.update(&[*v]); + for out in pipeline.outputs() { + let _ = black_box(out); + } + } + }) + }); + group.finish(); +} + +criterion_group!(benches, one_feature, stateless_heavy, feature_set); +criterion_main!(benches); diff --git a/src/pipeline/tests.rs b/src/pipeline/tests.rs index 703982f..8285202 100644 --- a/src/pipeline/tests.rs +++ b/src/pipeline/tests.rs @@ -433,3 +433,41 @@ fn output_domains_and_warm_ups() { assert_eq!(p.output_warm_up(2), 100); assert_eq!(p.warm_up(), 100); } + +/// Specs of the deprecated spec module compile to pipelines that compute +/// exactly the same values, so moving to pipelines changes no feature. +#[test] +fn spec_expressions_compute_the_same_as_pipelines() { + let mut specs = vec![ + "zscore(ema(x, 5) - sma(x, 10), 20)".to_string(), + "std(ln_return(x), 5) / std(ln_return(x), 20)".to_string(), + "-tanh(x - ema(x, 3)) * 2 + 1".to_string(), + "gte(lte(roc(x, 2), 0.5), -0.5)".to_string(), + "1 / (x - lag(x, 1))".to_string(), + ]; + let spec_functions = Function::ALL + .iter() + .filter(|f| crate::spec::Function::from_name(f.name()).is_some()); + for function in spec_functions { + let params = min_params(*function); + let tail = String::from_iter(params.iter().map(|p| format!(", {p}"))); + for arg in ["x", "roc(x, 1)", "x - lag(x, 1)"] { + specs.push(format!("{}({arg}{tail})", function.name())); + } + } + assert!(specs.len() > 100, "every spec function is covered"); + for source in &specs { + let mut old = crate::spec::Expr::parse(source) + .and_then(|e| e.build(&crate::spec::Limits::default())) + .unwrap_or_else(|e| panic!("{source}: {e}")); + let pipeline: Pipeline = source.parse().unwrap_or_else(|e| panic!("{source}: {e}")); + let mut new = Feature::try_from(pipeline).unwrap(); + for input in adversarial_inputs() { + for v in input { + old.update(v); + new.update(v); + assert_eq!(old.last(), new.last(), "{source}"); + } + } + } +} From 3a44c06a0b53c8b32245563ad28a970dd1f9b35f Mon Sep 17 00:00:00 2001 From: MathisWellmann Date: Sun, 11 Oct 2026 23:14:49 +0200 Subject: [PATCH 13/17] spec: deprecate in favour of pipeline Every spec compiles as a pipeline to bit-identical values (tested), and pipelines add several inputs and outputs, shared subexpressions, domain checks with located diagnostics, and run about a third faster. The public items are deprecated rather than the module, because a deprecated module also flags the test harness's references to its own tests. Inside the module the lint is allowed until it is removed. The README now shows the pipeline text form instead of specs. --- README.md | 50 ++++++++++++++++++++++++++++++------------- benches/pipeline.rs | 2 ++ src/pipeline/tests.rs | 1 + src/spec/build.rs | 4 ++++ src/spec/error.rs | 3 +++ src/spec/expr.rs | 6 ++++++ src/spec/function.rs | 4 ++++ src/spec/mod.rs | 9 ++++++++ 8 files changed, 64 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index f67a9eb..5b66255 100644 --- a/README.md +++ b/README.md @@ -52,25 +52,45 @@ for v in &test_values { Each View will first call it's chained View to get it's last value, which will then be used to update the state of the View. Some Views have additional parameters such as ALMA. -### Features as text: the `spec` module -A chain can also be written as a spec string, parsed and built at runtime, -so features can be stored, compared and generated (e.g. by a search or an -LLM agent) as data, without compiling code: +### Features as text: the `pipeline` module +Features can also be written as text, compiled and run at runtime, so they +can be stored, compared and generated (e.g. by a search or an LLM agent) as +data, without compiling code. A pipeline computes many features over several +input series in one flat program: ```rust -use sliding_features::{View, spec::{Expr, Limits}}; - -let spec: Expr = "zscore(ema(x, 10) - ema(x, 50), 200)".parse().unwrap(); -let mut view = spec.build(&Limits::default()).unwrap(); -view.update(100.0); +use sliding_features::pipeline::{Domain, Input, Limits, Pipeline}; + +let source = " + let r = ln_return(price) + vol_ratio = std(r, 20) / std(r, 100) + trend = zscore(ema(price, 10) - ema(price, 50), 200) +"; +let inputs = vec![Input::new("price", Domain::positive())]; +let mut pipeline = Pipeline::compile(source, inputs, Limits::default()).unwrap(); +pipeline.update(&[100.0]); +assert_eq!(pipeline.output(0), None, "warming up"); ``` -`x` is the input, `+ - * /` combine views fed the same input, and every -function takes an expression and then numeric parameters, e.g. `pfe(x, 20, 5)`. -`Function::reference()` renders the table of all functions. `Limits` bounds -the window lengths and tree size, and a built view only outputs finite values -(a division by zero is "not ready" instead of infinite). The `Display` form of -an `Expr` is canonical, so equal strings mean equal features. +- `name = expr` defines an output, `let name = expr` a helper, and every + function takes an expression and then numeric parameters, e.g. `pfe(x, 20, 5)`. + `pipeline::reference()` renders the language guide and the table of all + functions, ready for a prompt. +- Equal subexpressions are computed once, and an update runs a flat list of + operations with no allocation, recursion or dynamic dispatch. +- Before any data is seen, the compiler derives every node's value range from + the inputs' domains: it rejects calls that can never be ready (`ln` of a + negative value) and constant outputs, and warns about calls that are not + always ready, with line, column and a hint how to fix it. +- Outputs are only ever finite: a division by zero is "not ready" instead of + infinite. `Limits` bounds windows, node count and nesting, so text from an + untrusted author cannot make a pipeline arbitrarily slow or large. +- The `Display` form is canonical, so equal strings mean equal features. +- `pipeline::Builder` assembles the same pipelines from code, and + `pipeline::Feature` turns a one-input, one-output pipeline into a `View`. + +The older `spec` module is deprecated: every spec compiles as a pipeline to +the same values. ### Examples See examples folder for some code ideas diff --git a/benches/pipeline.rs b/benches/pipeline.rs index 5ce5154..e8421f7 100644 --- a/benches/pipeline.rs +++ b/benches/pipeline.rs @@ -1,6 +1,8 @@ //! A pipeline against the same features as a hand-written generic chain //! and as the spec module's boxed view trees. +#![allow(deprecated, reason = "compares against the spec module it replaces")] + use std::{ hint::black_box, num::NonZeroUsize, diff --git a/src/pipeline/tests.rs b/src/pipeline/tests.rs index 8285202..924509c 100644 --- a/src/pipeline/tests.rs +++ b/src/pipeline/tests.rs @@ -437,6 +437,7 @@ fn output_domains_and_warm_ups() { /// Specs of the deprecated spec module compile to pipelines that compute /// exactly the same values, so moving to pipelines changes no feature. #[test] +#[allow(deprecated, reason = "compares against the module it replaces")] fn spec_expressions_compute_the_same_as_pipelines() { let mut specs = vec![ "zscore(ema(x, 5) - sma(x, 10), 20)".to_string(), diff --git a/src/spec/build.rs b/src/spec/build.rs index c3191af..b99b8d6 100644 --- a/src/spec/build.rs +++ b/src/spec/build.rs @@ -59,10 +59,14 @@ use crate::{ }; /// A view assembled at runtime. +#[deprecated(note = "use `pipeline::Feature`, a single-output pipeline that is a `View`")] pub type DynView = Box + Send>; /// Resource bounds of a spec, so a spec from an untrusted author (e.g. an /// LLM) cannot make a feature arbitrarily slow or large. +#[deprecated( + note = "use the `pipeline` module: its text form compiles every spec to the same values" +)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Limits { /// The largest window parameter. diff --git a/src/spec/error.rs b/src/spec/error.rs index b6b0fd3..d7926af 100644 --- a/src/spec/error.rs +++ b/src/spec/error.rs @@ -3,6 +3,9 @@ use std::fmt; /// Why a spec was rejected. +#[deprecated( + note = "use the `pipeline` module: its text form compiles every spec to the same values" +)] #[derive(Debug, Clone, PartialEq, Eq)] pub struct SpecError { /// The byte offset in the spec text, for syntax errors. diff --git a/src/spec/expr.rs b/src/spec/expr.rs index f2652f9..78084a5 100644 --- a/src/spec/expr.rs +++ b/src/spec/expr.rs @@ -10,6 +10,9 @@ use super::{ }; /// An arithmetic operator between two expressions. +#[deprecated( + note = "use the `pipeline` module: its text form compiles every spec to the same values" +)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum BinaryOp { /// `a + b` @@ -37,6 +40,9 @@ impl BinaryOp { /// /// Its [`fmt::Display`] form is canonical: parsing it yields the same tree, /// so two specs that print the same compute the same feature. +#[deprecated( + note = "use the `pipeline` module: its text form compiles every spec to the same values" +)] #[derive(Debug, Clone, PartialEq)] pub enum Expr { /// The input series, `x`. diff --git a/src/spec/function.rs b/src/spec/function.rs index c690cc2..9c9bb2d 100644 --- a/src/spec/function.rs +++ b/src/spec/function.rs @@ -3,6 +3,9 @@ use std::fmt::Write as _; /// The kind of a parameter that follows the (expression) first argument. +#[deprecated( + note = "use the `pipeline` module: its text form compiles every spec to the same values" +)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Param { /// A window length, counted in updates of the argument: a positive @@ -29,6 +32,7 @@ macro_rules! functions { /// /// Every function takes an expression as its first argument and then /// its [`Function::params`]. + #[deprecated(note = "use `pipeline::Function`")] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Function { $(#[doc = $doc] $variant,)* diff --git a/src/spec/mod.rs b/src/spec/mod.rs index ff36f8a..37ca54f 100644 --- a/src/spec/mod.rs +++ b/src/spec/mod.rs @@ -1,5 +1,11 @@ //! Features as text: a small expression language over this crate's views. //! +//! **Deprecated**: use the [`pipeline`](crate::pipeline) module. Every spec +//! is a valid pipeline and computes the same values, and a pipeline also +//! has several inputs and outputs, shares equal subexpressions, checks +//! domains before it runs and is about a third faster. +//! [`pipeline::Feature`](crate::pipeline::Feature) replaces [`DynView`]. +//! //! A spec describes a chain of views fed one input series `x`. It is parsed //! into an [`Expr`], checked against resource [`Limits`] and built into a //! [`DynView`] at runtime, so features can be written, stored, compared and @@ -31,6 +37,9 @@ //! assert_eq!(view.last(), Some(2.0)); //! ``` +// The module uses its own deprecated items until it is removed. +#![allow(deprecated, reason = "the deprecated module uses its own items")] + mod build; mod error; mod expr; From a941c7a81d93843455a6da9f261fbd06156276a5 Mon Sep 17 00:00:00 2001 From: MathisWellmann Date: Mon, 12 Oct 2026 00:40:37 +0200 Subject: [PATCH 14/17] min_max_normalizer: clamp new extremes to [-1, 1] The normalizer scales the current value by the previous window's range, so it does not see itself, but a new high or low then mapped beyond the documented [-1, 1] (5/3 in the rising-sequence test). Clamping keeps the documented range and stays causal: a new extreme reads as -1 or 1. The pipeline now declares [-1, 1] for min_max, and its domain test checks it. --- src/pipeline/function.rs | 4 +-- src/sliding_windows/min_max_normalizer.rs | 41 ++++++++++++++++++----- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/src/pipeline/function.rs b/src/pipeline/function.rs index 927afa8..0185cc5 100644 --- a/src/pipeline/function.rs +++ b/src/pipeline/function.rs @@ -397,9 +397,9 @@ functions! { } MinMax { name: "min_max", params: [Window], min_window: 2, requires: Any, - range: |_, _| Domain::REAL, + range: |_, _| Domain::closed(-1.0, 1.0), build: |p| MinMaxNormalizer::new(Echo::new(), p.window(0)).into(), - doc: "Position in the [min, max] range of the previous window, scaled to [-1, 1] (beyond it on new extremes).", + doc: "Position in the [min, max] range of the previous window, scaled to [-1, 1]; new extremes give -1 or 1.", } Mad { name: "mad", params: [Window], min_window: 2, requires: Any, diff --git a/src/sliding_windows/min_max_normalizer.rs b/src/sliding_windows/min_max_normalizer.rs index 1d9c327..e42ff05 100644 --- a/src/sliding_windows/min_max_normalizer.rs +++ b/src/sliding_windows/min_max_normalizer.rs @@ -14,7 +14,8 @@ use crate::View; /// /// Normalizes values to the [-1, 1] range using the min and max of a sliding /// window of *past* values. The current value is intentionally excluded from -/// the normalization window to avoid lookahead / data-leakage bias. +/// the normalization window to avoid lookahead / data-leakage bias, so it can +/// lie beyond the window's extremes: such a value is clamped to -1 or 1. /// /// No output is emitted until the sliding window is completely filled. /// During warm-up `last()` returns `None`. @@ -99,13 +100,15 @@ where if self.min == self.max { self.out = Some(T::zero()); } else { - self.out = Some( - -T::one() - + ((view_last - self.min) * T::from(2.0).expect("can convert")) - / (self.max - self.min), - ); + let scaled = -T::one() + + ((view_last - self.min) * T::from(2.0).expect("can convert")) + / (self.max - self.min); + // A new extreme lies outside the past window's range. + self.out = Some(scaled.max(-T::one()).min(T::one())); } - debug_assert!(self.out.unwrap().is_finite(), "output must be finite"); + let out = self.out.expect("set above"); + debug_assert!(out.is_finite(), "output must be finite"); + debug_assert!(out.abs() <= T::one(), "output must lie in [-1, 1]"); // Slide the window. let old = self.q_vals.pop_front().expect("Its checked above that the length is >= the non-zero window length, therefore this must be `Some`"); @@ -269,9 +272,10 @@ mod tests { // After all updates, the last value (100) was normalized against // the previous window [60, 70, 80, 90] (min=60, max=90). - // normalized(100) = -1 + 2*(100-60)/(90-60) = -1 + 2*40/30 = 5/3 + // normalized(100) = -1 + 2*(100-60)/(90-60) = 5/3, a new high + // beyond the range, clamped to 1. let got = n.last().unwrap(); - let expected = 5.0 / 3.0; + let expected = 1.0; let diff = (got - expected).abs(); assert!( diff < 1e-12, @@ -440,4 +444,23 @@ mod tests { } } } + + /// New extremes beyond the past window are clamped, so the output + /// stays in the documented [-1, 1] range; it used to exceed it. + #[test] + fn min_max_normalizer_clamps_new_extremes() { + let mut n = MinMaxNormalizer::new(Echo::new(), NonZeroUsize::new(3).unwrap()); + for v in [0.0, 10.0, 5.0] { + n.update(v); + } + n.update(50.0); + assert_eq!(n.last(), Some(1.0)); + n.update(-50.0); + assert_eq!(n.last(), Some(-1.0)); + for v in &TEST_DATA { + n.update(*v); + let out = n.last().unwrap(); + assert!((-1.0..=1.0).contains(&out), "{out}"); + } + } } From 032d544d14f0f61cbf86e99199fb8a7dec7a38ed Mon Sep 17 00:00:00 2001 From: MathisWellmann Date: Mon, 12 Oct 2026 00:41:26 +0200 Subject: [PATCH 15/17] polarized_fractal_efficiency: follow the standard formula, bounded by 1 Three deviations from Hannula's PFE let the output leave its documented [-1, 1]: the path length summed window_len - 2 segments while the straight line spanned window_len steps, instead of both spanning the window_len - 1 steps between its values, and the sign came from the last step instead of the move over the window, so the output flipped on every one-step reversal. Both distances now span the same steps, so the ratio is at most 1 by the triangle inequality (rounding is clamped), and the sign is that of last - first. A window of 1 underflowed window_len - 2; the constructor now asserts at least two values. The pipeline declares [-1, 1] for pfe again. --- img/polarized_fractal_efficiency.png | Bin 150887 -> 96871 bytes src/pipeline/function.rs | 6 +- .../polarized_fractal_efficiency.rs | 92 +++++++++++++++--- 3 files changed, 80 insertions(+), 18 deletions(-) diff --git a/img/polarized_fractal_efficiency.png b/img/polarized_fractal_efficiency.png index 5ccb73ce56137c57ed45fa5d8e20e62d544d0fac..b0b315899f2bf495b9b1cefdb26611f232f950f5 100644 GIT binary patch literal 96871 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 kyZfpMopVopP6ty~{P4@cG1*2ZM2 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%0Ahqa} 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 AF#rGn literal 150887 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 diff --git a/src/pipeline/function.rs b/src/pipeline/function.rs index 0185cc5..4341f47 100644 --- a/src/pipeline/function.rs +++ b/src/pipeline/function.rs @@ -493,11 +493,9 @@ functions! { } Pfe { name: "pfe", params: [Window, Window], min_window: 3, requires: Any, - // Not [-1, 1] as documented: the path length sums one segment too - // few, so the efficiency exceeds 1. - range: |_, _| Domain::REAL, + range: |_, _| Domain::closed(-1.0, 1.0), build: |p| PolarizedFractalEfficiency::new(Echo::new(), ema(p.window(1)), p.window(0)).into(), - doc: "Polarized fractal efficiency (window, EMA smoothing window): near 1 in a straight up-trend, near -1 down.", + doc: "Polarized fractal efficiency (window, EMA smoothing window), in [-1, 1]: near 1 in a straight up-trend, near -1 down.", } Fisher { name: "fisher", params: [Window, Window], min_window: 2, requires: Any, diff --git a/src/sliding_windows/polarized_fractal_efficiency.rs b/src/sliding_windows/polarized_fractal_efficiency.rs index 34ca07c..d3f9b6b 100644 --- a/src/sliding_windows/polarized_fractal_efficiency.rs +++ b/src/sliding_windows/polarized_fractal_efficiency.rs @@ -31,8 +31,16 @@ where T: Float, { /// Create a new PolarizedFractalEfficiency indicator with a chained view, custom moving - /// average and a window length + /// average and a window length. + /// + /// # Panics + /// + /// If `window_len` is 1: the efficiency of a single value is undefined. pub fn new(view: V, moving_average: M, window_len: NonZeroUsize) -> Self { + assert!( + window_len.get() >= 2, + "a fractal efficiency needs at least two values" + ); Self { view, moving_average, @@ -60,20 +68,8 @@ where } self.q_vals.push_back(val); - let window_len = T::from(self.window_len.get()).expect("can convert"); if self.q_vals.len() >= self.window_len.get() { - let mut s = T::zero(); - let wl: usize = self.window_len.get() - 1; - for i in 0..self.window_len.get() - 2 { - let v_0 = *self.q_vals.get(wl - i).unwrap(); - let v_1 = *self.q_vals.get(wl - i - 1).unwrap(); - s = s + ((v_0 - v_1).powi(2) + T::one()).sqrt(); - } - let mut p = - ((val - *self.q_vals.front().unwrap()).powi(2) + window_len.powi(2)).sqrt() / s; - if val < *self.q_vals.get(self.window_len.get() - 2).unwrap() { - p = -p; - } + let p = efficiency(&self.q_vals); // apply a moving average self.moving_average.update(p); self.out = self.moving_average.last(); @@ -88,6 +84,31 @@ where } } +/// The polarized efficiency of the path through `vals`, one unit of time +/// apart: the straight distance from the first to the last value over the +/// length of the path, negative when the last value is below the first. +/// +/// Both span the same `n = len - 1` steps, so by the triangle inequality the +/// magnitude is at most 1 (up to rounding, which is clamped). +fn efficiency(vals: &VecDeque) -> T { + assert!(vals.len() >= 2, "a path has at least two values"); + let (first, last) = ( + *vals.front().expect("not empty"), + *vals.back().expect("not empty"), + ); + let steps = T::from(vals.len() - 1).expect("can convert"); + let path = vals + .iter() + .zip(vals.iter().skip(1)) + .fold(T::zero(), |sum, (a, b)| { + sum + ((*b - *a).powi(2) + T::one()).sqrt() + }); + debug_assert!(path >= steps, "every step is at least one unit long"); + let straight = ((last - first).powi(2) + steps.powi(2)).sqrt(); + let magnitude = (straight / path).min(T::one()); + if last < first { -magnitude } else { magnitude } +} + #[cfg(test)] mod tests { use super::*; @@ -115,6 +136,49 @@ mod tests { } } + /// A straight path is fully efficient, in its direction; one segment + /// too few in the path length used to push it beyond 1. + #[test] + fn efficiency_of_reference_paths() { + let path = |vals: &[f64]| efficiency(&VecDeque::from_iter(vals.iter().copied())); + assert!((path(&[1.0, 2.0, 3.0, 4.0]) - 1.0).abs() < 1e-12); + assert!((path(&[4.0, 3.0, 2.0, 1.0]) + 1.0).abs() < 1e-12); + assert_eq!(path(&[5.0, 5.0, 5.0]), 1.0, "a flat path is straight"); + // Up 3 then down 2: the direction is that of the whole window, not + // of the last step. Straight sqrt(1 + 4), path sqrt(10) + sqrt(5). + let expected = 5.0_f64.sqrt() / (10.0_f64.sqrt() + 5.0_f64.sqrt()); + assert!((path(&[1.0, 4.0, 2.0]) - expected).abs() < 1e-12); + let zigzag = path(&[0.0, 10.0, 0.0, 10.0, 0.0]); + assert!(zigzag.abs() < 0.11, "{zigzag}"); + } + + #[test] + fn pfe_without_smoothing_stays_in_range() { + for window in [2, 3, 10] { + let mut pfe = PolarizedFractalEfficiency::new( + Echo::new(), + Echo::new(), + NonZeroUsize::new(window).unwrap(), + ); + for v in &TEST_DATA { + pfe.update(*v); + if let Some(val) = pfe.last() { + assert!((-1.0..=1.0).contains(&val), "window {window}: {val}"); + } + } + } + } + + #[test] + #[should_panic(expected = "at least two values")] + fn window_of_one_is_rejected() { + let _ = PolarizedFractalEfficiency::::new( + Echo::new(), + Echo::new(), + NonZeroUsize::new(1).unwrap(), + ); + } + #[test] fn polarized_fractal_efficiency_plot() { let mut pfe = PolarizedFractalEfficiency::new( From 2f338f4eae8afb12ba2d342dcfe8d174c3ae2a1d Mon Sep 17 00:00:00 2001 From: MathisWellmann Date: Mon, 12 Oct 2026 00:43:01 +0200 Subject: [PATCH 16/17] spec: remove in favour of pipeline There is now one way to assemble features at runtime: the pipeline module, whose text form accepts every spec and computed bit-identical values in the test that compared them. The View impl for Box goes too: it only existed so spec could nest boxed views, method calls on a Box work without it, and pipeline::Feature is the runtime view. The spec equivalence test goes with the module. The bench now compares a feature set as one pipeline against one pipeline per feature, which isolates the gain from shared subexpressions (24.6 ms -> 19.1 ms per 100k updates of 8 features here). The README explains when to use chained views and when pipelines. This is a breaking change for users of spec and of View for Box. --- README.md | 15 +- benches/pipeline.rs | 45 ++--- src/lib.rs | 37 ----- src/pipeline/compiled.rs | 2 +- src/pipeline/tests.rs | 39 ----- src/spec/build.rs | 347 --------------------------------------- src/spec/error.rs | 51 ------ src/spec/expr.rs | 325 ------------------------------------ src/spec/function.rs | 163 ------------------ src/spec/mod.rs | 61 ------- src/spec/parse.rs | 319 ----------------------------------- 11 files changed, 21 insertions(+), 1383 deletions(-) delete mode 100644 src/spec/build.rs delete mode 100644 src/spec/error.rs delete mode 100644 src/spec/expr.rs delete mode 100644 src/spec/function.rs delete mode 100644 src/spec/mod.rs delete mode 100644 src/spec/parse.rs diff --git a/README.md b/README.md index 5b66255..0b16af1 100644 --- a/README.md +++ b/README.md @@ -52,11 +52,13 @@ for v in &test_values { Each View will first call it's chained View to get it's last value, which will then be used to update the state of the View. Some Views have additional parameters such as ALMA. -### Features as text: the `pipeline` module -Features can also be written as text, compiled and run at runtime, so they -can be stored, compared and generated (e.g. by a search or an LLM agent) as -data, without compiling code. A pipeline computes many features over several -input series in one flat program: +### Features at runtime: the `pipeline` module +Chained views are the zero-cost choice when the features are known at compile +time. When they are only known at runtime, write them as text and compile +them with the `pipeline` module, the crate's one way to assemble features at +runtime. Features can then be stored, compared and generated (e.g. by a +search or an LLM agent) as data, without compiling code. A pipeline computes +many features over several input series in one flat program: ```rust use sliding_features::pipeline::{Domain, Input, Limits, Pipeline}; @@ -89,9 +91,6 @@ assert_eq!(pipeline.output(0), None, "warming up"); - `pipeline::Builder` assembles the same pipelines from code, and `pipeline::Feature` turns a one-input, one-output pipeline into a `View`. -The older `spec` module is deprecated: every spec compiles as a pipeline to -the same values. - ### Examples See examples folder for some code ideas ```shell diff --git a/benches/pipeline.rs b/benches/pipeline.rs index e8421f7..1cf3ab2 100644 --- a/benches/pipeline.rs +++ b/benches/pipeline.rs @@ -1,7 +1,5 @@ -//! A pipeline against the same features as a hand-written generic chain -//! and as the spec module's boxed view trees. - -#![allow(deprecated, reason = "compares against the spec module it replaces")] +//! A pipeline against the same feature as a hand-written generic chain, and +//! a feature set as one pipeline against one pipeline per feature. use std::{ hint::black_box, @@ -28,11 +26,6 @@ use sliding_features::{ Sma, ZScoreStandardization, }, - spec::{ - DynView, - Expr, - Limits, - }, }; const N: usize = 100_000; @@ -46,13 +39,6 @@ fn random_walk() -> Vec { })) } -fn spec(source: &str) -> DynView { - Expr::parse(source) - .expect("parses") - .build(&Limits::default()) - .expect("builds") -} - fn feature(source: &str) -> Feature { let pipeline: Pipeline = source.parse().expect("compiles"); Feature::try_from(pipeline).expect("one input and one output") @@ -66,7 +52,8 @@ fn drive(view: &mut impl View, vals: &[f64]) { } } -/// One feature: the overhead of dispatch per node. +/// One feature: the cost of interpreting the program against code the +/// compiler monomorphized. fn one_feature(c: &mut Criterion) { let vals = random_walk(); let source = "zscore(ema(x, 10) - sma(x, 50), 200)"; @@ -84,31 +71,25 @@ fn one_feature(c: &mut Criterion) { drive(&mut view, &vals); }) }); - group.bench_function("spec_dyn_view", |b| { - b.iter(|| drive(&mut spec(source), &vals)) - }); group.bench_function("pipeline", |b| { b.iter(|| drive(&mut feature(source), &vals)) }); group.finish(); } -/// Mostly stateless nodes: where a boxed tree pays most per node. +/// Mostly stateless nodes: the per-operation overhead of the interpreter. fn stateless_heavy(c: &mut Criterion) { let vals = random_walk(); let source = "tanh(abs(-(-(-(-(x * 2 + 1)))) - ema(x, 5)) * 0.01)"; let mut group = c.benchmark_group("pipeline_stateless_heavy_100k"); - group.bench_function("spec_dyn_view", |b| { - b.iter(|| drive(&mut spec(source), &vals)) - }); group.bench_function("pipeline", |b| { b.iter(|| drive(&mut feature(source), &vals)) }); group.finish(); } -/// A feature set with shared subexpressions: one pipeline computes each -/// of them once, separate trees compute them per feature. +/// A feature set with shared subexpressions: one pipeline computes each of +/// them once, one pipeline per feature computes them per feature. fn feature_set(c: &mut Criterion) { let vals = random_walk(); let sources = [ @@ -122,18 +103,18 @@ fn feature_set(c: &mut Criterion) { "tanh(zscore(ln_return(x), 100))", ]; let mut group = c.benchmark_group("pipeline_feature_set_100k"); - group.bench_function("spec_dyn_views", |b| { + group.bench_function("pipeline_per_feature", |b| { b.iter(|| { - let mut views = Vec::from_iter(sources.iter().map(|s| spec(s))); + let mut features = Vec::from_iter(sources.iter().map(|s| feature(s))); for v in &vals { - for view in &mut views { - view.update(*v); - let _ = black_box(view.last()); + for feature in &mut features { + feature.update(*v); + let _ = black_box(feature.last()); } } }) }); - group.bench_function("pipeline", |b| { + group.bench_function("one_pipeline", |b| { let source = sources.join("\n"); b.iter(|| { let mut pipeline: Pipeline = source.parse().expect("compiles"); diff --git a/src/lib.rs b/src/lib.rs index 61447ee..2e87dd5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,7 +9,6 @@ pub mod pipeline; pub mod pure_functions; pub mod rolling; pub mod sliding_windows; -pub mod spec; #[cfg(test)] mod plot; @@ -25,45 +24,9 @@ pub trait View { fn last(&self) -> Option; } -/// A boxed view is a view, so a chain can be assembled at runtime: every -/// combinator takes its input by value as `V: View`, and -/// `Box>` is the only way to nest views whose types are not -/// known at compile time. -impl + ?Sized> View for Box { - #[inline(always)] - fn update(&mut self, val: T) { - (**self).update(val); - } - - #[inline(always)] - fn last(&self) -> Option { - (**self).last() - } -} - #[cfg(test)] mod tests { // Used in benchmarks. #[allow(unused_imports)] use criterion::*; - - use super::*; - use crate::{ - pure_functions::Echo, - sliding_windows::Sma, - }; - - #[test] - fn boxed_views_chain_like_concrete_ones() { - let window = std::num::NonZeroUsize::new(3).expect("non zero"); - let inner: Box> = Box::new(Sma::new(Echo::new(), window)); - let mut boxed = Sma::new(inner, window); - let mut concrete = Sma::new(Sma::new(Echo::new(), window), window); - for v in [1.0, 4.0, 2.0, 8.0, 5.0, 7.0] { - boxed.update(v); - concrete.update(v); - assert_eq!(boxed.last(), concrete.last()); - } - assert!(boxed.last().is_some()); - } } diff --git a/src/pipeline/compiled.rs b/src/pipeline/compiled.rs index 0e46863..a351a1b 100644 --- a/src/pipeline/compiled.rs +++ b/src/pipeline/compiled.rs @@ -226,7 +226,7 @@ fn output_nodes(bindings: &[Binding]) -> Vec { } /// A pipeline with one input and one output, used as a [`View`], e.g. to -/// chain it with other views or to replace the spec module's `DynView`. +/// chain it with other views. #[derive(Debug, Clone)] pub struct Feature(Pipeline); diff --git a/src/pipeline/tests.rs b/src/pipeline/tests.rs index 924509c..703982f 100644 --- a/src/pipeline/tests.rs +++ b/src/pipeline/tests.rs @@ -433,42 +433,3 @@ fn output_domains_and_warm_ups() { assert_eq!(p.output_warm_up(2), 100); assert_eq!(p.warm_up(), 100); } - -/// Specs of the deprecated spec module compile to pipelines that compute -/// exactly the same values, so moving to pipelines changes no feature. -#[test] -#[allow(deprecated, reason = "compares against the module it replaces")] -fn spec_expressions_compute_the_same_as_pipelines() { - let mut specs = vec![ - "zscore(ema(x, 5) - sma(x, 10), 20)".to_string(), - "std(ln_return(x), 5) / std(ln_return(x), 20)".to_string(), - "-tanh(x - ema(x, 3)) * 2 + 1".to_string(), - "gte(lte(roc(x, 2), 0.5), -0.5)".to_string(), - "1 / (x - lag(x, 1))".to_string(), - ]; - let spec_functions = Function::ALL - .iter() - .filter(|f| crate::spec::Function::from_name(f.name()).is_some()); - for function in spec_functions { - let params = min_params(*function); - let tail = String::from_iter(params.iter().map(|p| format!(", {p}"))); - for arg in ["x", "roc(x, 1)", "x - lag(x, 1)"] { - specs.push(format!("{}({arg}{tail})", function.name())); - } - } - assert!(specs.len() > 100, "every spec function is covered"); - for source in &specs { - let mut old = crate::spec::Expr::parse(source) - .and_then(|e| e.build(&crate::spec::Limits::default())) - .unwrap_or_else(|e| panic!("{source}: {e}")); - let pipeline: Pipeline = source.parse().unwrap_or_else(|e| panic!("{source}: {e}")); - let mut new = Feature::try_from(pipeline).unwrap(); - for input in adversarial_inputs() { - for v in input { - old.update(v); - new.update(v); - assert_eq!(old.last(), new.last(), "{source}"); - } - } - } -} diff --git a/src/spec/build.rs b/src/spec/build.rs deleted file mode 100644 index b99b8d6..0000000 --- a/src/spec/build.rs +++ /dev/null @@ -1,347 +0,0 @@ -//! Turns a validated [`Expr`] into a runtime chain of this crate's views. - -use std::num::NonZeroUsize; - -use super::{ - BinaryOp, - Expr, - Function, - SpecError, -}; -use crate::{ - View, - pure_functions::{ - Abs, - Add, - Constant, - Echo, - GTE, - LTE, - Ln, - Multiply, - Subtract, - Tanh, - }, - rolling::{ - Drawdown, - LnReturn, - }, - sliding_windows::{ - Alma, - BinaryEntropy, - CenterOfGravity, - CorrelationTrendIndicator, - Cumulative, - CyberCycle, - EhlersFisherTransform, - Ema, - Lag, - LaguerreFilter, - LaguerreRSI, - MadScaler, - Max, - Min, - MinMaxNormalizer, - MyRSI, - NoiseEliminationTechnology, - PolarizedFractalEfficiency, - ReFlex, - Roc, - RoofingFilter, - Rsi, - Sma, - SuperSmoother, - TrendFlex, - Vst, - WelfordOnline, - ZScoreStandardization, - }, -}; - -/// A view assembled at runtime. -#[deprecated(note = "use `pipeline::Feature`, a single-output pipeline that is a `View`")] -pub type DynView = Box + Send>; - -/// Resource bounds of a spec, so a spec from an untrusted author (e.g. an -/// LLM) cannot make a feature arbitrarily slow or large. -#[deprecated( - note = "use the `pipeline` module: its text form compiles every spec to the same values" -)] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Limits { - /// The largest window parameter. - pub max_window: usize, - /// The most nodes in the tree. - pub max_nodes: usize, - /// The longest root-to-leaf path. - pub max_depth: usize, -} - -impl Default for Limits { - fn default() -> Self { - Self { - max_window: 100_000, - max_nodes: 64, - max_depth: 16, - } - } -} - -impl Limits { - /// No bounds besides the signatures: what parsing checks. - pub fn unbounded() -> Self { - Self { - max_window: usize::MAX, - max_nodes: usize::MAX, - max_depth: usize::MAX, - } - } -} - -/// Passes only finite values on: one non-finite value (e.g. an overflow) -/// would otherwise stay in every window downstream for its whole length. -struct Finite(V); - -impl> View for Finite { - fn update(&mut self, val: f64) { - self.0.update(val); - } - - fn last(&self) -> Option { - self.0.last().filter(|v| v.is_finite()) - } -} - -/// Passes only positive values on, for functions defined on them. -struct Positive(V); - -impl> View for Positive { - fn update(&mut self, val: f64) { - self.0.update(val); - } - - fn last(&self) -> Option { - self.0.last().filter(|v| *v > 0.0) - } -} - -/// `a / b`, not ready while `b` is zero (the library's `Divide` asserts a -/// non-zero divisor, which a spec cannot guarantee). -struct SafeDiv { - a: A, - b: B, -} - -impl, B: View> View for SafeDiv { - fn update(&mut self, val: f64) { - self.a.update(val); - self.b.update(val); - } - - fn last(&self) -> Option { - let (a, b) = (self.a.last()?, self.b.last()?); - (b != 0.0).then(|| a / b).filter(|v| v.is_finite()) - } -} - -impl Expr { - /// Check the spec against `limits` and assemble its view chain. - /// - /// Feed the view finite values only. Its output is always finite: a - /// division by zero, a logarithm of a non-positive value or an overflow - /// makes that node not ready (`last()` is `None`) instead of passing a - /// non-finite value on. - pub fn build(&self, limits: &Limits) -> Result { - self.validate(limits)?; - Ok(self.assemble()) - } - - fn assemble(&self) -> DynView { - let view: DynView = match self { - Expr::Input => Box::new(Echo::new()), - Expr::Const(c) => Box::new(Constant::new(*c)), - Expr::Neg(e) => Box::new(Multiply::new(Constant::new(-1.0), e.assemble())), - Expr::Binary { op, lhs, rhs } => { - let (a, b) = (lhs.assemble(), rhs.assemble()); - match op { - BinaryOp::Add => Box::new(Add::new(a, b)), - BinaryOp::Sub => Box::new(Subtract::new(a, b)), - BinaryOp::Mul => Box::new(Multiply::new(a, b)), - BinaryOp::Div => Box::new(SafeDiv { a, b }), - } - } - Expr::Call { - function, - arg, - params, - } => call(*function, arg.assemble(), params), - }; - Box::new(Finite(view)) - } -} - -/// The view of one validated call. -fn call(function: Function, a: DynView, params: &[f64]) -> DynView { - // Validated: positive integers far below 2^53, exact as f64. - let w = |i: usize| NonZeroUsize::new(params[i] as usize).expect("validated window"); - let ema = |i: usize| Ema::new(Echo::new(), w(i)); - use Function as F; - match function { - F::Abs => Box::new(Abs::new(a)), - F::Ln => Box::new(Ln::new(a)), - F::Tanh => Box::new(Tanh::new(a)), - F::LnReturn => Box::new(LnReturn::new(Positive(a))), - F::Drawdown => Box::new(Drawdown::new(Positive(a))), - F::Gte => Box::new(GTE::new(a, params[0])), - F::Lte => Box::new(LTE::new(a, params[0])), - F::Sma => Box::new(Sma::new(a, w(0))), - F::Ema => Box::new(Ema::new(a, w(0))), - F::Alma => Box::new(Alma::new(a, w(0))), - F::Roc => Box::new(Roc::new(a, w(0))), - F::Lag => Box::new(Lag::new(a, w(0))), - F::Min => Box::new(Min::new(a, w(0))), - F::Max => Box::new(Max::new(a, w(0))), - F::MinMax => Box::new(MinMaxNormalizer::new(a, w(0))), - F::Mad => Box::new(MadScaler::new(a, w(0))), - F::ZScore => Box::new(ZScoreStandardization::new(a, w(0))), - F::Std => Box::new(WelfordOnline::new(a, w(0))), - F::Cumulative => Box::new(Cumulative::new(a, w(0))), - F::Rsi => Box::new(Rsi::new(a, w(0))), - F::MyRsi => Box::new(MyRSI::new(a, w(0))), - F::LaguerreRsi => Box::new(LaguerreRSI::new(a, w(0))), - F::LaguerreFilter => Box::new(LaguerreFilter::new(a, params[0])), - F::SuperSmoother => Box::new(SuperSmoother::new(a, w(0))), - F::RoofingFilter => Box::new(RoofingFilter::new(a, w(0), w(1))), - F::ReFlex => Box::new(ReFlex::new(a, w(0))), - F::TrendFlex => Box::new(TrendFlex::new(a, w(0))), - F::CyberCycle => Box::new(CyberCycle::new(a, w(0))), - F::CenterOfGravity => Box::new(CenterOfGravity::new(a, w(0))), - F::Cti => Box::new(CorrelationTrendIndicator::new(a, w(0))), - F::Pfe => Box::new(PolarizedFractalEfficiency::new(a, ema(1), w(0))), - F::Fisher => Box::new(EhlersFisherTransform::new(a, ema(1), w(0))), - F::BinaryEntropy => Box::new(BinaryEntropy::new(a, w(0))), - F::Vst => Box::new(Vst::new(a, w(0))), - F::Net => Box::new(NoiseEliminationTechnology::new(a, w(0))), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::spec::Param; - - fn build(spec: &str) -> DynView { - Expr::parse(spec) - .expect("parses") - .build(&Limits::default()) - .expect("builds") - } - - /// A built spec computes exactly what the hand-written chain computes. - #[test] - fn matches_the_hand_written_chain() { - let window = |n| NonZeroUsize::new(n).expect("non zero"); - let mut built = build("zscore(ema(x, 5) - sma(x, 10), 20)"); - let mut manual = ZScoreStandardization::new( - Subtract::new( - Ema::new(Echo::new(), window(5)), - Sma::new(Echo::new(), window(10)), - ), - window(20), - ); - let rng = romu::Rng::from_seed_with_64bit(1); - let mut price = 100.0; - for _ in 0..500 { - price += rng.f64() - 0.5; - built.update(price); - manual.update(price); - assert_eq!(built.last(), manual.last()); - } - assert!(built.last().is_some()); - } - - #[test] - fn division_by_zero_is_not_ready_instead_of_infinite() { - let mut v = build("x / (x - x)"); - v.update(3.0); - assert_eq!(v.last(), None); - let mut v = build("1 / x"); - v.update(4.0); - assert_eq!(v.last(), Some(0.25)); - } - - #[test] - fn ln_return_ignores_non_positive_input() { - let mut v = build("ln_return(x - 1)"); - for x in [2.0, 0.5, 3.0] { - v.update(x); - } - // 0.5 - 1 < 0 never reached the log return. - assert!((v.last().expect("ready") - 2.0_f64.ln()).abs() < 1e-12); - } - - #[test] - fn limits_are_enforced() { - let e = Expr::parse("sma(x, 500)").expect("parses"); - let tight = Limits { - max_window: 100, - ..Limits::default() - }; - assert!(e.build(&tight).is_err()); - assert!(e.build(&Limits::default()).is_ok()); - } - - /// Inputs that break naive indicators: a trend, a constant, zeros, - /// alternating signs, huge values. - fn adversarial_inputs() -> Vec> { - let rng = romu::Rng::from_seed_with_64bit(7); - let mut walk = 100.0; - vec![ - (0..300) - .map(|_| { - walk += rng.f64() - 0.5; - walk - }) - .collect(), - vec![5.0; 300], - vec![0.0; 300], - (0..300) - .map(|i| if i % 2 == 0 { 1.0 } else { -1.0 }) - .collect(), - (0..300).map(|i| 1e12 * f64::from(i % 7)).collect(), - ] - } - - /// Every function, at its minimum window, on every adversarial input - /// and composed with a sign-changing argument, never panics (debug - /// assertions on) and only ever outputs finite values. - #[test] - fn every_function_survives_adversarial_input() { - let mut failures = Vec::new(); - for function in Function::ALL { - let params = Vec::from_iter(function.params().iter().map(|p| match p { - Param::Window => function.min_window().to_string(), - Param::Real => "0.5".to_string(), - })); - let tail = params.iter().map(|p| format!(", {p}")).collect::(); - for arg in ["x", "roc(x, 1)", "x - x"] { - let spec = format!("{}({arg}{tail})", function.name()); - for input in adversarial_inputs() { - let run = std::panic::catch_unwind(|| { - let mut v = build(&spec); - input.iter().all(|x| { - v.update(*x); - v.last().is_none_or(f64::is_finite) - }) - }); - match run { - Ok(true) => {} - Ok(false) => failures.push(format!("{spec}: non-finite output")), - Err(_) => failures.push(format!("{spec}: panicked on {:?}", &input[..3])), - } - } - } - } - assert!(failures.is_empty(), "{failures:#?}"); - } -} diff --git a/src/spec/error.rs b/src/spec/error.rs deleted file mode 100644 index d7926af..0000000 --- a/src/spec/error.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! The error of parsing or validating a spec. - -use std::fmt; - -/// Why a spec was rejected. -#[deprecated( - note = "use the `pipeline` module: its text form compiles every spec to the same values" -)] -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SpecError { - /// The byte offset in the spec text, for syntax errors. - position: Option, - message: String, -} - -impl SpecError { - pub(super) fn parse(position: usize, message: String) -> Self { - Self { - position: Some(position), - message, - } - } - - pub(super) fn invalid(message: String) -> Self { - Self { - position: None, - message, - } - } - - /// The byte offset in the spec text the error points at, if any. - pub fn position(&self) -> Option { - self.position - } - - /// What is wrong, without the position. - pub fn message(&self) -> &str { - &self.message - } -} - -impl fmt::Display for SpecError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self.position { - Some(at) => write!(f, "at byte {at}: {}", self.message), - None => f.write_str(&self.message), - } - } -} - -impl std::error::Error for SpecError {} diff --git a/src/spec/expr.rs b/src/spec/expr.rs deleted file mode 100644 index 78084a5..0000000 --- a/src/spec/expr.rs +++ /dev/null @@ -1,325 +0,0 @@ -//! The expression tree of a spec, its canonical text form and its checks. - -use std::fmt; - -use super::{ - Function, - Limits, - Param, - SpecError, -}; - -/// An arithmetic operator between two expressions. -#[deprecated( - note = "use the `pipeline` module: its text form compiles every spec to the same values" -)] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum BinaryOp { - /// `a + b` - Add, - /// `a - b` - Sub, - /// `a * b` - Mul, - /// `a / b`, not ready while `b` is zero. - Div, -} - -impl BinaryOp { - fn symbol(self) -> char { - match self { - BinaryOp::Add => '+', - BinaryOp::Sub => '-', - BinaryOp::Mul => '*', - BinaryOp::Div => '/', - } - } -} - -/// A feature as an expression over one input series `x`. -/// -/// Its [`fmt::Display`] form is canonical: parsing it yields the same tree, -/// so two specs that print the same compute the same feature. -#[deprecated( - note = "use the `pipeline` module: its text form compiles every spec to the same values" -)] -#[derive(Debug, Clone, PartialEq)] -pub enum Expr { - /// The input series, `x`. - Input, - /// A constant. - Const(f64), - /// The negated expression, `-a`. - Neg(Box), - /// Arithmetic between two expressions fed the same input. - Binary { - /// The operator. - op: BinaryOp, - /// The left operand. - lhs: Box, - /// The right operand. - rhs: Box, - }, - /// A function applied to an expression, with its parameters. - Call { - /// The function. - function: Function, - /// The expression the function is applied to. - arg: Box, - /// The parameters after the argument, see [`Function::params`]. - params: Vec, - }, -} - -impl fmt::Display for Expr { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Expr::Input => f.write_str("x"), - Expr::Const(c) => write!(f, "{c}"), - Expr::Neg(e) => write!(f, "-{e}"), - Expr::Binary { op, lhs, rhs } => write!(f, "({lhs} {} {rhs})", op.symbol()), - Expr::Call { - function, - arg, - params, - } => { - write!(f, "{}({arg}", function.name())?; - for p in params { - write!(f, ", {p}")?; - } - f.write_str(")") - } - } - } -} - -impl std::str::FromStr for Expr { - type Err = SpecError; - - fn from_str(s: &str) -> Result { - super::parse::parse(s) - } -} - -impl Expr { - /// Parse a spec, e.g. `zscore(ema(x, 10) - ema(x, 50), 200)`. - /// - /// Checks the syntax and every call's signature; [`Expr::validate`] - /// checks the resource limits. - pub fn parse(s: &str) -> Result { - super::parse::parse(s) - } - - /// The number of nodes in the tree. - pub fn node_count(&self) -> usize { - 1 + self.children().map(Expr::node_count).sum::() - } - - /// The length of the longest path from the root to a leaf. - pub fn depth(&self) -> usize { - 1 + self.children().map(Expr::depth).max().unwrap_or(0) - } - - /// The largest window parameter anywhere in the tree (0 without one). - pub fn max_window(&self) -> usize { - let own = match self { - Expr::Call { - function, params, .. - } => window_params(*function, params).max().unwrap_or(0), - _ => 0, - }; - self.children().map(Expr::max_window).fold(own, usize::max) - } - - /// An estimate of the input updates needed before the feature is ready: - /// the window lengths summed along the longest chain. Use it to size the - /// history replayed before the feature is read. - pub fn warm_up(&self) -> usize { - match self { - Expr::Input | Expr::Const(_) => 0, - Expr::Neg(e) => e.warm_up(), - Expr::Binary { lhs, rhs, .. } => lhs.warm_up().max(rhs.warm_up()), - Expr::Call { - function, - arg, - params, - } => { - let own: usize = window_params(*function, params).sum(); - arg.warm_up() + own + usize::from(*function == Function::LnReturn) - } - } - } - - fn children(&self) -> impl Iterator { - let (a, b) = match self { - Expr::Input | Expr::Const(_) => (None, None), - Expr::Neg(e) | Expr::Call { arg: e, .. } => (Some(e.as_ref()), None), - Expr::Binary { lhs, rhs, .. } => (Some(lhs.as_ref()), Some(rhs.as_ref())), - }; - a.into_iter().chain(b) - } - - /// Check the tree against `limits` and every call against its - /// signature, so that [`Expr::build`] cannot fail or panic. - pub fn validate(&self, limits: &Limits) -> Result<(), SpecError> { - if self.node_count() > limits.max_nodes { - return Err(SpecError::invalid(format!( - "{} nodes exceed the limit of {}", - self.node_count(), - limits.max_nodes - ))); - } - if self.depth() > limits.max_depth { - return Err(SpecError::invalid(format!( - "depth {} exceeds the limit of {}", - self.depth(), - limits.max_depth - ))); - } - self.validate_nodes(limits) - } - - fn validate_nodes(&self, limits: &Limits) -> Result<(), SpecError> { - match self { - Expr::Const(c) if !c.is_finite() => { - return Err(SpecError::invalid(format!("constant {c} is not finite"))); - } - Expr::Call { - function, params, .. - } => validate_call(*function, params, limits)?, - _ => {} - } - self.children().try_for_each(|c| c.validate_nodes(limits)) - } -} - -/// The window parameters of a call, as lengths. -fn window_params(function: Function, params: &[f64]) -> impl Iterator + '_ { - function - .params() - .iter() - .zip(params) - .filter(|(kind, _)| **kind == Param::Window) - // Validated windows are small positive integers, exact as f64. - .map(|(_, value)| *value as usize) -} - -/// Check one call's parameters against its signature and `limits`. -pub(super) fn validate_call( - function: Function, - params: &[f64], - limits: &Limits, -) -> Result<(), SpecError> { - let expected = function.params(); - if params.len() != expected.len() { - return Err(SpecError::invalid(format!( - "{} takes {} parameter(s) after its argument, got {}", - function.signature(), - expected.len(), - params.len() - ))); - } - for (kind, value) in expected.iter().zip(params) { - if !value.is_finite() { - return Err(SpecError::invalid(format!( - "{}: parameter {value} is not finite", - function.name() - ))); - } - if *kind == Param::Window { - let valid = value.fract() == 0.0 - && *value >= function.min_window() as f64 - && *value <= limits.max_window as f64; - if !valid { - return Err(SpecError::invalid(format!( - "{}: window {value} must be an integer in [{}, {}]", - function.name(), - function.min_window(), - limits.max_window - ))); - } - } - } - if function == Function::LaguerreFilter && !(params[0] > 0.0 && params[0] < 1.0) { - return Err(SpecError::invalid(format!( - "laguerre_filter: gamma {} must lie in (0, 1)", - params[0] - ))); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn call(function: Function, arg: Expr, params: &[f64]) -> Expr { - Expr::Call { - function, - arg: Box::new(arg), - params: params.to_vec(), - } - } - - #[test] - fn display_is_canonical() { - let e = Expr::Binary { - op: BinaryOp::Sub, - lhs: Box::new(call(Function::Ema, Expr::Input, &[10.0])), - rhs: Box::new(Expr::Neg(Box::new(Expr::Const(0.5)))), - }; - assert_eq!(e.to_string(), "(ema(x, 10) - -0.5)"); - let pfe = call(Function::Pfe, Expr::Input, &[20.0, 5.0]); - assert_eq!(pfe.to_string(), "pfe(x, 20, 5)"); - } - - #[test] - fn size_and_warm_up() { - let inner = call(Function::Sma, Expr::Input, &[10.0]); - let e = call(Function::ZScore, inner, &[50.0]); - assert_eq!(e.node_count(), 3); - assert_eq!(e.depth(), 3); - assert_eq!(e.max_window(), 50); - assert_eq!(e.warm_up(), 60); - let both = Expr::Binary { - op: BinaryOp::Add, - lhs: Box::new(e.clone()), - rhs: Box::new(call(Function::Lag, Expr::Input, &[100.0])), - }; - assert_eq!(both.warm_up(), 100); - } - - #[test] - fn validation_rejects_what_build_cannot_handle() { - let limits = Limits::default(); - let ok = call(Function::Pfe, Expr::Input, &[3.0, 3.0]); - assert!(ok.validate(&limits).is_ok()); - for bad in [ - call(Function::Sma, Expr::Input, &[0.0]), - call(Function::Sma, Expr::Input, &[2.5]), - call(Function::Sma, Expr::Input, &[]), - call(Function::Pfe, Expr::Input, &[2.0, 3.0]), - call(Function::Sma, Expr::Input, &[1e9]), - call(Function::LaguerreFilter, Expr::Input, &[1.0]), - call(Function::Gte, Expr::Input, &[f64::NAN]), - Expr::Const(f64::INFINITY), - ] { - assert!(bad.validate(&limits).is_err(), "{bad:?}"); - } - } - - #[test] - fn validation_bounds_the_tree() { - let mut e = Expr::Input; - for _ in 0..20 { - e = Expr::Neg(Box::new(e)); - } - assert!(e.validate(&Limits::default()).is_err(), "too deep"); - let limits = Limits { - max_depth: 100, - max_nodes: 5, - ..Limits::default() - }; - assert!(e.validate(&limits).is_err(), "too many nodes"); - } -} diff --git a/src/spec/function.rs b/src/spec/function.rs deleted file mode 100644 index 9c9bb2d..0000000 --- a/src/spec/function.rs +++ /dev/null @@ -1,163 +0,0 @@ -//! The functions of the spec language and their signatures. - -use std::fmt::Write as _; - -/// The kind of a parameter that follows the (expression) first argument. -#[deprecated( - note = "use the `pipeline` module: its text form compiles every spec to the same values" -)] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum Param { - /// A window length, counted in updates of the argument: a positive - /// integer. - Window, - /// A finite real number. - Real, -} - -impl Param { - fn name(self) -> &'static str { - match self { - Param::Window => "window", - Param::Real => "real", - } - } -} - -/// Declares [`Function`] with its name, parameters, minimum window and -/// description from one table, so they cannot drift apart. -macro_rules! functions { - ($($variant:ident => $name:literal, [$($param:ident),*], $min:literal, $doc:literal;)*) => { - /// A function of the spec language, wrapping one `View` of this crate. - /// - /// Every function takes an expression as its first argument and then - /// its [`Function::params`]. - #[deprecated(note = "use `pipeline::Function`")] - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] - pub enum Function { - $(#[doc = $doc] $variant,)* - } - - impl Function { - /// Every function, in declaration order. - pub const ALL: &'static [Function] = &[$(Function::$variant,)*]; - - /// The name the function is called by in a spec. - pub fn name(self) -> &'static str { - match self { $(Function::$variant => $name,)* } - } - - /// The parameters after the expression argument. - pub fn params(self) -> &'static [Param] { - match self { $(Function::$variant => &[$(Param::$param),*],)* } - } - - /// The smallest window length the function accepts: shorter - /// windows are degenerate (e.g. an RSI over one value). - pub fn min_window(self) -> usize { - match self { $(Function::$variant => $min,)* } - } - - /// What the function computes, one line. - pub fn description(self) -> &'static str { - match self { $(Function::$variant => $doc,)* } - } - } - }; -} - -functions! { - Abs => "abs", [], 1, "Absolute value."; - Ln => "ln", [], 1, "Natural logarithm; not ready for non-positive values."; - Tanh => "tanh", [], 1, "Hyperbolic tangent, squashes into (-1, 1)."; - LnReturn => "ln_return", [], 1, "Log return between consecutive updates of a positive argument."; - Drawdown => "drawdown", [], 1, "Largest relative peak-to-valley decline of a positive argument so far."; - Gte => "gte", [Real], 1, "Clips values below the real to it: max(a, real)."; - Lte => "lte", [Real], 1, "Clips values above the real to it: min(a, real)."; - Sma => "sma", [Window], 1, "Simple moving average."; - Ema => "ema", [Window], 1, "Exponential moving average, alpha = 2 / (window + 1)."; - Alma => "alma", [Window], 2, "Arnaud Legoux moving average (sigma 6, offset 0.85)."; - Roc => "roc", [Window], 1, "Rate of change in percent against the value `window` updates ago."; - Lag => "lag", [Window], 1, "The value `window` updates ago."; - Min => "min", [Window], 1, "Minimum over the window."; - Max => "max", [Window], 1, "Maximum over the window."; - MinMax => "min_max", [Window], 2, "Position in the [min, max] range of the previous window, scaled to [-1, 1]."; - Mad => "mad", [Window], 2, "Robust z-score: (a - median) / (1.4826 MAD) of the previous window."; - ZScore => "zscore", [Window], 2, "Z-score against the mean and standard deviation of the previous window."; - Std => "std", [Window], 2, "Standard deviation over the window (Welford)."; - Cumulative => "cumulative", [Window], 1, "Sum over the window."; - Rsi => "rsi", [Window], 2, "Relative strength index."; - MyRsi => "my_rsi", [Window], 2, "John Ehlers' MyRSI, in [-1, 1]."; - LaguerreRsi => "laguerre_rsi", [Window], 2, "John Ehlers' Laguerre RSI."; - LaguerreFilter => "laguerre_filter", [Real], 1, "John Ehlers' Laguerre filter with damping gamma in (0, 1)."; - SuperSmoother => "super_smoother", [Window], 2, "John Ehlers' two-pole super smoother low-pass filter."; - RoofingFilter => "roofing_filter", [Window, Window], 2, "John Ehlers' roofing filter (low-pass window, high-pass window): a band-pass."; - ReFlex => "re_flex", [Window], 2, "John Ehlers' ReFlex zero-lag cycle indicator."; - TrendFlex => "trend_flex", [Window], 2, "John Ehlers' TrendFlex zero-lag trend indicator."; - CyberCycle => "cyber_cycle", [Window], 3, "John Ehlers' cyber cycle."; - CenterOfGravity => "center_of_gravity", [Window], 2, "John Ehlers' center of gravity oscillator."; - Cti => "cti", [Window], 2, "John Ehlers' correlation trend indicator: correlation with a straight line, in [-1, 1]."; - Pfe => "pfe", [Window, Window], 3, "Polarized fractal efficiency (window, EMA smoothing window), in [-1, 1]."; - Fisher => "fisher", [Window, Window], 2, "John Ehlers' Fisher transform (window, EMA smoothing window)."; - BinaryEntropy => "binary_entropy", [Window], 2, "Shannon entropy of the signs over the window."; - Vst => "vst", [Window], 2, "Variance stabilizing transform: change scaled by the rolling standard deviation."; - Net => "net", [Window], 2, "John Ehlers' noise elimination technology (Kendall correlation)."; -} - -impl Function { - /// The function called `name`, if any. - pub fn from_name(name: &str) -> Option { - Self::ALL.iter().copied().find(|f| f.name() == name) - } - - /// The call signature, e.g. `sma(a, window)`. - pub fn signature(self) -> String { - let mut out = format!("{}(a", self.name()); - for param in self.params() { - let _ = write!(out, ", {}", param.name()); - } - out.push(')'); - out - } - - /// A markdown table of every function: its signature and description. - /// Meant for documentation and for prompting an agent that writes specs. - pub fn reference() -> String { - let mut out = String::from("| function | description |\n|---|---|\n"); - for function in Self::ALL { - let _ = writeln!( - out, - "| `{}` | {} |", - function.signature(), - function.description() - ); - } - out - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn names_are_unique_and_round_trip() { - for function in Function::ALL { - assert_eq!(Function::from_name(function.name()), Some(*function)); - assert!(function.min_window() >= 1); - } - let mut names = Vec::from_iter(Function::ALL.iter().map(|f| f.name())); - names.sort_unstable(); - names.dedup(); - assert_eq!(names.len(), Function::ALL.len()); - assert_eq!(Function::from_name("nope"), None); - } - - #[test] - fn the_reference_lists_every_signature() { - let reference = Function::reference(); - assert!(reference.contains("| `pfe(a, window, window)` |")); - assert!(reference.contains("| `abs(a)` |")); - assert_eq!(reference.lines().count(), Function::ALL.len() + 2); - } -} diff --git a/src/spec/mod.rs b/src/spec/mod.rs deleted file mode 100644 index 37ca54f..0000000 --- a/src/spec/mod.rs +++ /dev/null @@ -1,61 +0,0 @@ -//! Features as text: a small expression language over this crate's views. -//! -//! **Deprecated**: use the [`pipeline`](crate::pipeline) module. Every spec -//! is a valid pipeline and computes the same values, and a pipeline also -//! has several inputs and outputs, shares equal subexpressions, checks -//! domains before it runs and is about a third faster. -//! [`pipeline::Feature`](crate::pipeline::Feature) replaces [`DynView`]. -//! -//! A spec describes a chain of views fed one input series `x`. It is parsed -//! into an [`Expr`], checked against resource [`Limits`] and built into a -//! [`DynView`] at runtime, so features can be written, stored, compared and -//! shared as data, for example by a search or an LLM agent, without -//! compiling code. -//! -//! ```text -//! zscore(ema(x, 10) - ema(x, 50), 200) // a normalized EMA crossover -//! std(ln_return(x), 60) / std(ln_return(x), 600) // short vs long volatility -//! ``` -//! -//! Grammar: numbers, `x`, `+ - * /` with the usual precedence, unary `-`, -//! parentheses and calls `name(expr, param, ...)` whose parameters are -//! number literals; see [`Function::reference`] for every function. Binary -//! operators feed both sides the same input. -//! -//! A built view only ever outputs finite values: a division by zero or a -//! logarithm of a non-positive value makes the node not ready instead. -//! -//! ``` -//! use sliding_features::{View, spec::{Expr, Limits}}; -//! -//! let spec: Expr = "x - sma(x,2)".parse().unwrap(); -//! assert_eq!(spec.to_string(), "(x - sma(x, 2))"); -//! let mut view = spec.build(&Limits::default()).unwrap(); -//! view.update(100.0); -//! assert_eq!(view.last(), None, "the SMA needs two values"); -//! view.update(104.0); -//! assert_eq!(view.last(), Some(2.0)); -//! ``` - -// The module uses its own deprecated items until it is removed. -#![allow(deprecated, reason = "the deprecated module uses its own items")] - -mod build; -mod error; -mod expr; -mod function; -mod parse; - -pub use build::{ - DynView, - Limits, -}; -pub use error::SpecError; -pub use expr::{ - BinaryOp, - Expr, -}; -pub use function::{ - Function, - Param, -}; diff --git a/src/spec/parse.rs b/src/spec/parse.rs deleted file mode 100644 index e616adb..0000000 --- a/src/spec/parse.rs +++ /dev/null @@ -1,319 +0,0 @@ -//! Recursive-descent parser of the spec language. -//! -//! ```text -//! expr := term (('+' | '-') term)* -//! term := unary (('*' | '/') unary)* -//! unary := '-' unary | atom -//! atom := number | 'x' | name '(' expr (',' number)* ')' | '(' expr ')' -//! ``` - -use super::{ - BinaryOp, - Expr, - Function, - SpecError, -}; - -#[derive(Debug, Clone, PartialEq)] -enum Token { - Number(f64), - Name(String), - Open, - Close, - Comma, - Op(char), - End, -} - -/// A token and the byte offset it starts at. -type Spanned = (Token, usize); - -fn lex(s: &str) -> Result, SpecError> { - let bytes = s.as_bytes(); - let mut tokens = Vec::new(); - let mut i = 0; - while i < bytes.len() { - let c = bytes[i]; - let start = i; - match c { - b' ' | b'\t' | b'\n' | b'\r' => i += 1, - b'(' | b')' | b',' | b'+' | b'-' | b'*' | b'/' => { - tokens.push(( - match c { - b'(' => Token::Open, - b')' => Token::Close, - b',' => Token::Comma, - _ => Token::Op(char::from(c)), - }, - start, - )); - i += 1; - } - b'0'..=b'9' | b'.' => { - i = number_end(bytes, i); - let text = &s[start..i]; - let value = text - .parse::() - .map_err(|_| SpecError::parse(start, format!("invalid number `{text}`")))?; - tokens.push((Token::Number(value), start)); - } - b'a'..=b'z' | b'A'..=b'Z' | b'_' => { - while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') { - i += 1; - } - tokens.push((Token::Name(s[start..i].to_string()), start)); - } - _ => { - let ch = s[start..].chars().next().expect("in bounds"); - return Err(SpecError::parse(start, format!("unexpected `{ch}`"))); - } - } - } - tokens.push((Token::End, s.len())); - Ok(tokens) -} - -/// The end of the number starting at `i`: digits, a point, an exponent. -fn number_end(bytes: &[u8], mut i: usize) -> usize { - while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') { - i += 1; - } - if i < bytes.len() && (bytes[i] == b'e' || bytes[i] == b'E') { - let mut j = i + 1; - if j < bytes.len() && (bytes[j] == b'+' || bytes[j] == b'-') { - j += 1; - } - if j < bytes.len() && bytes[j].is_ascii_digit() { - i = j; - while i < bytes.len() && bytes[i].is_ascii_digit() { - i += 1; - } - } - } - i -} - -struct Parser { - tokens: Vec, - pos: usize, -} - -impl Parser { - fn peek(&self) -> &Spanned { - &self.tokens[self.pos] - } - - fn next(&mut self) -> Spanned { - let token = self.tokens[self.pos].clone(); - if token.0 != Token::End { - self.pos += 1; - } - token - } - - fn expect(&mut self, expected: &Token, what: &str) -> Result<(), SpecError> { - let (token, at) = self.next(); - if &token == expected { - Ok(()) - } else { - Err(SpecError::parse( - at, - format!("expected {what}, found {}", describe(&token)), - )) - } - } - - fn expr(&mut self) -> Result { - let mut lhs = self.term()?; - while let Token::Op(c @ ('+' | '-')) = self.peek().0 { - self.next(); - let op = if c == '+' { - BinaryOp::Add - } else { - BinaryOp::Sub - }; - let rhs = self.term()?; - lhs = binary(op, lhs, rhs); - } - Ok(lhs) - } - - fn term(&mut self) -> Result { - let mut lhs = self.unary()?; - while let Token::Op(c @ ('*' | '/')) = self.peek().0 { - self.next(); - let op = if c == '*' { - BinaryOp::Mul - } else { - BinaryOp::Div - }; - let rhs = self.unary()?; - lhs = binary(op, lhs, rhs); - } - Ok(lhs) - } - - fn unary(&mut self) -> Result { - if self.peek().0 == Token::Op('-') { - self.next(); - // A negated literal is a negative constant, so `-0.5` prints and - // parses back as the same tree. - return Ok(match self.unary()? { - Expr::Const(c) => Expr::Const(-c), - e => Expr::Neg(Box::new(e)), - }); - } - self.atom() - } - - fn atom(&mut self) -> Result { - let (token, at) = self.next(); - match token { - Token::Number(value) => Ok(Expr::Const(value)), - Token::Name(name) if name == "x" => Ok(Expr::Input), - Token::Name(name) => self.call(&name, at), - Token::Open => { - let inner = self.expr()?; - self.expect(&Token::Close, "`)`")?; - Ok(inner) - } - other => Err(SpecError::parse( - at, - format!("expected an expression, found {}", describe(&other)), - )), - } - } - - fn call(&mut self, name: &str, at: usize) -> Result { - let function = Function::from_name(name) - .ok_or_else(|| SpecError::parse(at, format!("unknown function `{name}`")))?; - self.expect(&Token::Open, &format!("`(` after `{name}`"))?; - let arg = self.expr()?; - let mut params = Vec::new(); - while self.peek().0 == Token::Comma { - self.next(); - let param_at = self.peek().1; - match self.unary()? { - Expr::Const(value) => params.push(value), - _ => { - return Err(SpecError::parse( - param_at, - format!("parameters of `{name}` must be numbers"), - )); - } - } - } - self.expect(&Token::Close, "`)` or `,`")?; - super::expr::validate_call(function, ¶ms, &super::Limits::unbounded()) - .map_err(|e| SpecError::parse(at, e.message().to_string()))?; - Ok(Expr::Call { - function, - arg: Box::new(arg), - params, - }) - } -} - -fn binary(op: BinaryOp, lhs: Expr, rhs: Expr) -> Expr { - Expr::Binary { - op, - lhs: Box::new(lhs), - rhs: Box::new(rhs), - } -} - -fn describe(token: &Token) -> String { - match token { - Token::Number(v) => format!("number `{v}`"), - Token::Name(n) => format!("`{n}`"), - Token::Open => "`(`".to_string(), - Token::Close => "`)`".to_string(), - Token::Comma => "`,`".to_string(), - Token::Op(c) => format!("`{c}`"), - Token::End => "the end".to_string(), - } -} - -/// Parse a whole spec. -pub(super) fn parse(s: &str) -> Result { - let mut parser = Parser { - tokens: lex(s)?, - pos: 0, - }; - let expr = parser.expr()?; - parser.expect(&Token::End, "the end of the spec")?; - Ok(expr) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn roundtrip(s: &str) -> Expr { - let e = parse(s).unwrap_or_else(|err| panic!("{s}: {err}")); - let printed = e.to_string(); - assert_eq!( - parse(&printed).expect("canonical form parses"), - e, - "{printed}" - ); - e - } - - #[test] - fn precedence_and_associativity() { - assert_eq!(roundtrip("x + x * 2").to_string(), "(x + (x * 2))"); - assert_eq!(roundtrip("(x + x) * 2").to_string(), "((x + x) * 2)"); - assert_eq!(roundtrip("x - x - 1").to_string(), "((x - x) - 1)"); - assert_eq!(roundtrip("x / 2 / 4").to_string(), "((x / 2) / 4)"); - } - - #[test] - fn calls_unary_minus_and_numbers() { - let e = roundtrip("zscore(ema(x, 10) - ema(x,50), 200)"); - assert_eq!(e.to_string(), "zscore((ema(x, 10) - ema(x, 50)), 200)"); - assert_eq!(roundtrip("-0.5"), Expr::Const(-0.5)); - assert_eq!(roundtrip("gte(x, -1.5e-3)").to_string(), "gte(x, -0.0015)"); - assert_eq!(roundtrip("--x").to_string(), "--x"); - assert_eq!(roundtrip("-(x + 1)").to_string(), "-(x + 1)"); - roundtrip("pfe(roc(x, 1), 20, 5)"); - roundtrip(" laguerre_filter( x ,0.8 ) "); - } - - #[test] - fn every_function_round_trips() { - for function in Function::ALL { - let params = Vec::from_iter(function.params().iter().map(|p| match p { - super::super::Param::Window => function.min_window().to_string(), - super::super::Param::Real => "0.5".to_string(), - })); - let mut s = format!("{}(x", function.name()); - for p in params { - s.push_str(", "); - s.push_str(&p); - } - s.push(')'); - roundtrip(&s); - } - } - - #[test] - fn errors_point_at_the_problem() { - for (spec, position, fragment) in [ - ("sma(x)", 0, "takes 1 parameter"), - ("smaa(x, 3)", 0, "unknown function"), - ("sma(x, y)", 7, "`y`"), - ("sma(x, sma(x, 2))", 7, "must be numbers"), - ("x +", 3, "expected an expression"), - ("(x", 2, "expected `)`"), - ("x x", 2, "end of the spec"), - ("x # 2", 2, "unexpected `#`"), - ("sma(x, 2.5)", 0, "must be an integer"), - ("1..2", 0, "invalid number"), - ] { - let err = parse(spec).expect_err(spec); - assert_eq!(err.position(), Some(position), "{spec}: {err}"); - assert!(err.to_string().contains(fragment), "{spec}: {err}"); - } - } -} From dee51054a0eb098de797d73116f987f6fdfc7054 Mon Sep 17 00:00:00 2001 From: MathisWellmann Date: Mon, 12 Oct 2026 00:46:32 +0200 Subject: [PATCH 17/17] bump version to 11.0.0 (pipeline module; spec and View for Box removed) Removing spec and the View impl for Box breaks their users, and the Sma, Cumulative, PolarizedFractalEfficiency and MinMaxNormalizer fixes change outputs, so this is a major release. --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 86e6e3b..9b0ec7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1207,7 +1207,7 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "sliding_features" -version = "10.1.0" +version = "11.0.0" dependencies = [ "ballpark", "criterion", diff --git a/Cargo.toml b/Cargo.toml index 4730a59..d9fce03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sliding_features" -version = "10.1.0" +version = "11.0.0" authors = ["MathisWellmann "] edition = "2024" license-file = "LICENSE"