diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml index 254a353c..8d3931f8 100644 --- a/.github/workflows/js.yml +++ b/.github/workflows/js.yml @@ -30,10 +30,10 @@ jobs: - uses: actions/setup-node@v4 - name: Install dependencies run: npm ci - working-directory: crates/augurs-js/testpkg + working-directory: js/testpkg - name: Run typecheck run: npm run typecheck - working-directory: crates/augurs-js/testpkg + working-directory: js/testpkg - name: Run tests run: npm run test:ci - working-directory: crates/augurs-js/testpkg + working-directory: js/testpkg diff --git a/Cargo.toml b/Cargo.toml index e7601eb1..7a4dbebb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,12 @@ members = [ "crates/*", "examples/*", + "js/*", +] +exclude = [ + # These aren't crates, they're Javascript packages in an inconvenient location. + "js/augurs", + "js/testpkg", ] resolver = "2" @@ -35,21 +41,28 @@ augurs-prophet = { version = "0.5.3", path = "crates/augurs-prophet" } augurs-seasons = { version = "0.5.3", path = "crates/augurs-seasons" } augurs-testing = { path = "crates/augurs-testing" } +augurs-core-js = { path = "js/augurs-core-js" } + anyhow = "1.0.89" bytemuck = "1.18.0" chrono = "0.4.38" distrs = "0.2.1" +getrandom = { version = "0.2.10", features = ["js"] } itertools = "0.13.0" +js-sys = "0.3.64" num-traits = "0.2.19" rand = "0.8.5" roots = "0.0.8" serde = { version = "1.0.166", features = ["derive"] } statrs = "0.17.1" serde_json = "1.0.128" +serde-wasm-bindgen = "0.6.0" thiserror = "1.0.40" tinyvec = "1.6.0" tracing = "0.1.40" tracing-subscriber = { version = "0.3.18", default-features = false } +tsify-next = { version = "0.5.3", default-features = false, features = ["js"] } +wasm-bindgen = "=0.2.93" assert_approx_eq = "1.1.0" criterion = "0.5.1" diff --git a/README.md b/README.md index 7561629b..fbd88975 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ APIs are subject to change, and functionality may not be fully implemented. | [`augurs-prophet`][] | The Prophet time series forecasting algorithm | alpha | | [`augurs-seasons`][] | Seasonality detection using periodograms | alpha - working and tested against Python in limited scenarios | | [`augurs-testing`][] | Testing data and, eventually, evaluation harness for implementations | alpha - just data right now | -| [`augurs-js`][] | WASM bindings to augurs | alpha | +| [`js/*`][js-libs] | WASM bindings to augurs | alpha | | [`pyaugurs`][] | Python bindings to augurs | alpha | ## Developing @@ -79,11 +79,11 @@ Licensed under the Apache License, Version 2.0 ` console.log("Initialized augurs")); -``` - -1. Use the various ETS, changepoint, outlier, or seasonality detection algorithms. For example: - -```javascript -import { ets, seasonalities } from "@bsull/augurs" - -const y = new Float64Array([1.0, 2.0, 3.0, 1.0, 2.0, 3.0]); // your time series data -const seasonLengths = seasonalities(y); -const model = ets(seasonLengths, { impute: true }); -model.fit(y); - -const predictionInterval = 0.95; -// Generate in-sample predictions for the training set. -const { point, lower, upper } = model.predictInSample(predictionInterval); -// Generate out-of-sample forecasts. -const { point: futurePoint, lower: futureLower, upper: futureUpper } = model.predict(10, predictionInterval); -``` - -## Troubleshooting - -### Webpack - -Some of the dependencies of `augurs` require a few changes to the Webpack configuration to work correctly. -Adding this to your `webpack.config.js` should be enough: - -```javascript -{ - experiments: { - // Required to load WASM modules. - asyncWebAssembly: true, - }, - module: { - rules: [ - { - test: /\@bsull\/augurs\/.*\.js$/, - resolve: { - fullySpecified: false - } - }, - ] - }, -} -``` - -[repo]: https://github.com/grafana/augurs diff --git a/crates/augurs-js/prepublish.js b/crates/augurs-js/prepublish.js deleted file mode 100644 index 126aadd8..00000000 --- a/crates/augurs-js/prepublish.js +++ /dev/null @@ -1,46 +0,0 @@ -// This script does a few things before publishing the package to npm: -// 1. ensures that the "main" field in package.json is set to "augurs.js", which is -// the same as checking that wasm-pack was run with the "--out-name augurs" flag. -// 2. adds the "snippets/" directory to the files array in package.json. -// This is needed because of https://github.com/rustwasm/wasm-pack/issues/1206. -// 3. renames the npm package from "augurs-js" to "augurs". -// Once https://github.com/rustwasm/wasm-pack/issues/949 is fixed we can remove this part. -const fs = require('fs'); -const path = require('path'); - -try { - const pkgPath = path.join(__dirname, "pkg/package.json"); - - // Check if package.json exists - if (!fs.existsSync(pkgPath)) { - console.error(`Error: File ${pkgPath} not found.`); - process.exit(1); - } - - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); - - // Check that the 'main' field is set to "augurs.js". - if (pkg.main !== 'augurs.js') { - console.error(`Error: The 'main' field in package.json is not set to "augurs.js". Make sure you passed '--out-name augurs' to 'wasm-pack build'.`); - process.exit(1); - } - - // Add snippets to the files array. If no files array exists, create one. - pkg.files = pkg.files || []; - if (!pkg.files.includes('snippets/')) { - pkg.files.push('snippets/'); - console.log('Added "snippets/" to package.json.'); - } else { - console.log('"snippets/" already exists in package.json.'); - } - - // Rename the npm package from "@bsull/augurs-js" to "@bsull/augurs". - pkg.name = '@bsull/augurs'; - console.log('Renamed the npm package from "augurs-js" to "augurs".'); - - fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2)); - console.log('Successfully updated package.json.'); -} catch (error) { - console.error(`An error occurred: ${error.message}`); - process.exit(1); -} diff --git a/crates/augurs-js/rust-toolchain.toml b/crates/augurs-js/rust-toolchain.toml deleted file mode 100644 index 01179ac5..00000000 --- a/crates/augurs-js/rust-toolchain.toml +++ /dev/null @@ -1,8 +0,0 @@ -# Build augurs-js with the nightly toolchain and wasm32-unknown-unknown target. -# This is required for the `wasm-bindgen-rayon` dependency, which requires -# some nightly-only features (see .cargo/config.toml). -[toolchain] -channel = "nightly-2024-09-01" -components = ["rust-src"] -targets = ["wasm32-unknown-unknown"] -profile = "minimal" diff --git a/js/augurs-changepoint-js/Cargo.toml b/js/augurs-changepoint-js/Cargo.toml new file mode 100644 index 00000000..b22dfa6c --- /dev/null +++ b/js/augurs-changepoint-js/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "augurs-changepoint-js" +version.workspace = true +authors.workspace = true +documentation.workspace = true +repository.workspace = true +license.workspace = true +edition.workspace = true +keywords.workspace = true +description = "JavaScript bindings for the augurs-changepoint library." +publish = false + +[lib] +bench = false +crate-type = ["cdylib", "rlib"] +doc = false +doctest = false +test = false + +[dependencies] +augurs-core-js.workspace = true +augurs-changepoint = { workspace = true, features = ["serde"] } +getrandom = { version = "0.2.10", features = ["js"] } +js-sys = "0.3.64" +serde.workspace = true +serde-wasm-bindgen = "0.6.0" +tracing.workspace = true +tsify-next = { version = "0.5.3", default-features = false, features = ["js"] } +wasm-bindgen.workspace = true + +[package.metadata.wasm-pack.profile.release] +# previously had just ['-O4'] +wasm-opt = ['-O4', '--enable-bulk-memory', '--enable-threads'] diff --git a/crates/augurs-js/src/changepoints.rs b/js/augurs-changepoint-js/src/lib.rs similarity index 99% rename from crates/augurs-js/src/changepoints.rs rename to js/augurs-changepoint-js/src/lib.rs index a8875a33..855ac112 100644 --- a/crates/augurs-js/src/changepoints.rs +++ b/js/augurs-changepoint-js/src/lib.rs @@ -7,8 +7,7 @@ use wasm_bindgen::prelude::*; use augurs_changepoint::{ dist, ArgpcpDetector, BocpdDetector, DefaultArgpcpDetector, Detector, NormalGammaDetector, }; - -use crate::VecF64; +use augurs_core_js::VecF64; #[derive(Debug)] enum EitherDetector { @@ -49,6 +48,7 @@ const DEFAULT_HAZARD_LAMBDA: f64 = 250.0; #[wasm_bindgen] impl ChangepointDetector { #[wasm_bindgen(constructor)] + #[allow(non_snake_case)] pub fn new(detectorType: ChangepointDetectorType) -> Result { match detectorType { ChangepointDetectorType::NormalGamma => Self::normal_gamma(None), diff --git a/js/augurs-clustering-js/Cargo.toml b/js/augurs-clustering-js/Cargo.toml new file mode 100644 index 00000000..786378b1 --- /dev/null +++ b/js/augurs-clustering-js/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "augurs-clustering-js" +version.workspace = true +authors.workspace = true +documentation.workspace = true +repository.workspace = true +license.workspace = true +edition.workspace = true +keywords.workspace = true +description = "JavaScript bindings for the augurs-clustering library." +publish = false + +[lib] +bench = false +crate-type = ["cdylib", "rlib"] +doc = false +doctest = false +test = false + +[dependencies] +augurs-core-js.workspace = true +augurs-clustering.workspace = true +getrandom.workspace = true +js-sys.workspace = true +serde.workspace = true +serde-wasm-bindgen.workspace = true +tracing.workspace = true +tsify-next.workspace = true +wasm-bindgen.workspace = true + +[package.metadata.wasm-pack.profile.release] +# previously had just ['-O4'] +wasm-opt = ['-O4', '--enable-bulk-memory', '--enable-threads'] + +[lints] +workspace = true diff --git a/crates/augurs-js/src/clustering.rs b/js/augurs-clustering-js/src/lib.rs similarity index 84% rename from crates/augurs-js/src/clustering.rs rename to js/augurs-clustering-js/src/lib.rs index a2418284..a3c8a691 100644 --- a/crates/augurs-js/src/clustering.rs +++ b/js/augurs-clustering-js/src/lib.rs @@ -4,7 +4,7 @@ use serde::Deserialize; use tsify_next::Tsify; use wasm_bindgen::prelude::*; -use crate::dtw::DistanceMatrix; +use augurs_core_js::{DistanceMatrix, VecVecF64}; /// Options for the dynamic time warping calculation. #[derive(Clone, Debug, Default, Deserialize, Tsify)] @@ -43,7 +43,8 @@ impl DbscanClusterer { /// /// The return value is an `Int32Array` of cluster IDs, with `-1` indicating noise. #[wasm_bindgen] - pub fn fit(&self, distanceMatrix: &DistanceMatrix) -> Vec { - self.inner.fit(distanceMatrix.inner()) + #[allow(non_snake_case)] + pub fn fit(&self, distanceMatrix: VecVecF64) -> Result, JsError> { + Ok(self.inner.fit(&DistanceMatrix::new(distanceMatrix)?.into())) } } diff --git a/crates/augurs-js/Cargo.toml b/js/augurs-core-js/Cargo.toml similarity index 51% rename from crates/augurs-js/Cargo.toml rename to js/augurs-core-js/Cargo.toml index 3f1724aa..a299626e 100644 --- a/crates/augurs-js/Cargo.toml +++ b/js/augurs-core-js/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "augurs-js" +name = "augurs-core-js" version.workspace = true authors.workspace = true documentation.workspace = true @@ -7,7 +7,7 @@ repository.workspace = true license.workspace = true edition.workspace = true keywords.workspace = true -description = "JavaScript bindings for the augurs time series library." +description = "JavaScript bindings for core augurs functionality." publish = false [lib] @@ -20,30 +20,19 @@ test = false [features] default = ["logging"] logging = ["wasm-tracing"] -parallel = ["wasm-bindgen-rayon"] [dependencies] -augurs-changepoint = { workspace = true } -augurs-clustering = { workspace = true } augurs-core = { workspace = true } -augurs-dtw = { workspace = true, features = ["parallel"] } -augurs-ets = { workspace = true, features = ["mstl"] } -augurs-forecaster.workspace = true -augurs-mstl = { workspace = true } -augurs-outlier = { workspace = true } -augurs-prophet = { workspace = true, features = ["serde"] } -augurs-seasons = { workspace = true } console_error_panic_hook = "0.1.7" -getrandom = { version = "0.2.10", features = ["js"] } -js-sys = "0.3.64" +# getrandom = { version = "0.2.10", features = ["js"] } +js-sys.workspace = true serde.workspace = true serde_json = "1" -serde-wasm-bindgen = "0.6.0" +serde-wasm-bindgen.workspace = true tracing.workspace = 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 } +tsify-next.workspace = true +wasm-bindgen.workspace = true wasm-tracing = { version = "0.2.1", optional = true } [package.metadata.wasm-pack.profile.release] diff --git a/crates/augurs-js/src/lib.rs b/js/augurs-core-js/src/lib.rs similarity index 62% rename from crates/augurs-js/src/lib.rs rename to js/augurs-core-js/src/lib.rs index 2f44847d..622c0b75 100644 --- a/crates/augurs-js/src/lib.rs +++ b/js/augurs-core-js/src/lib.rs @@ -1,65 +1,11 @@ -#![doc = include_str!("../README.md")] -// Annoying, hopefully https://github.com/madonoharu/tsify/issues/42 will -// be resolved at some point. -#![allow(non_snake_case, clippy::empty_docs)] - +//! JS bindings for core augurs functionality. +use js_sys::Float64Array; use serde::Serialize; use tsify_next::Tsify; use wasm_bindgen::prelude::*; -/// Initialize the rayon thread pool. -/// -/// This must be called once (from a Javascript context) and awaited -/// before using parallel mode of algorithms, to set up the thread pool. -/// -/// # Example (JS) -/// -/// ```js -/// // worker.ts -/// import init, { Dbscan, Dtw, initThreadPool} from '@bsull/augurs'; -/// -/// init().then(async () => { -/// console.debug('augurs initialized'); -/// await initThreadPool(navigator.hardwareConcurrency * 2); -/// console.debug('augurs thread pool initialized'); -/// }); -/// -/// export function dbscan(series: Float64Array[], epsilon: number, minClusterSize: number): number[] { -/// const distanceMatrix = Dtw.euclidean({ window: 10, parallelize: true }).distanceMatrix(series); -/// const clusterLabels = new Dbscan({ epsilon, minClusterSize }).fit(distanceMatrix); -/// return Array.from(clusterLabels); -/// } -/// -/// // index.js -/// import { dbscan } from './worker'; -/// -/// async function runClustering(series: Float64Array[]): Promise { -/// return dbscan(series, 0.1, 10); // await only required if using workerize-loader -/// } -/// -/// // or using e.g. workerize-loader to run in a dedicated worker: -/// import worker from 'workerize-loader?ready&name=augurs!./worker'; -/// -/// const instance = worker() -/// -/// async function runClustering(series: Float64Array[]): Promise { -/// await instance.ready; -/// return instance.dbscan(series, 0.1, 10); -/// } -/// ``` -#[cfg(feature = "parallel")] -pub use wasm_bindgen_rayon::init_thread_pool; - -mod changepoints; -pub mod clustering; -mod dtw; -pub mod ets; #[cfg(feature = "logging")] pub mod logging; -pub mod mstl; -mod outlier; -mod prophet; -pub mod seasons; /// Initialize the logger and panic hook. /// @@ -149,23 +95,84 @@ extern "C" { } impl VecUsize { - fn convert(self) -> Result, JsError> { + /// Convert to a `Vec`. + pub 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> { + /// Convert to a `Vec`. + pub 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> { + /// Convert to a `Vec>`. + pub 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") }) } } + +/// A distance matrix. +/// +/// This is intentionally opaque; it should only be passed back to `augurs` for further processing, +/// e.g. to calculate nearest neighbors or perform clustering. +#[derive(Debug)] +pub struct DistanceMatrix { + inner: augurs_core::DistanceMatrix, +} + +impl DistanceMatrix { + /// Get the inner distance matrix. + pub fn inner(&self) -> &augurs_core::DistanceMatrix { + &self.inner + } +} + +impl DistanceMatrix { + /// Create a new `DistanceMatrix` from a raw distance matrix. + #[allow(non_snake_case)] + pub fn new(distance_matrix: VecVecF64) -> Result { + Ok(Self { + inner: augurs_core::DistanceMatrix::try_from_square(distance_matrix.convert()?)?, + }) + } + + /// Get the shape of the distance matrix. + pub fn shape(&self) -> Vec { + let (m, n) = self.inner.shape(); + vec![m, n] + } + + /// Get the distance matrix as an array of arrays. + 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 } + } +} + +impl From for augurs_core::DistanceMatrix { + fn from(matrix: DistanceMatrix) -> Self { + Self::try_from_square(matrix.inner.into_inner()).unwrap() + } +} diff --git a/crates/augurs-js/src/logging.rs b/js/augurs-core-js/src/logging.rs similarity index 100% rename from crates/augurs-js/src/logging.rs rename to js/augurs-core-js/src/logging.rs diff --git a/js/augurs-dtw-js/Cargo.toml b/js/augurs-dtw-js/Cargo.toml new file mode 100644 index 00000000..822d1d65 --- /dev/null +++ b/js/augurs-dtw-js/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "augurs-dtw-js" +version.workspace = true +authors.workspace = true +documentation.workspace = true +repository.workspace = true +license.workspace = true +edition.workspace = true +keywords.workspace = true +description = "JavaScript bindings for the augurs-dtw library." +publish = false + +[lib] +bench = false +crate-type = ["cdylib", "rlib"] +doc = false +doctest = false +test = false + +[features] +# Enable parallelism for the DTW distance matrix calculation. +# This requires some additional build flags - see +# https://github.com/RReverser/wasm-bindgen-rayon?tab=readme-ov-file#building-rust-code +# for more details - and so is disabled by default. +parallel = ["wasm-bindgen-rayon"] + +[dependencies] +augurs-core-js.workspace = true +augurs-dtw.workspace = true +js-sys.workspace = true +serde.workspace = true +serde-wasm-bindgen.workspace = true +tsify-next.workspace = true +wasm-bindgen.workspace = true +wasm-bindgen-rayon = { version = "1.2.1", optional = true } + +[package.metadata.wasm-pack.profile.release] +# previously had just ['-O4'] +wasm-opt = ['-O4', '--enable-bulk-memory', '--enable-threads'] + +[lints] +workspace = true diff --git a/crates/augurs-js/src/dtw.rs b/js/augurs-dtw-js/src/lib.rs similarity index 75% rename from crates/augurs-js/src/dtw.rs rename to js/augurs-dtw-js/src/lib.rs index 0dbffd8d..76985080 100644 --- a/crates/augurs-js/src/dtw.rs +++ b/js/augurs-dtw-js/src/lib.rs @@ -7,10 +7,9 @@ use serde::Deserialize; use tsify_next::Tsify; use wasm_bindgen::prelude::*; +use augurs_core_js::{DistanceMatrix, VecF64, VecVecF64}; use augurs_dtw::{Euclidean, Manhattan}; -use crate::{VecF64, VecVecF64}; - enum InnerDtw { Euclidean(augurs_dtw::Dtw), Manhattan(augurs_dtw::Dtw), @@ -80,68 +79,6 @@ pub struct DtwOptions { pub parallelize: Option, } -/// A distance matrix. -/// -/// This is intentionally opaque; it should only be passed back to `augurs` for further processing, -/// e.g. to calculate nearest neighbors or perform clustering. -#[derive(Debug)] -#[wasm_bindgen] -pub struct DistanceMatrix { - inner: augurs_core::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 } - } -} - -impl From for augurs_core::DistanceMatrix { - fn from(matrix: DistanceMatrix) -> Self { - Self::try_from_square(matrix.inner.into_inner()).unwrap() - } -} - /// The distance function to use for Dynamic Time Warping. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Tsify)] #[serde(rename_all = "lowercase")] @@ -168,6 +105,7 @@ pub struct Dtw { impl Dtw { /// Create a new `Dtw` instance. #[wasm_bindgen(constructor)] + #[allow(non_snake_case)] pub fn new(distanceFunction: DistanceFunction, opts: Option) -> Self { match distanceFunction { DistanceFunction::Euclidean => Self::euclidean(opts), @@ -233,9 +171,52 @@ impl Dtw { /// /// The series do not all have to be the same length. #[wasm_bindgen(js_name = distanceMatrix)] - pub fn distance_matrix(&self, series: VecVecF64) -> Result { + pub fn distance_matrix(&self, series: VecVecF64) -> Result, JsError> { let vecs = series.convert()?; let slices = vecs.iter().map(Vec::as_slice).collect::>(); - Ok(self.inner.distance_matrix(&slices)) + Ok(self.inner.distance_matrix(&slices).to_array()) } } + +/// Initialize the rayon thread pool. +/// +/// This must be called once (from a Javascript context) and awaited +/// before using parallel mode of algorithms, to set up the thread pool. +/// +/// # Example (JS) +/// +/// ```js +/// // worker.ts +/// import init, { Dbscan, Dtw, initThreadPool} from '@bsull/augurs'; +/// +/// init().then(async () => { +/// console.debug('augurs initialized'); +/// await initThreadPool(navigator.hardwareConcurrency * 2); +/// console.debug('augurs thread pool initialized'); +/// }); +/// +/// export function dbscan(series: Float64Array[], epsilon: number, minClusterSize: number): number[] { +/// const distanceMatrix = Dtw.euclidean({ window: 10, parallelize: true }).distanceMatrix(series); +/// const clusterLabels = new Dbscan({ epsilon, minClusterSize }).fit(distanceMatrix); +/// return Array.from(clusterLabels); +/// } +/// +/// // index.js +/// import { dbscan } from './worker'; +/// +/// async function runClustering(series: Float64Array[]): Promise { +/// return dbscan(series, 0.1, 10); // await only required if using workerize-loader +/// } +/// +/// // or using e.g. workerize-loader to run in a dedicated worker: +/// import worker from 'workerize-loader?ready&name=augurs!./worker'; +/// +/// const instance = worker() +/// +/// async function runClustering(series: Float64Array[]): Promise { +/// await instance.ready; +/// return instance.dbscan(series, 0.1, 10); +/// } +/// ``` +#[cfg(feature = "parallel")] +pub use wasm_bindgen_rayon::init_thread_pool; diff --git a/js/augurs-ets-js/Cargo.toml b/js/augurs-ets-js/Cargo.toml new file mode 100644 index 00000000..75e912b0 --- /dev/null +++ b/js/augurs-ets-js/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "augurs-ets-js" +version.workspace = true +authors.workspace = true +documentation.workspace = true +repository.workspace = true +license.workspace = true +edition.workspace = true +keywords.workspace = true +description = "JavaScript bindings for the augurs-ets library." +publish = false + +[lib] +bench = false +crate-type = ["cdylib", "rlib"] +doc = false +doctest = false +test = false + +[dependencies] +augurs-core.workspace = true +augurs-core-js.workspace = true +augurs-ets.workspace = true +getrandom.workspace = true +serde.workspace = true +serde-wasm-bindgen.workspace = true +tsify-next.workspace = true +wasm-bindgen.workspace = true + +[package.metadata.wasm-pack.profile.release] +# previously had just ['-O4'] +wasm-opt = ['-O4', '--enable-bulk-memory', '--enable-threads'] + +[lints] +workspace = true diff --git a/crates/augurs-js/src/ets.rs b/js/augurs-ets-js/src/lib.rs similarity index 96% rename from crates/augurs-js/src/ets.rs rename to js/augurs-ets-js/src/lib.rs index 276948fc..4b227820 100644 --- a/crates/augurs-js/src/ets.rs +++ b/js/augurs-ets-js/src/lib.rs @@ -3,8 +3,7 @@ use wasm_bindgen::prelude::*; use augurs_core::prelude::*; - -use crate::{Forecast, VecF64}; +use augurs_core_js::{Forecast, VecF64}; /// Automatic ETS model selection. #[derive(Debug)] @@ -23,6 +22,7 @@ impl AutoETS { /// /// If the `spec` string is invalid, this function returns an error. #[wasm_bindgen(constructor)] + #[allow(non_snake_case)] pub fn new(seasonLength: usize, spec: String) -> Result { let inner = augurs_ets::AutoETS::new(seasonLength, spec.as_str())?; Ok(Self { diff --git a/js/augurs-mstl-js/Cargo.toml b/js/augurs-mstl-js/Cargo.toml new file mode 100644 index 00000000..4c7c438a --- /dev/null +++ b/js/augurs-mstl-js/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "augurs-mstl-js" +version.workspace = true +authors.workspace = true +documentation.workspace = true +repository.workspace = true +license.workspace = true +edition.workspace = true +keywords.workspace = true +description = "JavaScript bindings for the augurs-mstl library." +publish = false + +[lib] +bench = false +crate-type = ["cdylib", "rlib"] +doc = false +doctest = false +test = false + +[dependencies] +augurs-core-js.workspace = true +augurs-ets = { workspace = true, features = ["mstl"] } +augurs-forecaster.workspace = true +augurs-mstl.workspace = true +getrandom.workspace = true +serde.workspace = true +serde-wasm-bindgen.workspace = true +tsify-next.workspace = true +wasm-bindgen.workspace = true + +[package.metadata.wasm-pack.profile.release] +# previously had just ['-O4'] +wasm-opt = ['-O4', '--enable-bulk-memory', '--enable-threads'] + +[lints] +workspace = true diff --git a/crates/augurs-js/src/mstl.rs b/js/augurs-mstl-js/src/lib.rs similarity index 97% rename from crates/augurs-js/src/mstl.rs rename to js/augurs-mstl-js/src/lib.rs index 8337490f..59590f6f 100644 --- a/crates/augurs-js/src/mstl.rs +++ b/js/augurs-mstl-js/src/lib.rs @@ -1,4 +1,5 @@ //! JavaScript bindings for the MSTL model. +//! use serde::Deserialize; use tsify_next::Tsify; use wasm_bindgen::prelude::*; @@ -7,7 +8,7 @@ use augurs_ets::{trend::AutoETSTrendModel, AutoETS}; use augurs_forecaster::{Forecaster, Transform}; use augurs_mstl::{MSTLModel, TrendModel}; -use crate::{Forecast, VecF64, VecUsize}; +use augurs_core_js::{Forecast, VecF64, VecUsize}; /// The type of trend forecaster to use. #[derive(Debug, Clone, Copy, Deserialize, Tsify)] @@ -75,6 +76,7 @@ impl MSTL { /// /// If provided, `level` must be a float between 0 and 1. #[wasm_bindgen] + #[allow(non_snake_case)] pub fn predictInSample(&self, level: Option) -> Result { let forecasts = self.forecaster.predict_in_sample(level); Ok(forecasts.map(Into::into).map_err(|e| e.to_string())?) diff --git a/js/augurs-outlier-js/Cargo.toml b/js/augurs-outlier-js/Cargo.toml new file mode 100644 index 00000000..f86b39c0 --- /dev/null +++ b/js/augurs-outlier-js/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "augurs-outlier-js" +version.workspace = true +authors.workspace = true +documentation.workspace = true +repository.workspace = true +license.workspace = true +edition.workspace = true +keywords.workspace = true +description = "JavaScript bindings for the augurs-outlier library." +publish = false + +[lib] +bench = false +crate-type = ["cdylib", "rlib"] +doc = false +doctest = false +test = false + +[dependencies] +augurs-core-js.workspace = true +augurs-outlier = { workspace = true, features = ["serde"] } +getrandom.workspace = true +serde.workspace = true +serde-wasm-bindgen.workspace = true +tsify-next.workspace = true +wasm-bindgen.workspace = true + +[package.metadata.wasm-pack.profile.release] +# previously had just ['-O4'] +wasm-opt = ['-O4', '--enable-bulk-memory', '--enable-threads'] + +[lints] +workspace = true diff --git a/crates/augurs-js/src/outlier.rs b/js/augurs-outlier-js/src/lib.rs similarity index 96% rename from crates/augurs-js/src/outlier.rs rename to js/augurs-outlier-js/src/lib.rs index 14f4851c..9c086929 100644 --- a/crates/augurs-js/src/outlier.rs +++ b/js/augurs-outlier-js/src/lib.rs @@ -1,12 +1,12 @@ +//! JS bindings for outlier detection algorithms. use std::collections::BTreeSet; -use augurs_outlier::OutlierDetector as _; use serde::{Deserialize, Serialize}; use tsify_next::Tsify; - use wasm_bindgen::prelude::*; -use crate::VecVecF64; +use augurs_core_js::VecVecF64; +use augurs_outlier::OutlierDetector as _; // Enums representing outlier detectors and 'loaded' outlier detectors // (i.e. detectors that have already preprocessed some data and are @@ -98,7 +98,9 @@ pub struct OutlierDetectorOptions { #[serde(rename_all = "lowercase")] #[tsify(from_wasm_abi)] pub enum OutlierDetectorType { + /// A DBSCAN-based outlier detector. Dbscan, + /// A MAD-based outlier detector. Mad, } @@ -115,7 +117,7 @@ impl OutlierDetector { /// Create a new outlier detector. #[wasm_bindgen(constructor)] pub fn new( - detectorType: OutlierDetectorType, + #[allow(non_snake_case)] detectorType: OutlierDetectorType, options: OutlierDetectorOptions, ) -> Result { match detectorType { @@ -134,6 +136,7 @@ impl OutlierDetector { }) } + /// Create a new outlier detector using the MAD algorithm. pub fn mad(options: OutlierDetectorOptions) -> Result { Ok(Self { detector: Detector::Mad(augurs_outlier::MADDetector::with_sensitivity( @@ -178,6 +181,7 @@ pub struct LoadedOutlierDetector { #[wasm_bindgen] impl LoadedOutlierDetector { + /// Detect outliers in the given time series. #[wasm_bindgen] pub fn detect(&self) -> Result { Ok(self.detector.detect()?.into()) diff --git a/js/augurs-prophet-js/Cargo.toml b/js/augurs-prophet-js/Cargo.toml new file mode 100644 index 00000000..7873ede4 --- /dev/null +++ b/js/augurs-prophet-js/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "augurs-prophet-js" +version.workspace = true +authors.workspace = true +documentation.workspace = true +repository.workspace = true +license.workspace = true +edition.workspace = true +keywords.workspace = true +description = "JavaScript bindings for the augurs-prophet library." +publish = false + +[lib] +bench = false +crate-type = ["cdylib", "rlib"] +doc = false +doctest = false +test = false + +[dependencies] +augurs-core-js.workspace = true +augurs-prophet = { workspace = true, features = ["serde"] } +getrandom.workspace = true +js-sys.workspace = true +serde.workspace = true +serde_json = "1" +serde-wasm-bindgen.workspace = true +tracing.workspace = true +tsify-next.workspace = true +wasm-bindgen.workspace = true + +[package.metadata.wasm-pack.profile.release] +# previously had just ['-O4'] +wasm-opt = ['-O4', '--enable-bulk-memory', '--enable-threads'] + +[lints] +workspace = true diff --git a/crates/augurs-js/src/prophet.rs b/js/augurs-prophet-js/src/lib.rs similarity index 93% rename from crates/augurs-js/src/prophet.rs rename to js/augurs-prophet-js/src/lib.rs index b6308bb4..633c9e96 100644 --- a/crates/augurs-js/src/prophet.rs +++ b/js/augurs-prophet-js/src/lib.rs @@ -1,12 +1,13 @@ +//! JS bindings for the Prophet model. use std::{collections::HashMap, num::TryFromIntError}; -use augurs_prophet::PositiveFloat; use js_sys::Float64Array; use serde::{Deserialize, Serialize}; use tsify_next::Tsify; use wasm_bindgen::prelude::*; -use crate::{Forecast, ForecastIntervals}; +use augurs_core_js::{Forecast, ForecastIntervals}; +use augurs_prophet::PositiveFloat; // 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 @@ -67,7 +68,7 @@ impl augurs_prophet::Optimizer for JsOptimizer { ) -> Result { let this = JsValue::null(); let opts: OptimizeOptions = opts.into(); - let init: InitialParams<'_> = init.into(); + let init: InitialParams = init.into(); let init = serde_wasm_bindgen::to_value(&init) .map_err(augurs_prophet::optimizer::Error::custom)?; let data = @@ -259,8 +260,7 @@ impl From for augurs_prophet::optimizer::OptimizeOpts { #[derive(Clone, Serialize, Tsify)] #[serde(rename_all = "camelCase")] #[tsify(into_wasm_abi, type_prefix = "Prophet")] -struct InitialParams<'a> { - _phantom: std::marker::PhantomData<&'a ()>, +struct InitialParams { /// Base trend growth rate. pub k: f64, /// Trend offset. @@ -277,16 +277,13 @@ struct InitialParams<'a> { pub sigma_obs: f64, } -impl<'a> From<&'a augurs_prophet::optimizer::InitialParams> for InitialParams<'a> { - fn from(params: &'a augurs_prophet::optimizer::InitialParams) -> Self { - // SAFETY: We're creating a view of the `delta` field which has lifetime 'a. - // The view is valid as long as the `InitialParams` is alive. Effectively - // we're tying the lifetime of this struct to the lifetime of the input, - // even though `Float64Array` doesn't have a lifetime. - let delta = unsafe { Float64Array::view(¶ms.delta) }; - let beta = unsafe { Float64Array::view(¶ms.beta) }; +impl From<&augurs_prophet::optimizer::InitialParams> for InitialParams { + fn from(params: &augurs_prophet::optimizer::InitialParams) -> Self { + let delta = Float64Array::new_with_length(params.delta.len() as u32); + delta.copy_from(¶ms.delta); + let beta = Float64Array::new_with_length(params.beta.len() as u32); + beta.copy_from(¶ms.beta); Self { - _phantom: std::marker::PhantomData, k: params.k, m: params.m, delta, @@ -639,16 +636,40 @@ type TimestampSeconds = i64; #[serde(rename_all = "camelCase")] #[tsify(from_wasm_abi, type_prefix = "Prophet")] pub struct TrainingData { + /// The timestamps of the time series. + /// + /// These should be in seconds since the epoch. #[tsify(type = "TimestampSeconds[] | BigInt64Array")] pub ds: Vec, + + /// The time series values to fit the model to. #[tsify(type = "number[] | Float64Array")] pub y: Vec, + + /// Optionally, an upper bound (cap) on the values of the time series. + /// + /// Only used if the model's growth type is `logistic`. #[tsify(optional, type = "number[] | Float64Array")] pub cap: Option>, + + /// Optionally, a lower bound (floor) on the values of the time series. + /// + /// Only used if the model's growth type is `logistic`. #[tsify(optional, type = "number[] | Float64Array")] pub floor: Option>, + + /// Optional indicator variables for conditional seasonalities. + /// + /// The keys of the map are the names of the seasonality components, + /// and the values are boolean arrays of length `T` where `true` indicates + /// that the component is active for the corresponding time point. + /// + /// There must be a key in this map for each seasonality component + /// that is marked as conditional in the model. #[tsify(optional)] pub seasonality_conditions: Option>>, + + /// Optional exogynous regressors. #[tsify(optional, type = "Map")] pub x: Option>>, } @@ -686,14 +707,36 @@ impl TryFrom for augurs_prophet::TrainingData { #[serde(rename_all = "camelCase")] #[tsify(from_wasm_abi, type_prefix = "Prophet")] pub struct PredictionData { + /// The timestamps of the time series. + /// + /// These should be in seconds since the epoch. #[tsify(type = "TimestampSeconds[]")] pub ds: Vec, + + /// Optionally, an upper bound (cap) on the values of the time series. + /// + /// Only used if the model's growth type is `logistic`. #[tsify(optional)] pub cap: Option>, + + /// Optionally, a lower bound (floor) on the values of the time series. + /// + /// Only used if the model's growth type is `logistic`. #[tsify(optional)] pub floor: Option>, + + /// Optional indicator variables for conditional seasonalities. + /// + /// The keys of the map are the names of the seasonality components, + /// and the values are boolean arrays of length `T` where `true` indicates + /// that the component is active for the corresponding time point. + /// + /// There must be a key in this map for each seasonality component + /// that is marked as conditional in the model. #[tsify(optional)] pub seasonality_conditions: Option>>, + + /// Optional exogynous regressors. #[tsify(optional)] pub x: Option>>, } @@ -719,19 +762,16 @@ impl TryFrom for augurs_prophet::PredictionData { } } -impl From<(Option, augurs_prophet::FeaturePrediction)> for Forecast { - fn from((level, value): (Option, augurs_prophet::FeaturePrediction)) -> Self { - Self { - point: value.point, - intervals: level - .zip(value.lower) - .zip(value.upper) - .map(|((level, lower), upper)| ForecastIntervals { - level, - lower, - upper, - }), - } +fn make_forecast(level: Option, predictions: augurs_prophet::FeaturePrediction) -> Forecast { + Forecast { + point: predictions.point, + intervals: level.zip(predictions.lower).zip(predictions.upper).map( + |((level, lower), upper)| ForecastIntervals { + level, + lower, + upper, + }, + ), } } @@ -801,26 +841,26 @@ impl From<(Option, augurs_prophet::Predictions)> for Predictions { fn from((level, value): (Option, augurs_prophet::Predictions)) -> Self { Self { ds: value.ds, - yhat: (level, value.yhat).into(), - trend: (level, value.trend).into(), + yhat: make_forecast(level, value.yhat), + trend: make_forecast(level, value.trend), cap: value.cap, floor: value.floor, - additive: (level, value.additive).into(), - multiplicative: (level, value.multiplicative).into(), + additive: make_forecast(level, value.additive), + multiplicative: make_forecast(level, value.multiplicative), holidays: value .holidays .into_iter() - .map(|(k, v)| (k, (level, v).into())) + .map(|(k, v)| (k, make_forecast(level, v))) .collect(), seasonalities: value .seasonalities .into_iter() - .map(|(k, v)| (k, (level, v).into())) + .map(|(k, v)| (k, make_forecast(level, v))) .collect(), regressors: value .regressors .into_iter() - .map(|(k, v)| (k, (level, v).into())) + .map(|(k, v)| (k, make_forecast(level, v))) .collect(), } } diff --git a/js/augurs-seasons-js/Cargo.toml b/js/augurs-seasons-js/Cargo.toml new file mode 100644 index 00000000..f98d9ef4 --- /dev/null +++ b/js/augurs-seasons-js/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "augurs-seasons-js" +version.workspace = true +authors.workspace = true +documentation.workspace = true +repository.workspace = true +license.workspace = true +edition.workspace = true +keywords.workspace = true +description = "JavaScript bindings for the augurs-seasons library." +publish = false + +[lib] +bench = false +crate-type = ["cdylib", "rlib"] +doc = false +doctest = false +test = false + +[dependencies] +augurs-core-js.workspace = true +augurs-seasons.workspace = true +serde.workspace = true +serde-wasm-bindgen.workspace = true +tsify-next.workspace = true +wasm-bindgen.workspace = true + +[package.metadata.wasm-pack.profile.release] +# previously had just ['-O4'] +wasm-opt = ['-O4', '--enable-bulk-memory', '--enable-threads'] + +[lints] +workspace = true diff --git a/crates/augurs-js/src/seasons.rs b/js/augurs-seasons-js/src/lib.rs similarity index 98% rename from crates/augurs-js/src/seasons.rs rename to js/augurs-seasons-js/src/lib.rs index f2016332..ab4592c4 100644 --- a/crates/augurs-js/src/seasons.rs +++ b/js/augurs-seasons-js/src/lib.rs @@ -4,10 +4,9 @@ use serde::Deserialize; use tsify_next::Tsify; use wasm_bindgen::prelude::*; +use augurs_core_js::VecF64; use augurs_seasons::{Detector, PeriodogramDetector}; -use crate::VecF64; - /// Options for detecting seasonal periods. #[derive(Debug, Default, Deserialize, Tsify)] #[serde(rename_all = "camelCase")] diff --git a/js/justfile b/js/justfile new file mode 100644 index 00000000..9adbc134 --- /dev/null +++ b/js/justfile @@ -0,0 +1,42 @@ +build: \ + (build-inner "changepoint") \ + (build-inner "clustering") \ + (build-inner "core") \ + (build-inner "dtw") \ + (build-inner "ets") \ + (build-inner "mstl") \ + (build-inner "outlier") \ + (build-inner "prophet") \ + (build-inner "seasons") + just fix-package-json + +build-inner target args='': + cd augurs-{{target}}-js && \ + rm -rf ./pkg && \ + wasm-pack build \ + --scope bsull \ + --out-name {{target}} \ + --release \ + --target web \ + --no-pack \ + --out-dir ../augurs \ + -- {{args}} + +fix-package-json: + #!/usr/bin/env bash + set -euxo pipefail + cp package.json.tmpl augurs/package.json + VERSION=$(cargo metadata --format-version 1 | jq -r '.packages[] | select (.name == "augurs") | .version') + jq < augurs/package.json ". | .version = \"$VERSION\"" > augurs/package.json.tmp + mv augurs/package.json.tmp augurs/package.json + +test: + cd testpkg && \ + npm ci && \ + npm run typecheck && \ + npm run test:ci + +publish: + cd augurs && \ + npm publish --access public + diff --git a/js/package.json.tmpl b/js/package.json.tmpl new file mode 100644 index 00000000..c7db1307 --- /dev/null +++ b/js/package.json.tmpl @@ -0,0 +1,41 @@ +{ + "name": "@bsull/augurs", + "description": "JavaScript bindings for the augurs time series library.", + "version": "0.5.0", + "collaborators": [ + "Ben Sully { const y = [0.5, 1.0, 0.4, 0.8, 1.5, 0.9, 0.6, 25.3, 20.4, 27.3, 30.0]; diff --git a/crates/augurs-js/testpkg/clustering.test.ts b/js/testpkg/clustering.test.ts similarity index 76% rename from crates/augurs-js/testpkg/clustering.test.ts rename to js/testpkg/clustering.test.ts index e5a2213e..66ed7d39 100644 --- a/crates/augurs-js/testpkg/clustering.test.ts +++ b/js/testpkg/clustering.test.ts @@ -1,10 +1,12 @@ import { readFileSync } from "node:fs"; -import { DbscanClusterer, DistanceMatrix, Dtw, initSync } from '../pkg'; +import { DbscanClusterer, initSync as initClusteringSync } from '@bsull/augurs/clustering'; +import { Dtw, initSync as initDtwSync } from '@bsull/augurs/dtw'; import { describe, expect, it } from 'vitest'; -initSync({ module: readFileSync('node_modules/@bsull/augurs/augurs_bg.wasm') }); +initClusteringSync({ module: readFileSync('node_modules/@bsull/augurs/clustering_bg.wasm') }); +initDtwSync({ module: readFileSync('node_modules/@bsull/augurs/dtw_bg.wasm') }); describe('clustering', () => { it('can be instantiated', () => { @@ -14,23 +16,23 @@ describe('clustering', () => { 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([ + const labels = clusterer.fit([ [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([ + const labels = clusterer.fit([ 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])); }); diff --git a/crates/augurs-js/testpkg/dtw.test.ts b/js/testpkg/dtw.test.ts similarity index 79% rename from crates/augurs-js/testpkg/dtw.test.ts rename to js/testpkg/dtw.test.ts index a5b90eef..741d9c1d 100644 --- a/crates/augurs-js/testpkg/dtw.test.ts +++ b/js/testpkg/dtw.test.ts @@ -1,10 +1,10 @@ import { readFileSync } from "node:fs"; -import { Dtw, initSync } from '../pkg'; +import { Dtw, initSync } from '@bsull/augurs/dtw'; import { describe, expect, it } from 'vitest'; -initSync({ module: readFileSync('node_modules/@bsull/augurs/augurs_bg.wasm') }); +initSync({ module: readFileSync('node_modules/@bsull/augurs/dtw_bg.wasm') }); describe('dtw', () => { describe('distance', () => { @@ -39,10 +39,11 @@ describe('dtw', () => { expect(dtw).toBeInstanceOf(Dtw); }); - it('can be instantiated with a custom parallelize', () => { - const dtw = Dtw.euclidean({ parallelize: true }); - expect(dtw).toBeInstanceOf(Dtw); - }); + // Commented out because we compile without parallelism for now. + // 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(); @@ -112,28 +113,24 @@ describe('dtw', () => { 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); + expect(dists).toHaveLength(3); + expect(dists[0]).toHaveLength(3); + expect(dists[0][0]).toBeCloseTo(0.0); + expect(dists[0][1]).toBeCloseTo(5.0990195135927845); + expect(dists[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); + expect(dists).toBeInstanceOf(Array); + expect(dists).toHaveLength(3); + expect(dists[0]).toBeInstanceOf(Float64Array); + expect(dists[0]).toHaveLength(3); + expect(dists[0][0]).toBeCloseTo(0.0); + expect(dists[0][1]).toBeCloseTo(5.0990195135927845); + expect(dists[0][2]).toBeCloseTo(10.392304845413264); }); it('gives a useful error when passed the wrong kind of data', () => { diff --git a/crates/augurs-js/testpkg/ets.test.ts b/js/testpkg/ets.test.ts similarity index 90% rename from crates/augurs-js/testpkg/ets.test.ts rename to js/testpkg/ets.test.ts index 6d8f7f87..418dd7ed 100644 --- a/crates/augurs-js/testpkg/ets.test.ts +++ b/js/testpkg/ets.test.ts @@ -1,10 +1,10 @@ import { readFileSync } from "node:fs"; -import { AutoETS, initSync } from '../pkg'; +import { AutoETS, initSync } from '@bsull/augurs/ets'; import { describe, expect, it } from 'vitest'; -initSync({ module: readFileSync('node_modules/@bsull/augurs/augurs_bg.wasm') }); +initSync({ module: readFileSync('node_modules/@bsull/augurs/ets_bg.wasm') }); const y = Array.from({ length: 20 }, (_, i) => i % 5); diff --git a/crates/augurs-js/testpkg/logging.test.ts b/js/testpkg/logging.test.ts similarity index 86% rename from crates/augurs-js/testpkg/logging.test.ts rename to js/testpkg/logging.test.ts index ee61e007..5d4a1b7a 100644 --- a/crates/augurs-js/testpkg/logging.test.ts +++ b/js/testpkg/logging.test.ts @@ -1,10 +1,10 @@ import { readFileSync } from "node:fs"; -import { initLogging, initSync } from '../pkg'; +import { initLogging, initSync } from '@bsull/augurs'; import { describe, expect, it } from 'vitest'; -initSync({ module: readFileSync('node_modules/@bsull/augurs/augurs_bg.wasm') }); +initSync({ module: readFileSync('node_modules/@bsull/augurs/core_bg.wasm') }); describe('logging', () => { it('can be initialized with no config', () => { diff --git a/crates/augurs-js/testpkg/mstl.test.ts b/js/testpkg/mstl.test.ts similarity index 89% rename from crates/augurs-js/testpkg/mstl.test.ts rename to js/testpkg/mstl.test.ts index 71e00cd3..45ae5abb 100644 --- a/crates/augurs-js/testpkg/mstl.test.ts +++ b/js/testpkg/mstl.test.ts @@ -1,10 +1,10 @@ import { readFileSync } from "node:fs"; -import { ets, MSTL, initSync } from '../pkg'; +import { ets, MSTL, initSync } from '@bsull/augurs/mstl'; import { describe, expect, it } from 'vitest'; -initSync({ module: readFileSync('node_modules/@bsull/augurs/augurs_bg.wasm') }); +initSync({ module: readFileSync('node_modules/@bsull/augurs/mstl_bg.wasm') }); const y = Array.from({ length: 20 }, (_, i) => i % 5); @@ -20,7 +20,7 @@ describe('ets', () => { expect(model).toBeInstanceOf(MSTL); }); - it('can be instantiated with a standalone function', () => { + it('can be instantiated with a (deprecated) standalone function', () => { // @ts-ignore const model = ets([10]); expect(model).toBeInstanceOf(MSTL); diff --git a/crates/augurs-js/testpkg/outlier.test.ts b/js/testpkg/outlier.test.ts similarity index 97% rename from crates/augurs-js/testpkg/outlier.test.ts rename to js/testpkg/outlier.test.ts index 43c40b2e..e9afa32f 100644 --- a/crates/augurs-js/testpkg/outlier.test.ts +++ b/js/testpkg/outlier.test.ts @@ -1,10 +1,10 @@ import { readFileSync } from "node:fs"; -import { OutlierDetector, initSync } from '../pkg'; +import { OutlierDetector, initSync } from '@bsull/augurs/outlier'; import { describe, expect, it } from 'vitest'; -initSync({ module: readFileSync('node_modules/@bsull/augurs/augurs_bg.wasm') }); +initSync({ module: readFileSync('node_modules/@bsull/augurs/outlier_bg.wasm') }); describe('dbscan', () => { diff --git a/crates/augurs-js/testpkg/package-lock.json b/js/testpkg/package-lock.json similarity index 99% rename from crates/augurs-js/testpkg/package-lock.json rename to js/testpkg/package-lock.json index 98d73bc8..9cd80871 100644 --- a/crates/augurs-js/testpkg/package-lock.json +++ b/js/testpkg/package-lock.json @@ -6,21 +6,27 @@ "": { "name": "@bsull/augurs-js-testpkg", "devDependencies": { - "@bsull/augurs": "../pkg", + "@bsull/augurs": "../augurs", "@bsull/augurs-prophet-wasmstan": "^0.2.0", "@types/node": "^22.7.5", "typescript": "^5.6.3", "vitest": "^2.1.3" } }, + "../augurs": { + "name": "@bsull/augurs", + "version": "0.5.0", + "dev": true, + "license": "MIT OR Apache-2.0" + }, "../pkg": { "name": "@bsull/augurs-js", "version": "0.5.3", - "dev": true, + "extraneous": true, "license": "MIT OR Apache-2.0" }, "node_modules/@bsull/augurs": { - "resolved": "../pkg", + "resolved": "../augurs", "link": true }, "node_modules/@bsull/augurs-prophet-wasmstan": { diff --git a/crates/augurs-js/testpkg/package.json b/js/testpkg/package.json similarity index 90% rename from crates/augurs-js/testpkg/package.json rename to js/testpkg/package.json index ef4f7789..5669f28f 100644 --- a/crates/augurs-js/testpkg/package.json +++ b/js/testpkg/package.json @@ -3,7 +3,7 @@ "type": "module", "private": true, "devDependencies": { - "@bsull/augurs": "../pkg", + "@bsull/augurs": "../augurs", "@bsull/augurs-prophet-wasmstan": "^0.2.0", "@types/node": "^22.7.5", "typescript": "^5.6.3", diff --git a/crates/augurs-js/testpkg/prophet.real.test.ts b/js/testpkg/prophet.real.test.ts similarity index 99% rename from crates/augurs-js/testpkg/prophet.real.test.ts rename to js/testpkg/prophet.real.test.ts index 4b5b73d1..aac1d42c 100644 --- a/crates/augurs-js/testpkg/prophet.real.test.ts +++ b/js/testpkg/prophet.real.test.ts @@ -1,7 +1,7 @@ import { webcrypto } from 'node:crypto' import { readFileSync } from "node:fs"; -import { Prophet, initSync, initLogging, ProphetOptimizeOptions } from '../pkg'; +import { Prophet, initSync, initLogging, ProphetOptimizeOptions } from '@bsull/augurs/prophet'; import { optimizer } from '@bsull/augurs-prophet-wasmstan'; import { describe, expect, it } from 'vitest'; @@ -11,7 +11,7 @@ import { describe, expect, it } from 'vitest'; // @ts-ignore globalThis.crypto = webcrypto -initSync({ module: readFileSync('node_modules/@bsull/augurs/augurs_bg.wasm') }); +initSync({ module: readFileSync('node_modules/@bsull/augurs/prophet_bg.wasm') }); initLogging({ maxLevel: 'trace' }) describe('Prophet', () => { diff --git a/crates/augurs-js/testpkg/prophet.test.ts b/js/testpkg/prophet.test.ts similarity index 92% rename from crates/augurs-js/testpkg/prophet.test.ts rename to js/testpkg/prophet.test.ts index 2eec5450..d23b9406 100644 --- a/crates/augurs-js/testpkg/prophet.test.ts +++ b/js/testpkg/prophet.test.ts @@ -1,7 +1,7 @@ import { webcrypto } from 'node:crypto' import { readFileSync } from "node:fs"; -import { Prophet, initSync } from '../pkg'; +import { Prophet, initSync } from '@bsull/augurs/prophet'; import { optimizer } from '@bsull/augurs-prophet-wasmstan'; import { describe, expect, it } from 'vitest'; @@ -11,7 +11,7 @@ import { describe, expect, it } from 'vitest'; // @ts-ignore globalThis.crypto = webcrypto -initSync({ module: readFileSync('node_modules/@bsull/augurs/augurs_bg.wasm') }); +initSync({ module: readFileSync('node_modules/@bsull/augurs/prophet_bg.wasm') }); const ds = [1704067200, 1704871384, 1705675569, 1706479753, 1707283938, 1708088123, 1708892307, 1709696492, 1710500676, 1711304861, 1712109046, 1712913230, diff --git a/crates/augurs-js/testpkg/seasons.test.ts b/js/testpkg/seasons.test.ts similarity index 80% rename from crates/augurs-js/testpkg/seasons.test.ts rename to js/testpkg/seasons.test.ts index bdce36b6..ffa27f06 100644 --- a/crates/augurs-js/testpkg/seasons.test.ts +++ b/js/testpkg/seasons.test.ts @@ -1,10 +1,10 @@ import { readFileSync } from "node:fs"; -import { seasonalities, initSync } from '../pkg'; +import { seasonalities, initSync } from '@bsull/augurs/seasons'; import { describe, expect, it } from 'vitest'; -initSync({ module: readFileSync('node_modules/@bsull/augurs/augurs_bg.wasm') }); +initSync({ module: readFileSync('node_modules/@bsull/augurs/seasons_bg.wasm') }); describe('seasons', () => { const y = [ diff --git a/crates/augurs-js/testpkg/tsconfig.json b/js/testpkg/tsconfig.json similarity index 92% rename from crates/augurs-js/testpkg/tsconfig.json rename to js/testpkg/tsconfig.json index 56a8ab81..6e6512de 100644 --- a/crates/augurs-js/testpkg/tsconfig.json +++ b/js/testpkg/tsconfig.json @@ -1,7 +1,6 @@ { "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. */ @@ -9,9 +8,8 @@ // "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. */ + "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. */ @@ -23,11 +21,10 @@ // "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. */ + "module": "node16", /* 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. */ + "moduleResolution": "node16", /* 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. */ @@ -43,12 +40,10 @@ // "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. */ @@ -71,18 +66,16 @@ // "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. */ + "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. */ - + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ /* Type Checking */ - "strict": true, /* Enable all strict type-checking options. */ + "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. */ @@ -102,9 +95,8 @@ // "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. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ } } diff --git a/justfile b/justfile index e8272ef0..ba528daf 100644 --- a/justfile +++ b/justfile @@ -1,21 +1,15 @@ set ignore-comments build-augurs-js: - cd crates/augurs-js && \ - rm -rf ./pkg && \ - wasm-pack build --scope bsull --out-name augurs --release --target web -- --features parallel + rm -rf js/augurs/* + just js/build test-augurs-js: build-augurs-js - cd crates/augurs-js/testpkg && \ - npm ci && \ - npm run typecheck && \ - npm run test:ci + just js/test # Build and publish the augurs JS package to npm with the @bsull scope. publish-augurs-js: test-augurs-js - cd crates/augurs-js && \ - node prepublish && \ - wasm-pack publish --access public + just js/publish test: cargo nextest run \ @@ -33,14 +27,19 @@ test-all: --all-features \ --all-targets \ --workspace \ - --exclude augurs-js \ + --exclude *-js \ --exclude pyaugurs \ -E 'not (binary(/iai/) | binary(/prophet-cmdstan/))' doctest: - # Ignore augurs-js and pyaugurs since they either won't compile with all features enabled + # Ignore JS and pyaugurs crates since they either won't compile with all features enabled # or doesn't have any meaningful doctests anyway, since they're not published. - cargo test --doc --all-features --workspace --exclude augurs-js --exclude pyaugurs + cargo test \ + --doc \ + --all-features \ + --workspace \ + --exclude *-js \ + --exclude pyaugurs \ doc: cargo doc --all-features --workspace --exclude augurs-js --exclude pyaugurs --open