feat!: Gaussian's EP operations stop wearing arithmetic's clothes

`Gaussian` publicly implemented `Mul`, `Div`, `Add` and `Sub`. They were
the EP product, cavity and variance-space convolutions, and every one of
them lies to a reader who takes the operator at face value:

    a = N(10, 2)   b = N(4, 3)   c = N(1, 1)

    a * b        N(8.15, 1.66)   not 40
    a - b        sigma GREW, 2 -> sqrt(4 + 9)
    a * N(1, 0)  mu = NaN        "multiply by one"
    a / c        pi = -0.75      mu() prints a confident 0

The last is this crate's signature defect on a public operator. `Div` is
the cavity and can legitimately leave a negative precision, which is not
a distribution — and `mu()`/`sigma()` guard `pi <= 0` and report `0.0`
and `inf`, so it comes back as a plausible number with no panic, no
`Debug` marker and nothing to test against.

The four impls are now `pub(crate)` inherent methods that say what they
do: `ep_product`, `cavity`, `convolve`, `convolve_diff`, plus `scale`
for the one operation that genuinely is arithmetic. Nothing in a user's
workflow needed operator syntax; inference did, and it still has it.

`pi()` and `tau()` follow. Storing natural parameters is a performance
decision — it makes message passing two adds — not a contract. The
public surface is now exactly: `from_ms`, `from_mv`, `mu`, `sigma`,
`variance`, `probability_below`, `probability_above`. `from_mv` and
`variance` are promoted from `pub(crate)`; they are the honest pair for
callers who already hold a variance and should not pay a round trip
through the square root.

Four integration tests asserted bit-identity on `(pi, tau)`. They assert
it on `(mu, variance)` instead — still `assert_eq!`, still exact, and
`1/pi` and `tau/pi` are deterministic, so bit-equal natural parameters
give bit-equal moments. `a_nan_sigma_passes_through_from_ms` drops its
`|| g.pi().is_nan()` half: `sigma()` substitutes for `pi <= 0` and
`pi == inf`, so NaN survives to it only from a NaN precision.

`benches/gaussian.rs` is deleted. It timed two f64 additions through the
public operators, and keeping those public solely to feed it is the same
thing #73 objected to when a benchmark was dictating five public types.
The paths it covered are exercised by `batch` and `history_converge`
through the real call chain.

Closes #71.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
This commit is contained in:
2026-09-09 23:13:10 +02:00
co-authored by Claude Opus 5
parent 8e34410db0
commit 076a7ded8c
13 changed files with 163 additions and 175 deletions
+86 -64
View File
@@ -1,5 +1,3 @@
use std::ops;
use crate::{MU, N_INF, SIGMA};
/// A Gaussian distribution stored in natural parameters.
@@ -75,11 +73,12 @@ impl Gaussian {
/// Construct from mean and *variance*, skipping the square-root round trip.
///
/// `from_ms(mu, var.sqrt())` immediately squares the root away again to
/// recover `pi = 1/var`. Variance-combining operations (`Add`, `Sub`,
/// `exclude`, `forget`) work in variance space throughout, so they go
/// through here instead and never take a root.
/// recover `pi = 1/var`. Variance-combining operations work in variance
/// space throughout, so they go through here instead and never take a
/// root. Use it whenever you already hold a variance —
/// [`variance`](Gaussian::variance) is its inverse.
#[inline]
pub(crate) fn from_mv(mu: f64, var: f64) -> Self {
pub fn from_mv(mu: f64, var: f64) -> Self {
if var == f64::INFINITY {
Self { pi: 0.0, tau: 0.0 }
} else if var == 0.0 {
@@ -107,8 +106,7 @@ impl Gaussian {
/// means more certain; `0.0` is an improper, uninformative message and
/// `inf` is a point mass.
#[inline]
#[must_use]
pub fn pi(&self) -> f64 {
pub(crate) fn pi(&self) -> f64 {
self.pi
}
@@ -117,8 +115,7 @@ impl Gaussian {
/// Stored rather than derived, for the same reason as [`Gaussian::pi`].
/// Meaningful only alongside `pi`: on its own it is not a location.
#[inline]
#[must_use]
pub fn tau(&self) -> f64 {
pub(crate) fn tau(&self) -> f64 {
self.tau
}
@@ -142,12 +139,14 @@ impl Gaussian {
}
}
/// Variance, `1 / pi`, without the root-and-square of `sigma().powi(2)`.
/// Variance, without the root-and-square of `sigma().powi(2)`.
///
/// Mirrors `sigma()`'s treatment of the improper (`pi <= 0`) and point-mass
/// (`pi == inf`) cases.
/// Mirrors [`sigma`](Gaussian::sigma)'s treatment of the improper
/// (infinite) and point-mass (zero) cases, and is the inverse of
/// [`from_mv`](Gaussian::from_mv).
#[inline]
pub(crate) fn variance(&self) -> f64 {
#[must_use]
pub fn variance(&self) -> f64 {
if self.pi <= 0.0 {
f64::INFINITY
} else if self.pi.is_infinite() {
@@ -279,34 +278,65 @@ impl Default for Gaussian {
}
}
impl ops::Add<Gaussian> for Gaussian {
type Output = Gaussian;
/// Variance addition: (mu1 + mu2, sqrt(σ1² + σ2²)).
/// Used for combining performance and noise; rare relative to mul/div.
fn add(self, rhs: Gaussian) -> Self::Output {
Self::from_mv(self.mu() + rhs.mu(), self.variance() + rhs.variance())
}
}
impl ops::Sub<Gaussian> for Gaussian {
type Output = Gaussian;
/// (mu1 - mu2, sqrt(σ1² + σ2²)). Same sigma combination as Add.
fn sub(self, rhs: Gaussian) -> Self::Output {
Self::from_mv(self.mu() - rhs.mu(), self.variance() + rhs.variance())
}
}
impl ops::Mul<Gaussian> for Gaussian {
type Output = Gaussian;
/// Factor product: nat-param add. Hot path — two f64 additions, no sqrt.
fn mul(self, rhs: Gaussian) -> Self::Output {
impl Gaussian {
/// The EP factor **product**: multiply two messages about the same
/// variable.
///
/// Two natural-parameter additions and no square root, which is why the
/// type stores `pi` and `tau` rather than `mu` and `sigma`. This is the
/// hot path.
///
/// Not arithmetic — `N(10, 2).ep_product(N(4, 3))` is `N(8.15, 1.66)`,
/// nowhere near 40. It used to be spelled `a * b`, on a public `Mul` impl,
/// where that was a trap rather than a shorthand.
#[inline]
pub(crate) fn ep_product(self, rhs: Gaussian) -> Gaussian {
Self::from_natural(self.pi + rhs.pi, self.tau + rhs.tau)
}
}
impl ops::Mul<f64> for Gaussian {
type Output = Gaussian;
fn mul(self, scalar: f64) -> Self::Output {
/// The EP **cavity**: divide out a message this belief already absorbed.
///
/// The inverse of [`ep_product`](Gaussian::ep_product), and two
/// subtractions rather than two additions.
///
/// **May return an improper result.** Cancelling a message that carried
/// most of the precision leaves `pi <= 0`, which is not a distribution.
/// `mu()` reports `0.0` and `sigma()` reports `inf` for such a value —
/// both are the accessors' policy for "undefined", not answers. Measured:
/// `N(10, 2).cavity(N(1, 1))` has `pi = -0.75`, and its `mu()` prints a
/// confident `0`. That is why this is not a public operator.
#[inline]
pub(crate) fn cavity(self, rhs: Gaussian) -> Gaussian {
Self::from_natural(self.pi - rhs.pi, self.tau - rhs.tau)
}
/// Convolve two independent Gaussians: `N(mu1 + mu2, sqrt(v1 + v2))`.
///
/// The distribution of a *sum* of independent variables, so the variances
/// add — the result is always wider than either input. Used to combine a
/// skill with performance noise. Goes through `from_mv` and takes no root.
#[inline]
pub(crate) fn convolve(self, rhs: Gaussian) -> Gaussian {
Self::from_mv(self.mu() + rhs.mu(), self.variance() + rhs.variance())
}
/// Convolve a *difference*: `N(mu1 - mu2, sqrt(v1 + v2))`.
///
/// The means subtract and the variances still **add**, because a
/// difference of independent variables is no more certain than a sum. That
/// is the half that made the old `Sub` impl misleading: `a - b` grew the
/// sigma from 2 to `sqrt(4 + 9)`.
#[inline]
pub(crate) fn convolve_diff(self, rhs: Gaussian) -> Gaussian {
Self::from_mv(self.mu() - rhs.mu(), self.variance() + rhs.variance())
}
/// Scale by a constant: `mu` by `scalar`, `sigma` by `|scalar|`.
///
/// The one operation that *is* ordinary arithmetic — it is the
/// distribution of `scalar * X`. Used for per-member weights.
#[inline]
pub(crate) fn scale(self, scalar: f64) -> Gaussian {
if !scalar.is_finite() {
return N_INF;
}
@@ -321,14 +351,6 @@ impl ops::Mul<f64> for Gaussian {
}
}
impl ops::Div<Gaussian> for Gaussian {
type Output = Gaussian;
/// Cavity: nat-param sub. Hot path — two f64 subtractions, no sqrt.
fn div(self, rhs: Gaussian) -> Self::Output {
Self::from_natural(self.pi - rhs.pi, self.tau - rhs.tau)
}
}
#[cfg(test)]
mod tests {
/// A message that did not change must report no change, even when it is
@@ -386,64 +408,64 @@ mod tests {
// Subtracting such a message must not produce NaN (the original failure path).
let proper = Gaussian::from_ms(9.75, 1.256);
let diff = proper - tiny_neg;
let diff = proper.convolve_diff(tiny_neg);
assert!(diff.pi().is_finite() && !diff.pi().is_nan());
assert!(diff.tau().is_finite() && !diff.tau().is_nan());
}
#[test]
fn test_add() {
fn convolve_adds_variances() {
let n = Gaussian::from_ms(25.0, 25.0 / 3.0);
let m = Gaussian::from_ms(0.0, 1.0);
let r = n + m;
let r = n.convolve(m);
assert!((r.mu() - 25.0).abs() < 1e-12);
assert!((r.sigma() - 8.393118874676116).abs() < 1e-10);
}
#[test]
fn test_sub() {
fn convolve_diff_subtracts_means_and_adds_variances() {
let n = Gaussian::from_ms(25.0, 25.0 / 3.0);
let m = Gaussian::from_ms(1.0, 1.0);
let r = n - m;
let r = n.convolve_diff(m);
assert!((r.mu() - 24.0).abs() < 1e-12);
assert!((r.sigma() - 8.393118874676116).abs() < 1e-10);
}
#[test]
fn test_mul() {
fn ep_product_is_not_arithmetic() {
let n = Gaussian::from_ms(25.0, 25.0 / 3.0);
let m = Gaussian::from_ms(0.0, 1.0);
let r = n * m;
let r = n.ep_product(m);
assert!((r.mu() - 0.35488958990536273).abs() < 1e-10);
assert!((r.sigma() - 0.992876838486922).abs() < 1e-10);
}
#[test]
fn test_div() {
fn cavity_undoes_a_product() {
let n = Gaussian::from_ms(25.0, 25.0 / 3.0);
let m = Gaussian::from_ms(0.0, 1.0);
let r = m / n;
let r = m.cavity(n);
assert!((r.mu() - (-0.3652597402597402)).abs() < 1e-10);
assert!((r.sigma() - 1.0072787050317253).abs() < 1e-10);
}
#[test]
fn test_n00_is_add_identity() {
// N00 (sigma=0) is the additive identity for the variance-convolution Add op.
// N_INF (sigma=inf) is the identity for the EP-product Mul op.
// N00 (sigma=0) is the identity for `convolve`.
// N_INF (sigma=inf) is the identity for `ep_product`.
let g = Gaussian::from_ms(3.0, 2.0);
let n00 = Gaussian::from_ms(0.0, 0.0);
let r = n00 + g;
let r = n00.convolve(g);
assert!((r.mu() - g.mu()).abs() < 1e-12);
assert!((r.sigma() - g.sigma()).abs() < 1e-12);
}
#[test]
fn test_mul_is_factor_product() {
// n * m in nat-params should be pi_n + pi_m, tau_n + tau_m
fn ep_product_adds_natural_parameters() {
// `ep_product` in nat-params should be pi_n + pi_m, tau_n + tau_m
let n = Gaussian::from_ms(2.0, 3.0);
let m = Gaussian::from_ms(1.0, 2.0);
let r = n * m;
let r = n.ep_product(m);
let expected_pi = n.pi() + m.pi();
let expected_tau = n.tau() + m.tau();
assert!((r.pi() - expected_pi).abs() < 1e-15);
@@ -451,10 +473,10 @@ mod tests {
}
#[test]
fn test_div_is_cavity() {
fn cavity_subtracts_natural_parameters() {
let n = Gaussian::from_ms(2.0, 1.0);
let m = Gaussian::from_ms(1.0, 2.0);
let r = n / m;
let r = n.cavity(m);
let expected_pi = n.pi() - m.pi();
let expected_tau = n.tau() - m.tau();
assert!((r.pi() - expected_pi).abs() < 1e-15);