From 7fe33a8c2a986aade19b31e537d76be345c9aa33 Mon Sep 17 00:00:00 2001 From: Ben Sully Date: Fri, 18 Oct 2024 09:14:41 +0100 Subject: [PATCH] fix!: clean up JS APIs (#135) --- .github/workflows/js.yml | 39 + crates/augurs-js/Cargo.toml | 5 +- crates/augurs-js/src/changepoints.rs | 31 +- crates/augurs-js/src/dtw.rs | 80 +- crates/augurs-js/src/ets.rs | 19 +- crates/augurs-js/src/lib.rs | 57 +- crates/augurs-js/src/logging.rs | 117 ++ crates/augurs-js/src/mstl.rs | 57 +- crates/augurs-js/src/outlier.rs | 99 +- crates/augurs-js/src/prophet.rs | 173 +-- crates/augurs-js/src/seasons.rs | 6 +- crates/augurs-js/testpkg/.gitignore | 1 + crates/augurs-js/testpkg/changepoints.test.ts | 58 + crates/augurs-js/testpkg/clustering.test.ts | 49 + crates/augurs-js/testpkg/dtw.test.ts | 145 ++ crates/augurs-js/testpkg/ets.test.ts | 47 + crates/augurs-js/testpkg/logging.test.ts | 27 + crates/augurs-js/testpkg/mstl.test.ts | 64 + crates/augurs-js/testpkg/outlier.test.ts | 155 ++ crates/augurs-js/testpkg/package-lock.json | 1244 +++++++++++++++++ crates/augurs-js/testpkg/package.json | 17 + crates/augurs-js/testpkg/prophet.test.ts | 57 + crates/augurs-js/testpkg/seasons.test.ts | 28 + crates/augurs-js/testpkg/tsconfig.json | 110 ++ justfile | 7 +- 25 files changed, 2507 insertions(+), 185 deletions(-) create mode 100644 .github/workflows/js.yml create mode 100644 crates/augurs-js/src/logging.rs create mode 100644 crates/augurs-js/testpkg/.gitignore create mode 100644 crates/augurs-js/testpkg/changepoints.test.ts create mode 100644 crates/augurs-js/testpkg/clustering.test.ts create mode 100644 crates/augurs-js/testpkg/dtw.test.ts create mode 100644 crates/augurs-js/testpkg/ets.test.ts create mode 100644 crates/augurs-js/testpkg/logging.test.ts create mode 100644 crates/augurs-js/testpkg/mstl.test.ts create mode 100644 crates/augurs-js/testpkg/outlier.test.ts create mode 100644 crates/augurs-js/testpkg/package-lock.json create mode 100644 crates/augurs-js/testpkg/package.json create mode 100644 crates/augurs-js/testpkg/prophet.test.ts create mode 100644 crates/augurs-js/testpkg/seasons.test.ts create mode 100644 crates/augurs-js/testpkg/tsconfig.json diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml new file mode 100644 index 00000000..254a353c --- /dev/null +++ b/.github/workflows/js.yml @@ -0,0 +1,39 @@ +name: augurs-js + +on: + push: + branches: [ "main" ] + pull_request: + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + name: JS tests + runs-on: ubuntu-latest + steps: + - name: Checkout sources + uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@master + with: + toolchain: nightly-2024-09-01 + targets: wasm32-unknown-unknown + - uses: taiki-e/install-action@v2 + with: + tool: just,wasm-pack + + - name: Build augurs-js + run: just build-augurs-js + + - uses: actions/setup-node@v4 + - name: Install dependencies + run: npm ci + working-directory: crates/augurs-js/testpkg + - name: Run typecheck + run: npm run typecheck + working-directory: crates/augurs-js/testpkg + - name: Run tests + run: npm run test:ci + working-directory: crates/augurs-js/testpkg diff --git a/crates/augurs-js/Cargo.toml b/crates/augurs-js/Cargo.toml index ff27cbd7..212e07a9 100644 --- a/crates/augurs-js/Cargo.toml +++ b/crates/augurs-js/Cargo.toml @@ -19,6 +19,8 @@ doctest = false test = false [features] +default = ["logging"] +logging = ["wasm-tracing"] parallel = ["wasm-bindgen-rayon"] [dependencies] @@ -38,10 +40,11 @@ js-sys = "0.3.64" serde.workspace = true serde-wasm-bindgen = "0.6.0" tracing.workspace = true -tracing-wasm = { version = "0.2.1", optional = true } +tracing-subscriber = { workspace = true, features = ["registry"], default-features = false } tsify-next = { version = "0.5.3", default-features = false, features = ["js"] } wasm-bindgen = "=0.2.93" wasm-bindgen-rayon = { version = "1.2.1", optional = true } +wasm-tracing = { version = "0.2.1", optional = true } [package.metadata.wasm-pack.profile.release] # previously had just ['-O4'] diff --git a/crates/augurs-js/src/changepoints.rs b/crates/augurs-js/src/changepoints.rs index 2e782c8b..a8875a33 100644 --- a/crates/augurs-js/src/changepoints.rs +++ b/crates/augurs-js/src/changepoints.rs @@ -1,6 +1,5 @@ use std::num::NonZeroUsize; -use js_sys::Float64Array; use serde::{Deserialize, Serialize}; use tsify_next::Tsify; use wasm_bindgen::prelude::*; @@ -9,6 +8,8 @@ use augurs_changepoint::{ dist, ArgpcpDetector, BocpdDetector, DefaultArgpcpDetector, Detector, NormalGammaDetector, }; +use crate::VecF64; + #[derive(Debug)] enum EitherDetector { NormalGamma(NormalGammaDetector), @@ -24,6 +25,18 @@ impl EitherDetector { } } +/// The type of changepoint detector to use. +#[derive(Debug, Clone, Copy, Deserialize, Tsify)] +#[serde(rename_all = "kebab-case")] +#[tsify(from_wasm_abi)] +pub enum ChangepointDetectorType { + /// A Bayesian Online Changepoint Detector with a Normal Gamma prior. + NormalGamma, + /// An autoregressive Gaussian Process changepoint detector, + /// with the default kernel and parameters. + DefaultArgpcp, +} + /// A changepoint detector. #[derive(Debug)] #[wasm_bindgen] @@ -35,6 +48,14 @@ const DEFAULT_HAZARD_LAMBDA: f64 = 250.0; #[wasm_bindgen] impl ChangepointDetector { + #[wasm_bindgen(constructor)] + pub fn new(detectorType: ChangepointDetectorType) -> Result { + match detectorType { + ChangepointDetectorType::NormalGamma => Self::normal_gamma(None), + ChangepointDetectorType::DefaultArgpcp => Self::default_argpcp(None), + } + } + /// Create a new Bayesian Online changepoint detector with a Normal Gamma prior. #[wasm_bindgen(js_name = "normalGamma")] pub fn normal_gamma( @@ -73,10 +94,10 @@ impl ChangepointDetector { /// Detect changepoints in the given time series. #[wasm_bindgen(js_name = "detectChangepoints")] - pub fn detect_changepoints(&mut self, y: Float64Array) -> Changepoints { - Changepoints { - indices: self.detector.detect_changepoints(&y.to_vec()), - } + pub fn detect_changepoints(&mut self, y: VecF64) -> Result { + Ok(Changepoints { + indices: self.detector.detect_changepoints(&y.convert()?), + }) } } diff --git a/crates/augurs-js/src/dtw.rs b/crates/augurs-js/src/dtw.rs index f93b3acc..0dbffd8d 100644 --- a/crates/augurs-js/src/dtw.rs +++ b/crates/augurs-js/src/dtw.rs @@ -9,6 +9,8 @@ use wasm_bindgen::prelude::*; use augurs_dtw::{Euclidean, Manhattan}; +use crate::{VecF64, VecVecF64}; + enum InnerDtw { Euclidean(augurs_dtw::Dtw), Manhattan(augurs_dtw::Dtw), @@ -89,11 +91,45 @@ pub struct DistanceMatrix { } impl DistanceMatrix { + /// Get the inner distance matrix. pub fn inner(&self) -> &augurs_core::DistanceMatrix { &self.inner } } +#[wasm_bindgen] +impl DistanceMatrix { + /// Create a new `DistanceMatrix` from a raw distance matrix. + #[wasm_bindgen(constructor)] + pub fn new(distanceMatrix: VecVecF64) -> Result { + Ok(Self { + inner: augurs_core::DistanceMatrix::try_from_square(distanceMatrix.convert()?)?, + }) + } + + /// Get the shape of the distance matrix. + #[wasm_bindgen(js_name = shape)] + pub fn shape(&self) -> Vec { + let (m, n) = self.inner.shape(); + vec![m, n] + } + + /// Get the distance matrix as an array of arrays. + #[wasm_bindgen(js_name = toArray)] + pub fn to_array(&self) -> Vec { + self.inner + .clone() + .into_inner() + .into_iter() + .map(|x| { + let arr = Float64Array::new_with_length(x.len() as u32); + arr.copy_from(&x); + arr + }) + .collect() + } +} + impl From for DistanceMatrix { fn from(inner: augurs_core::DistanceMatrix) -> Self { Self { inner } @@ -106,6 +142,17 @@ impl From for augurs_core::DistanceMatrix { } } +/// The distance function to use for Dynamic Time Warping. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Tsify)] +#[serde(rename_all = "lowercase")] +#[tsify(from_wasm_abi)] +pub enum DistanceFunction { + /// Euclidean distance. + Euclidean, + /// Manhattan distance. + Manhattan, +} + /// Dynamic Time Warping. /// /// The `window` parameter can be used to specify the Sakoe-Chiba band size. @@ -119,9 +166,18 @@ pub struct Dtw { #[wasm_bindgen] impl Dtw { + /// Create a new `Dtw` instance. + #[wasm_bindgen(constructor)] + pub fn new(distanceFunction: DistanceFunction, opts: Option) -> Self { + match distanceFunction { + DistanceFunction::Euclidean => Self::euclidean(opts), + DistanceFunction::Manhattan => Self::manhattan(opts), + } + } + /// Create a new `Dtw` instance using the Euclidean distance. #[wasm_bindgen] - pub fn euclidean(opts: Option) -> Result { + pub fn euclidean(opts: Option) -> Dtw { let opts = opts.unwrap_or_default(); let mut dtw = augurs_dtw::Dtw::euclidean(); if let Some(window) = opts.window { @@ -140,14 +196,14 @@ impl Dtw { if let Some(parallelize) = opts.parallelize { dtw = dtw.parallelize(parallelize); } - Ok(Dtw { + Dtw { inner: InnerDtw::Euclidean(dtw), - }) + } } - /// Create a new `Dtw` instance using the Euclidean distance. + /// Create a new `Dtw` instance using the Manhattan distance. #[wasm_bindgen] - pub fn manhattan(opts: Option) -> Result { + pub fn manhattan(opts: Option) -> Dtw { let opts = opts.unwrap_or_default(); let mut dtw = augurs_dtw::Dtw::manhattan(); if let Some(window) = opts.window { @@ -162,24 +218,24 @@ impl Dtw { if let Some(upper_bound) = opts.upper_bound { dtw = dtw.with_upper_bound(upper_bound); } - Ok(Dtw { + Dtw { inner: InnerDtw::Manhattan(dtw), - }) + } } /// Calculate the distance between two arrays under Dynamic Time Warping. #[wasm_bindgen] - pub fn distance(&self, a: Float64Array, b: Float64Array) -> f64 { - self.inner.distance(&a.to_vec(), &b.to_vec()) + pub fn distance(&self, a: VecF64, b: VecF64) -> Result { + Ok(self.inner.distance(&a.convert()?, &b.convert()?)) } /// Compute the distance matrix between all pairs of series. /// /// The series do not all have to be the same length. #[wasm_bindgen(js_name = distanceMatrix)] - pub fn distance_matrix(&self, series: Vec) -> DistanceMatrix { - let vecs: Vec<_> = series.iter().map(|x| x.to_vec()).collect(); + pub fn distance_matrix(&self, series: VecVecF64) -> Result { + let vecs = series.convert()?; let slices = vecs.iter().map(Vec::as_slice).collect::>(); - self.inner.distance_matrix(&slices) + Ok(self.inner.distance_matrix(&slices)) } } diff --git a/crates/augurs-js/src/ets.rs b/crates/augurs-js/src/ets.rs index 61cd2c54..276948fc 100644 --- a/crates/augurs-js/src/ets.rs +++ b/crates/augurs-js/src/ets.rs @@ -1,11 +1,10 @@ //! JavaScript bindings for the AutoETS model. -use js_sys::Float64Array; use wasm_bindgen::prelude::*; use augurs_core::prelude::*; -use crate::Forecast; +use crate::{Forecast, VecF64}; /// Automatic ETS model selection. #[derive(Debug)] @@ -24,9 +23,8 @@ impl AutoETS { /// /// If the `spec` string is invalid, this function returns an error. #[wasm_bindgen(constructor)] - pub fn new(seasonLength: usize, spec: String) -> Result { - let inner = - augurs_ets::AutoETS::new(seasonLength, spec.as_str()).map_err(|e| e.to_string())?; + pub fn new(seasonLength: usize, spec: String) -> Result { + let inner = augurs_ets::AutoETS::new(seasonLength, spec.as_str())?; Ok(Self { inner, fitted: None, @@ -43,8 +41,8 @@ impl AutoETS { /// If no model can be found, or if any parameters are invalid, this function /// returns an error. #[wasm_bindgen] - pub fn fit(&mut self, y: Float64Array) -> Result<(), JsValue> { - self.fitted = Some(self.inner.fit(&y.to_vec()).map_err(|e| e.to_string())?); + pub fn fit(&mut self, y: VecF64) -> Result<(), JsError> { + self.fitted = Some(self.inner.fit(&y.convert()?)?); Ok(()) } @@ -57,13 +55,12 @@ impl AutoETS { /// /// This function will return an error if no model has been fit yet (using [`AutoETS::fit`]). #[wasm_bindgen] - pub fn predict(&self, horizon: usize, level: Option) -> Result { + pub fn predict(&self, horizon: usize, level: Option) -> Result { Ok(self .fitted .as_ref() .map(|x| x.predict(horizon, level)) - .ok_or("model not fit yet")? - .map(Into::into) - .map_err(|e| e.to_string())?) + .ok_or(JsError::new("model not fit yet"))? + .map(Into::into)?) } } diff --git a/crates/augurs-js/src/lib.rs b/crates/augurs-js/src/lib.rs index d9322e44..70885c80 100644 --- a/crates/augurs-js/src/lib.rs +++ b/crates/augurs-js/src/lib.rs @@ -60,6 +60,8 @@ mod changepoints; pub mod clustering; mod dtw; pub mod ets; +#[cfg(feature = "logging")] +pub mod logging; pub mod mstl; mod outlier; mod prophet; @@ -74,8 +76,6 @@ pub mod seasons; #[wasm_bindgen(start)] pub fn custom_init() { console_error_panic_hook::set_once(); - #[cfg(feature = "tracing-wasm")] - tracing_wasm::try_set_as_global_default().ok(); } // Wrapper types for the core types, so we can derive `Tsify` for them. @@ -122,3 +122,56 @@ impl From for Forecast { } } } + +// These custom types are needed to have the correct TypeScript types generated +// for functions which accept either `number[]` or typed arrays when called +// from Javascript. +// They should always be preferred over using `Vec` directly in functions +// exported to Javascript, even if it is a bit of hassle to convert them. +// They can be converted using: +// +// let y = y.convert()?; +#[wasm_bindgen] +extern "C" { + /// Custom type for `Vec`. + #[wasm_bindgen(typescript_type = "number[] | Uint32Array")] + #[derive(Debug)] + pub type VecU32; + + /// Custom type for `Vec`. + #[wasm_bindgen(typescript_type = "number[] | Uint32Array")] + #[derive(Debug)] + pub type VecUsize; + + /// Custom type for `Vec`. + #[wasm_bindgen(typescript_type = "number[] | Float64Array")] + #[derive(Debug)] + pub type VecF64; + + /// Custom type for `Vec>`. + #[wasm_bindgen(typescript_type = "number[][] | Float64Array[]")] + #[derive(Debug)] + pub type VecVecF64; +} + +impl VecUsize { + fn convert(self) -> Result, JsError> { + serde_wasm_bindgen::from_value(self.into()) + .map_err(|_| JsError::new("TypeError: expected array of integers or Uint32Array")) + } +} + +impl VecF64 { + fn convert(self) -> Result, JsError> { + serde_wasm_bindgen::from_value(self.into()) + .map_err(|_| JsError::new("TypeError: expected array of numbers or Float64Array")) + } +} + +impl VecVecF64 { + fn convert(self) -> Result>, JsError> { + serde_wasm_bindgen::from_value(self.into()).map_err(|_| { + JsError::new("TypeError: expected array of number arrays or array of Float64Array") + }) + } +} diff --git a/crates/augurs-js/src/logging.rs b/crates/augurs-js/src/logging.rs new file mode 100644 index 00000000..617a4afe --- /dev/null +++ b/crates/augurs-js/src/logging.rs @@ -0,0 +1,117 @@ +//! Logging utilities. +//! +//! Currently uses `tracing-wasm` to emit logs to the browser console +//! or the browser's performance timeline. + +use serde::Deserialize; +use tracing_subscriber::{layer::SubscriberExt, Registry}; +use tsify_next::Tsify; +use wasm_bindgen::prelude::*; +use wasm_tracing::WASMLayer; + +/// The maximum log level to emit. +/// +/// The default is `Level::Info`. +#[derive(Debug, Default, Clone, Copy, Deserialize, Tsify)] +#[serde(rename_all = "camelCase")] +#[tsify(from_wasm_abi)] +pub enum Level { + /// Emit logs at or above the `TRACE` level. + Trace, + /// Emit logs at or above the `DEBUG` level. + Debug, + /// Emit logs at or above the `INFO` level. + #[default] + Info, + /// Emit logs at or above the `WARN` level. + Warn, + /// Emit logs at or above the `ERROR` level. + Error, +} + +impl From for tracing::Level { + fn from(value: Level) -> Self { + match value { + Level::Trace => tracing::Level::TRACE, + Level::Debug => tracing::Level::DEBUG, + Level::Info => tracing::Level::INFO, + Level::Warn => tracing::Level::WARN, + Level::Error => tracing::Level::ERROR, + } + } +} + +/// The target for augurs log events. +#[derive(Debug, Default, Clone, Copy, Deserialize, Tsify)] +#[serde(rename_all = "camelCase")] +#[tsify(from_wasm_abi)] +pub enum LogTarget { + /// Emit logs to the browser console. + #[default] + Console, + /// Emit logs to the browser's performance timeline. + Performance, +} + +fn default_coloured_logs() -> bool { + true +} + +/// Log configuration. +#[derive(Debug, Default, Clone, Deserialize, Tsify)] +#[serde(rename_all = "camelCase")] +#[tsify(from_wasm_abi)] +pub struct LogConfig { + /// The maximum log level to emit. + /// + /// Defaults to `INFO`. + #[serde(default)] + pub max_level: Level, + + /// The target for augurs log events. + /// + /// Defaults to logging to the browser console. + #[serde(default)] + pub target: LogTarget, + + /// Whether to emit coloured logs. + /// + /// Defaults to `true`. + #[serde(alias = "colour", default = "default_coloured_logs")] + pub color: bool, + + /// Whether to show detailed fields such as augurs' file names and line numbers + /// in the logs. + /// + /// Probably not wise in production. + /// + /// Defaults to `false`. + #[serde(default)] + pub show_detailed_fields: bool, +} + +/// Initialize logging. +/// +/// You can use this to emit logs from augurs to the browser console. +/// The default is to log everything to the console, but you can +/// change the log level and whether logs are emitted to the console +/// or to the browser's performance timeline. +/// +/// IMPORTANT: this function should only be called once. It will throw +/// an exception if called more than once. +#[wasm_bindgen(js_name = "initLogging")] +pub fn init_logging(config: Option) -> Result<(), JsError> { + let config = config.unwrap_or_default(); + let config = wasm_tracing::WASMLayerConfigBuilder::new() + .set_show_fields(config.show_detailed_fields) + .set_report_logs_in_timings(matches!(config.target, LogTarget::Performance)) + .set_console_config(if config.color { + wasm_tracing::ConsoleConfig::ReportWithConsoleColor + } else { + wasm_tracing::ConsoleConfig::ReportWithoutConsoleColor + }) + .set_max_level(config.max_level.into()) + .build(); + tracing::subscriber::set_global_default(Registry::default().with(WASMLayer::new(config))) + .map_err(|_| JsError::new("logging already initialized")) +} diff --git a/crates/augurs-js/src/mstl.rs b/crates/augurs-js/src/mstl.rs index 84ae2864..8337490f 100644 --- a/crates/augurs-js/src/mstl.rs +++ b/crates/augurs-js/src/mstl.rs @@ -1,5 +1,4 @@ //! JavaScript bindings for the MSTL model. -use js_sys::Float64Array; use serde::Deserialize; use tsify_next::Tsify; use wasm_bindgen::prelude::*; @@ -8,7 +7,17 @@ use augurs_ets::{trend::AutoETSTrendModel, AutoETS}; use augurs_forecaster::{Forecaster, Transform}; use augurs_mstl::{MSTLModel, TrendModel}; -use crate::Forecast; +use crate::{Forecast, VecF64, VecUsize}; + +/// The type of trend forecaster to use. +#[derive(Debug, Clone, Copy, Deserialize, Tsify)] +#[serde(rename_all = "kebab-case")] +#[tsify(from_wasm_abi)] +#[non_exhaustive] +pub enum MSTLTrendModel { + /// Use the `ETS` trend model. + Ets, +} /// A MSTL model. #[derive(Debug)] @@ -19,10 +28,35 @@ pub struct MSTL { #[wasm_bindgen] impl MSTL { + /// Create a new MSTL model with the given periods using the given trend model. + #[wasm_bindgen(constructor)] + pub fn new( + trend_forecaster: MSTLTrendModel, + periods: VecUsize, + options: Option, + ) -> Result { + match trend_forecaster { + MSTLTrendModel::Ets => MSTL::ets(periods, options), + } + } + + /// Create a new MSTL model with the given periods using the `AutoETS` trend model. + #[wasm_bindgen] + pub fn ets(periods: VecUsize, options: Option) -> Result { + let ets: Box = + Box::new(AutoETSTrendModel::from(AutoETS::non_seasonal())); + let model = MSTLModel::new(periods.convert()?, ets); + let forecaster = + Forecaster::new(model).with_transforms(options.unwrap_or_default().into_transforms()); + Ok(MSTL { forecaster }) + } + /// Fit the model to the given time series. #[wasm_bindgen] - pub fn fit(&mut self, y: Float64Array) -> Result<(), JsValue> { - self.forecaster.fit(y.to_vec()).map_err(|e| e.to_string())?; + pub fn fit(&mut self, y: VecF64) -> Result<(), JsValue> { + self.forecaster + .fit(y.convert()?) + .map_err(|e| e.to_string())?; Ok(()) } @@ -77,13 +111,12 @@ impl ETSOptions { } } -#[wasm_bindgen] /// Create a new MSTL model with the given periods using the `AutoETS` trend model. -pub fn ets(periods: Vec, options: Option) -> MSTL { - let ets: Box = - Box::new(AutoETSTrendModel::from(AutoETS::non_seasonal())); - let model = MSTLModel::new(periods, ets); - let forecaster = - Forecaster::new(model).with_transforms(options.unwrap_or_default().into_transforms()); - MSTL { forecaster } +/// +/// @deprecated use `MSTL.ets` instead +#[wasm_bindgen] +#[deprecated(since = "0.4.2", note = "use `MSTL.ets` instead")] +#[allow(deprecated)] +pub fn ets(periods: VecUsize, options: Option) -> Result { + MSTL::ets(periods, options) } diff --git a/crates/augurs-js/src/outlier.rs b/crates/augurs-js/src/outlier.rs index ce843ba9..14f4851c 100644 --- a/crates/augurs-js/src/outlier.rs +++ b/crates/augurs-js/src/outlier.rs @@ -1,12 +1,13 @@ use std::collections::BTreeSet; use augurs_outlier::OutlierDetector as _; -use js_sys::Float64Array; use serde::{Deserialize, Serialize}; use tsify_next::Tsify; use wasm_bindgen::prelude::*; +use crate::VecVecF64; + // Enums representing outlier detectors and 'loaded' outlier detectors // (i.e. detectors that have already preprocessed some data and are // ready to detect). @@ -22,21 +23,18 @@ impl Detector { /// /// This is provided as a separate method to allow for the /// preprocessed data to be cached in the future. - fn preprocess(&self, y: Float64Array, n_timestamps: usize) -> Result { + fn preprocess(&self, series: &[Vec]) -> Result { + let series: Vec<_> = series.iter().map(|x| x.as_slice()).collect(); match self { Self::Dbscan(detector) => { - let vec = y.to_vec(); - let y: Vec<_> = vec.chunks(n_timestamps).map(Into::into).collect(); - let data = detector.preprocess(&y)?; + let data = detector.preprocess(&series)?; Ok(LoadedDetector::Dbscan { detector: detector.clone(), data, }) } Self::Mad(detector) => { - let vec = y.to_vec(); - let y: Vec<_> = vec.chunks(n_timestamps).map(Into::into).collect(); - let data = detector.preprocess(&y)?; + let data = detector.preprocess(&series)?; Ok(LoadedDetector::Mad { detector: detector.clone(), data, @@ -46,18 +44,15 @@ impl Detector { } /// Preprocess and perform outlier detection on the data. - fn detect(&self, y: Float64Array, n_timestamps: usize) -> Result { + fn detect(&self, series: &[Vec]) -> Result { + let series: Vec<_> = series.iter().map(|x| x.as_slice()).collect(); match self { Self::Dbscan(detector) => { - let vec = y.to_vec(); - let y: Vec<_> = vec.chunks(n_timestamps).map(Into::into).collect(); - let data = detector.preprocess(&y)?; + let data = detector.preprocess(&series)?; Ok(detector.detect(&data)?.into()) } Self::Mad(detector) => { - let vec = y.to_vec(); - let y: Vec<_> = vec.chunks(n_timestamps).map(Into::into).collect(); - let data = detector.preprocess(&y)?; + let data = detector.preprocess(&series)?; Ok(detector.detect(&data)?.into()) } } @@ -90,7 +85,7 @@ impl LoadedDetector { /// Options for the DBSCAN outlier detector. #[derive(Debug, Default, Deserialize, Tsify)] #[tsify(from_wasm_abi)] -pub struct DbscanDetectorOptions { +pub struct OutlierDetectorOptions { /// A scale-invariant sensitivity parameter. /// /// This must be in (0, 1) and will be used to estimate a sensible @@ -98,24 +93,13 @@ pub struct DbscanDetectorOptions { pub sensitivity: f64, } -#[derive(Debug, Default, Deserialize, Tsify)] +/// The type of outlier detector to use. +#[derive(Debug, Clone, Copy, Deserialize, Tsify)] +#[serde(rename_all = "lowercase")] #[tsify(from_wasm_abi)] -pub struct MADDetectorOptions { - /// A scale-invariant sensitivity parameter. - /// - /// This must be in (0, 1) and will be used to estimate a sensible - /// value of epsilon based on the data. - pub sensitivity: f64, -} - -#[derive(Debug, Deserialize, Tsify)] -#[tsify(from_wasm_abi)] -/// Options for outlier detectors. -pub enum OutlierDetectorOptions { - #[serde(rename = "dbscan")] - Dbscan(DbscanDetectorOptions), - #[serde(rename = "mad")] - Mad(MADDetectorOptions), +pub enum OutlierDetectorType { + Dbscan, + Mad, } /// A detector for detecting outlying time series in a group of series. @@ -128,9 +112,21 @@ pub struct OutlierDetector { #[wasm_bindgen] impl OutlierDetector { + /// Create a new outlier detector. + #[wasm_bindgen(constructor)] + pub fn new( + detectorType: OutlierDetectorType, + options: OutlierDetectorOptions, + ) -> Result { + match detectorType { + OutlierDetectorType::Dbscan => Self::dbscan(options), + OutlierDetectorType::Mad => Self::mad(options), + } + } + /// Create a new outlier detector using the DBSCAN algorithm. #[wasm_bindgen] - pub fn dbscan(options: DbscanDetectorOptions) -> Result { + pub fn dbscan(options: OutlierDetectorOptions) -> Result { Ok(Self { detector: Detector::Dbscan(augurs_outlier::DbscanDetector::with_sensitivity( options.sensitivity, @@ -138,7 +134,7 @@ impl OutlierDetector { }) } - pub fn mad(options: MADDetectorOptions) -> Result { + pub fn mad(options: OutlierDetectorOptions) -> Result { Ok(Self { detector: Detector::Mad(augurs_outlier::MADDetector::with_sensitivity( options.sensitivity, @@ -152,8 +148,8 @@ impl OutlierDetector { /// you should use the `preprocess` method to cache the preprocessed data, /// then call `detect` on the `LoadedOutlierDetector` returned by `preprocess`. #[wasm_bindgen] - pub fn detect(&self, y: Float64Array, nTimestamps: usize) -> Result { - self.detector.detect(y, nTimestamps) + pub fn detect(&self, y: VecVecF64) -> Result { + self.detector.detect(&y.convert()?) } /// Preprocess the data for the detector. @@ -163,13 +159,9 @@ impl OutlierDetector { /// /// This is useful if you plan to run the detector multiple times on the same data. #[wasm_bindgen] - pub fn preprocess( - &self, - y: Float64Array, - nTimestamps: usize, - ) -> Result { + pub fn preprocess(&self, y: VecVecF64) -> Result { Ok(LoadedOutlierDetector { - detector: self.detector.preprocess(y, nTimestamps)?, + detector: self.detector.preprocess(&y.convert()?)?, }) } } @@ -199,13 +191,10 @@ impl LoadedOutlierDetector { /// are incompatible. #[wasm_bindgen(js_name = "updateDetector")] pub fn update_detector(&mut self, options: OutlierDetectorOptions) -> Result<(), JsError> { - match (&mut self.detector, options) { - ( - LoadedDetector::Dbscan { - ref mut detector, .. - }, - OutlierDetectorOptions::Dbscan(options), - ) => { + match &mut self.detector { + LoadedDetector::Dbscan { + ref mut detector, .. + } => { // This isn't ideal because it doesn't maintain any other state of the detector, // but it's the best we can do without adding an `update` method to the `OutlierDetector` // trait, which would in turn require some sort of config associated type. @@ -214,12 +203,9 @@ impl LoadedOutlierDetector { augurs_outlier::DbscanDetector::with_sensitivity(options.sensitivity)?, ); } - ( - LoadedDetector::Mad { - ref mut detector, .. - }, - OutlierDetectorOptions::Mad(options), - ) => { + LoadedDetector::Mad { + ref mut detector, .. + } => { // This isn't ideal because it doesn't maintain any other state of the detector, // but it's the best we can do without adding an `update` method to the `OutlierDetector` // trait, which would in turn require some sort of config associated type. @@ -228,7 +214,6 @@ impl LoadedOutlierDetector { augurs_outlier::MADDetector::with_sensitivity(options.sensitivity)?, ); } - _ => return Err(JsError::new("Mismatch between detector and options")), } Ok(()) } diff --git a/crates/augurs-js/src/prophet.rs b/crates/augurs-js/src/prophet.rs index baae8bac..484b359e 100644 --- a/crates/augurs-js/src/prophet.rs +++ b/crates/augurs-js/src/prophet.rs @@ -6,6 +6,8 @@ use serde::{Deserialize, Serialize}; use tsify_next::Tsify; use wasm_bindgen::prelude::*; +use crate::{Forecast, ForecastIntervals}; + // We just take an untyped Function here, but we'll use the `Tsify` macro to // customize the type in `ProphetOptions`. Here we add the JSDoc and type // annotations for the function to make it harder to misuse. @@ -30,13 +32,13 @@ const OPTIMIZER_FUNCTION: &'static str = r#" * @returns An object containing the the optimized parameters and any log * messages. */ -type OptimizerFunction = (init: InitialParams, data: Data, opts: OptimizeOptions) => OptimizeOutput; +type ProphetOptimizerFunction = (init: ProphetInitialParams, data: ProphetStanData, opts: ProphetOptimizeOptions) => ProphetOptimizeOutput; /** * An optimizer for the Prophet model. */ -interface Optimizer { - optimize: OptimizerFunction; +interface ProphetOptimizer { + optimize: ProphetOptimizerFunction; } "#; @@ -117,17 +119,20 @@ impl augurs_prophet::Optimizer for JsOptimizer { #[wasm_bindgen] pub struct Prophet { inner: augurs_prophet::Prophet, + level: Option, } #[wasm_bindgen] impl Prophet { /// Create a new Prophet model. #[wasm_bindgen(constructor)] - pub fn new(opts: ProphetOptions) -> Result { + pub fn new(opts: Options) -> Result { let (optimizer, opts): (JsOptimizer, augurs_prophet::OptProphetOptions) = opts.try_into()?; + let level = opts.interval_width.map(Into::into); Ok(Self { inner: augurs_prophet::Prophet::new(opts.into(), optimizer), + level, }) } @@ -147,7 +152,8 @@ impl Prophet { pub fn predict(&self, data: Option) -> Result { let data: Option = data.map(TryInto::try_into).transpose()?; - Ok(self.inner.predict(data)?.into()) + let predictions = self.inner.predict(data)?; + Ok(Predictions::from((self.level, predictions))) } } @@ -157,7 +163,7 @@ impl Prophet { /// Arguments for optimization. #[derive(Debug, Clone, Serialize, Tsify)] #[serde(rename_all = "camelCase")] -#[tsify(into_wasm_abi)] +#[tsify(into_wasm_abi, type_prefix = "Prophet")] struct OptimizeOptions { /// Algorithm to use. pub algorithm: Option, @@ -213,7 +219,7 @@ impl From<&augurs_prophet::optimizer::OptimizeOpts> for OptimizeOptions { /// The initial parameters for the optimization. #[derive(Clone, Serialize, Tsify)] #[serde(rename_all = "camelCase")] -#[tsify(into_wasm_abi)] +#[tsify(into_wasm_abi, type_prefix = "Prophet")] struct InitialParams<'a> { _phantom: std::marker::PhantomData<&'a ()>, /// Base trend growth rate. @@ -254,7 +260,7 @@ impl<'a> From<&'a augurs_prophet::optimizer::InitialParams> for InitialParams<'a /// The algorithm to use for optimization. One of: 'BFGS', 'LBFGS', 'Newton'. #[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Tsify)] #[serde(rename_all = "lowercase")] -#[tsify(into_wasm_abi)] +#[tsify(into_wasm_abi, type_prefix = "Prophet")] pub enum Algorithm { /// Use the Newton algorithm. Newton, @@ -277,7 +283,7 @@ impl From for Algorithm { /// The type of trend to use. #[derive(Clone, Copy, Eq, Ord, PartialEq, PartialOrd, Serialize, Tsify)] #[serde(rename_all = "camelCase")] -#[tsify(into_wasm_abi)] +#[tsify(into_wasm_abi, type_prefix = "Prophet")] enum TrendIndicator { /// Linear trend (default). Linear, @@ -300,7 +306,7 @@ impl From for TrendIndicator { /// Data for the Prophet model. #[derive(Clone, Serialize, Tsify)] #[serde(rename_all = "camelCase")] -#[tsify(into_wasm_abi)] +#[tsify(into_wasm_abi, type_prefix = "ProphetStan")] struct Data<'a> { _phantom: std::marker::PhantomData<&'a ()>, /// Number of time periods. @@ -308,6 +314,7 @@ struct Data<'a> { /// but WIT identifiers must be lower kebab-case. pub n: i32, #[serde(with = "serde_wasm_bindgen::preserve")] + #[tsify(type = "Float64Array")] /// Time series, length n. pub y: Float64Array, /// Time, length n. @@ -327,6 +334,7 @@ struct Data<'a> { #[tsify(type = "Float64Array")] pub t_change: Float64Array, /// The type of trend to use. + #[tsify(type = "ProphetTrendIndicator")] pub trend_indicator: TrendIndicator, /// Number of regressors. /// Must be greater than or equal to 1. @@ -402,7 +410,7 @@ impl<'a> From<&'a augurs_prophet::optimizer::Data> for Data<'a> { /// Log messages from the optimizer. #[derive(Debug, Clone, Deserialize, Tsify)] #[serde(rename_all = "camelCase")] -#[tsify(from_wasm_abi)] +#[tsify(from_wasm_abi, type_prefix = "Prophet")] struct Logs { /// Debug logs. pub debug: String, @@ -456,7 +464,7 @@ impl Logs { /// The output of the optimizer. #[derive(Debug, Clone, Deserialize, Tsify)] #[serde(rename_all = "camelCase")] -#[tsify(from_wasm_abi)] +#[tsify(from_wasm_abi, type_prefix = "Prophet")] struct OptimizeOutput { /// Logs emitted by the optimizer, split by log level. pub logs: Logs, @@ -467,7 +475,7 @@ struct OptimizeOutput { /// The optimal parameters found by the optimizer. #[derive(Debug, Clone, Deserialize, Tsify)] #[serde(rename_all = "camelCase")] -#[tsify(from_wasm_abi)] +#[tsify(from_wasm_abi, type_prefix = "Prophet")] struct OptimizedParams { /// Base trend growth rate. pub k: f64, @@ -509,17 +517,19 @@ type TimestampSeconds = i64; /// floor and cap columns. #[derive(Clone, Debug, Default, Deserialize, Tsify)] #[serde(rename_all = "camelCase")] -#[tsify(from_wasm_abi)] +#[tsify(from_wasm_abi, type_prefix = "Prophet")] pub struct TrainingData { + #[tsify(type = "TimestampSeconds[] | BigInt64Array")] pub ds: Vec, + #[tsify(type = "number[] | Float64Array")] pub y: Vec, - #[tsify(optional)] + #[tsify(optional, type = "number[] | Float64Array")] pub cap: Option>, - #[tsify(optional)] + #[tsify(optional, type = "number[] | Float64Array")] pub floor: Option>, #[tsify(optional)] pub seasonality_conditions: Option>>, - #[tsify(optional)] + #[tsify(optional, type = "Map")] pub x: Option>>, } @@ -554,8 +564,9 @@ impl TryFrom for augurs_prophet::TrainingData { /// regressors, you must include them in the prediction data. #[derive(Clone, Debug, Default, Deserialize, Tsify)] #[serde(rename_all = "camelCase")] -#[tsify(from_wasm_abi)] +#[tsify(from_wasm_abi, type_prefix = "Prophet")] pub struct PredictionData { + #[tsify(type = "TimestampSeconds[]")] pub ds: Vec, #[tsify(optional)] pub cap: Option>, @@ -588,36 +599,18 @@ impl TryFrom for augurs_prophet::PredictionData { } } -/// The prediction for a feature. -/// -/// 'Feature' could refer to the forecasts themselves (`yhat`) -/// or any of the other component features which contribute to -/// the final estimate, such as trend, seasonality, seasonalities, -/// regressors or holidays. -#[derive(Clone, Debug, Default, Serialize, Tsify)] -#[serde(rename_all = "camelCase")] -#[tsify(into_wasm_abi)] -pub struct FeaturePrediction { - /// The point estimate for this feature. - pub point: Vec, - /// The lower estimate for this feature. - /// - /// Only present if `uncertainty_samples` was greater than zero - /// when the model was created. - pub lower: Option>, - /// The upper estimate for this feature. - /// - /// Only present if `uncertainty_samples` was greater than zero - /// when the model was created. - pub upper: Option>, -} - -impl From for FeaturePrediction { - fn from(value: augurs_prophet::FeaturePrediction) -> Self { +impl From<(Option, augurs_prophet::FeaturePrediction)> for Forecast { + fn from((level, value): (Option, augurs_prophet::FeaturePrediction)) -> Self { Self { point: value.point, - lower: value.lower, - upper: value.upper, + intervals: level + .zip(value.lower) + .zip(value.upper) + .map(|((level, lower), upper)| ForecastIntervals { + level, + lower, + upper, + }), } } } @@ -631,18 +624,21 @@ impl From for FeaturePrediction { /// Certain fields (such as `cap` and `floor`) may be `None` if the /// model did not use them (e.g. the model was not configured to use /// logistic trend). -#[derive(Clone, Debug, Default, Serialize, Tsify)] +#[derive(Clone, Debug, Serialize, Tsify)] #[serde(rename_all = "camelCase")] -#[tsify(into_wasm_abi)] +#[tsify(into_wasm_abi, type_prefix = "Prophet")] pub struct Predictions { + #[tsify(type = "TimestampSeconds[]")] /// The timestamps of the forecasts. pub ds: Vec, /// Forecasts of the input time series `y`. - pub yhat: FeaturePrediction, + #[tsify(type = "Forecast")] + pub yhat: Forecast, /// The trend contribution at each time point. - pub trend: FeaturePrediction, + #[tsify(type = "Forecast")] + pub trend: Forecast, /// The cap for the logistic growth. /// @@ -658,48 +654,53 @@ pub struct Predictions { /// /// This includes seasonalities, holidays and regressors if their mode /// was configured to be [`FeatureMode::Additive`](crate::FeatureMode::Additive). - pub additive: FeaturePrediction, + #[tsify(type = "Forecast")] + pub additive: Forecast, /// The combined combination of all _multiplicative_ components. /// /// This includes seasonalities, holidays and regressors if their mode /// was configured to be [`FeatureMode::Multiplicative`](crate::FeatureMode::Multiplicative). - pub multiplicative: FeaturePrediction, + #[tsify(type = "Forecast")] + pub multiplicative: Forecast, /// Mapping from holiday name to that holiday's contribution. - pub holidays: HashMap, + #[tsify(type = "Map")] + pub holidays: HashMap, /// Mapping from seasonality name to that seasonality's contribution. - pub seasonalities: HashMap, + #[tsify(type = "Map")] + pub seasonalities: HashMap, /// Mapping from regressor name to that regressor's contribution. - pub regressors: HashMap, + #[tsify(type = "Map")] + pub regressors: HashMap, } -impl From for Predictions { - fn from(value: augurs_prophet::Predictions) -> Self { +impl From<(Option, augurs_prophet::Predictions)> for Predictions { + fn from((level, value): (Option, augurs_prophet::Predictions)) -> Self { Self { ds: value.ds, - yhat: value.yhat.into(), - trend: value.trend.into(), + yhat: (level, value.yhat).into(), + trend: (level, value.trend).into(), cap: value.cap, floor: value.floor, - additive: value.additive.into(), - multiplicative: value.multiplicative.into(), + additive: (level, value.additive).into(), + multiplicative: (level, value.multiplicative).into(), holidays: value .holidays .into_iter() - .map(|(k, v)| (k, v.into())) + .map(|(k, v)| (k, (level, v).into())) .collect(), seasonalities: value .seasonalities .into_iter() - .map(|(k, v)| (k, v.into())) + .map(|(k, v)| (k, (level, v).into())) .collect(), regressors: value .regressors .into_iter() - .map(|(k, v)| (k, v.into())) + .map(|(k, v)| (k, (level, v).into())) .collect(), } } @@ -716,14 +717,14 @@ impl From for Predictions { /// [documentation]: https://facebook.github.io/prophet/docs/quick_start.html #[derive(Clone, Debug, Default, Deserialize, Tsify)] #[serde(rename_all = "camelCase")] -#[tsify(from_wasm_abi)] -pub struct ProphetOptions { +#[tsify(from_wasm_abi, type_prefix = "Prophet")] +pub struct Options { /// Optimizer, used to find the maximum likelihood estimate of the /// Prophet Stan model parameters. /// /// See the documentation for `ProphetOptions` for more details. #[serde(with = "serde_wasm_bindgen::preserve")] - #[tsify(type = "Optimizer")] + #[tsify(type = "ProphetOptimizer")] pub optimizer: js_sys::Object, /// The type of growth (trend) to use. @@ -735,7 +736,7 @@ pub struct ProphetOptions { /// An optional list of changepoints. /// /// If not provided, changepoints will be automatically selected. - #[tsify(optional)] + #[tsify(optional, type = "TimestampSeconds[]")] pub changepoints: Option>, /// The number of potential changepoints to include. @@ -859,10 +860,10 @@ pub struct ProphetOptions { pub holidays_mode: Option, } -impl TryFrom for (JsOptimizer, augurs_prophet::OptProphetOptions) { +impl TryFrom for (JsOptimizer, augurs_prophet::OptProphetOptions) { type Error = JsError; - fn try_from(value: ProphetOptions) -> Result { + fn try_from(value: Options) -> Result { let Ok(val) = js_sys::Reflect::get(&value.optimizer, &js_sys::JsString::from("optimize")) else { return Err(JsError::new("optimizer does not have `optimize` property")); @@ -919,7 +920,7 @@ impl TryFrom for (JsOptimizer, augurs_prophet::OptProphetOptions /// The type of growth to use. #[derive(Clone, Copy, Debug, Eq, PartialEq, Default, Deserialize, Tsify)] #[serde(rename_all = "camelCase")] -#[tsify(from_wasm_abi)] +#[tsify(from_wasm_abi, type_prefix = "Prophet")] pub enum GrowthType { /// Linear growth (default). #[default] @@ -942,8 +943,8 @@ impl From for augurs_prophet::GrowthType { /// Define whether to include a specific seasonality, and how it should be specified. #[derive(Clone, Copy, Debug, Eq, PartialEq, Default, Deserialize, Tsify)] -#[serde(rename_all = "camelCase")] -#[tsify(from_wasm_abi)] +#[serde(rename_all = "camelCase", tag = "type")] +#[tsify(from_wasm_abi, type_prefix = "Prophet")] pub enum SeasonalityOption { /// Automatically determine whether to include this seasonality. /// @@ -960,9 +961,15 @@ pub enum SeasonalityOption { #[default] Auto, /// Manually specify whether to include this seasonality. - Manual(bool), + Manual { + /// Whether to include this seasonality. + enabled: bool, + }, /// Enable this seasonality and use the provided number of Fourier terms. - Fourier(u32), + Fourier { + /// The order of the Fourier terms to use. + order: u32, + }, } impl TryFrom for augurs_prophet::SeasonalityOption { @@ -971,8 +978,8 @@ impl TryFrom for augurs_prophet::SeasonalityOption { fn try_from(value: SeasonalityOption) -> Result { match value { SeasonalityOption::Auto => Ok(Self::Auto), - SeasonalityOption::Manual(b) => Ok(Self::Manual(b)), - SeasonalityOption::Fourier(n) => Ok(Self::Fourier(n.try_into()?)), + SeasonalityOption::Manual { enabled } => Ok(Self::Manual(enabled)), + SeasonalityOption::Fourier { order } => Ok(Self::Fourier(order.try_into()?)), } } } @@ -980,7 +987,7 @@ impl TryFrom for augurs_prophet::SeasonalityOption { /// How to scale the data prior to fitting the model. #[derive(Clone, Copy, Debug, Eq, PartialEq, Default, Deserialize, Tsify)] #[serde(rename_all = "camelCase")] -#[tsify(from_wasm_abi)] +#[tsify(from_wasm_abi, type_prefix = "Prophet")] pub enum Scaling { /// Use abs-max scaling (the default). #[default] @@ -1005,7 +1012,7 @@ impl From for augurs_prophet::Scaling { /// The enum will be marked as `non_exhaustive` until that point. #[derive(Clone, Copy, Debug, Eq, PartialEq, Default, Deserialize, Tsify)] #[serde(rename_all = "camelCase")] -#[tsify(from_wasm_abi)] +#[tsify(from_wasm_abi, type_prefix = "Prophet")] pub enum EstimationMode { /// Use MLE estimation. #[default] @@ -1032,7 +1039,7 @@ impl From for augurs_prophet::EstimationMode { /// The mode of a seasonality, regressor, or holiday. #[derive(Clone, Copy, Debug, Eq, PartialEq, Default, Deserialize, Tsify)] #[serde(rename_all = "camelCase")] -#[tsify(from_wasm_abi)] +#[tsify(from_wasm_abi, type_prefix = "Prophet")] pub enum FeatureMode { /// Additive mode. #[default] @@ -1053,9 +1060,10 @@ impl From for augurs_prophet::FeatureMode { /// A holiday to be considered by the Prophet model. #[derive(Clone, Debug, Deserialize, Tsify)] #[serde(rename_all = "camelCase")] -#[tsify(from_wasm_abi)] +#[tsify(from_wasm_abi, type_prefix = "Prophet")] pub struct Holiday { /// The dates of the holiday. + #[tsify(type = "TimestampSeconds[]")] pub ds: Vec, /// The lower window for the holiday. @@ -1064,6 +1072,7 @@ pub struct Holiday { /// that it is observed. For example, if the holiday is on /// 2023-01-01 and the lower window is -1, then the holiday will /// _also_ be observed on 2022-12-31. + #[tsify(optional)] pub lower_window: Option>, /// The upper window for the holiday. @@ -1072,9 +1081,11 @@ pub struct Holiday { /// that it is observed. For example, if the holiday is on /// 2023-01-01 and the upper window is 1, then the holiday will /// _also_ be observed on 2023-01-02. + #[tsify(optional)] pub upper_window: Option>, /// The prior scale for the holiday. + #[tsify(optional)] pub prior_scale: Option, } diff --git a/crates/augurs-js/src/seasons.rs b/crates/augurs-js/src/seasons.rs index e72b5e27..f2016332 100644 --- a/crates/augurs-js/src/seasons.rs +++ b/crates/augurs-js/src/seasons.rs @@ -6,6 +6,8 @@ use wasm_bindgen::prelude::*; use augurs_seasons::{Detector, PeriodogramDetector}; +use crate::VecF64; + /// Options for detecting seasonal periods. #[derive(Debug, Default, Deserialize, Tsify)] #[serde(rename_all = "camelCase")] @@ -47,6 +49,6 @@ impl From for PeriodogramDetector { /// Detect the seasonal periods in a time series. #[wasm_bindgen] -pub fn seasonalities(y: &[f64], options: Option) -> Vec { - PeriodogramDetector::from(options.unwrap_or_default()).detect(y) +pub fn seasonalities(y: VecF64, options: Option) -> Result, JsError> { + Ok(PeriodogramDetector::from(options.unwrap_or_default()).detect(&y.convert()?)) } diff --git a/crates/augurs-js/testpkg/.gitignore b/crates/augurs-js/testpkg/.gitignore new file mode 100644 index 00000000..07e6e472 --- /dev/null +++ b/crates/augurs-js/testpkg/.gitignore @@ -0,0 +1 @@ +/node_modules diff --git a/crates/augurs-js/testpkg/changepoints.test.ts b/crates/augurs-js/testpkg/changepoints.test.ts new file mode 100644 index 00000000..aaa33dc9 --- /dev/null +++ b/crates/augurs-js/testpkg/changepoints.test.ts @@ -0,0 +1,58 @@ +import { readFileSync } from "node:fs"; + +import { ChangepointDetector, initSync } from '../pkg'; + +import { describe, expect, it } from 'vitest'; + +initSync({ module: readFileSync('node_modules/@bsull/augurs/augurs_bg.wasm') }); + +describe('changepoints', () => { + const y = [0.5, 1.0, 0.4, 0.8, 1.5, 0.9, 0.6, 25.3, 20.4, 27.3, 30.0]; + + describe('normalGamma', () => { + it('can be used with a constructor', () => { + const detector = new ChangepointDetector('normal-gamma'); + expect(detector).toBeInstanceOf(ChangepointDetector); + const cps = detector.detectChangepoints(y); + expect(cps.indices).toEqual([0, 6]); + }); + + it('can be used with a static method', () => { + const detector = ChangepointDetector.normalGamma(); + expect(detector).toBeInstanceOf(ChangepointDetector); + const cps = detector.detectChangepoints(y); + expect(cps.indices).toEqual([0, 6]); + }); + + it('can be used with typed arrays', () => { + const detector = ChangepointDetector.normalGamma(); + expect(detector).toBeInstanceOf(ChangepointDetector); + const cps = detector.detectChangepoints(new Float64Array(y)); + expect(cps.indices).toEqual([0, 6]); + }); + }); + + describe('defaultArgpcp', () => { + it('can be used with a constructor', () => { + const detector = new ChangepointDetector('default-argpcp'); + expect(detector).toBeInstanceOf(ChangepointDetector); + const cps = detector.detectChangepoints(y); + expect(cps.indices).toEqual([0, 6]); + }); + + it('can be used with a static method', () => { + const detector = ChangepointDetector.defaultArgpcp(); + expect(detector).toBeInstanceOf(ChangepointDetector); + const cps = detector.detectChangepoints(y); + expect(cps.indices).toEqual([0, 6]); + }); + + it('can be used with typed arrays', () => { + const detector = ChangepointDetector.defaultArgpcp(); + expect(detector).toBeInstanceOf(ChangepointDetector); + const cps = detector.detectChangepoints(new Float64Array(y)); + expect(cps.indices).toEqual([0, 6]); + }); + }); +}); + diff --git a/crates/augurs-js/testpkg/clustering.test.ts b/crates/augurs-js/testpkg/clustering.test.ts new file mode 100644 index 00000000..e5a2213e --- /dev/null +++ b/crates/augurs-js/testpkg/clustering.test.ts @@ -0,0 +1,49 @@ +import { readFileSync } from "node:fs"; + +import { DbscanClusterer, DistanceMatrix, Dtw, initSync } from '../pkg'; + +import { describe, expect, it } from 'vitest'; + +initSync({ module: readFileSync('node_modules/@bsull/augurs/augurs_bg.wasm') }); + +describe('clustering', () => { + it('can be instantiated', () => { + const clusterer = new DbscanClusterer({ epsilon: 0.5, minClusterSize: 2 }); + expect(clusterer).toBeInstanceOf(DbscanClusterer); + }); + + it('can be fit with a raw distance matrix of number arrays', () => { + const clusterer = new DbscanClusterer({ epsilon: 1.0, minClusterSize: 2 }); + const labels = clusterer.fit(new DistanceMatrix([ + [0, 1, 2, 3], + [1, 0, 3, 3], + [2, 3, 0, 4], + [3, 3, 4, 0], + ])); + expect(labels).toEqual(new Int32Array([0, 0, -1, -1])); + }); + + it('can be fit with a raw distance matrix of typed arrays', () => { + const clusterer = new DbscanClusterer({ epsilon: 1.0, minClusterSize: 2 }); + const labels = clusterer.fit(new DistanceMatrix([ + new Float64Array([0, 1, 2, 3]), + new Float64Array([1, 0, 3, 3]), + new Float64Array([2, 3, 0, 4]), + new Float64Array([3, 3, 4, 0]), + ])); + expect(labels).toEqual(new Int32Array([0, 0, -1, -1])); + }); + + it('can be fit with a distance matrix from augurs', () => { + const dtw = Dtw.euclidean(); + const distanceMatrix = dtw.distanceMatrix([ + [1, 3, 4], + [1, 3, 3.9], + [1.1, 2.9, 4.1], + [5, 6.2, 10], + ]); + const clusterer = new DbscanClusterer({ epsilon: 0.5, minClusterSize: 2 }); + const labels = clusterer.fit(distanceMatrix); + expect(labels).toEqual(new Int32Array([0, 0, 0, -1])); + }) +}); diff --git a/crates/augurs-js/testpkg/dtw.test.ts b/crates/augurs-js/testpkg/dtw.test.ts new file mode 100644 index 00000000..a5b90eef --- /dev/null +++ b/crates/augurs-js/testpkg/dtw.test.ts @@ -0,0 +1,145 @@ +import { readFileSync } from "node:fs"; + +import { Dtw, initSync } from '../pkg'; + +import { describe, expect, it } from 'vitest'; + +initSync({ module: readFileSync('node_modules/@bsull/augurs/augurs_bg.wasm') }); + +describe('dtw', () => { + describe('distance', () => { + describe('euclidean', () => { + it('can be instantiated with a constructor', () => { + const dtw = new Dtw('euclidean'); + expect(dtw).toBeInstanceOf(Dtw); + }); + + it('can be instantiated with a static method', () => { + const dtw = Dtw.euclidean(); + expect(dtw).toBeInstanceOf(Dtw); + }); + + it('can be instantiated with a custom window', () => { + const dtw = Dtw.euclidean({ window: 10 }); + expect(dtw).toBeInstanceOf(Dtw); + }); + + it('can be instantiated with a custom max distance', () => { + const dtw = Dtw.euclidean({ maxDistance: 10.0 }); + expect(dtw).toBeInstanceOf(Dtw); + }); + + it('can be instantiated with a custom lower bound', () => { + const dtw = Dtw.euclidean({ lowerBound: 10.0 }); + expect(dtw).toBeInstanceOf(Dtw); + }); + + it('can be instantiated with a custom upper bound', () => { + const dtw = Dtw.euclidean({ upperBound: 10.0 }); + expect(dtw).toBeInstanceOf(Dtw); + }); + + it('can be instantiated with a custom parallelize', () => { + const dtw = Dtw.euclidean({ parallelize: true }); + expect(dtw).toBeInstanceOf(Dtw); + }); + + it('can be fit with number arrays', () => { + const dtw = Dtw.euclidean(); + expect(dtw.distance([0.0, 1.0, 2.0], [3.0, 4.0, 5.0])).toBeCloseTo(5.0990195135927845); + }); + + it('can be fit with typed arrays', () => { + const dtw = Dtw.euclidean(); + expect(dtw.distance(new Float64Array([0.0, 1.0, 2.0]), new Float64Array([3.0, 4.0, 5.0]))).toBeCloseTo(5.0990195135927845); + }); + + it('can be fit with different length arrays', () => { + const dtw = Dtw.euclidean(); + expect(dtw.distance([0.0, 1.0, 2.0], [3.0, 4.0, 5.0, 6.0])).toBeCloseTo(6.48074069840786); + }); + + it('can be fit with empty arrays', () => { + const dtw = Dtw.euclidean(); + expect(dtw.distance([], [3.0, 4.0, 5.0])).toBe(Infinity); + expect(dtw.distance([3.0, 4.0, 5.0], [])).toBe(Infinity); + }); + + it('gives a useful error when passed the wrong kind of data', () => { + const dtw = Dtw.euclidean(); + // @ts-expect-error + expect(() => dtw.distance(['hi', 2, 3], [4, 5, 6])).toThrowError('TypeError: expected array of numbers or Float64Array'); + }) + }); + + describe('manhattan', () => { + it('can be instantiated with a constructor', () => { + const dtw = new Dtw('manhattan'); + expect(dtw).toBeInstanceOf(Dtw); + }); + + it('can be instantiated with a static method', () => { + const dtw = Dtw.manhattan(); + expect(dtw).toBeInstanceOf(Dtw); + }); + + it('can be fit with number arrays', () => { + const dtw = Dtw.manhattan(); + expect(dtw.distance( + [0., 0., 1., 2., 1., 0., 1., 0., 0.], + [0., 1., 2., 0., 0., 0., 0., 0., 0.], + )).toBeCloseTo(2.0); + }); + + it('can be fit with typed arrays', () => { + const dtw = Dtw.manhattan(); + expect(dtw.distance( + new Float64Array([0., 0., 1., 2., 1., 0., 1., 0., 0.]), + new Float64Array([0., 1., 2., 0., 0., 0., 0., 0., 0.]), + )).toBeCloseTo(2.0); + }); + + it('gives a useful error when passed the wrong kind of data', () => { + const dtw = Dtw.manhattan(); + // @ts-expect-error + expect(() => dtw.distance(['hi', 2, 3], [4, 5, 6])).toThrowError('TypeError: expected array of numbers or Float64Array'); + }); + }); + }); + + describe('distanceMatrix', () => { + it('can be fit with number arrays', () => { + const dtw = Dtw.euclidean(); + const series = [[0.0, 1.0, 2.0], [3.0, 4.0, 5.0], [6.0, 7.0, 8.0]]; + const dists = dtw.distanceMatrix(series); + expect(dists.shape()).toEqual(new Uint32Array([3, 3])); + const distsArray = dists.toArray(); + expect(distsArray).toHaveLength(3); + expect(distsArray[0]).toHaveLength(3); + expect(distsArray[0][0]).toBeCloseTo(0.0); + expect(distsArray[0][1]).toBeCloseTo(5.0990195135927845); + expect(distsArray[0][2]).toBeCloseTo(10.392304845413264); + }); + + it('can be fit with typed arrays', () => { + const dtw = Dtw.euclidean(); + const series = [new Float64Array([0.0, 1.0, 2.0]), new Float64Array([3.0, 4.0, 5.0]), new Float64Array([6.0, 7.0, 8.0])]; + const dists = dtw.distanceMatrix(series); + expect(dists.shape()).toEqual(new Uint32Array([3, 3])); + const distsArray = dists.toArray(); + expect(distsArray).toBeInstanceOf(Array); + expect(distsArray).toHaveLength(3); + expect(distsArray[0]).toBeInstanceOf(Float64Array); + expect(distsArray[0]).toHaveLength(3); + expect(distsArray[0][0]).toBeCloseTo(0.0); + expect(distsArray[0][1]).toBeCloseTo(5.0990195135927845); + expect(distsArray[0][2]).toBeCloseTo(10.392304845413264); + }); + + it('gives a useful error when passed the wrong kind of data', () => { + const dtw = Dtw.euclidean(); + // @ts-expect-error + expect(() => dtw.distanceMatrix([1, 2, 3])).toThrowError('TypeError: expected array of number arrays or array of Float64Array'); + }); + }); +}); diff --git a/crates/augurs-js/testpkg/ets.test.ts b/crates/augurs-js/testpkg/ets.test.ts new file mode 100644 index 00000000..6d8f7f87 --- /dev/null +++ b/crates/augurs-js/testpkg/ets.test.ts @@ -0,0 +1,47 @@ +import { readFileSync } from "node:fs"; + +import { AutoETS, initSync } from '../pkg'; + +import { describe, expect, it } from 'vitest'; + +initSync({ module: readFileSync('node_modules/@bsull/augurs/augurs_bg.wasm') }); + +const y = Array.from({ length: 20 }, (_, i) => i % 5); + +describe('ets', () => { + it('can be instantiated with a regular array', () => { + const model = new AutoETS(10, 'ZZZ'); + expect(model).toBeInstanceOf(AutoETS); + }); + + it('can be fit', () => { + const model = new AutoETS(5, 'ZZZ'); + model.fit(y); + }); + + it('can be fit with a typed array', () => { + const model = new AutoETS(5, 'ZZZ'); + model.fit(new Float64Array(y)); + }) + + it('returns regular arrays', () => { + const model = new AutoETS(5, 'ZZZ'); + model.fit(new Float64Array(y)); + expect(model.predict(10).point).toBeInstanceOf(Array); + }); + + it('returns regular arrays for intervals', () => { + const model = new AutoETS(5, 'ZZZ'); + model.fit(new Float64Array(y)); + const level = 0.95; + const prediction = model.predict(10, level); + expect(prediction.point).toBeInstanceOf(Array); + expect(prediction.point).toHaveLength(10); + expect(prediction.intervals?.level).toEqual(level); + expect(prediction.intervals?.lower).toBeInstanceOf(Array); + expect(prediction.intervals?.upper).toBeInstanceOf(Array); + expect(prediction.intervals?.lower).toHaveLength(10); + expect(prediction.intervals?.upper).toHaveLength(10); + }) +}) + diff --git a/crates/augurs-js/testpkg/logging.test.ts b/crates/augurs-js/testpkg/logging.test.ts new file mode 100644 index 00000000..ee61e007 --- /dev/null +++ b/crates/augurs-js/testpkg/logging.test.ts @@ -0,0 +1,27 @@ +import { readFileSync } from "node:fs"; + +import { initLogging, initSync } from '../pkg'; + +import { describe, expect, it } from 'vitest'; + +initSync({ module: readFileSync('node_modules/@bsull/augurs/augurs_bg.wasm') }); + +describe('logging', () => { + it('can be initialized with no config', () => { + expect(() => initLogging()).not.toThrow(); + }); + + it('can be initialized with a config', () => { + // Note: this will throw because there's a global default logger which can't + // be unset for performance reasons. + // We mainly just want to make sure that the config is accepted. + expect(() => initLogging({ maxLevel: 'info', target: 'console', color: true })) + .toThrow("logging already initialized"); + }); + + it('can be initialized multiple times without panicking', () => { + // These will throw exceptions but at least they're not panics. + expect(() => initLogging()).toThrow("logging already initialized"); + expect(() => initLogging()).toThrow("logging already initialized"); + }); +}) diff --git a/crates/augurs-js/testpkg/mstl.test.ts b/crates/augurs-js/testpkg/mstl.test.ts new file mode 100644 index 00000000..71e00cd3 --- /dev/null +++ b/crates/augurs-js/testpkg/mstl.test.ts @@ -0,0 +1,64 @@ +import { readFileSync } from "node:fs"; + +import { ets, MSTL, initSync } from '../pkg'; + +import { describe, expect, it } from 'vitest'; + +initSync({ module: readFileSync('node_modules/@bsull/augurs/augurs_bg.wasm') }); + +const y = Array.from({ length: 20 }, (_, i) => i % 5); + +describe('ets', () => { + + it('can be instantiated with a constructor', () => { + const model = new MSTL('ets', [10]); + expect(model).toBeInstanceOf(MSTL); + }); + + it('can be instantiated with a static method', () => { + const model = MSTL.ets([10]); + expect(model).toBeInstanceOf(MSTL); + }); + + it('can be instantiated with a standalone function', () => { + // @ts-ignore + const model = ets([10]); + expect(model).toBeInstanceOf(MSTL); + }); + + it('can be instantiated with a regular array', () => { + const model = MSTL.ets([10]); + expect(model).toBeInstanceOf(MSTL); + }); + + it('can be instantiated with a typed array', () => { + const model = MSTL.ets(new Uint32Array([7])); + expect(model).toBeInstanceOf(MSTL); + }); + + it('can be fit', () => { + const model = MSTL.ets([5]); + model.fit(y); + }); + + it('can be fit with a typed array', () => { + const model = MSTL.ets(new Uint32Array([5])); + model.fit(new Float64Array(y)); + }) + + it('returns regular arrays', () => { + const model = MSTL.ets(new Uint32Array([5])); + model.fit(new Float64Array(y)); + expect(model.predictInSample().point).toBeInstanceOf(Array); + }); + + it('returns regular arrays for intervals', () => { + const model = MSTL.ets(new Uint32Array([5])); + model.fit(new Float64Array(y)); + const level = 0.95; + expect(model.predictInSample(level).point).toBeInstanceOf(Array); + expect(model.predictInSample(level).intervals!.lower).toBeInstanceOf(Array); + expect(model.predictInSample(level).intervals!.upper).toBeInstanceOf(Array); + expect(model.predictInSample(level).intervals!.level).toEqual(level); + }) +}) diff --git a/crates/augurs-js/testpkg/outlier.test.ts b/crates/augurs-js/testpkg/outlier.test.ts new file mode 100644 index 00000000..43c40b2e --- /dev/null +++ b/crates/augurs-js/testpkg/outlier.test.ts @@ -0,0 +1,155 @@ +import { readFileSync } from "node:fs"; + +import { OutlierDetector, initSync } from '../pkg'; + +import { describe, expect, it } from 'vitest'; + +initSync({ module: readFileSync('node_modules/@bsull/augurs/augurs_bg.wasm') }); + +describe('dbscan', () => { + + it('can be instantiated with a constructor', () => { + const detector = new OutlierDetector('dbscan', { sensitivity: 0.5 }); + expect(detector).toBeInstanceOf(OutlierDetector); + }); + + it('can be instantiated with a static method', () => { + const detector = OutlierDetector.dbscan({ sensitivity: 0.5 }); + expect(detector).toBeInstanceOf(OutlierDetector); + }); + + it('gives a useful error when passed the wrong kind of data', () => { + const detector = OutlierDetector.dbscan({ sensitivity: 0.5 }); + // @ts-expect-error + expect(() => detector.detect([1, 3, 4])).toThrowError('TypeError: expected array of number arrays'); + }) + + it('detects outliers using the concise API', () => { + const detector = OutlierDetector.dbscan({ sensitivity: 0.5 }); + const outliers = detector.detect([ + [1, 3, 4], + [1, 3, 3.9], + [1.1, 2.9, 4.1], + [1, 2.9, 10], + ]); + expect(outliers.outlyingSeries).toEqual([3]); + // expect(outliers.clusterBand).toEqual({ min: [1, 3, 4.1], max: [1.1, 3, 4.1] }); + expect(outliers.seriesResults).toHaveLength(4); + expect(outliers.seriesResults[0].isOutlier).toBe(false); + expect(outliers.seriesResults[0].scores).toEqual([0.0, 0.0, 0.0]); + expect(outliers.seriesResults[1].isOutlier).toBe(false); + expect(outliers.seriesResults[1].scores).toEqual([0.0, 0.0, 0.0]); + expect(outliers.seriesResults[2].isOutlier).toBe(false); + expect(outliers.seriesResults[2].scores).toEqual([0.0, 0.0, 0.0]); + expect(outliers.seriesResults[3].isOutlier).toBe(true); + expect(outliers.seriesResults[3].scores).toEqual([0.0, 0.0, 1.0]); + expect(outliers.seriesResults[3].outlierIntervals).toEqual([{ start: 2, end: undefined }]); + }); + + it('concise API works with typed arrays', () => { + const detector = OutlierDetector.dbscan({ sensitivity: 0.5 }); + const outliers = detector.detect([ + new Float64Array([1, 3, 4]), + new Float64Array([1, 3, 3.9]), + new Float64Array([1.1, 2.9, 4.1]), + new Float64Array([1, 2.9, 10]), + ]); + expect(outliers.outlyingSeries).toEqual([3]); + // expect(outliers.clusterBand).toEqual({ min: [1, 3, 4.1], max: [1.1, 3, 4.1] }); + expect(outliers.seriesResults).toHaveLength(4); + expect(outliers.seriesResults[0].isOutlier).toBe(false); + expect(outliers.seriesResults[0].scores).toEqual([0.0, 0.0, 0.0]); + expect(outliers.seriesResults[1].isOutlier).toBe(false); + expect(outliers.seriesResults[1].scores).toEqual([0.0, 0.0, 0.0]); + expect(outliers.seriesResults[2].isOutlier).toBe(false); + expect(outliers.seriesResults[2].scores).toEqual([0.0, 0.0, 0.0]); + expect(outliers.seriesResults[3].isOutlier).toBe(true); + expect(outliers.seriesResults[3].scores).toEqual([0.0, 0.0, 1.0]); + expect(outliers.seriesResults[3].outlierIntervals).toEqual([{ start: 2, end: undefined }]); + }); + + it('can be preloaded and run multiple times', () => { + const detector = OutlierDetector.dbscan({ sensitivity: 0.5 }); + const loaded = detector.preprocess([ + [1, 3, 4], + [1, 3, 3.9], + [1.1, 2.9, 4.1], + [1, 2.9, 10], + ]); + let outliers = loaded.detect(); + expect(outliers.outlyingSeries).toEqual([3]); + // expect(outliers.clusterBand).toEqual({ min: [1, 3, 4.1], max: [1.1, 3, 4.1] }); + + loaded.updateDetector({ sensitivity: 0.01 }); + outliers = loaded.detect(); + expect(outliers.outlyingSeries).toEqual([]); + // expect(outliers.clusterBand).toEqual({ min: [1, 3, 4.1], max: [1.1, 3, 4.1] }); + }); +}); + +describe('mad', () => { + it('can be instantiated', () => { + const detector = OutlierDetector.mad({ sensitivity: 0.5 }); + expect(detector).toBeInstanceOf(OutlierDetector); + }); + + it('gives a useful error when passed the wrong kind of data', () => { + const detector = OutlierDetector.mad({ sensitivity: 0.5 }); + // @ts-expect-error + expect(() => detector.detect([1, 3, 4])).toThrowError('TypeError: expected array of number arrays'); + }) + + it('detects outliers using the concise API', () => { + const detector = OutlierDetector.mad({ sensitivity: 0.5 }); + const outliers = detector.detect([ + [1, 3, 4], + [1, 3, 3.9], + [1.1, 2.9, 4.1], + [1, 2.9, 10], + ]); + expect(outliers.outlyingSeries).toEqual([3]); + // expect(outliers.clusterBand).toEqual({ min: [1, 3, 4.1], max: [1.1, 3, 4.1] }); + expect(outliers.seriesResults).toHaveLength(4); + expect(outliers.seriesResults[0].isOutlier).toBe(false); + expect(outliers.seriesResults[1].isOutlier).toBe(false); + expect(outliers.seriesResults[2].isOutlier).toBe(false); + expect(outliers.seriesResults[3].isOutlier).toBe(true); + expect(outliers.seriesResults[3].outlierIntervals).toEqual([{ start: 2, end: undefined }]); + }); + + it('concise API works with typed arrays', () => { + const detector = OutlierDetector.mad({ sensitivity: 0.5 }); + const outliers = detector.detect([ + new Float64Array([1, 3, 4]), + new Float64Array([1, 3, 3.9]), + new Float64Array([1.1, 2.9, 4.1]), + new Float64Array([1, 2.9, 10]), + ]); + expect(outliers.outlyingSeries).toEqual([3]); + // expect(outliers.clusterBand).toEqual({ min: [1, 3, 4.1], max: [1.1, 3, 4.1] }); + expect(outliers.seriesResults).toHaveLength(4); + expect(outliers.seriesResults[0].isOutlier).toBe(false); + expect(outliers.seriesResults[1].isOutlier).toBe(false); + expect(outliers.seriesResults[2].isOutlier).toBe(false); + expect(outliers.seriesResults[3].isOutlier).toBe(true); + expect(outliers.seriesResults[3].outlierIntervals).toEqual([{ start: 2, end: undefined }]); + }); + + it('can be preloaded and run multiple times', () => { + const detector = OutlierDetector.mad({ sensitivity: 0.5 }); + const loaded = detector.preprocess([ + [1, 3, 4], + [1, 3, 3.9], + [1.1, 2.9, 4.1], + [1, 2.9, 10], + ]); + let outliers = loaded.detect(); + expect(outliers.outlyingSeries).toEqual([3]); + // expect(outliers.clusterBand).toEqual({ min: [1, 3, 4.1], max: [1.1, 3, 4.1] }); + + loaded.updateDetector({ sensitivity: 0.01 }); + outliers = loaded.detect(); + expect(outliers.outlyingSeries).toEqual([]); + // expect(outliers.clusterBand).toEqual({ min: [1, 3, 4.1], max: [1.1, 3, 4.1] }); + }); +}); diff --git a/crates/augurs-js/testpkg/package-lock.json b/crates/augurs-js/testpkg/package-lock.json new file mode 100644 index 00000000..7ab5f54a --- /dev/null +++ b/crates/augurs-js/testpkg/package-lock.json @@ -0,0 +1,1244 @@ +{ + "name": "@bsull/augurs-js-testpkg", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@bsull/augurs-js-testpkg", + "devDependencies": { + "@bsull/augurs": "../pkg", + "@bsull/augurs-prophet-wasmstan": "0.1.1", + "@types/node": "^22.7.5", + "typescript": "^5.6.3", + "vitest": "^2.1.3" + } + }, + "../pkg": { + "name": "@bsull/augurs-js", + "version": "0.4.2", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@bsull/augurs": { + "resolved": "../pkg", + "link": true + }, + "node_modules/@bsull/augurs-prophet-wasmstan": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@bsull/augurs-prophet-wasmstan/-/augurs-prophet-wasmstan-0.1.1.tgz", + "integrity": "sha512-EnrfnEI3vhI36Bk/yiDsdiKd9FH85/kJWDew1NorRrGoUBJlhUd+Ymvru5YB3AJCprACAOo+xwYKbNVQ5Z1BYg==", + "dev": true, + "dependencies": { + "@bytecodealliance/preview2-shim": "^0.17.0" + } + }, + "node_modules/@bytecodealliance/preview2-shim": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@bytecodealliance/preview2-shim/-/preview2-shim-0.17.0.tgz", + "integrity": "sha512-JorcEwe4ud0x5BS/Ar2aQWOQoFzjq/7jcnxYXCvSMh0oRm0dQXzOA+hqLDBnOMks1LLBA7dmiLLsEBl09Yd6iQ==", + "dev": true + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.24.0.tgz", + "integrity": "sha512-Q6HJd7Y6xdB48x8ZNVDOqsbh2uByBhgK8PiQgPhwkIw/HC/YX5Ghq2mQY5sRMZWHb3VsFkWooUVOZHKr7DmDIA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.24.0.tgz", + "integrity": "sha512-ijLnS1qFId8xhKjT81uBHuuJp2lU4x2yxa4ctFPtG+MqEE6+C5f/+X/bStmxapgmwLwiL3ih122xv8kVARNAZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.24.0.tgz", + "integrity": "sha512-bIv+X9xeSs1XCk6DVvkO+S/z8/2AMt/2lMqdQbMrmVpgFvXlmde9mLcbQpztXm1tajC3raFDqegsH18HQPMYtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.24.0.tgz", + "integrity": "sha512-X6/nOwoFN7RT2svEQWUsW/5C/fYMBe4fnLK9DQk4SX4mgVBiTA9h64kjUYPvGQ0F/9xwJ5U5UfTbl6BEjaQdBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.24.0.tgz", + "integrity": "sha512-0KXvIJQMOImLCVCz9uvvdPgfyWo93aHHp8ui3FrtOP57svqrF/roSSR5pjqL2hcMp0ljeGlU4q9o/rQaAQ3AYA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.24.0.tgz", + "integrity": "sha512-it2BW6kKFVh8xk/BnHfakEeoLPv8STIISekpoF+nBgWM4d55CZKc7T4Dx1pEbTnYm/xEKMgy1MNtYuoA8RFIWw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.24.0.tgz", + "integrity": "sha512-i0xTLXjqap2eRfulFVlSnM5dEbTVque/3Pi4g2y7cxrs7+a9De42z4XxKLYJ7+OhE3IgxvfQM7vQc43bwTgPwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.24.0.tgz", + "integrity": "sha512-9E6MKUJhDuDh604Qco5yP/3qn3y7SLXYuiC0Rpr89aMScS2UAmK1wHP2b7KAa1nSjWJc/f/Lc0Wl1L47qjiyQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.24.0.tgz", + "integrity": "sha512-2XFFPJ2XMEiF5Zi2EBf4h73oR1V/lycirxZxHZNc93SqDN/IWhYYSYj8I9381ikUFXZrz2v7r2tOVk2NBwxrWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.24.0.tgz", + "integrity": "sha512-M3Dg4hlwuntUCdzU7KjYqbbd+BLq3JMAOhCKdBE3TcMGMZbKkDdJ5ivNdehOssMCIokNHFOsv7DO4rlEOfyKpg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.24.0.tgz", + "integrity": "sha512-mjBaoo4ocxJppTorZVKWFpy1bfFj9FeCMJqzlMQGjpNPY9JwQi7OuS1axzNIk0nMX6jSgy6ZURDZ2w0QW6D56g==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.24.0.tgz", + "integrity": "sha512-ZXFk7M72R0YYFN5q13niV0B7G8/5dcQ9JDp8keJSfr3GoZeXEoMHP/HlvqROA3OMbMdfr19IjCeNAnPUG93b6A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.24.0.tgz", + "integrity": "sha512-w1i+L7kAXZNdYl+vFvzSZy8Y1arS7vMgIy8wusXJzRrPyof5LAb02KGr1PD2EkRcl73kHulIID0M501lN+vobQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.24.0.tgz", + "integrity": "sha512-VXBrnPWgBpVDCVY6XF3LEW0pOU51KbaHhccHw6AS6vBWIC60eqsH19DAeeObl+g8nKAz04QFdl/Cefta0xQtUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.24.0.tgz", + "integrity": "sha512-xrNcGDU0OxVcPTH/8n/ShH4UevZxKIO6HJFK0e15XItZP2UcaiLFd5kiX7hJnqCbSztUF8Qot+JWBC/QXRPYWQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.24.0.tgz", + "integrity": "sha512-fbMkAF7fufku0N2dE5TBXcNlg0pt0cJue4xBRE2Qc5Vqikxr4VCgKj/ht6SMdFcOacVA9rqF70APJ8RN/4vMJw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true + }, + "node_modules/@types/node": { + "version": "22.7.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.6.tgz", + "integrity": "sha512-/d7Rnj0/ExXDMcioS78/kf1lMzYk4BZV8MZGTBKzTGZ6/406ukkbYlIsZmMPhcR5KlkunDHQLrtAVmSq7r+mSw==", + "dev": true, + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.3.tgz", + "integrity": "sha512-SNBoPubeCJhZ48agjXruCI57DvxcsivVDdWz+SSsmjTT4QN/DfHk3zB/xKsJqMs26bLZ/pNRLnCf0j679i0uWQ==", + "dev": true, + "dependencies": { + "@vitest/spy": "2.1.3", + "@vitest/utils": "2.1.3", + "chai": "^5.1.1", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.3.tgz", + "integrity": "sha512-eSpdY/eJDuOvuTA3ASzCjdithHa+GIF1L4PqtEELl6Qa3XafdMLBpBlZCIUCX2J+Q6sNmjmxtosAG62fK4BlqQ==", + "dev": true, + "dependencies": { + "@vitest/spy": "2.1.3", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.11" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/spy": "2.1.3", + "msw": "^2.3.5", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.3.tgz", + "integrity": "sha512-XH1XdtoLZCpqV59KRbPrIhFCOO0hErxrQCMcvnQete3Vibb9UeIOX02uFPfVn3Z9ZXsq78etlfyhnkmIZSzIwQ==", + "dev": true, + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.3.tgz", + "integrity": "sha512-JGzpWqmFJ4fq5ZKHtVO3Xuy1iF2rHGV4d/pdzgkYHm1+gOzNZtqjvyiaDGJytRyMU54qkxpNzCx+PErzJ1/JqQ==", + "dev": true, + "dependencies": { + "@vitest/utils": "2.1.3", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.3.tgz", + "integrity": "sha512-qWC2mWc7VAXmjAkEKxrScWHWFyCQx/cmiZtuGqMi+WwqQJ2iURsVY4ZfAK6dVo6K2smKRU6l3BPwqEBvhnpQGg==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "2.1.3", + "magic-string": "^0.30.11", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.3.tgz", + "integrity": "sha512-Nb2UzbcUswzeSP7JksMDaqsI43Sj5+Kry6ry6jQJT4b5gAK+NS9NED6mDb8FlMRCX8m5guaHCDZmqYMMWRy5nQ==", + "dev": true, + "dependencies": { + "tinyspy": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.3.tgz", + "integrity": "sha512-xpiVfDSg1RrYT0tX6czgerkpcKFmFOF/gCr30+Mve5V2kewCy4Prn1/NDMSRwaSmT7PRaOF83wu+bEtsY1wrvA==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "2.1.3", + "loupe": "^3.1.1", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.1.tgz", + "integrity": "sha512-pT1ZgP8rPNqUgieVaEY+ryQr6Q4HXNg8Ei9UnLUrjN4IA7dvQC5JB+/kxVcPNDHyBcc/26CXPkbNzq3qwrOEKA==", + "dev": true, + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/loupe": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz", + "integrity": "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==", + "dev": true + }, + "node_modules/magic-string": { + "version": "0.30.12", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.12.tgz", + "integrity": "sha512-Ea8I3sQMVXr8JhN4z+H/d8zwo+tYDgHE9+5G4Wnrwhs0gaK9fXTKx0Tw5Xwsd/bCPTTZNRAdpyzvoeORe9LYpw==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true + }, + "node_modules/pathval": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", + "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", + "dev": true, + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/postcss": { + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.24.0.tgz", + "integrity": "sha512-DOmrlGSXNk1DM0ljiQA+i+o0rSLhtii1je5wgk60j49d1jHT5YYttBv1iWOnYSTG+fZZESUOSNiAl89SIet+Cg==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.6" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.24.0", + "@rollup/rollup-android-arm64": "4.24.0", + "@rollup/rollup-darwin-arm64": "4.24.0", + "@rollup/rollup-darwin-x64": "4.24.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.24.0", + "@rollup/rollup-linux-arm-musleabihf": "4.24.0", + "@rollup/rollup-linux-arm64-gnu": "4.24.0", + "@rollup/rollup-linux-arm64-musl": "4.24.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.24.0", + "@rollup/rollup-linux-riscv64-gnu": "4.24.0", + "@rollup/rollup-linux-s390x-gnu": "4.24.0", + "@rollup/rollup-linux-x64-gnu": "4.24.0", + "@rollup/rollup-linux-x64-musl": "4.24.0", + "@rollup/rollup-win32-arm64-msvc": "4.24.0", + "@rollup/rollup-win32-ia32-msvc": "4.24.0", + "@rollup/rollup-win32-x64-msvc": "4.24.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "node_modules/std-env": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.7.0.tgz", + "integrity": "sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==", + "dev": true + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "node_modules/tinyexec": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.1.tgz", + "integrity": "sha512-WiCJLEECkO18gwqIp6+hJg0//p23HXp4S+gGtAKu3mI2F2/sXC4FvHvXvB0zJVVaTPhx1/tOwdbRsa1sOBIKqQ==", + "dev": true + }, + "node_modules/tinypool": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.1.tgz", + "integrity": "sha512-URZYihUbRPcGv95En+sz6MfghfIc2OJ1sv/RmhWZLouPY0/8Vo80viwPvg3dlaS9fuq7fQMEfgRRK7BBZThBEA==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true + }, + "node_modules/vite": { + "version": "5.4.9", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.9.tgz", + "integrity": "sha512-20OVpJHh0PAM0oSOELa5GaZNWeDjcAvQjGXy2Uyr+Tp+/D2/Hdz6NLgpJLsarPTA2QJ6v8mX2P1ZfbsSKvdMkg==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.3.tgz", + "integrity": "sha512-I1JadzO+xYX887S39Do+paRePCKoiDrWRRjp9kkG5he0t7RXNvPAJPCQSJqbGN4uCrFFeS3Kj3sLqY8NMYBEdA==", + "dev": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.6", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.3.tgz", + "integrity": "sha512-Zrxbg/WiIvUP2uEzelDNTXmEMJXuzJ1kCpbDvaKByFA9MNeO95V+7r/3ti0qzJzrxdyuUw5VduN7k+D3VmVOSA==", + "dev": true, + "dependencies": { + "@vitest/expect": "2.1.3", + "@vitest/mocker": "2.1.3", + "@vitest/pretty-format": "^2.1.3", + "@vitest/runner": "2.1.3", + "@vitest/snapshot": "2.1.3", + "@vitest/spy": "2.1.3", + "@vitest/utils": "2.1.3", + "chai": "^5.1.1", + "debug": "^4.3.6", + "magic-string": "^0.30.11", + "pathe": "^1.1.2", + "std-env": "^3.7.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.0", + "tinypool": "^1.0.0", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.3", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.3", + "@vitest/ui": "2.1.3", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/crates/augurs-js/testpkg/package.json b/crates/augurs-js/testpkg/package.json new file mode 100644 index 00000000..7b767b07 --- /dev/null +++ b/crates/augurs-js/testpkg/package.json @@ -0,0 +1,17 @@ +{ + "name": "@bsull/augurs-js-testpkg", + "type": "module", + "private": true, + "devDependencies": { + "@bsull/augurs": "../pkg", + "@bsull/augurs-prophet-wasmstan": "0.1.1", + "@types/node": "^22.7.5", + "typescript": "^5.6.3", + "vitest": "^2.1.3" + }, + "scripts": { + "test": "vitest", + "test:ci": "vitest run", + "typecheck": "tsc --noEmit" + } +} diff --git a/crates/augurs-js/testpkg/prophet.test.ts b/crates/augurs-js/testpkg/prophet.test.ts new file mode 100644 index 00000000..2eec5450 --- /dev/null +++ b/crates/augurs-js/testpkg/prophet.test.ts @@ -0,0 +1,57 @@ +import { webcrypto } from 'node:crypto' +import { readFileSync } from "node:fs"; + +import { Prophet, initSync } from '../pkg'; +import { optimizer } from '@bsull/augurs-prophet-wasmstan'; + +import { describe, expect, it } from 'vitest'; + +// Required for Rust's `rand::thread_rng` to support NodeJS modules. +// See https://docs.rs/getrandom#nodejs-es-module-support. +// @ts-ignore +globalThis.crypto = webcrypto + +initSync({ module: readFileSync('node_modules/@bsull/augurs/augurs_bg.wasm') }); + +const ds = [1704067200, 1704871384, 1705675569, 1706479753, 1707283938, 1708088123, + 1708892307, 1709696492, 1710500676, 1711304861, 1712109046, 1712913230, +]; +const y = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0]; +const cap = Array(y.length).fill(12.0); +const floor = Array(y.length).fill(0.0); + +describe('Prophet', () => { + it('can be instantiated', () => { + const prophet = new Prophet({ optimizer }); + expect(prophet).toBeInstanceOf(Prophet); + }); + + it('can be fit with arrays', () => { + const prophet = new Prophet({ optimizer }); + prophet.fit({ ds, y }) + const preds = prophet.predict(); + expect(preds.yhat.point).toHaveLength(y.length); + }); + + it('can be fit with typed arrays', () => { + const prophet = new Prophet({ optimizer }); + prophet.fit({ ds: new BigInt64Array(ds.map(BigInt)), y: new Float64Array(y) }); + const preds = prophet.predict(); + expect(preds.yhat.point).toHaveLength(y.length); + }); + + it('accepts cap/floor', () => { + const prophet = new Prophet({ optimizer }); + prophet.fit({ ds, y, cap, floor }); + const preds = prophet.predict(); + expect(preds.yhat.point).toHaveLength(y.length); + }); + + it('returns regular arrays', () => { + const prophet = new Prophet({ optimizer }); + prophet.fit({ ds, y }) + const preds = prophet.predict(); + expect(preds.yhat.point).toHaveLength(y.length); + expect(preds.yhat.point).toBeInstanceOf(Array); + }); +}); diff --git a/crates/augurs-js/testpkg/seasons.test.ts b/crates/augurs-js/testpkg/seasons.test.ts new file mode 100644 index 00000000..bdce36b6 --- /dev/null +++ b/crates/augurs-js/testpkg/seasons.test.ts @@ -0,0 +1,28 @@ +import { readFileSync } from "node:fs"; + +import { seasonalities, initSync } from '../pkg'; + +import { describe, expect, it } from 'vitest'; + +initSync({ module: readFileSync('node_modules/@bsull/augurs/augurs_bg.wasm') }); + +describe('seasons', () => { + const y = [ + 0.1, 0.3, 0.8, 0.5, + 0.1, 0.31, 0.79, 0.48, + 0.09, 0.29, 0.81, 0.49, + 0.11, 0.28, 0.78, 0.53, + 0.1, 0.3, 0.8, 0.5, + 0.1, 0.31, 0.79, 0.48, + 0.09, 0.29, 0.81, 0.49, + 0.11, 0.28, 0.78, 0.53, + ]; + + it('works with number arrays', () => { + expect(seasonalities(y)).toEqual(new Uint32Array([4])); + }); + + it('works with number arrays', () => { + expect(seasonalities(new Float64Array(y))).toEqual(new Uint32Array([4])); + }); +}); diff --git a/crates/augurs-js/testpkg/tsconfig.json b/crates/augurs-js/testpkg/tsconfig.json new file mode 100644 index 00000000..56a8ab81 --- /dev/null +++ b/crates/augurs-js/testpkg/tsconfig.json @@ -0,0 +1,110 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + + /* Modules */ + "module": "commonjs", /* Specify what module code is generated. */ + // "rootDir": "./", /* Specify the root folder within your source files. */ + // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + // "types": [], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ + // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ + // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ + // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ + // "noUncheckedSideEffectImports": true, /* Check side effect imports. */ + // "resolveJsonModule": true, /* Enable importing .json files. */ + // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + // "outDir": "./", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ + // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + + /* Type Checking */ + "strict": true, /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +} diff --git a/justfile b/justfile index eb8a4782..90311eac 100644 --- a/justfile +++ b/justfile @@ -1,9 +1,12 @@ set ignore-comments +build-augurs-js: + cd crates/augurs-js && \ + wasm-pack build --scope bsull --out-name augurs --release --target web -- --features parallel + # Build and publish the augurs-js package to npm with the @bsull scope. -publish-npm: +publish-npm: build-augurs-js cd crates/augurs-js && \ - wasm-pack build --release --scope bsull --out-name augurs --target web -- --features parallel,tracing-wasm && \ node prepublish && \ wasm-pack publish --access public