use crate::error::{CsError, CsResult};
use crate::handle::LcgRng;
#[derive(Debug, Clone)]
pub struct SmcCsConfig {
pub n_particles: usize,
pub signal_dim: usize,
pub noise_variance: f64,
pub ess_threshold: f64,
pub alpha_shape: f64,
pub alpha_mean: f64,
pub rejuvenate: bool,
}
impl Default for SmcCsConfig {
fn default() -> Self {
Self {
n_particles: 64,
signal_dim: 16,
noise_variance: 1e-2,
ess_threshold: 0.5,
alpha_shape: 2.0,
alpha_mean: 1.0,
rejuvenate: true,
}
}
}
#[derive(Debug, Clone)]
struct Particle {
alpha: Vec<f64>,
mean: Vec<f64>,
cov: Vec<f64>,
weight: f64,
}
impl Particle {
fn from_alpha(alpha: Vec<f64>, weight: f64) -> Self {
let d = alpha.len();
let mean = vec![0.0_f64; d];
let mut cov = vec![0.0_f64; d * d];
for j in 0..d {
cov[j * d + j] = 1.0 / alpha[j];
}
Self {
alpha,
mean,
cov,
weight,
}
}
fn sequential_update(&mut self, phi: &[f64], y: f64, noise_variance: f64) -> f64 {
let d = self.mean.len();
let mut g = vec![0.0_f64; d];
for i in 0..d {
let row = i * d;
let mut acc = 0.0_f64;
for j in 0..d {
acc += self.cov[row + j] * phi[j];
}
g[i] = acc;
}
let mut phi_sigma_phi = 0.0_f64;
for j in 0..d {
phi_sigma_phi += phi[j] * g[j];
}
let s = phi_sigma_phi.max(0.0) + noise_variance;
let s_safe = s.max(1e-300);
let mut y_hat = 0.0_f64;
for j in 0..d {
y_hat += phi[j] * self.mean[j];
}
let innovation = y - y_hat;
let scale = innovation / s_safe;
for i in 0..d {
self.mean[i] += g[i] * scale;
}
let inv_s = 1.0 / s_safe;
for i in 0..d {
let gi = g[i];
if gi == 0.0 {
continue;
}
let row = i * d;
let gi_inv_s = gi * inv_s;
for j in 0..d {
self.cov[row + j] -= gi_inv_s * g[j];
}
}
for i in 0..d {
for j in (i + 1)..d {
let avg = 0.5 * (self.cov[i * d + j] + self.cov[j * d + i]);
self.cov[i * d + j] = avg;
self.cov[j * d + i] = avg;
}
}
gaussian_pdf(innovation, s_safe)
}
}
#[derive(Debug, Clone)]
pub struct SmcCs {
config: SmcCsConfig,
particles: Vec<Particle>,
rng: LcgRng,
n_observed: usize,
n_resamples: usize,
}
impl SmcCs {
pub fn new(config: SmcCsConfig, rng: LcgRng) -> CsResult<Self> {
if config.n_particles == 0 {
return Err(CsError::InvalidParameter(
"smc_cs: n_particles must be > 0".into(),
));
}
if config.signal_dim == 0 {
return Err(CsError::InvalidParameter(
"smc_cs: signal_dim must be > 0".into(),
));
}
if config.noise_variance <= 0.0 || !config.noise_variance.is_finite() {
return Err(CsError::InvalidParameter(format!(
"smc_cs: noise_variance must be finite and > 0, got {}",
config.noise_variance
)));
}
if config.ess_threshold <= 0.0 || config.ess_threshold > 1.0 {
return Err(CsError::InvalidParameter(format!(
"smc_cs: ess_threshold must be in (0, 1], got {}",
config.ess_threshold
)));
}
if config.alpha_shape <= 0.0 || !config.alpha_shape.is_finite() {
return Err(CsError::InvalidParameter(format!(
"smc_cs: alpha_shape must be finite and > 0, got {}",
config.alpha_shape
)));
}
if config.alpha_mean <= 0.0 || !config.alpha_mean.is_finite() {
return Err(CsError::InvalidParameter(format!(
"smc_cs: alpha_mean must be finite and > 0, got {}",
config.alpha_mean
)));
}
let mut rng = rng;
let d = config.signal_dim;
let n_p = config.n_particles;
let w0 = 1.0 / n_p as f64;
let mut particles = Vec::with_capacity(n_p);
for _ in 0..n_p {
let alpha = sample_alpha_vector(d, config.alpha_shape, config.alpha_mean, &mut rng);
particles.push(Particle::from_alpha(alpha, w0));
}
Ok(Self {
config,
particles,
rng,
n_observed: 0,
n_resamples: 0,
})
}
pub fn update(&mut self, phi_row: &[f64], y: f64) -> CsResult<()> {
let d = self.config.signal_dim;
if phi_row.len() != d {
return Err(CsError::DimensionMismatch {
a: phi_row.len(),
b: d,
});
}
if !y.is_finite() {
return Err(CsError::InvalidParameter(
"smc_cs: measurement y must be finite".into(),
));
}
if phi_row.iter().any(|v| !v.is_finite()) {
return Err(CsError::InvalidParameter(
"smc_cs: phi_row entries must be finite".into(),
));
}
let noise_variance = self.config.noise_variance;
let mut weight_sum = 0.0_f64;
for particle in &mut self.particles {
let likelihood = particle.sequential_update(phi_row, y, noise_variance);
particle.weight *= likelihood;
weight_sum += particle.weight;
}
if weight_sum <= 0.0 || !weight_sum.is_finite() {
return Err(CsError::NumericalInstability(
"smc_cs: all particle weights underflowed to zero".into(),
));
}
let inv = 1.0 / weight_sum;
for particle in &mut self.particles {
particle.weight *= inv;
}
self.n_observed += 1;
let ess = self.effective_sample_size();
let threshold = self.config.ess_threshold * self.config.n_particles as f64;
if ess < threshold {
self.resample()?;
}
Ok(())
}
pub fn update_batch(&mut self, phi: &[f64], n_rows: usize, y: &[f64]) -> CsResult<()> {
let d = self.config.signal_dim;
if phi.len() != n_rows * d {
return Err(CsError::ShapeMismatch {
expected: vec![n_rows, d],
got: vec![phi.len()],
});
}
if y.len() != n_rows {
return Err(CsError::DimensionMismatch {
a: y.len(),
b: n_rows,
});
}
for t in 0..n_rows {
let row = &phi[t * d..(t + 1) * d];
self.update(row, y[t])?;
}
Ok(())
}
#[must_use]
pub fn estimate(&self) -> Vec<f64> {
let d = self.config.signal_dim;
let mut out = vec![0.0_f64; d];
for particle in &self.particles {
let w = particle.weight;
for j in 0..d {
out[j] += w * particle.mean[j];
}
}
out
}
#[must_use]
pub fn posterior_variance(&self) -> Vec<f64> {
let d = self.config.signal_dim;
let mean = self.estimate();
let mut second = vec![0.0_f64; d];
for particle in &self.particles {
let w = particle.weight;
for j in 0..d {
let m_j = particle.mean[j];
let var_j = particle.cov[j * d + j];
second[j] += w * (var_j + m_j * m_j);
}
}
let mut out = vec![0.0_f64; d];
for j in 0..d {
out[j] = (second[j] - mean[j] * mean[j]).max(0.0);
}
out
}
#[must_use]
pub fn effective_sample_size(&self) -> f64 {
let mut sum_sq = 0.0_f64;
for particle in &self.particles {
sum_sq += particle.weight * particle.weight;
}
if sum_sq <= 0.0 {
return self.config.n_particles as f64;
}
1.0 / sum_sq
}
#[must_use]
pub fn n_observed(&self) -> usize {
self.n_observed
}
#[must_use]
pub fn n_resamples(&self) -> usize {
self.n_resamples
}
#[must_use]
pub fn n_particles(&self) -> usize {
self.config.n_particles
}
#[must_use]
pub fn weights(&self) -> Vec<f64> {
self.particles.iter().map(|p| p.weight).collect()
}
fn resample(&mut self) -> CsResult<()> {
let n_p = self.config.n_particles;
let n_p_f = n_p as f64;
let mut cumulative = Vec::with_capacity(n_p);
let mut acc = 0.0_f64;
for particle in &self.particles {
acc += particle.weight;
cumulative.push(acc);
}
let total = acc;
if total <= 0.0 || !total.is_finite() {
return Err(CsError::NumericalInstability(
"smc_cs: cannot resample from a zero-mass population".into(),
));
}
let step = total / n_p_f;
let u0 = self.rng.next_f64() * step;
let mut survivors: Vec<usize> = Vec::with_capacity(n_p);
let mut cursor = 0usize;
for k in 0..n_p {
let pointer = u0 + step * k as f64;
while cursor < n_p - 1 && cumulative[cursor] < pointer {
cursor += 1;
}
survivors.push(cursor);
}
let w0 = 1.0 / n_p_f;
let mut new_particles: Vec<Particle> = Vec::with_capacity(n_p);
for &idx in &survivors {
let mut p = self.particles[idx].clone();
p.weight = w0;
new_particles.push(p);
}
if self.config.rejuvenate {
self.rejuvenate_population(&mut new_particles, &survivors);
}
self.particles = new_particles;
self.n_resamples += 1;
Ok(())
}
fn rejuvenate_population(&mut self, particles: &mut [Particle], survivors: &[usize]) {
let d = self.config.signal_dim;
let mut seen = vec![false; self.config.n_particles];
for (slot, &idx) in survivors.iter().enumerate() {
if !seen[idx] {
seen[idx] = true;
continue; }
let particle = &mut particles[slot];
for j in 0..d {
let factor = (0.15 * self.rng.next_normal()).exp();
let old_alpha = particle.alpha[j];
let new_alpha = (old_alpha * factor).clamp(1e-8, 1e12);
let ratio = old_alpha / new_alpha;
let sqrt_ratio = ratio.sqrt();
for col in 0..d {
particle.cov[j * d + col] *= sqrt_ratio;
particle.cov[col * d + j] *= sqrt_ratio;
}
particle.alpha[j] = new_alpha;
}
for i in 0..d {
for col in (i + 1)..d {
let avg = 0.5 * (particle.cov[i * d + col] + particle.cov[col * d + i]);
particle.cov[i * d + col] = avg;
particle.cov[col * d + i] = avg;
}
}
}
}
}
fn gaussian_pdf(x: f64, variance: f64) -> f64 {
let inv_var = 1.0 / variance;
let coeff = (inv_var / std::f64::consts::TAU).sqrt();
coeff * (-0.5 * x * x * inv_var).exp()
}
fn sample_gamma(shape: f64, scale: f64, rng: &mut LcgRng) -> f64 {
let (k, boost) = if shape < 1.0 {
let u = rng.next_f64().max(1e-300);
(shape + 1.0, u.powf(1.0 / shape))
} else {
(shape, 1.0)
};
let d = k - 1.0 / 3.0;
let c = 1.0 / (9.0 * d).sqrt();
let value;
loop {
let z = rng.next_normal();
let v_base = 1.0 + c * z;
if v_base <= 0.0 {
continue;
}
let v = v_base * v_base * v_base;
let u = rng.next_f64().max(1e-300);
if u < 1.0 - 0.0331 * z * z * z * z {
value = d * v;
break;
}
if u.ln() < 0.5 * z * z + d * (1.0 - v + v.ln()) {
value = d * v;
break;
}
}
(value * boost * scale).max(1e-300)
}
fn sample_alpha_vector(d: usize, shape: f64, mean: f64, rng: &mut LcgRng) -> Vec<f64> {
let scale = mean / shape;
let mut alpha = Vec::with_capacity(d);
for _ in 0..d {
let a = sample_gamma(shape, scale, rng).clamp(1e-8, 1e12);
alpha.push(a);
}
alpha
}
#[cfg(test)]
mod tests {
use super::*;
fn stream_and_error(engine: &mut SmcCs, x_true: &[f64], n_rows: usize, meas_seed: u64) -> f64 {
let d = x_true.len();
let mut mrng = LcgRng::new(meas_seed);
for _ in 0..n_rows {
let mut phi = vec![0.0_f64; d];
for v in phi.iter_mut() {
*v = mrng.next_normal();
}
let mut y = 0.0_f64;
for j in 0..d {
y += phi[j] * x_true[j];
}
y += 0.01 * mrng.next_normal();
engine.update(&phi, y).expect("update ok");
}
let est = engine.estimate();
let mut err = 0.0_f64;
for j in 0..d {
let diff = est[j] - x_true[j];
err += diff * diff;
}
err.sqrt()
}
fn default_config(d: usize, n_p: usize) -> SmcCsConfig {
SmcCsConfig {
n_particles: n_p,
signal_dim: d,
noise_variance: 1e-3,
ess_threshold: 0.5,
alpha_shape: 2.0,
alpha_mean: 0.5,
rejuvenate: true,
}
}
#[test]
fn error_decreases_with_more_rows() {
let d = 12usize;
let mut x_true = vec![0.0_f64; d];
x_true[2] = 1.5;
x_true[7] = -2.0;
x_true[10] = 0.8;
let cfg = default_config(d, 96);
let mut eng_few = SmcCs::new(cfg.clone(), LcgRng::new(11)).expect("new ok");
let err_few = stream_and_error(&mut eng_few, &x_true, 6, 4242);
let mut eng_many = SmcCs::new(cfg, LcgRng::new(11)).expect("new ok");
let err_many = stream_and_error(&mut eng_many, &x_true, 40, 4242);
assert!(
err_many < err_few,
"more measurements should reduce error: few={err_few:.4}, many={err_many:.4}"
);
assert!(err_many < 0.5, "final error too large: {err_many:.4}");
}
#[test]
fn ess_in_valid_range() {
let d = 8usize;
let n_p = 64usize;
let mut x_true = vec![0.0_f64; d];
x_true[1] = 1.0;
x_true[5] = -1.2;
let cfg = default_config(d, n_p);
let mut eng = SmcCs::new(cfg, LcgRng::new(7)).expect("new ok");
let init_ess = eng.effective_sample_size();
assert!(
(init_ess - n_p as f64).abs() < 1e-9,
"init ESS = {init_ess}"
);
let mut mrng = LcgRng::new(303);
for _ in 0..30 {
let mut phi = vec![0.0_f64; d];
for v in phi.iter_mut() {
*v = mrng.next_normal();
}
let mut y = 0.0_f64;
for j in 0..d {
y += phi[j] * x_true[j];
}
eng.update(&phi, y).expect("update ok");
let ess = eng.effective_sample_size();
assert!(
ess > 0.0 && ess <= n_p as f64 + 1e-9,
"ESS out of range: {ess}"
);
}
}
#[test]
fn weights_normalised() {
let d = 6usize;
let cfg = default_config(d, 48);
let mut eng = SmcCs::new(cfg, LcgRng::new(21)).expect("new ok");
let mut x_true = vec![0.0_f64; d];
x_true[3] = 2.0;
let mut mrng = LcgRng::new(99);
for _ in 0..20 {
let mut phi = vec![0.0_f64; d];
for v in phi.iter_mut() {
*v = mrng.next_normal();
}
let mut y = 0.0_f64;
for j in 0..d {
y += phi[j] * x_true[j];
}
eng.update(&phi, y).expect("update ok");
let sum: f64 = eng.weights().iter().sum();
assert!((sum - 1.0).abs() < 1e-9, "weights sum = {sum}");
assert!(
eng.weights().iter().all(|w| *w >= 0.0 && w.is_finite()),
"weights must be non-negative and finite"
);
}
}
#[test]
fn resampling_triggers() {
let d = 10usize;
let cfg = SmcCsConfig {
n_particles: 80,
signal_dim: d,
noise_variance: 1e-3,
ess_threshold: 0.9,
alpha_shape: 2.0,
alpha_mean: 0.5,
rejuvenate: true,
};
let mut eng = SmcCs::new(cfg, LcgRng::new(5)).expect("new ok");
let mut x_true = vec![0.0_f64; d];
x_true[0] = 3.0;
x_true[6] = -2.5;
let mut mrng = LcgRng::new(808);
for _ in 0..25 {
let mut phi = vec![0.0_f64; d];
for v in phi.iter_mut() {
*v = mrng.next_normal();
}
let mut y = 0.0_f64;
for j in 0..d {
y += phi[j] * x_true[j];
}
eng.update(&phi, y).expect("update ok");
}
assert!(
eng.n_resamples() > 0,
"resampling should have fired at least once"
);
let sum: f64 = eng.weights().iter().sum();
assert!(
(sum - 1.0).abs() < 1e-9,
"post-resample weights sum = {sum}"
);
}
#[test]
fn one_sparse_recovery_accurate() {
let d = 16usize;
let mut x_true = vec![0.0_f64; d];
x_true[9] = 4.0;
let cfg = default_config(d, 128);
let mut eng = SmcCs::new(cfg, LcgRng::new(33)).expect("new ok");
let err = stream_and_error(&mut eng, &x_true, 50, 123_456);
assert!(err < 0.3, "1-sparse recovery error too large: {err:.4}");
let est = eng.estimate();
assert!(est[9] > 3.0, "active coeff under-recovered: {}", est[9]);
let max_off = (0..d)
.filter(|&j| j != 9)
.map(|j| est[j].abs())
.fold(0.0_f64, f64::max);
assert!(max_off < 0.5, "off-support leakage too large: {max_off:.4}");
}
#[test]
fn posterior_variance_shrinks() {
let d = 8usize;
let mut x_true = vec![0.0_f64; d];
x_true[4] = 2.0;
let cfg = default_config(d, 96);
let mut eng = SmcCs::new(cfg, LcgRng::new(64)).expect("new ok");
let var_before = eng.posterior_variance();
assert!(
var_before.iter().all(|v| *v >= 0.0 && v.is_finite()),
"initial variance must be non-negative finite"
);
let _ = stream_and_error(&mut eng, &x_true, 40, 7777);
let var_after = eng.posterior_variance();
assert!(
var_after.iter().all(|v| *v >= 0.0 && v.is_finite()),
"posterior variance must stay non-negative finite"
);
let total_before: f64 = var_before.iter().sum();
let total_after: f64 = var_after.iter().sum();
assert!(
total_after < total_before,
"variance should shrink: before={total_before:.4}, after={total_after:.4}"
);
}
#[test]
fn batch_matches_sequential() {
let d = 7usize;
let n_rows = 15usize;
let mut x_true = vec![0.0_f64; d];
x_true[2] = 1.0;
x_true[5] = -1.0;
let mut mrng = LcgRng::new(2024);
let mut phi = vec![0.0_f64; n_rows * d];
let mut y = vec![0.0_f64; n_rows];
for t in 0..n_rows {
for j in 0..d {
phi[t * d + j] = mrng.next_normal();
}
let mut acc = 0.0_f64;
for j in 0..d {
acc += phi[t * d + j] * x_true[j];
}
y[t] = acc;
}
let cfg = default_config(d, 64);
let mut eng_seq = SmcCs::new(cfg.clone(), LcgRng::new(50)).expect("new ok");
for t in 0..n_rows {
eng_seq
.update(&phi[t * d..(t + 1) * d], y[t])
.expect("update ok");
}
let mut eng_batch = SmcCs::new(cfg, LcgRng::new(50)).expect("new ok");
eng_batch.update_batch(&phi, n_rows, &y).expect("batch ok");
let est_seq = eng_seq.estimate();
let est_batch = eng_batch.estimate();
for j in 0..d {
assert!(
(est_seq[j] - est_batch[j]).abs() < 1e-12,
"batch vs sequential mismatch at {j}: {} vs {}",
est_seq[j],
est_batch[j]
);
}
}
#[test]
fn update_dimension_mismatch() {
let d = 5usize;
let cfg = default_config(d, 16);
let mut eng = SmcCs::new(cfg, LcgRng::new(1)).expect("new ok");
let phi = vec![1.0_f64, 2.0, 3.0];
assert!(matches!(
eng.update(&phi, 1.0),
Err(CsError::DimensionMismatch { .. })
));
let phi_ok = vec![1.0_f64; d];
assert!(matches!(
eng.update(&phi_ok, f64::NAN),
Err(CsError::InvalidParameter(_))
));
assert!(matches!(
eng.update_batch(&[1.0, 2.0, 3.0], 2, &[1.0, 2.0]),
Err(CsError::ShapeMismatch { .. })
));
assert!(matches!(
eng.update_batch(&vec![0.0_f64; 2 * d], 2, &[1.0]),
Err(CsError::DimensionMismatch { .. })
));
}
#[test]
fn invalid_config_rejected() {
let mut cfg = default_config(4, 0);
assert!(matches!(
SmcCs::new(cfg.clone(), LcgRng::new(1)),
Err(CsError::InvalidParameter(_))
));
cfg = default_config(0, 8);
assert!(matches!(
SmcCs::new(cfg.clone(), LcgRng::new(1)),
Err(CsError::InvalidParameter(_))
));
cfg = default_config(4, 8);
cfg.noise_variance = -1.0;
assert!(matches!(
SmcCs::new(cfg.clone(), LcgRng::new(1)),
Err(CsError::InvalidParameter(_))
));
cfg = default_config(4, 8);
cfg.ess_threshold = 1.5;
assert!(matches!(
SmcCs::new(cfg.clone(), LcgRng::new(1)),
Err(CsError::InvalidParameter(_))
));
cfg = default_config(4, 8);
cfg.ess_threshold = 0.0;
assert!(matches!(
SmcCs::new(cfg.clone(), LcgRng::new(1)),
Err(CsError::InvalidParameter(_))
));
cfg = default_config(4, 8);
cfg.alpha_shape = 0.0;
assert!(matches!(
SmcCs::new(cfg.clone(), LcgRng::new(1)),
Err(CsError::InvalidParameter(_))
));
cfg = default_config(4, 8);
cfg.alpha_mean = -2.0;
assert!(matches!(
SmcCs::new(cfg, LcgRng::new(1)),
Err(CsError::InvalidParameter(_))
));
}
#[test]
fn deterministic_given_seed() {
let d = 9usize;
let mut x_true = vec![0.0_f64; d];
x_true[1] = 1.0;
x_true[8] = -1.5;
let cfg = default_config(d, 64);
let mut eng_a = SmcCs::new(cfg.clone(), LcgRng::new(314)).expect("new ok");
let err_a = stream_and_error(&mut eng_a, &x_true, 25, 271);
let mut eng_b = SmcCs::new(cfg, LcgRng::new(314)).expect("new ok");
let err_b = stream_and_error(&mut eng_b, &x_true, 25, 271);
assert!(
(err_a - err_b).abs() < 1e-12,
"non-deterministic: {err_a} vs {err_b}"
);
let est_a = eng_a.estimate();
let est_b = eng_b.estimate();
for j in 0..d {
assert!(
(est_a[j] - est_b[j]).abs() < 1e-12,
"estimate mismatch at {j}"
);
}
}
#[test]
fn outputs_finite() {
let d = 10usize;
let mut x_true = vec![0.0_f64; d];
x_true[0] = 1.0;
x_true[3] = -1.0;
x_true[9] = 2.0;
let cfg = default_config(d, 80);
let mut eng = SmcCs::new(cfg, LcgRng::new(123)).expect("new ok");
let _ = stream_and_error(&mut eng, &x_true, 35, 4321);
let est = eng.estimate();
assert!(est.iter().all(|v| v.is_finite()), "estimate has non-finite");
let var = eng.posterior_variance();
assert!(var.iter().all(|v| v.is_finite()), "variance has non-finite");
assert_eq!(est.len(), d);
assert_eq!(var.len(), d);
assert_eq!(eng.n_observed(), 35);
}
#[test]
fn gamma_sampler_positive_mean() {
let mut rng = LcgRng::new(2718);
let shape = 2.0;
let mean = 1.5;
let scale = mean / shape;
let n = 5000;
let mut sum = 0.0_f64;
for _ in 0..n {
let g = sample_gamma(shape, scale, &mut rng);
assert!(
g > 0.0 && g.is_finite(),
"gamma draw must be positive finite"
);
sum += g;
}
let empirical = sum / n as f64;
assert!(
(empirical - mean).abs() < 0.15 * mean,
"empirical gamma mean {empirical:.3} far from {mean:.3}"
);
}
}