Skip to content

Commit 981b46b

Browse files
committed
feat: add Wasm components for outlier and changepoint detection; generate Go bindings
This is a proof of concept of using [`gravity`] to generate Go bindings for some of augurs' functionality. There are two stages: 1. Build Wasm components which wraps some of `augurs` key methods 2. Generate Go bindings from the Wasm component using gravity. This results in a Go package with some subpackages that can be used to call augurs code. I'm using a fork of `gravity` for now to get a few more features, which will hopefully be merged upstream soon. [`gravity`]: https://github.com/sd2k/gravity
1 parent 0a00f22 commit 981b46b

File tree

23 files changed

+2168
-4
lines changed

23 files changed

+2168
-4
lines changed

.github/workflows/go.yml

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
name: Go
2+
3+
on:
4+
push:
5+
branches: ["main"]
6+
pull_request:
7+
8+
env:
9+
CARGO_TERM_COLOR: always
10+
11+
permissions: {}
12+
13+
jobs:
14+
go-bindings:
15+
name: Go bindings
16+
runs-on: ubuntu-latest
17+
steps:
18+
- name: Checkout
19+
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
20+
with:
21+
persist-credentials: false
22+
23+
- name: Install Rust toolchain
24+
uses: moonrepo/setup-rust@ede6de059f8046a5e236c94046823e2af11ca670 # v1.2.2
25+
with:
26+
channel: nightly-2025-09-21
27+
targets: x86_64-unknown-linux-gnu,wasm32-unknown-unknown
28+
bins: just
29+
env:
30+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
31+
32+
- name: Install Go toolchain
33+
uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0
34+
with:
35+
go-version: "1.25"
36+
cache-dependency-path: |
37+
components/rust/outlier/go.sum
38+
39+
- name: Install gravity
40+
# Use a custom fork of gravity with a bunch of extra features.
41+
run: cargo install --git https://github.com/sd2k/gravity --rev 20dee8fd100bb70274c7c05f37cc8bde1059bae0
42+
43+
- name: Build Wasm components
44+
run: just go/build-wasm
45+
46+
- name: Generate Go bindings
47+
run: just go/generate
48+
49+
- name: Test Go bindings
50+
run: just go/test
51+
52+
- name: Ensure no diffs
53+
run: git diff --exit-code -- ./go

Cargo.toml

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
[workspace]
22
members = [
3+
"components/rust/*",
34
"crates/*",
45
"examples/*",
56
"js/*",
@@ -56,9 +57,9 @@ rand = "0.9.0"
5657
rand_distr = "0.5.1"
5758
roots = "0.0.8"
5859
serde = { version = "1.0.166", features = ["derive"] }
59-
statrs = "0.18.0"
6060
serde_json = "1.0.128"
6161
serde-wasm-bindgen = "0.6.0"
62+
statrs = "0.18.0"
6263
thiserror = "2.0.3"
6364
tinyvec = "1.6.0"
6465
tracing = "0.1.40"
@@ -97,8 +98,9 @@ debug = true
9798

9899
[patch.crates-io]
99100
# rand 0.9 merged into main, waiting for a new release.
100-
argmin = { git = "https://github.com/argmin-rs/argmin", branch = "main" }
101-
argmin-math = { git = "https://github.com/argmin-rs/argmin", branch = "main" }
101+
# meanwhile, had to fork to add a feature flag for timers
102+
argmin = { git = "https://github.com/sd2k/argmin", branch = "timer-feature" }
103+
argmin-math = { git = "https://github.com/sd2k/argmin", branch = "timer-feature" }
102104

103105
# Depends on rv PR and release (see below).
104106
changepoint = { git = "https://github.com/sd2k/changepoint", branch = "patched-deps-to-bump-rand" }

components/rust/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Rust Wasm Components
2+
3+
This directory contains various Wasm components implemented in Rust using the augurs library.
4+
5+
The resulting Wasm files can be used either directly or by generating host bindings for a specific language using e.g. [`gravity`](https://github.com/arcjet/gravity).
6+
7+
See the `go` directory for a Go package which is generated from these components. That directory also contains a `justfile` for building the components, which requires some specific `RUSTFLAGS`.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
[package]
2+
name = "augurs-changepoint-component"
3+
license.workspace = true
4+
authors.workspace = true
5+
documentation.workspace = true
6+
repository.workspace = true
7+
version.workspace = true
8+
edition.workspace = true
9+
keywords.workspace = true
10+
publish = false
11+
12+
[lib]
13+
crate-type = ["cdylib"]
14+
15+
[dependencies]
16+
augurs-changepoint.workspace = true
17+
wit-bindgen.workspace = true
18+
wit-component.workspace = true
19+
20+
[lints]
21+
workspace = true
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
//! Implementation of a Wasm component that can perform changepoint detection on time series.
2+
3+
use std::{fmt, num::NonZeroUsize};
4+
5+
use augurs_changepoint::{
6+
self, dist::NormalGamma, BocpdDetector, DefaultArgpcpDetector, DefaultArgpcpDetectorBuilder,
7+
Detector,
8+
};
9+
10+
// Wrap the wit-bindgen macro in a module so we don't get warned about missing docs in the generated trait.
11+
mod bindings {
12+
wit_bindgen::generate!({
13+
world: "changepoint",
14+
default_bindings_module: "bindings",
15+
});
16+
}
17+
use crate::bindings::{
18+
export,
19+
grafana::augurs::types::{Algorithm, ArgpcpParams, Input, NormalGammaParams, Output},
20+
Guest,
21+
};
22+
23+
struct ChangepointWorld;
24+
export!(ChangepointWorld);
25+
26+
impl Guest for ChangepointWorld {
27+
fn detect(input: Input) -> Result<Output, String> {
28+
detect(input).map_err(|e| e.to_string())
29+
}
30+
}
31+
32+
/// An error type for the changepoint detector.
33+
#[derive(Debug)]
34+
pub enum ChangepointError {
35+
/// An invalid parameter was provided to the Normal Gamma distribution.
36+
NormalGammaError(augurs_changepoint::dist::NormalGammaError),
37+
/// An overflow occurred when converting an integer to a `NonZeroUsize`.
38+
TryFromIntError(std::num::TryFromIntError),
39+
/// An invalid parameter was provided to the max lag.
40+
InvalidMaxLag(u32),
41+
}
42+
43+
impl From<std::num::TryFromIntError> for ChangepointError {
44+
fn from(value: std::num::TryFromIntError) -> Self {
45+
Self::TryFromIntError(value)
46+
}
47+
}
48+
49+
impl From<augurs_changepoint::dist::NormalGammaError> for ChangepointError {
50+
fn from(value: augurs_changepoint::dist::NormalGammaError) -> Self {
51+
Self::NormalGammaError(value)
52+
}
53+
}
54+
55+
impl fmt::Display for ChangepointError {
56+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57+
match self {
58+
Self::NormalGammaError(e) => write!(f, "invalid Normal Gamma distribution: {}", e),
59+
Self::TryFromIntError(e) => write!(f, "overflow converting to u32: {}", e),
60+
Self::InvalidMaxLag(ml) => write!(f, "invalid max lag: {}", ml),
61+
}
62+
}
63+
}
64+
65+
impl std::error::Error for ChangepointError {}
66+
67+
impl TryFrom<ArgpcpParams> for DefaultArgpcpDetectorBuilder {
68+
type Error = ChangepointError;
69+
70+
fn try_from(params: ArgpcpParams) -> Result<Self, Self::Error> {
71+
let mut builder = DefaultArgpcpDetector::builder();
72+
if let Some(cv) = params.constant_value {
73+
builder = builder.constant_value(cv);
74+
}
75+
if let Some(ls) = params.length_scale {
76+
builder = builder.length_scale(ls);
77+
}
78+
if let Some(nl) = params.noise_level {
79+
builder = builder.noise_level(nl);
80+
}
81+
if let Some(ml) = params.max_lag {
82+
let ml = NonZeroUsize::new(ml.try_into().map_err(ChangepointError::TryFromIntError)?)
83+
.ok_or(ChangepointError::InvalidMaxLag(ml))?;
84+
builder = builder.max_lag(ml);
85+
}
86+
if let Some(a0) = params.alpha0 {
87+
builder = builder.alpha0(a0);
88+
}
89+
if let Some(b0) = params.beta0 {
90+
builder = builder.beta0(b0);
91+
}
92+
if let Some(h) = params.logistic_hazard.h {
93+
builder = builder.logistic_hazard_h(h);
94+
}
95+
if let Some(a) = params.logistic_hazard.a {
96+
builder = builder.logistic_hazard_a(a);
97+
}
98+
if let Some(b) = params.logistic_hazard.b {
99+
builder = builder.logistic_hazard_b(b);
100+
}
101+
Ok(builder)
102+
}
103+
}
104+
105+
fn convert_normal_gamma(
106+
params: Option<NormalGammaParams>,
107+
) -> Result<NormalGamma, ChangepointError> {
108+
Ok(NormalGamma::new(
109+
params.and_then(|p| p.mu).unwrap_or(0.0),
110+
params.and_then(|p| p.rho).unwrap_or(1.0),
111+
params.and_then(|p| p.s).unwrap_or(1.0),
112+
params.and_then(|p| p.v).unwrap_or(1.0),
113+
)?)
114+
}
115+
116+
fn detect(input: Input) -> Result<Output, ChangepointError> {
117+
match input.algorithm {
118+
Algorithm::Argpcp(params) => Ok(DefaultArgpcpDetectorBuilder::try_from(params)?
119+
.build()
120+
.detect_changepoints(&input.data)
121+
.into_iter()
122+
.map(|i| i.try_into())
123+
.collect::<Result<_, _>>()?),
124+
Algorithm::Bocpd(params) => Ok(BocpdDetector::normal_gamma(
125+
params.hazard_lambda.unwrap_or(250.0),
126+
convert_normal_gamma(params.normal_gamma_params)?,
127+
)
128+
.detect_changepoints(&input.data)
129+
.into_iter()
130+
.map(|i| i.try_into())
131+
.collect::<Result<_, _>>()?),
132+
}
133+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/// The augurs package.
2+
package grafana:augurs;
3+
4+
/// Types used by the changepoint detector world.
5+
interface types {
6+
/// The input to changepoint detection.
7+
record input {
8+
/// The data to detect changepoints in.
9+
data: list<f64>,
10+
11+
/// The algorithm to use.
12+
algorithm: algorithm,
13+
}
14+
15+
/// The indices of the most likely changepoints.
16+
type output = list<u32>;
17+
18+
/// The parameters for the logistic hazard function.
19+
///
20+
/// Any unset parameters will use the default values.
21+
record logistic-hazard {
22+
/// Logit scaled, scaling factor for the logistic hazard function.
23+
///
24+
/// Increasing increases hazard over the whole space; more negative numbers
25+
/// result in larger run lengths.
26+
///
27+
/// Defaults to -5.0.
28+
h: option<f64>,
29+
/// Scale term. Roughly, the slope of the logistic hazard function.
30+
///
31+
/// Higher numbers increase the variance of run-lengths.
32+
///
33+
/// Defaults to 1.0.
34+
a: option<f64>,
35+
/// Translation term (increasing moves the logistic to the left).
36+
///
37+
/// Defaults to 1.0.
38+
b: option<f64>,
39+
}
40+
41+
/// The parameters for the Autoregressive Gaussian Process Changepoint Detection (Argpcp) algorithm.
42+
///
43+
/// Any unset parameters will use the default values.
44+
record argpcp-params {
45+
/// The value of the constant kernel.
46+
constant-value: option<f64>,
47+
/// The length scale of the RBF kernel.
48+
length-scale: option<f64>,
49+
/// The noise level of the white kernel.
50+
noise-level: option<f64>,
51+
/// The maximum autoregressive lag.
52+
max-lag: option<u32>,
53+
/// The scale Gamma distribution alpha parameter.
54+
alpha0: option<f64>,
55+
/// The scale Gamma distribution beta parameter.
56+
beta0: option<f64>,
57+
58+
/// The logistic hazard function parameters.
59+
logistic-hazard: logistic-hazard,
60+
}
61+
62+
/// The parameters for the Normal Gamma prior.
63+
///
64+
/// Any unset parameters will use the default values.
65+
record normal-gamma-params {
66+
/// The prior mean.
67+
///
68+
/// Defaults to 0.0.
69+
mu: option<f64>,
70+
/// The relative precision of μ versus data.
71+
///
72+
/// Defaults to 1.0.
73+
rho: option<f64>,
74+
/// The mean of rho (the precision) is v/s.
75+
///
76+
/// Defaults to 1.0.
77+
s: option<f64>,
78+
/// The degrees of freedom of precision of rho.
79+
///
80+
/// Defaults to 1.0.
81+
v: option<f64>,
82+
}
83+
84+
/// The parameters for the Bayesian Online Changepoint Detection (BOCPD) algorithm.
85+
///
86+
/// Any unset parameters will use the default values.
87+
record bocpd-params {
88+
/// The hazard lambda.
89+
///
90+
/// `1/hazard` is the probability of the next step being a changepoint.
91+
/// Therefore, the larger the value, the lower the prior probability
92+
/// is for the any point to be a change-point.
93+
/// Mean run-length is lambda - 1.
94+
///
95+
/// Defaults to 250.0.
96+
hazard-lambda: option<f64>,
97+
98+
/// The parameters of the Normal Gamma prior.
99+
normal-gamma-params: option<normal-gamma-params>,
100+
}
101+
102+
/// A changepoint detection algorithm.
103+
variant algorithm {
104+
/// The Autoregressive Gaussian Process Changepoint Detection (Argpcp) algorithm.
105+
argpcp(argpcp-params),
106+
/// The Bayesian Online Changepoint Detection (BOCPD) algorithm.
107+
///
108+
/// The algorithm is created with a Normal Gamma prior. Other priors
109+
/// are not yet supported.
110+
bocpd(bocpd-params),
111+
}
112+
}
113+
114+
world changepoint {
115+
use types.{input, output};
116+
/// Detect changepoints in the input.
117+
export detect: func(input: input) -> result<output, string>;
118+
}

components/rust/outlier/Cargo.toml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
[package]
2+
name = "augurs-outlier-component"
3+
license.workspace = true
4+
authors.workspace = true
5+
documentation.workspace = true
6+
repository.workspace = true
7+
version.workspace = true
8+
edition.workspace = true
9+
keywords.workspace = true
10+
publish = false
11+
12+
[lib]
13+
crate-type = ["cdylib"]
14+
15+
[dependencies]
16+
augurs-outlier.workspace = true
17+
wit-bindgen.workspace = true
18+
wit-component.workspace = true
19+
20+
[lints]
21+
workspace = true

0 commit comments

Comments
 (0)