Skip to main content

shape_runtime/intrinsics/
stochastic.rs

1//! Stochastic process intrinsics — full migration to typed marshal layer.
2//!
3//! Per the intrinsics-typed-CC migration's per-file table, all 4 stochastic
4//! process intrinsics (`brownian_motion`, `gbm`, `ou_process`, `random_walk`)
5//! migrate to `register_typed_fn_N` typed entries via
6//! [`create_stochastic_intrinsics_module`]. Inputs are scalar `i64`/`f64`;
7//! outputs project through `ConcreteReturn::ArrayF64`.
8//!
9//! `gbm` (arity 5) and `ou_process` (arity 6) use the N2 marshal-API
10//! arity extension (`register_typed_fn_5` / `register_typed_fn_6`,
11//! committed in `5dcb1ce` per supervisor sign-off on N2 sync-only at
12//! first landing). `brownian_motion` (arity 3) and `random_walk` (arity 2)
13//! use the pre-existing arity-3 / arity-2 helpers.
14//!
15//! Sampling uses the thread-local RNG from the `random` module via
16//! `random::with_rng`.
17
18use super::random;
19use crate::marshal::{
20    register_typed_fn_2, register_typed_fn_3, register_typed_fn_5, register_typed_fn_6,
21};
22use crate::module_exports::ModuleExports;
23use crate::typed_module_exports::{ConcreteReturn, ConcreteType, TypedReturn};
24use rand::Rng;
25
26// ───────────────────── Module factory (4 typed entries) ─────────────────────
27
28/// Create the stochastic intrinsics module with all 4 typed-marshal entry points.
29pub fn create_stochastic_intrinsics_module() -> ModuleExports {
30    let mut module = ModuleExports::new("std::core::intrinsics::stochastic");
31    module.description = "Stochastic process intrinsics (Brownian motion, GBM, OU, random walk)"
32        .to_string();
33
34    register_typed_fn_3::<_, i64, f64, f64>(
35        &mut module,
36        "__intrinsic_brownian_motion",
37        "Brownian motion path of length n with timestep dt and volatility sigma",
38        [("n", "int"), ("dt", "number"), ("sigma", "number")],
39        ConcreteType::ArrayNumber,
40        |n, dt, sigma, _ctx| {
41            if n < 0 {
42                return Err("__intrinsic_brownian_motion: n must be non-negative".to_string());
43            }
44            if dt <= 0.0 {
45                return Err("__intrinsic_brownian_motion: dt must be positive".to_string());
46            }
47            if sigma < 0.0 {
48                return Err(
49                    "__intrinsic_brownian_motion: sigma must be non-negative".to_string()
50                );
51            }
52            let n = n as usize;
53            let scale = sigma * dt.sqrt();
54            let path = random::with_rng(|rng| {
55                let mut path = Vec::with_capacity(n);
56                let mut x = 0.0;
57                for i in 0..n {
58                    if i > 0 {
59                        x += scale * standard_normal(rng);
60                    }
61                    path.push(x);
62                }
63                path
64            });
65            Ok(TypedReturn::Concrete(ConcreteReturn::ArrayF64(path)))
66        },
67    );
68
69    register_typed_fn_5::<_, i64, f64, f64, f64, f64>(
70        &mut module,
71        "__intrinsic_gbm",
72        "Geometric Brownian Motion path: s0 * exp((mu - 0.5*sigma^2)*dt + sigma*sqrt(dt)*Z)",
73        [
74            ("n", "int"),
75            ("dt", "number"),
76            ("mu", "number"),
77            ("sigma", "number"),
78            ("s0", "number"),
79        ],
80        ConcreteType::ArrayNumber,
81        |n, dt, mu, sigma, s0, _ctx| {
82            if n < 0 {
83                return Err("__intrinsic_gbm: n must be non-negative".to_string());
84            }
85            if dt <= 0.0 {
86                return Err("__intrinsic_gbm: dt must be positive".to_string());
87            }
88            if sigma < 0.0 {
89                return Err("__intrinsic_gbm: sigma must be non-negative".to_string());
90            }
91            if s0 <= 0.0 {
92                return Err("__intrinsic_gbm: s0 must be positive".to_string());
93            }
94            let n = n as usize;
95            let drift = (mu - 0.5 * sigma * sigma) * dt;
96            let diffusion_scale = sigma * dt.sqrt();
97            let path = random::with_rng(|rng| {
98                let mut path = Vec::with_capacity(n);
99                let mut s = s0;
100                for i in 0..n {
101                    if i > 0 {
102                        let z = standard_normal(rng);
103                        s *= (drift + diffusion_scale * z).exp();
104                    }
105                    path.push(s);
106                }
107                path
108            });
109            Ok(TypedReturn::Concrete(ConcreteReturn::ArrayF64(path)))
110        },
111    );
112
113    register_typed_fn_6::<_, i64, f64, f64, f64, f64, f64>(
114        &mut module,
115        "__intrinsic_ou_process",
116        "Ornstein-Uhlenbeck mean-reverting process: dx = theta*(mu - x)*dt + sigma*sqrt(dt)*Z",
117        [
118            ("n", "int"),
119            ("dt", "number"),
120            ("theta", "number"),
121            ("mu", "number"),
122            ("sigma", "number"),
123            ("x0", "number"),
124        ],
125        ConcreteType::ArrayNumber,
126        |n, dt, theta, mu, sigma, x0, _ctx| {
127            if n < 0 {
128                return Err("__intrinsic_ou_process: n must be non-negative".to_string());
129            }
130            if dt <= 0.0 {
131                return Err("__intrinsic_ou_process: dt must be positive".to_string());
132            }
133            if theta < 0.0 {
134                return Err("__intrinsic_ou_process: theta must be non-negative".to_string());
135            }
136            if sigma < 0.0 {
137                return Err("__intrinsic_ou_process: sigma must be non-negative".to_string());
138            }
139            let n = n as usize;
140            let diffusion_scale = sigma * dt.sqrt();
141            let path = random::with_rng(|rng| {
142                let mut path = Vec::with_capacity(n);
143                let mut x = x0;
144                for i in 0..n {
145                    if i > 0 {
146                        let z = standard_normal(rng);
147                        x += theta * (mu - x) * dt + diffusion_scale * z;
148                    }
149                    path.push(x);
150                }
151                path
152            });
153            Ok(TypedReturn::Concrete(ConcreteReturn::ArrayF64(path)))
154        },
155    );
156
157    register_typed_fn_2::<_, i64, f64>(
158        &mut module,
159        "__intrinsic_random_walk",
160        "Discrete +/- step_size random walk of length n",
161        [("n", "int"), ("step_size", "number")],
162        ConcreteType::ArrayNumber,
163        |n, step_size, _ctx| {
164            if n < 0 {
165                return Err("__intrinsic_random_walk: n must be non-negative".to_string());
166            }
167            if step_size <= 0.0 {
168                return Err("__intrinsic_random_walk: step_size must be positive".to_string());
169            }
170            let n = n as usize;
171            let path = random::with_rng(|rng| {
172                let mut path = Vec::with_capacity(n);
173                let mut x = 0.0;
174                for i in 0..n {
175                    if i > 0 {
176                        let step = if rng.r#gen::<f64>() < 0.5 {
177                            -step_size
178                        } else {
179                            step_size
180                        };
181                        x += step;
182                    }
183                    path.push(x);
184                }
185                path
186            });
187            Ok(TypedReturn::Concrete(ConcreteReturn::ArrayF64(path)))
188        },
189    );
190
191    module
192}
193
194// ───────────────────── Helpers (used by typed bodies) ─────────────────────
195
196/// Sample a standard normal (Box-Muller).
197#[inline]
198fn standard_normal(rng: &mut rand_chacha::ChaCha8Rng) -> f64 {
199    let u1: f64 = rng.r#gen();
200    let u2: f64 = rng.r#gen();
201    (-2.0_f64 * u1.ln()).sqrt() * (2.0_f64 * std::f64::consts::PI * u2).cos()
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::intrinsics::random as random_intrinsics;
208    use rand::SeedableRng;
209
210    fn mean_variance(samples: &[f64]) -> (f64, f64) {
211        let mean = samples.iter().sum::<f64>() / samples.len() as f64;
212        let var = samples.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / samples.len() as f64;
213        (mean, var)
214    }
215
216    #[test]
217    fn test_brownian_motion_increments_unit_variance() {
218        random_intrinsics::with_rng(|rng| {
219            *rng = rand_chacha::ChaCha8Rng::seed_from_u64(999);
220        });
221
222        let path = random_intrinsics::with_rng(|rng| {
223            let n = 10001usize;
224            let scale = 1.0_f64 * 1.0_f64.sqrt();
225            let mut path = Vec::with_capacity(n);
226            let mut x = 0.0;
227            for i in 0..n {
228                if i > 0 {
229                    x += scale * standard_normal(rng);
230                }
231                path.push(x);
232            }
233            path
234        });
235
236        let increments: Vec<f64> = (1..path.len()).map(|i| path[i] - path[i - 1]).collect();
237        let (mean, var) = mean_variance(&increments);
238        assert!(mean.abs() < 0.05);
239        assert!((var - 1.0).abs() < 0.1);
240    }
241
242    #[test]
243    fn test_gbm_positive_path() {
244        random_intrinsics::with_rng(|rng| {
245            *rng = rand_chacha::ChaCha8Rng::seed_from_u64(42);
246        });
247
248        let n = 100usize;
249        let dt: f64 = 1.0 / 252.0;
250        let mu = 0.1;
251        let sigma = 0.2;
252        let s0 = 100.0;
253        let drift = (mu - 0.5 * sigma * sigma) * dt;
254        let diffusion_scale = sigma * dt.sqrt();
255        let path = random_intrinsics::with_rng(|rng| {
256            let mut path = Vec::with_capacity(n);
257            let mut s = s0;
258            for i in 0..n {
259                if i > 0 {
260                    let z = standard_normal(rng);
261                    s *= (drift + diffusion_scale * z).exp();
262                }
263                path.push(s);
264            }
265            path
266        });
267        for &v in &path {
268            assert!(v > 0.0);
269        }
270    }
271
272    #[test]
273    fn test_random_walk_step_size_invariant() {
274        random_intrinsics::with_rng(|rng| {
275            *rng = rand_chacha::ChaCha8Rng::seed_from_u64(11);
276        });
277
278        let n = 101usize;
279        let step = 2.0;
280        let path: Vec<f64> = random_intrinsics::with_rng(|rng| {
281            let mut path = Vec::with_capacity(n);
282            let mut x = 0.0;
283            for i in 0..n {
284                if i > 0 {
285                    let s = if rng.r#gen::<f64>() < 0.5 {
286                        -step
287                    } else {
288                        step
289                    };
290                    x += s;
291                }
292                path.push(x);
293            }
294            path
295        });
296
297        for i in 1..path.len() {
298            let diff = (path[i] - path[i - 1]).abs();
299            assert!((diff - step).abs() < 1e-9);
300        }
301    }
302}