name: add-diffusion-process
description: How to add a new diffusion / SDE process to stochastic-rs-stochastic. Invoke when implementing GBM-like, OU-like, Vasicek-like, CIR-like, Heston-like models that satisfy dX_t = drift dt + diffusion dW_t.
Add diffusion process — stochastic-rs-stochastic
This SKILL covers the recipe for adding a new diffusion process
(stochastic-rs-stochastic/src/diffusion/<name>.rs). The recipe
applies equally to volatility / interest-rate / credit-style
diffusions; for fractional processes (driven by fBm or a Volterra
kernel) see add-fractional-process. For jump processes see
add-jump-process.
1. The trait surface
A new process must implement ProcessExt<T> for the standard sample
shape:
// stochastic-rs-stochastic/src/traits.rs
pub trait ProcessExt<T: FloatExt> {
type Output;
fn sample(&self) -> Self::Output;
// Optional overrides (defaults are: call `sample` and unwrap)
fn sample_par(&self, _m: usize) -> Vec<Self::Output> { ... }
fn sample_pair(&self) -> [Self::Output; 2] { ... }
fn sample_pair_par(&self, _m: usize) -> Vec<[Self::Output; 2]> { ... }
}
For the typical 1-D path output Array1<T>, defaults are fine. For
2-D / multi-asset output (e.g. Heston returning [Array1<T>; 2] for
price + vol), you set type Output = [Array1<T>; 2] and provide the
joint sampler in sample().
2. The struct + constructor parity
Every diffusion struct ships both an unseeded and a seeded
constructor with the same parameter list, plus an explicit t total
horizon:
// stochastic-rs-stochastic/src/diffusion/foo.rs
use ndarray::Array1;
use stochastic_rs_core::simd_rng::Deterministic;
use stochastic_rs_core::simd_rng::SeedExt;
use stochastic_rs_core::simd_rng::Unseeded;
use crate::traits::FloatExt;
use crate::traits::ProcessExt;
pub struct Foo<T: FloatExt, S: SeedExt = Unseeded> {
/// Mean-reversion speed.
pub theta: T,
/// Long-run mean.
pub mu: T,
/// Diffusion / noise scale.
pub sigma: T,
/// Number of discretisation points (≥ 2).
pub n: usize,
/// Initial value (defaults to zero / 1.0 — match the SDE convention).
pub x0: Option<T>,
/// Total horizon; defaults to 1.0.
pub t: Option<T>,
/// Seed strategy (compile-time: Unseeded or Deterministic).
pub seed: S,
}
impl<T: FloatExt> Foo<T> {
#[must_use]
pub fn new(theta: T, mu: T, sigma: T, n: usize, x0: Option<T>, t: Option<T>) -> Self {
assert!(n >= 2, "n must be at least 2");
Self {
theta, mu, sigma, n, x0, t, seed: Unseeded,
}
}
}
impl<T: FloatExt> Foo<T, Deterministic> {
#[must_use]
pub fn seeded(
theta: T, mu: T, sigma: T, n: usize, x0: Option<T>, t: Option<T>, seed: u64,
) -> Self {
assert!(n >= 2, "n must be at least 2");
Self {
theta, mu, sigma, n, x0, t, seed: Deterministic::new(seed),
}
}
}
The phantom seed: S field is the compile-time switch between the two.
Both constructors validate n >= 2 upfront — the rc.2 Fukasawa-Hurst
fix taught us to thread seeded constructors through every test that
samples a process.
3. The naming convention — theta vs mu
Mandatory: the workspace uses
theta: mean-reversion speed (κ in many texts, e.g. Brigo).mu: long-run mean level (θ in many texts).
The rc.0 CIR bug shipped because Vasicek and CIR had theta/mu
swapped between source and tests. Every new diffusion that has a
mean-reversion-speed × long-run-mean structure (dX = θ(μ-X)dt + ...)
must keep this convention. If the canonical paper uses different
symbols, document the translation in the struct's doc comment but use
our names in the field.
4. The sample implementation
For Euler-Maruyama discretisation:
impl<T: FloatExt, S: SeedExt> ProcessExt<T> for Foo<T, S> {
type Output = Array1<T>;
fn sample(&self) -> Self::Output {
let t = self.t.unwrap_or(T::one());
let dt = t / T::from_usize_(self.n - 1);
let mut rng = self.seed.derive(); // <-- rng from the seed strategy
let mut path = Array1::<T>::zeros(self.n);
path[0] = self.x0.unwrap_or(T::zero());
for i in 1..self.n {
let z = rng.sample::<f64, StandardNormal>();
let z = T::from_f64_fast(z);
let drift = self.theta * (self.mu - path[i - 1]);
let diffusion = self.sigma;
path[i] = path[i - 1] + drift * dt + diffusion * dt.sqrt() * z;
}
path
}
}
Use T::from_f64_fast (not T::from) for compile-time-known constant
conversions — it's the workspace convention for the FloatExt
boundary.
For higher-order schemes (Milstein, SRK2, SRK4), see crate::sde::*
helpers — there's a generic milstein_step, srk2_step etc. that take
drift/diffusion closures.
5. Python wrapper macro
After the inherent + ProcessExt impls, append the Python wrapper macro at the bottom of the source file:
py_process_1d!(PyFoo, Foo,
sig: (theta, mu, sigma, n, x0 = 0.0, t = 1.0, m = None, seed = None),
params: (theta: f64, mu: f64, sigma: f64, n: usize, x0: f64, t: f64, m: Option<usize>),
);
The macro generates:
#[pyclass(unsendable)]PyFoowith__new__accepting the listed signaturesample(n)returning a numpy array (via theIntoF64shim fromstochastic-rs-core::python)sample_par(m, n)returning a 2-D numpy array (parallel paths)
For 2-D output (e.g. Heston), use py_process_2x1d! instead. For
multi-asset correlated (returning Array2<T>), use py_process_2d!.
After the macro, remember to register the class in
stochastic-rs-py/src/lib.rs:
use stochastic_rs_stochastic::diffusion::foo::PyFoo;
// ...
m.add_class::<PyFoo>()?;
6. Backward-compat aliases
If you rename an existing process (e.g. for the mean-reversion-speed
convention fix), add an alias in
stochastic-rs-stochastic/src/diffusion/aliases.rs:
#[deprecated(since = "X.Y.Z", note = "renamed to Foo; use Foo::new instead")]
pub use super::foo::Foo as OldFoo;
The alias keeps the old name compiling for one release cycle. Drop it in the next major.
7. Testing requirements
A new diffusion ships with at least four tests:
#[cfg(test)]
mod tests {
use super::*;
/// 1. Seeded determinism.
#[test]
fn seeded_is_deterministic() {
let p1 = Foo::seeded(0.5, 0.0, 0.1, 100, None, None, 42).sample();
let p2 = Foo::seeded(0.5, 0.0, 0.1, 100, None, None, 42).sample();
for i in 0..100 {
assert_eq!(p1[i], p2[i]);
}
}
/// 2. Pure drift (sigma = 0) collapses to deterministic ODE.
#[test]
fn zero_diffusion_matches_deterministic() {
// dX/dt = theta * (mu - X) → X(t) = mu + (X0 - mu) * exp(-theta * t)
// ...
}
/// 3. Theoretical moment recovery on long path.
#[test]
fn long_path_mean_matches_theory() {
// ...
}
/// 4. Constructor validates n >= 2.
#[test]
#[should_panic(expected = "n must be at least 2")]
fn rejects_n_below_two() {
let _ = Foo::<f64>::new(0.5, 0.0, 0.1, 1, None, None);
}
}
The first test (seeded determinism) is non-negotiable; without it calibrators that consume the process get nondeterministic regression tests downstream.
8. CLAUDE.md / prelude updates
Per CLAUDE.md, the prelude does NOT include individual process
types — users go through stochastic_rs::stochastic::diffusion::foo::Foo.
But the umbrella crate's "Workspace layout" section may mention notable
new processes (e.g. "120+ processes, incl. interest::lmm::Lmm"). Update
that line if your new process is material.
9. Anti-patterns
- Do not call
thread_rng()directly insample(). Always go throughself.seed.derive()soFoo::seeded(...)produces reproducible paths. - Do not name fields
kappa/theta(Brigo convention). The workspace usestheta/mu. Sticking to local conventions when the surrounding code uses ours produces silent numeric bugs (rc.0 CIR). - Do not add a process without a
seededconstructor. The user will need it for testing eventually; adding it later breaks the API. - Do not put validation behind
debug_assert!.assert!(n >= 2)is a permanent invariant; debug_assert hides it from release builds and lets users hit cryptic out-of-bounds panics inpath[0].
10. Reference impls (in increasing complexity)
Bm(process/bm.rs) — single-line Brownian motion, no parameters besidesn.Gbm(diffusion/gbm.rs) — geometric BM, two parameters.Vasicek(diffusion/vasicek.rs) — mean-reverting OU; thetheta/mureference.Cir(diffusion/cir.rs) — CIR with reflection at 0; rc.0 fixed the field-naming convention.Fou(diffusion/fou.rs) — fractional OU; goes throughadd-fractional-processonce you wrapFgn.Heston(volatility/heston.rs) — 2-D output ([price, vol]); usespy_process_2x1d!.
Related SKILLs
add-fractional-process— for Hurst-parameterised processes wrappingFgnor extendingMarkovLift.add-jump-process— for compound-Poisson / Lévy-driven additions.python-bindings— invoked bypy_process_*!and the registration step.feature-flag-management— if your process needs an optional GPU backend or LAPACK helper.