Skip to main content

scirs2_integrate/sde/
jump_diffusion.rs

1//! Jump-diffusion processes
2//!
3//! This module provides simulation of processes that combine a continuous diffusion
4//! component with a discrete jump component driven by a Poisson process.
5//!
6//! ## Processes
7//!
8//! | Function/Struct | Model |
9//! |-----------------|-------|
10//! | [`merton_jump_diffusion`] | Merton (1976) log-normal jumps |
11//! | [`compound_poisson_process`] | General compound Poisson process |
12//! | [`kou_double_exponential`] | Kou (2002) double-exponential jumps |
13//! | [`JumpDiffusionProblem`] | Generic jump-diffusion solver |
14
15use crate::error::{IntegrateError, IntegrateResult};
16use scirs2_core::random::prelude::*;
17use scirs2_core::random::{Normal, Uniform};
18use scirs2_core::Distribution;
19
20// ─────────────────────────────────────────────────────────────────────────────
21// Merton Jump-Diffusion
22// ─────────────────────────────────────────────────────────────────────────────
23
24/// Simulate the Merton (1976) jump-diffusion model.
25///
26/// ```text
27/// dS = (μ - λ·k̄) S dt + σ S dW + J·S dN
28/// ```
29///
30/// where:
31/// - `N` is a Poisson process with intensity `λ`
32/// - Jump size `J = exp(μ_j + σ_j·Z) - 1`, `Z ~ N(0,1)`
33/// - `k̄ = exp(μ_j + σ_j²/2) - 1` (mean jump)
34///
35/// # Arguments
36///
37/// * `mu` — drift rate
38/// * `sigma` — diffusion volatility (σ > 0)
39/// * `lambda` — Poisson jump intensity (λ ≥ 0)
40/// * `mu_j` — mean of log-jump size
41/// * `sigma_j` — std of log-jump size (σ_j ≥ 0)
42/// * `s0` — initial price (S₀ > 0)
43/// * `t_span` — simulation interval `(t0, t1)`
44/// * `dt` — time step (> 0)
45/// * `rng` — random number generator
46///
47/// # Returns
48///
49/// A vector of `(t, S(t))` pairs.
50///
51/// # Errors
52///
53/// Returns an error if any parameter is invalid.
54///
55/// # Examples
56///
57/// ```
58/// use scirs2_integrate::sde::jump_diffusion::merton_jump_diffusion;
59/// use scirs2_core::random::prelude::*;
60///
61/// let mut rng = seeded_rng(42);
62/// let path = merton_jump_diffusion(0.05, 0.2, 1.0, -0.1, 0.2, 100.0, (0.0, 1.0), 0.01, &mut rng).unwrap();
63/// assert!(path.iter().all(|&(_, s)| s > 0.0), "Merton S must stay positive");
64/// ```
65pub fn merton_jump_diffusion(
66    mu: f64,
67    sigma: f64,
68    lambda: f64,
69    mu_j: f64,
70    sigma_j: f64,
71    s0: f64,
72    t_span: (f64, f64),
73    dt: f64,
74    rng: &mut StdRng,
75) -> IntegrateResult<Vec<(f64, f64)>> {
76    if sigma <= 0.0 {
77        return Err(IntegrateError::InvalidInput(format!(
78            "sigma must be > 0, got {sigma}"
79        )));
80    }
81    if lambda < 0.0 {
82        return Err(IntegrateError::InvalidInput(format!(
83            "lambda must be >= 0, got {lambda}"
84        )));
85    }
86    if sigma_j < 0.0 {
87        return Err(IntegrateError::InvalidInput(format!(
88            "sigma_j must be >= 0, got {sigma_j}"
89        )));
90    }
91    if s0 <= 0.0 {
92        return Err(IntegrateError::InvalidInput(format!(
93            "s0 must be > 0, got {s0}"
94        )));
95    }
96    validate_t_span(t_span)?;
97    validate_dt(dt)?;
98
99    // Mean jump size: k_bar = E[J] = exp(mu_j + sigma_j^2/2) - 1
100    let k_bar = (mu_j + 0.5 * sigma_j * sigma_j).exp() - 1.0;
101    // Compensated drift
102    let mu_comp = mu - lambda * k_bar;
103
104    let normal = Normal::new(0.0, 1.0).map_err(|e| IntegrateError::InvalidInput(e.to_string()))?;
105    let uniform =
106        Uniform::new(0.0_f64, 1.0_f64).map_err(|e| IntegrateError::InvalidInput(e.to_string()))?;
107
108    let n_steps = ((t_span.1 - t_span.0) / dt).ceil() as usize;
109    let mut path = Vec::with_capacity(n_steps + 1);
110    path.push((t_span.0, s0));
111    let mut s = s0;
112    let mut t = t_span.0;
113
114    for _ in 0..n_steps {
115        let step = (t_span.1 - t).min(dt);
116        let sqrt_step = step.sqrt();
117        let z: f64 = normal.sample(rng);
118        let dw = z * sqrt_step;
119
120        // Continuous component (Euler-Maruyama)
121        let ds_cont = mu_comp * s * step + sigma * s * dw;
122
123        // Jump component: number of jumps in [t, t+step] ~ Poisson(lambda*step)
124        let n_jumps = poisson_sample(lambda * step, rng, &uniform)?;
125        let mut jump_factor = 1.0;
126        for _ in 0..n_jumps {
127            let z_j: f64 = normal.sample(rng);
128            let log_jump = mu_j + sigma_j * z_j;
129            jump_factor *= log_jump.exp(); // multiplicative: J + 1 = exp(log-jump)
130        }
131        // S_{t+dt} = S_t * jump_factor + continuous component
132        s = ((s + ds_cont) * jump_factor).max(1e-10);
133        t += step;
134        path.push((t, s));
135    }
136    Ok(path)
137}
138
139// ─────────────────────────────────────────────────────────────────────────────
140// Compound Poisson Process
141// ─────────────────────────────────────────────────────────────────────────────
142
143/// Simulate a compound Poisson process.
144///
145/// At each Poisson event (with intensity `lambda`) the process jumps by a random
146/// amount drawn from `jump_dist`.
147///
148/// Uses the thinning (Lewis-Shedler) algorithm to place Poisson events exactly:
149/// inter-arrival times are exponentially distributed with rate `lambda`.
150///
151/// # Arguments
152///
153/// * `lambda` — Poisson intensity (λ > 0)
154/// * `jump_dist` — callable returning a jump size given a mutable `StdRng`
155/// * `t_span` — simulation interval `(t0, t1)`
156/// * `rng` — random number generator
157///
158/// # Returns
159///
160/// A vector of `(t, X(t))` pairs at all jump times plus `t0` and `t1`.
161///
162/// # Errors
163///
164/// Returns an error if `lambda <= 0` or `t_span` is invalid.
165///
166/// # Examples
167///
168/// ```
169/// use scirs2_integrate::sde::jump_diffusion::compound_poisson_process;
170/// use scirs2_core::random::prelude::*;
171/// use scirs2_core::random::{Normal, Distribution};
172///
173/// let mut rng = seeded_rng(7);
174/// // Jumps ~ N(0, 1)
175/// let normal = Normal::new(0.0, 1.0).unwrap();
176/// let path = compound_poisson_process(
177///     2.0,
178///     |rng| normal.sample(rng),
179///     (0.0, 5.0),
180///     &mut rng,
181/// ).unwrap();
182/// // At least start and end points
183/// assert!(path.len() >= 2);
184/// ```
185pub fn compound_poisson_process<F>(
186    lambda: f64,
187    jump_dist: F,
188    t_span: (f64, f64),
189    rng: &mut StdRng,
190) -> IntegrateResult<Vec<(f64, f64)>>
191where
192    F: Fn(&mut StdRng) -> f64,
193{
194    if lambda <= 0.0 {
195        return Err(IntegrateError::InvalidInput(format!(
196            "lambda must be > 0, got {lambda}"
197        )));
198    }
199    validate_t_span(t_span)?;
200
201    // Inter-arrival times: Exp(lambda)
202    let uniform =
203        Uniform::new(0.0_f64, 1.0_f64).map_err(|e| IntegrateError::InvalidInput(e.to_string()))?;
204
205    let mut path = Vec::new();
206    path.push((t_span.0, 0.0));
207    let mut x = 0.0_f64;
208    let mut t = t_span.0;
209
210    loop {
211        // Sample next inter-arrival time: -ln(U)/lambda
212        let u: f64 = uniform.sample(rng);
213        let inter = -u.ln() / lambda;
214        t += inter;
215        if t >= t_span.1 {
216            break;
217        }
218        let jump = jump_dist(rng);
219        x += jump;
220        path.push((t, x));
221    }
222    path.push((t_span.1, x));
223    Ok(path)
224}
225
226// ─────────────────────────────────────────────────────────────────────────────
227// Kou Double-Exponential Jump-Diffusion
228// ─────────────────────────────────────────────────────────────────────────────
229
230/// Simulate the Kou (2002) double-exponential jump-diffusion model.
231///
232/// Jump sizes follow an asymmetric double-exponential distribution:
233/// - Upward jumps: Exp(η₁) with probability p
234/// - Downward jumps: -Exp(η₂) with probability (1-p)
235///
236/// # Arguments
237///
238/// * `mu` — drift (μ)
239/// * `sigma` — volatility (σ > 0)
240/// * `lambda` — jump intensity (λ ≥ 0)
241/// * `p` — probability of an upward jump (0 < p < 1)
242/// * `eta1` — rate of upward exponential jumps (η₁ > 1, for finite expectation)
243/// * `eta2` — rate of downward exponential jumps (η₂ > 0)
244/// * `s0` — initial price (S₀ > 0)
245/// * `t_span` — simulation interval
246/// * `dt` — time step
247/// * `rng` — random number generator
248///
249/// # Returns
250///
251/// A vector of `(t, S(t))` pairs.
252///
253/// # Errors
254///
255/// Returns an error if parameter constraints are violated.
256pub fn kou_double_exponential(
257    mu: f64,
258    sigma: f64,
259    lambda: f64,
260    p: f64,
261    eta1: f64,
262    eta2: f64,
263    s0: f64,
264    t_span: (f64, f64),
265    dt: f64,
266    rng: &mut StdRng,
267) -> IntegrateResult<Vec<(f64, f64)>> {
268    if sigma <= 0.0 {
269        return Err(IntegrateError::InvalidInput(format!(
270            "sigma must be > 0, got {sigma}"
271        )));
272    }
273    if lambda < 0.0 {
274        return Err(IntegrateError::InvalidInput(format!(
275            "lambda must be >= 0, got {lambda}"
276        )));
277    }
278    if !(0.0 < p && p < 1.0) {
279        return Err(IntegrateError::InvalidInput(format!(
280            "p must be in (0,1), got {p}"
281        )));
282    }
283    if eta1 <= 1.0 {
284        return Err(IntegrateError::InvalidInput(format!(
285            "eta1 must be > 1, got {eta1}"
286        )));
287    }
288    if eta2 <= 0.0 {
289        return Err(IntegrateError::InvalidInput(format!(
290            "eta2 must be > 0, got {eta2}"
291        )));
292    }
293    if s0 <= 0.0 {
294        return Err(IntegrateError::InvalidInput(format!(
295            "s0 must be > 0, got {s0}"
296        )));
297    }
298    validate_t_span(t_span)?;
299    validate_dt(dt)?;
300
301    // Mean jump E[J] = p/(eta1-1) - (1-p)/eta2  (log-price jump)
302    // k_bar = E[exp(Y) - 1] where Y is the jump size in log-price
303    let k_bar = p * eta1 / (eta1 - 1.0) + (1.0 - p) * eta2 / (eta2 + 1.0) - 1.0;
304    let mu_comp = mu - lambda * k_bar;
305
306    let normal = Normal::new(0.0, 1.0).map_err(|e| IntegrateError::InvalidInput(e.to_string()))?;
307    let uniform =
308        Uniform::new(0.0_f64, 1.0_f64).map_err(|e| IntegrateError::InvalidInput(e.to_string()))?;
309
310    let n_steps = ((t_span.1 - t_span.0) / dt).ceil() as usize;
311    let mut path = Vec::with_capacity(n_steps + 1);
312    path.push((t_span.0, s0));
313    let mut s = s0;
314    let mut t = t_span.0;
315
316    for _ in 0..n_steps {
317        let step = (t_span.1 - t).min(dt);
318        let sqrt_step = step.sqrt();
319        let z: f64 = normal.sample(rng);
320        let dw = z * sqrt_step;
321
322        let ds_cont = mu_comp * s * step + sigma * s * dw;
323        let n_jumps = poisson_sample(lambda * step, rng, &uniform)?;
324        let mut log_jump_sum: f64 = 0.0;
325        for _ in 0..n_jumps {
326            let u_type: f64 = uniform.sample(rng);
327            let u_mag: f64 = uniform.sample(rng);
328            if u_type < p {
329                // Upward: exponential with rate eta1
330                log_jump_sum += -u_mag.ln() / eta1;
331            } else {
332                // Downward: exponential with rate eta2
333                log_jump_sum -= -u_mag.ln() / eta2;
334            }
335        }
336        let jump_mult = log_jump_sum.exp();
337        s = ((s + ds_cont) * jump_mult).max(1e-10);
338        t += step;
339        path.push((t, s));
340    }
341    Ok(path)
342}
343
344// ─────────────────────────────────────────────────────────────────────────────
345// Generic Jump-Diffusion Problem
346// ─────────────────────────────────────────────────────────────────────────────
347
348/// Generic jump-diffusion problem specification.
349///
350/// ```text
351/// dX = f(X,t) dt + g(X,t) dW + dJ
352/// ```
353///
354/// where `dJ` is a compound Poisson process with intensity `jump_intensity`
355/// and jumps sampled from `jump_sampler`.
356pub struct JumpDiffusionProblem<Drift, Diffusion, JumpSampler>
357where
358    Drift: Fn(f64, f64) -> f64,
359    Diffusion: Fn(f64, f64) -> f64,
360    JumpSampler: Fn(&mut StdRng) -> f64,
361{
362    /// Drift coefficient f(x, t)
363    pub drift: Drift,
364    /// Diffusion coefficient g(x, t)
365    pub diffusion: Diffusion,
366    /// Poisson jump intensity (λ ≥ 0)
367    pub jump_intensity: f64,
368    /// Callable returning a jump size: `fn(&mut StdRng) -> f64`
369    pub jump_sampler: JumpSampler,
370    /// Initial value X(t0)
371    pub x0: f64,
372    /// Time span (t0, t1)
373    pub t_span: (f64, f64),
374}
375
376impl<Drift, Diffusion, JumpSampler> JumpDiffusionProblem<Drift, Diffusion, JumpSampler>
377where
378    Drift: Fn(f64, f64) -> f64,
379    Diffusion: Fn(f64, f64) -> f64,
380    JumpSampler: Fn(&mut StdRng) -> f64,
381{
382    /// Create a new jump-diffusion problem.
383    pub fn new(
384        drift: Drift,
385        diffusion: Diffusion,
386        jump_intensity: f64,
387        jump_sampler: JumpSampler,
388        x0: f64,
389        t_span: (f64, f64),
390    ) -> IntegrateResult<Self> {
391        if jump_intensity < 0.0 {
392            return Err(IntegrateError::InvalidInput(format!(
393                "jump_intensity must be >= 0, got {jump_intensity}"
394            )));
395        }
396        validate_t_span(t_span)?;
397        Ok(Self {
398            drift,
399            diffusion,
400            jump_intensity,
401            jump_sampler,
402            x0,
403            t_span,
404        })
405    }
406
407    /// Solve using Euler-Maruyama with exact Poisson jump timing.
408    ///
409    /// Returns a vector of `(t, X(t))` pairs.
410    pub fn solve(&self, dt: f64, rng: &mut StdRng) -> IntegrateResult<Vec<(f64, f64)>> {
411        validate_dt(dt)?;
412        let normal =
413            Normal::new(0.0, 1.0).map_err(|e| IntegrateError::InvalidInput(e.to_string()))?;
414        let uniform = Uniform::new(0.0_f64, 1.0_f64)
415            .map_err(|e| IntegrateError::InvalidInput(e.to_string()))?;
416
417        let n_steps = ((self.t_span.1 - self.t_span.0) / dt).ceil() as usize;
418        let mut path = Vec::with_capacity(n_steps + 1);
419        path.push((self.t_span.0, self.x0));
420        let mut x = self.x0;
421        let mut t = self.t_span.0;
422
423        for _ in 0..n_steps {
424            let step = (self.t_span.1 - t).min(dt);
425            let sqrt_step = step.sqrt();
426            let z: f64 = normal.sample(rng);
427            let fx = (self.drift)(x, t);
428            let gx = (self.diffusion)(x, t);
429            let n_jumps = poisson_sample(self.jump_intensity * step, rng, &uniform)?;
430            let jump_sum: f64 = (0..n_jumps).map(|_| (self.jump_sampler)(rng)).sum();
431            x = x + fx * step + gx * z * sqrt_step + jump_sum;
432            t += step;
433            path.push((t, x));
434        }
435        Ok(path)
436    }
437}
438
439// ─────────────────────────────────────────────────────────────────────────────
440// Internal helpers
441// ─────────────────────────────────────────────────────────────────────────────
442
443/// Sample from a Poisson distribution with parameter `lambda` using the
444/// Knuth algorithm (efficient for small lambda; for large lambda uses normal approx).
445fn poisson_sample(lambda: f64, rng: &mut StdRng, uniform: &Uniform<f64>) -> IntegrateResult<usize> {
446    if lambda < 0.0 {
447        return Err(IntegrateError::InvalidInput(format!(
448            "Poisson lambda must be >= 0, got {lambda}"
449        )));
450    }
451    if lambda == 0.0 {
452        return Ok(0);
453    }
454    // For large lambda, use Normal approximation
455    if lambda > 30.0 {
456        let normal = Normal::new(lambda, lambda.sqrt())
457            .map_err(|e| IntegrateError::InvalidInput(e.to_string()))?;
458        let sample: f64 = normal.sample(rng);
459        return Ok(sample.round().max(0.0) as usize);
460    }
461    // Knuth algorithm: P(N=k) = exp(-lambda)*lambda^k/k!
462    let threshold = (-lambda).exp();
463    let mut count = 0usize;
464    let mut product = uniform.sample(rng);
465    while product > threshold {
466        product *= uniform.sample(rng);
467        count += 1;
468    }
469    Ok(count)
470}
471
472fn validate_t_span(t_span: (f64, f64)) -> IntegrateResult<()> {
473    if t_span.0 >= t_span.1 {
474        Err(IntegrateError::InvalidInput(format!(
475            "t_span must satisfy t0 < t1, got {:?}",
476            t_span
477        )))
478    } else {
479        Ok(())
480    }
481}
482
483fn validate_dt(dt: f64) -> IntegrateResult<()> {
484    if dt <= 0.0 {
485        Err(IntegrateError::InvalidInput(format!(
486            "dt must be > 0, got {dt}"
487        )))
488    } else {
489        Ok(())
490    }
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496    use scirs2_core::random::prelude::seeded_rng;
497
498    #[test]
499    fn test_merton_positive_price() {
500        let mut rng = seeded_rng(42);
501        let path =
502            merton_jump_diffusion(0.05, 0.2, 1.0, -0.1, 0.2, 100.0, (0.0, 1.0), 0.01, &mut rng)
503                .expect("merton_jump_diffusion should succeed");
504        assert!(!path.is_empty());
505        assert!(
506            path.iter().all(|&(_, s)| s > 0.0),
507            "Merton S must stay positive"
508        );
509    }
510
511    #[test]
512    fn test_merton_no_jumps() {
513        // When lambda = 0, Merton reduces to GBM
514        let mut rng = seeded_rng(1);
515        let path =
516            merton_jump_diffusion(0.05, 0.2, 0.0, 0.0, 0.0, 100.0, (0.0, 1.0), 0.01, &mut rng)
517                .expect("merton_jump_diffusion should succeed");
518        assert!(path.iter().all(|&(_, s)| s > 0.0));
519    }
520
521    #[test]
522    fn test_merton_invalid_sigma() {
523        let mut rng = seeded_rng(0);
524        assert!(merton_jump_diffusion(
525            0.05,
526            -0.2,
527            1.0,
528            0.0,
529            0.1,
530            100.0,
531            (0.0, 1.0),
532            0.01,
533            &mut rng
534        )
535        .is_err());
536    }
537
538    #[test]
539    fn test_merton_invalid_s0() {
540        let mut rng = seeded_rng(0);
541        assert!(
542            merton_jump_diffusion(0.05, 0.2, 1.0, 0.0, 0.1, -1.0, (0.0, 1.0), 0.01, &mut rng)
543                .is_err()
544        );
545    }
546
547    #[test]
548    fn test_compound_poisson_basic() {
549        let mut rng = seeded_rng(7);
550        let normal =
551            Normal::new(0.0_f64, 1.0).expect("Normal::new should succeed with valid params");
552        let path = compound_poisson_process(2.0, |r| normal.sample(r), (0.0, 5.0), &mut rng)
553            .expect("compound_poisson_process should succeed");
554        // At minimum: (0.0, 0.0) and (5.0, x_final)
555        assert!(path.len() >= 2);
556        assert!((path[0].0).abs() < 1e-12, "starts at t0");
557        assert!((path[0].1).abs() < 1e-12, "starts at 0");
558        assert!(
559            (path.last().expect("path is non-empty").0 - 5.0).abs() < 1e-12,
560            "ends at t1"
561        );
562    }
563
564    #[test]
565    fn test_compound_poisson_mean_jumps() {
566        // E[N(T)] = lambda * T
567        let lambda = 3.0;
568        let t_end = 2.0;
569        let n_paths = 500;
570        let mut total_jumps = 0usize;
571        for seed in 0..n_paths as u64 {
572            let mut rng = seeded_rng(seed + 1000);
573            let path = compound_poisson_process(
574                lambda,
575                |_r| 1.0, // unit jumps
576                (0.0, t_end),
577                &mut rng,
578            )
579            .expect("compound_poisson_process should succeed");
580            // subtract 2 for start/end points
581            total_jumps += path.len().saturating_sub(2);
582        }
583        let mean_jumps = total_jumps as f64 / n_paths as f64;
584        let expected = lambda * t_end;
585        assert!(
586            (mean_jumps - expected).abs() / expected < 0.15,
587            "mean jumps {mean_jumps:.3} vs expected {expected:.3}"
588        );
589    }
590
591    #[test]
592    fn test_compound_poisson_invalid_lambda() {
593        let mut rng = seeded_rng(0);
594        assert!(compound_poisson_process(0.0, |_| 1.0, (0.0, 1.0), &mut rng).is_err());
595        assert!(compound_poisson_process(-1.0, |_| 1.0, (0.0, 1.0), &mut rng).is_err());
596    }
597
598    #[test]
599    fn test_kou_positive_price() {
600        let mut rng = seeded_rng(42);
601        let path = kou_double_exponential(
602            0.05,
603            0.2,
604            1.0,
605            0.4,
606            5.0,
607            3.0,
608            100.0,
609            (0.0, 1.0),
610            0.01,
611            &mut rng,
612        )
613        .expect("kou_double_exponential should succeed");
614        assert!(
615            path.iter().all(|&(_, s)| s > 0.0),
616            "Kou S must stay positive"
617        );
618    }
619
620    #[test]
621    fn test_kou_invalid_params() {
622        let mut rng = seeded_rng(0);
623        // eta1 must be > 1
624        assert!(kou_double_exponential(
625            0.05,
626            0.2,
627            1.0,
628            0.4,
629            0.5,
630            3.0,
631            100.0,
632            (0.0, 1.0),
633            0.01,
634            &mut rng
635        )
636        .is_err());
637        // p must be in (0,1)
638        assert!(kou_double_exponential(
639            0.05,
640            0.2,
641            1.0,
642            0.0,
643            5.0,
644            3.0,
645            100.0,
646            (0.0, 1.0),
647            0.01,
648            &mut rng
649        )
650        .is_err());
651    }
652
653    #[test]
654    fn test_jump_diffusion_problem() {
655        let mut rng = seeded_rng(99);
656        let normal =
657            Normal::new(0.0_f64, 0.5).expect("Normal::new should succeed with valid params");
658        let prob = JumpDiffusionProblem::new(
659            |x, _t| 0.05 * x,
660            |x, _t| 0.2 * x,
661            1.0,
662            move |r| normal.sample(r),
663            100.0,
664            (0.0, 1.0),
665        )
666        .expect("JumpDiffusionProblem::new should succeed");
667        let path = prob
668            .solve(0.01, &mut rng)
669            .expect("prob.solve should succeed");
670        assert!(!path.is_empty());
671        assert!((path[0].0 - 0.0).abs() < 1e-12);
672        assert!((path[0].1 - 100.0).abs() < 1e-12);
673    }
674
675    #[test]
676    fn test_poisson_sample_zero_rate() {
677        let uniform =
678            Uniform::new(0.0_f64, 1.0).expect("Uniform::new should succeed with valid range");
679        let mut rng = seeded_rng(0);
680        let n = poisson_sample(0.0, &mut rng, &uniform).expect("poisson_sample should succeed");
681        assert_eq!(n, 0);
682    }
683
684    #[test]
685    fn test_validate_t_span() {
686        assert!(validate_t_span((0.0, 1.0)).is_ok());
687        assert!(validate_t_span((1.0, 0.0)).is_err());
688        assert!(validate_t_span((1.0, 1.0)).is_err());
689    }
690}