From 869f905dd271a3fb96f47a94af61a6bbc8363a3c Mon Sep 17 00:00:00 2001 From: Ben Sully Date: Sat, 9 Nov 2024 15:04:27 +0000 Subject: [PATCH] Add Prophet WASM example (#152) * Add Prophet WASM example * Add some comments to rust workflow * Fix build failures now that we have a WASM example * Enable all targets and features for check/clippy * Improve build script error messages * Explicitly include prophet-wasmstan.wasm in published package * Reorder download steps --- .github/workflows/rust.yml | 16 ++--- crates/augurs-prophet/Cargo.toml | 28 +++++++-- crates/augurs-prophet/build.rs | 63 +++++++++++++++---- examples/forecasting/Cargo.toml | 10 ++- .../forecasting/examples/prophet_wasmstan.rs | 61 ++++++++++++++++++ 5 files changed, 154 insertions(+), 24 deletions(-) create mode 100644 examples/forecasting/examples/prophet_wasmstan.rs diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 6c8a2d0d..ef01d728 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -2,7 +2,7 @@ name: Rust on: push: - branches: [ "main" ] + branches: ["main"] pull_request: env: @@ -20,7 +20,7 @@ jobs: uses: moonrepo/setup-rust@v1 - name: Run cargo check - run: cargo check --all-targets + run: cargo check --all-targets --all-features wasmstan: name: Prophet WASMStan component @@ -39,15 +39,17 @@ jobs: with: bins: cargo-nextest,just - - name: Download Prophet Stan model - # Download the Prophet Stan model since an example requires it. - run: just download-prophet-stan-model - + # Download the Prophet wasmstan module since an example and some doctests require it. - name: Download Prophet WASMStan component artifact uses: actions/download-artifact@v4 with: name: prophet-wasmstan.wasm path: crates/augurs-prophet + + # Download the Prophet Stan model since an example requires it. + - name: Download Prophet Stan model + run: just download-prophet-stan-model + - name: Run cargo nextest run: just test-all - name: Run doc tests @@ -81,4 +83,4 @@ jobs: components: clippy - name: Run cargo clippy - run: cargo clippy -- -D warnings + run: cargo clippy --all-features --all-targets -- -D warnings diff --git a/crates/augurs-prophet/Cargo.toml b/crates/augurs-prophet/Cargo.toml index e44f5a49..76dd496c 100644 --- a/crates/augurs-prophet/Cargo.toml +++ b/crates/augurs-prophet/Cargo.toml @@ -8,6 +8,23 @@ version.workspace = true edition.workspace = true keywords.workspace = true description = "Prophet: time-series forecasting at scale, in Rust." +include = [ + ".gitignore", + "Cargo.toml", + "README.md", + "LICENSE-APACHE", + "LICENSE-MIT", + "CHANGELOG.md", + "build.rs", + "data", + "src/**/*", + "examples", + "tests", + "benches", + "prophet-wasmstan.wit", + "prophet-wasmstan.wasm", + "prophet.stan", +] [dependencies] anyhow.workspace = true @@ -45,15 +62,16 @@ bytemuck = ["dep:bytemuck"] cmdstan = ["dep:tempfile", "dep:serde_json", "serde"] compile-cmdstan = ["cmdstan", "dep:tempfile"] download = ["dep:ureq", "dep:zip"] -# Ignore cmdstan compilation in the build script. -# This should only be used for developing the library, not by -# end users, or you may end up with a broken build where the -# Prophet model isn't available to be compiled into the binary. -internal-ignore-cmdstan-failure = [] serde = ["dep:serde"] wasmstan = ["wasmstan-min"] wasmstan-min = ["dep:serde_json", "dep:wasmtime", "dep:wasmtime-wasi", "serde"] +# Ignore cmdstan compilation or wasmstan copying in the build script. +# This should only be used for developing the library, not by +# end users, or you may end up with a broken build where the +# Prophet model isn't available to be compiled into the binary. +internal-ignore-build-failures = [] + [lib] bench = false diff --git a/crates/augurs-prophet/build.rs b/crates/augurs-prophet/build.rs index d68dca6b..0975c54d 100644 --- a/crates/augurs-prophet/build.rs +++ b/crates/augurs-prophet/build.rs @@ -49,13 +49,29 @@ fn compile_cmdstan_model() -> Result<(), Box> { // Copy the executable to the final location. let dest_exe_path = build_dir.join("prophet"); - std::fs::copy(tmp_exe_path, &dest_exe_path)?; + std::fs::copy(&tmp_exe_path, &dest_exe_path).map_err(|e| { + eprintln!( + "error copying prophet binary from {} to {}: {}", + tmp_exe_path.display(), + dest_exe_path.display(), + e + ); + e + })?; eprintln!("Copied prophet exe to {}", dest_exe_path.display()); // Copy libtbb to the final location. let libtbb_path = stan_path.join("lib/libtbb.so.12"); let dest_libtbb_path = build_dir.join("libtbb.so.12"); - std::fs::copy(&libtbb_path, &dest_libtbb_path)?; + std::fs::copy(&libtbb_path, &dest_libtbb_path).map_err(|e| { + eprintln!( + "error copying libtbb from {} to {}: {}", + libtbb_path.display(), + dest_libtbb_path.display(), + e + ); + e + })?; eprintln!( "Copied libtbb.so from {} to {}", libtbb_path.display(), @@ -88,19 +104,19 @@ fn handle_cmdstan() -> Result<(), Box> { // This will cause things to fail at runtime if there isn't a Stan // installation, but that's okay because no-one should ever use this // feature. - #[cfg(feature = "internal-ignore-cmdstan-failure")] + #[cfg(feature = "internal-ignore-build-failures")] if _result.is_err() { create_empty_files(&["prophet", "libtbb.so.12"])?; } // Do the same thing in docs.rs builds. - #[cfg(not(feature = "internal-ignore-cmdstan-failure"))] + #[cfg(not(feature = "internal-ignore-build-failures"))] if std::env::var("DOCS_RS").is_ok() { create_empty_files(&["prophet", "libtbb.so.12"])?; } // If we're not in a docs.rs build and we don't have the 'ignore' // feature enabled, then we should fail if there's an error. - #[cfg(not(feature = "internal-ignore-cmdstan-failure"))] + #[cfg(not(feature = "internal-ignore-build-failures"))] if std::env::var("DOCS_RS").is_err() { _result?; } @@ -109,6 +125,8 @@ fn handle_cmdstan() -> Result<(), Box> { #[cfg(feature = "wasmstan")] fn copy_wasmstan() -> Result<(), Box> { + println!("cargo::rerun-if-changed=prophet-wasmstan.wasm"); + let out_dir = std::path::PathBuf::from(std::env::var("OUT_DIR")?); let prophet_path = std::path::PathBuf::from(concat!( env!("CARGO_MANIFEST_DIR"), @@ -116,7 +134,15 @@ fn copy_wasmstan() -> Result<(), Box> { )) .canonicalize()?; let wasmstan_path = out_dir.join("prophet-wasmstan.wasm"); - std::fs::copy(&prophet_path, &wasmstan_path)?; + std::fs::copy(&prophet_path, &wasmstan_path).map_err(|e| { + eprintln!( + "error copying prophet-wasmstan from {} to {}: {}", + prophet_path.display(), + wasmstan_path.display(), + e + ); + e + })?; eprintln!( "Copied prophet-wasmstan.wasm from {} to {}", prophet_path.display(), @@ -129,13 +155,28 @@ fn handle_wasmstan() -> Result<(), Box> { let _result = Ok::<(), Box>(()); #[cfg(feature = "wasmstan")] let _result = copy_wasmstan(); - + // This is a complete hack but lets us get away with still using + // the `--all-features` flag of Cargo without everything failing + // if there isn't a WASM module built, which takes a while in CI + // and isn't available in docs.rs. + // Basically, if have this feature enabled, skip any failures in + // the build process and just create some empty files. + // This will cause things to fail at runtime if there isn't a WASM module + // present, but that's okay because no-one should ever use this feature. + #[cfg(feature = "internal-ignore-build-failures")] + if _result.is_err() { + create_empty_files(&["prophet-wasmstan.wasm"])?; + } + // Do the same thing in docs.rs builds. + #[cfg(not(feature = "internal-ignore-build-failures"))] if std::env::var("DOCS_RS").is_ok() { - // In docs.rs we won't have (or need) the wasmstan file in the current directory, - // so we should just create an empty one so the build doesn't fail. create_empty_files(&["prophet-wasmstan.wasm"])?; - } else { - // Otherwise, fail the build if there was an error. + } + + // If we're not in a docs.rs build and we don't have the 'ignore' + // feature enabled, then we should fail if there's an error. + #[cfg(not(feature = "internal-ignore-build-failures"))] + if std::env::var("DOCS_RS").is_err() { _result?; } Ok(()) diff --git a/examples/forecasting/Cargo.toml b/examples/forecasting/Cargo.toml index 1ccf8114..15e9a265 100644 --- a/examples/forecasting/Cargo.toml +++ b/examples/forecasting/Cargo.toml @@ -11,6 +11,14 @@ keywords.workspace = true publish = false [dependencies] -augurs = { workspace = true, features = ["ets", "mstl", "forecaster", "prophet", "prophet-cmdstan", "seasons"] } +augurs = { workspace = true, features = [ + "ets", + "mstl", + "forecaster", + "prophet", + "prophet-cmdstan", + "prophet-wasmstan", + "seasons", +] } tracing.workspace = true tracing-subscriber = { workspace = true, default-features = true } diff --git a/examples/forecasting/examples/prophet_wasmstan.rs b/examples/forecasting/examples/prophet_wasmstan.rs new file mode 100644 index 00000000..518280aa --- /dev/null +++ b/examples/forecasting/examples/prophet_wasmstan.rs @@ -0,0 +1,61 @@ +//! Example of using the Prophet model with the cmdstan optimizer. +//! +//! To run this example, you must first download the Prophet Stan model +//! and libtbb shared library into the `prophet_stan_model` directory. +//! The easiest way to do this is to run the `download-stan-model` +//! binary in the `augurs-prophet` crate: +//! +//! ```sh +//! $ cargo run --features download --bin download-stan-model +//! $ cargo run --example prophet_cmdstan +//! ``` +use std::{collections::HashMap, time::Duration}; + +use augurs::prophet::{cmdstan::CmdstanOptimizer, Prophet, Regressor, TrainingData}; + +fn main() -> Result<(), Box> { + tracing_subscriber::fmt::init(); + tracing::info!("Running Prophet example"); + + let ds = vec![ + 1704067200, 1704871384, 1705675569, 1706479753, 1707283938, 1708088123, 1708892307, + 1709696492, 1710500676, 1711304861, 1712109046, 1712913230, 1713717415, + ]; + let y = vec![ + 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0, + ]; + let data = TrainingData::new(ds, y.clone())? + .with_regressors(HashMap::from([ + ( + "foo".to_string(), + vec![ + 1.0, 2.0, 1.0, 2.0, 1.0, 2.0, 1.0, 2.0, 1.0, 2.0, 1.0, 2.0, 1.0, + ], + ), + ( + "bar".to_string(), + vec![ + 4.0, 5.0, 6.0, 4.0, 5.0, 6.0, 4.0, 5.0, 6.0, 4.0, 5.0, 6.0, 4.0, + ], + ), + ])) + .unwrap(); + + let cmdstan = CmdstanOptimizer::with_prophet_path("prophet_stan_model/prophet_model.bin")? + .with_poll_interval(Duration::from_millis(100)) + .with_refresh(50); + // If you were using the embedded version of the cmdstan model, you'd use this: + // let cmdstan = CmdstanOptimizer::new_embedded(); + + let mut prophet = Prophet::new(Default::default(), cmdstan); + prophet.add_regressor("foo".to_string(), Regressor::additive()); + prophet.add_regressor("bar".to_string(), Regressor::additive()); + + prophet.fit(data, Default::default())?; + let predictions = prophet.predict(None)?; + assert_eq!(predictions.yhat.point.len(), y.len()); + assert!(predictions.yhat.lower.is_some()); + assert!(predictions.yhat.upper.is_some()); + println!("Predicted values: {:#?}", predictions.yhat); + Ok(()) +}