viva_math/ou
Ornstein-Uhlenbeck mood dynamics.
Mean-reverting stochastic process for affective dynamics — the canonical model for emotion regulation toward a baseline. Underlies VIVA’s homeostatic emotional decay.
SDE: dX_t = θ(μ - X_t) dt + σ dW_t
Parameters:
theta(θ) — mean-reversion speed (> 0). Larger = faster return to μ.mu(μ) — long-run mean (the attractor).sigma(σ) — diffusion (volatility, ≥ 0).
Analytical properties
Given X_0 = x_0:
E[X_t] = μ + (x_0 − μ) · e^(−θt)Var[X_t] = σ² / (2θ) · (1 − e^(−2θt))- Stationary:
X_∞ ~ N(μ, σ²/(2θ)) - Autocovariance at lag
τ:σ²/(2θ) · e^(−θ|τ|) - Half-life of expectation:
ln(2) / θ
Integration
step uses the exact transition kernel (Doob 1942) — no
discretization error regardless of dt. Closed form:
X_{t+Δ} = μ + (X_t − μ)·e^(−θΔ) + σ·sqrt((1 − e^(−2θΔ))/(2θ)) · Z
where Z ~ N(0, 1). For Euler-Maruyama on the same SDE, use
ode.euler_maruyama directly with a custom drift/diffusion.
References
- Uhlenbeck & Ornstein (1930) — On the theory of Brownian motion
- Oravecz, Tuerlinckx & Vandekerckhove (2009) — Ornstein-Uhlenbeck Process in Affective Dynamics
- Doob (1942) — The Brownian Movement and Stochastic Equations
Types
Scalar Ornstein-Uhlenbeck parameters.
pub type OUParams1D {
OUParams1D(theta: Float, mu: Float, sigma: Float)
}
Constructors
-
OUParams1D(theta: Float, mu: Float, sigma: Float)
Componentwise Ornstein-Uhlenbeck parameters over PAD.
Each axis (pleasure, arousal, dominance) gets its own θ, μ, σ — no cross-correlation between axes (diagonal covariance). Sufficient for affective dynamics where each dimension regulates independently.
pub type OUParamsVec3 {
OUParamsVec3(
theta: vector.Vec3,
mu: vector.Vec3,
sigma: vector.Vec3,
)
}
Constructors
-
OUParamsVec3( theta: vector.Vec3, mu: vector.Vec3, sigma: vector.Vec3, )
Values
pub fn autocovariance(params: OUParams1D, lag: Float) -> Float
Autocovariance at lag τ (in time units).
Cov(X_s, X_{s+τ}) = σ²/(2θ) · e^(−θ·|τ|) (stationary regime).
pub fn half_life(params: OUParams1D) -> Float
Half-life of mean reversion: ln(2) / θ.
Time at which E[X_t] has covered half the gap toward μ.
pub fn is_valid(params: OUParams1D) -> Bool
Check whether 1D parameters are physically meaningful.
Requires theta > 0 (otherwise no mean-reversion) and sigma >= 0.
pub fn is_valid_vec3(params: OUParamsVec3) -> Bool
Vec3 validity — every component of theta strictly positive and every
component of sigma non-negative.
pub fn mean_at(params: OUParams1D, x0: Float, t: Float) -> Float
Closed-form E[X_t | X_0 = x0].
μ + (x0 − μ) · e^(−θ·t)
pub fn mean_at_vec3(
params: OUParamsVec3,
x0: vector.Vec3,
t: Float,
) -> vector.Vec3
Closed-form E[X_t | X_0 = x0] componentwise.
pub fn simulate(
params: OUParams1D,
x0: Float,
dt: Float,
n: Int,
seed: random.Seed,
) -> #(List(Float), random.Seed)
Simulate n steps starting from x0 with constant time-step dt.
Returns the trajectory excluding the initial point (length n) and the
final seed for chaining.
Pre-computes the transition kernel (decay, std) once — the loop only
does a multiply-add and a normal draw per step.
pub fn simulate_vec3(
params: OUParamsVec3,
x0: vector.Vec3,
dt: Float,
n: Int,
seed: random.Seed,
) -> #(List(vector.Vec3), random.Seed)
Simulate Vec3 trajectory. Returns n Vec3 points excluding initial.
Pre-computes the three componentwise transition kernels once — the loop only does multiply-adds and three normal draws per step.
pub fn stationary_std(params: OUParams1D) -> Float
Stationary standard deviation.
pub fn stationary_variance(params: OUParams1D) -> Float
Stationary variance σ² / (2θ) — the variance of X_∞ ~ N(μ, σ²/(2θ)).
pub fn stationary_variance_vec3(
params: OUParamsVec3,
) -> vector.Vec3
Stationary variance per axis.
pub fn step(
params: OUParams1D,
x: Float,
dt: Float,
seed: random.Seed,
) -> #(Float, random.Seed)
One step of the exact OU transition kernel.
X_{t+dt} = μ + (X_t − μ)·e^(−θ·dt) + σ·sqrt((1 − e^(−2θ·dt))/(2θ)) · Z
Z ~ N(0, 1). No discretization error: works correctly even for large dt.
Caller-validated inputs: callers must ensure theta > 0, sigma >= 0,
and dt >= 0 (use is_valid for the params). With dt < 0 the variance
term goes negative and std_term silently collapses to 0.0, producing
a deterministic backward step that consumes a normal draw without using
it — not a physically meaningful transition.
pub fn step_vec3(
params: OUParamsVec3,
x: vector.Vec3,
dt: Float,
seed: random.Seed,
) -> #(vector.Vec3, random.Seed)
One Vec3 OU step. Each axis (P, A, D) updated independently via the exact 1D kernel. Three normals drawn from the seed in sequence.
pub fn variance_at(
params: OUParams1D,
x0: Float,
t: Float,
) -> Float
Closed-form Var[X_t | X_0 = x0].
σ² / (2θ) · (1 − e^(−2θ·t))
Note: the conditional variance is independent of x0 — OU’s noise
is additive Brownian, so all x0-dependence is absorbed into the mean.
The parameter is kept in the signature only to mirror mean_at and
variance_at_vec3 for API symmetry; pass any value.
Routed through scalar.expm1 so the Brownian limit σ²·t (as θ·t → 0)
is recovered without catastrophic cancellation.
pub fn variance_at_vec3(
params: OUParamsVec3,
x0: vector.Vec3,
t: Float,
) -> vector.Vec3
Closed-form Var[X_t | X_0 = x0] componentwise.