Skip to main content

prime_diffusion/
lib.rs

1//! `prime-diffusion` — Stochastic processes: Ornstein-Uhlenbeck and geometric Brownian motion.
2//!
3//! All public functions are pure (LOAD + COMPUTE only). No `&mut`, no side effects,
4//! no hidden state. Noise is either supplied externally (caller-provided standard normal `w`)
5//! or generated deterministically from a threaded `u32` seed via `prime_random::prng_gaussian`.
6//!
7//! # Temporal Assembly Model
8//! - **LOAD** — read parameters + state
9//! - **COMPUTE** — stochastic update formula
10//! - **APPEND** — return `(next_value, next_seed)` as a tuple
11//!
12//! STORE and JUMP do not exist here. Seeded variants thread the seed forward like
13//! `prime_random::prng_next` — pure state machines.
14//!
15//! # Included
16//! - `ou_step` — Ornstein-Uhlenbeck step (caller-supplied noise)
17//! - `ou_step_seeded` — OU step with deterministic Gaussian noise from threaded seed
18//! - `gbm_step` — Geometric Brownian motion step (caller-supplied noise)
19//! - `gbm_step_seeded` — GBM step with deterministic noise from threaded seed
20
21// ── Ornstein-Uhlenbeck ────────────────────────────────────────────────────────
22
23/// Ornstein-Uhlenbeck step with caller-supplied noise.
24///
25/// The O-U process is the canonical mean-reverting stochastic process. It is
26/// used in Idle Hero for economy curves (resource prices, rival activity) that
27/// should wander but always return to a set point.
28///
29/// # Math
30///
31/// ```text
32/// dx  =  θ(μ - x) dt + σ √dt · w
33/// x'  =  x + θ(μ - x) dt + σ √dt · w
34/// ```
35///
36/// where `w` is a sample from a standard normal distribution N(0, 1).
37///
38/// # Arguments
39/// * `x`     — current value
40/// * `mu`    — long-run mean (equilibrium point)
41/// * `theta` — mean-reversion speed (> 0; typical 0.1–1.0)
42/// * `sigma` — volatility / noise scale (> 0)
43/// * `dt`    — time step
44/// * `w`     — standard normal noise sample N(0, 1)
45///
46/// # Returns
47/// Next value `x'`.
48///
49/// # Edge cases
50/// * `dt = 0` → returns `x` unchanged
51/// * `theta = 0` → no mean reversion; pure random walk `x + σ√dt·w`
52/// * `sigma = 0` → deterministic decay to `mu`: `x + θ(μ−x)dt`
53///
54/// # Example
55/// ```rust
56/// use prime_diffusion::ou_step;
57/// // No noise — converges toward mu=0 from x=1.0
58/// let x1 = ou_step(1.0, 0.0, 0.5, 0.0, 0.1, 0.0);
59/// assert!((x1 - 0.95).abs() < 1e-5);
60/// ```
61pub fn ou_step(x: f32, mu: f32, theta: f32, sigma: f32, dt: f32, w: f32) -> f32 {
62    x + theta * (mu - x) * dt + sigma * dt.sqrt() * w
63}
64
65/// Ornstein-Uhlenbeck step with deterministic seeded noise.
66///
67/// Generates one standard-normal sample from `seed` via `prime_random::prng_gaussian`,
68/// then applies [`ou_step`]. Threads the seed forward so callers can chain steps
69/// without external RNG state.
70///
71/// # Arguments
72/// * `x`, `mu`, `theta`, `sigma`, `dt` — same as [`ou_step`]
73/// * `seed` — `u32` RNG state (non-zero); threads forward deterministically
74///
75/// # Returns
76/// `(x_next, next_seed)` — new value and advanced seed.
77///
78/// # Example
79/// ```rust
80/// use prime_diffusion::ou_step_seeded;
81/// let (x1, s1) = ou_step_seeded(1.0, 0.0, 0.5, 0.1, 0.01, 12345_u32);
82/// let (x2, _)  = ou_step_seeded(x1,  0.0, 0.5, 0.1, 0.01, s1);
83/// assert!(x1 != 1.0);
84/// ```
85pub fn ou_step_seeded(x: f32, mu: f32, theta: f32, sigma: f32, dt: f32, seed: u32) -> (f32, u32) {
86    let (w, next_seed) = prime_random::prng_gaussian(seed);
87    (ou_step(x, mu, theta, sigma, dt, w), next_seed)
88}
89
90// ── Geometric Brownian Motion ─────────────────────────────────────────────────
91
92/// Geometric Brownian motion step with caller-supplied noise.
93///
94/// GBM models multiplicative processes where the quantity is always positive —
95/// resource stockpiles, market prices, skill multipliers.
96///
97/// # Math
98///
99/// ```text
100/// Exact solution for one step:
101/// x'  =  x · exp((μ − σ²/2) dt + σ √dt · w)
102/// ```
103///
104/// # Arguments
105/// * `x`     — current value (must be > 0)
106/// * `mu`    — drift rate (annualised, or per unit time)
107/// * `sigma` — volatility (> 0)
108/// * `dt`    — time step
109/// * `w`     — standard normal noise sample N(0, 1)
110///
111/// # Returns
112/// Next value `x'` (always positive when `x > 0`).
113///
114/// # Edge cases
115/// * `dt = 0` → returns `x` unchanged
116/// * `sigma = 0` → deterministic exponential growth: `x · exp(μ·dt)`
117/// * `x = 0` → returns 0 (absorbing state)
118///
119/// # Example
120/// ```rust
121/// use prime_diffusion::gbm_step;
122/// // Zero drift, no noise → x unchanged
123/// let x1 = gbm_step(1.0, 0.0, 0.0, 0.1, 0.0);
124/// assert!((x1 - 1.0).abs() < 1e-5);
125/// ```
126pub fn gbm_step(x: f32, mu: f32, sigma: f32, dt: f32, w: f32) -> f32 {
127    x * ((mu - 0.5 * sigma * sigma) * dt + sigma * dt.sqrt() * w).exp()
128}
129
130/// Geometric Brownian motion step with deterministic seeded noise.
131///
132/// Identical to [`gbm_step`] but generates noise from `seed` via
133/// `prime_random::prng_gaussian` and threads the seed forward.
134///
135/// # Returns
136/// `(x_next, next_seed)`.
137///
138/// # Example
139/// ```rust
140/// use prime_diffusion::gbm_step_seeded;
141/// let (x1, s1) = gbm_step_seeded(1.0, 0.05, 0.2, 0.01, 42_u32);
142/// assert!(x1 > 0.0);
143/// ```
144pub fn gbm_step_seeded(x: f32, mu: f32, sigma: f32, dt: f32, seed: u32) -> (f32, u32) {
145    let (w, next_seed) = prime_random::prng_gaussian(seed);
146    (gbm_step(x, mu, sigma, dt, w), next_seed)
147}
148
149// ── Tests ─────────────────────────────────────────────────────────────────────
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    const EPSILON: f32 = 1e-4;
156    const SEED: u32 = 0xDEAD_BEEF;
157
158    // ── ou_step ───────────────────────────────────────────────────────────────
159
160    #[test]
161    fn ou_step_zero_dt_no_change() {
162        let x = ou_step(1.0, 0.0, 0.5, 0.3, 0.0, 1.0);
163        assert!((x - 1.0).abs() < EPSILON);
164    }
165
166    #[test]
167    fn ou_step_zero_noise_converges() {
168        // dx = θ(μ - x)dt, no noise
169        // x' = x + 0.5*(0 - 1)*0.1 = 1 - 0.05 = 0.95
170        let x = ou_step(1.0, 0.0, 0.5, 0.0, 0.1, 0.0);
171        assert!((x - 0.95).abs() < EPSILON, "x={x}");
172    }
173
174    #[test]
175    fn ou_step_zero_sigma_deterministic_decay() {
176        // With sigma=0, same as noiseless
177        let x = ou_step(2.0, 1.0, 1.0, 0.0, 0.1, 5.0);
178        // x' = 2 + 1*(1-2)*0.1 = 2 - 0.1 = 1.9
179        assert!((x - 1.9).abs() < EPSILON, "x={x}");
180    }
181
182    #[test]
183    fn ou_step_deterministic() {
184        let a = ou_step(1.0, 0.0, 0.3, 0.1, 0.01, 0.5);
185        let b = ou_step(1.0, 0.0, 0.3, 0.1, 0.01, 0.5);
186        assert_eq!(a, b);
187    }
188
189    #[test]
190    fn ou_step_mean_reversion() {
191        // Noiseless OU: x(t) = x0 * exp(-θt) → 0
192        // theta=1.0, 1000 steps, dt=0.01 → x = 10 * exp(-10) ≈ 4.5e-4
193        let x = (0..1000).fold(10.0_f32, |x, _| ou_step(x, 0.0, 1.0, 0.0, 0.01, 0.0));
194        assert!(x.abs() < 0.01, "x={x} — should be near 0 after 1000 steps");
195    }
196
197    // ── ou_step_seeded ────────────────────────────────────────────────────────
198
199    #[test]
200    fn ou_step_seeded_advances_value() {
201        let (x1, _) = ou_step_seeded(1.0, 0.0, 0.3, 0.1, 0.01, SEED);
202        assert!((x1 - 1.0).abs() > f32::EPSILON, "seeded step should produce movement");
203    }
204
205    #[test]
206    fn ou_step_seeded_threads_seed_forward() {
207        let (_, s1) = ou_step_seeded(1.0, 0.0, 0.3, 0.1, 0.01, SEED);
208        assert_ne!(s1, SEED, "seed must advance");
209    }
210
211    #[test]
212    fn ou_step_seeded_deterministic() {
213        let a = ou_step_seeded(1.0, 0.0, 0.3, 0.1, 0.01, SEED);
214        let b = ou_step_seeded(1.0, 0.0, 0.3, 0.1, 0.01, SEED);
215        assert_eq!(a, b);
216    }
217
218    #[test]
219    fn ou_step_seeded_chain_100_steps_bounded() {
220        // O-U process should stay near mu=0 over many steps
221        let (x, _) = (0..100).fold((0.0_f32, SEED), |(x, s), _| {
222            ou_step_seeded(x, 0.0, 0.3, 0.5, 0.01, s)
223        });
224        assert!(x.abs() < 5.0, "O-U should stay bounded; x={x}");
225    }
226
227    // ── gbm_step ──────────────────────────────────────────────────────────────
228
229    #[test]
230    fn gbm_step_zero_dt_no_change() {
231        let x = gbm_step(1.0, 0.05, 0.2, 0.0, 0.5);
232        assert!((x - 1.0).abs() < EPSILON, "x={x}");
233    }
234
235    #[test]
236    fn gbm_step_zero_sigma_deterministic_growth() {
237        // x' = x * exp(mu * dt) = 1.0 * exp(0.1 * 0.1) = exp(0.01)
238        let x = gbm_step(1.0, 0.1, 0.0, 0.1, 0.0);
239        let expected = (0.01_f32).exp();
240        assert!((x - expected).abs() < EPSILON, "x={x}, expected={expected}");
241    }
242
243    #[test]
244    fn gbm_step_always_positive() {
245        // GBM with positive x should never produce x ≤ 0
246        let x = (0..100).fold(1.0_f32, |x, i| gbm_step(x, 0.0, 0.3, 0.01, (i as f32 * 0.1).sin()));
247        assert!(x > 0.0, "GBM must stay positive; x={x}");
248    }
249
250    #[test]
251    fn gbm_step_deterministic() {
252        let a = gbm_step(1.0, 0.05, 0.2, 0.01, 0.5);
253        let b = gbm_step(1.0, 0.05, 0.2, 0.01, 0.5);
254        assert_eq!(a, b);
255    }
256
257    // ── gbm_step_seeded ───────────────────────────────────────────────────────
258
259    #[test]
260    fn gbm_step_seeded_positive() {
261        let (x1, _) = gbm_step_seeded(1.0, 0.05, 0.2, 0.01, SEED);
262        assert!(x1 > 0.0, "GBM result must be positive; x={x1}");
263    }
264
265    #[test]
266    fn gbm_step_seeded_deterministic() {
267        let a = gbm_step_seeded(1.0, 0.05, 0.2, 0.01, SEED);
268        let b = gbm_step_seeded(1.0, 0.05, 0.2, 0.01, SEED);
269        assert_eq!(a, b);
270    }
271
272    #[test]
273    fn gbm_step_seeded_threads_seed() {
274        let (_, s1) = gbm_step_seeded(1.0, 0.05, 0.2, 0.01, SEED);
275        assert_ne!(s1, SEED);
276    }
277
278    #[test]
279    fn gbm_step_seeded_chain_100_stays_positive() {
280        let (x, _) = (0..100).fold((1.0_f32, SEED), |(x, s), _| {
281            gbm_step_seeded(x, 0.0, 0.2, 0.01, s)
282        });
283        assert!(x > 0.0, "GBM chain must stay positive; x={x}");
284    }
285
286    // ── ou_step / gbm_step edge cases ─────────────────────────────────────────
287
288    #[test]
289    fn ou_step_zero_theta_no_reversion() {
290        // theta=0 → no mean-reversion, only diffusion term.
291        let x = ou_step(5.0, 0.0, 0.0, 0.1, 0.01, 1.0);
292        // Should not snap to mu; should be near 5.0 + small diffusion.
293        assert!((x - 5.0).abs() < 0.5, "theta=0: x moved too far: {x}");
294    }
295
296    #[test]
297    fn gbm_step_zero_mu_sigma_unchanged() {
298        // mu=0, sigma=0, w=anything → GBM exponent = 0 → x unchanged.
299        let x = gbm_step(2.5, 0.0, 0.0, 0.01, 1.0);
300        assert!((x - 2.5).abs() < EPSILON, "gbm_step with sigma=mu=0 should return x; got {x}");
301    }
302}