Skip to main content

ipfrs_tensorlogic/probabilistic_program_engine/
mod.rs

1//! Probabilistic Program Engine — Bayesian reasoning and posterior sampling.
2//!
3//! # Overview
4//!
5//! [`ProbabilisticProgramEngine`] provides a flexible probabilistic programming
6//! environment for Bayesian inference.  Variables are declared with a prior
7//! distribution; observations fix concrete likelihoods; the engine then draws
8//! posterior samples via one of four sampling strategies:
9//!
10//! * **Metropolis-Hastings** — single-variable random-walk MCMC.
11//! * **Gibbs Sampling** — coordinate-wise conditional sampling.
12//! * **Importance Sampling** — weighted samples from the prior.
13//! * **Rejection Sampling** — accept/reject from prior using unnormalised
14//!   likelihood.
15//!
16//! After sampling, marginal posteriors, credible intervals, and histogram
17//! approximations are available.
18//!
19//! All random number generation uses an inline xorshift64 PRNG seeded from
20//! [`PpeEngineConfig::seed`]; no external RNG crates are required.
21//!
22//! # Quick-Start
23//!
24//! ```rust
25//! use ipfrs_tensorlogic::probabilistic_program_engine::{
26//!     PpeEngineConfig, PpePrior, PpeSamplingMethod, ProbabilisticProgramEngine,
27//! };
28//!
29//! let config = PpeEngineConfig {
30//!     n_samples: 500,
31//!     burn_in: 100,
32//!     thinning: 2,
33//!     seed: 42,
34//! };
35//! let mut engine = ProbabilisticProgramEngine::new(config);
36//!
37//! // Add a Normally-distributed variable mu ~ N(0, 1).
38//! let mu_id = engine.add_variable("mu".into(), PpePrior::Normal { mean: 0.0, std: 1.0 });
39//!
40//! // Condition on an observation.
41//! engine.observe(mu_id, 0.5);
42//!
43//! // Run Metropolis-Hastings.
44//! let result = engine.sample(PpeSamplingMethod::MetropolisHastings).expect("example: should succeed in docs");
45//! assert!(result.accepted_samples > 0);
46//!
47//! // Posterior statistics.
48//! let mean = engine.posterior_mean(mu_id).expect("example: should succeed in docs");
49//! println!("Posterior mean of mu ≈ {mean:.4}");
50//! ```
51
52mod ppe_types;
53pub use ppe_types::*;
54
55mod ppe_sampling;
56use ppe_sampling::{
57    effective_sample_size, log_density, mh_propose, sample_prior, total_log_likelihood, uniform01,
58    xorshift64,
59};
60
61use std::collections::HashMap;
62
63// ─── ProbabilisticProgramEngine implementation ────────────────────────────────
64
65impl ProbabilisticProgramEngine {
66    /// Create a new engine with the given configuration.
67    pub fn new(config: PpeEngineConfig) -> Self {
68        let seed = if config.seed == 0 {
69            0xDEAD_BEEF_CAFE_1234
70        } else {
71            config.seed
72        };
73        Self {
74            rng_state: seed,
75            config,
76            variables: HashMap::new(),
77            var_order: Vec::new(),
78            observations: HashMap::new(),
79            samples: HashMap::new(),
80            last_method: None,
81            last_result: None,
82        }
83    }
84
85    // ── Variable management ────────────────────────────────────────────────
86
87    /// Register a new variable with the given name and prior.  Returns its
88    /// stable [`VarId`].
89    pub fn add_variable(&mut self, name: String, prior: PpePrior) -> VarId {
90        // Generate a deterministic-but-unique id from the PRNG.
91        let mut id_bytes = [0u8; 16];
92        let a = xorshift64(&mut self.rng_state).to_le_bytes();
93        let b = xorshift64(&mut self.rng_state).to_le_bytes();
94        id_bytes[..8].copy_from_slice(&a);
95        id_bytes[8..].copy_from_slice(&b);
96        let id = PpeVarId(id_bytes);
97
98        // Initialise value by drawing one sample from the prior.
99        let initial = sample_prior(&prior, &mut self.rng_state);
100        let var = ProbVar {
101            id,
102            name,
103            prior,
104            value: Some(initial),
105        };
106        self.variables.insert(id, var);
107        self.var_order.push(id);
108        id
109    }
110
111    /// Condition variable `var_id` on the observed value.
112    pub fn observe(&mut self, var_id: VarId, value: f64) {
113        self.observations.insert(var_id, value);
114        // Fix the variable's current value to the observation.
115        if let Some(var) = self.variables.get_mut(&var_id) {
116            var.value = Some(value);
117        }
118    }
119
120    /// Remove an observation, allowing the variable to be sampled freely.
121    pub fn clear_observation(&mut self, var_id: VarId) {
122        self.observations.remove(&var_id);
123    }
124
125    // ── Sampling ──────────────────────────────────────────────────────────
126
127    /// Run posterior sampling with the given method.
128    ///
129    /// # Errors
130    ///
131    /// Returns an error string if no variables have been registered.
132    pub fn sample(&mut self, method: PpeSamplingMethod) -> Result<PpeSampleResult, String> {
133        if self.variables.is_empty() {
134            return Err("No variables registered".to_string());
135        }
136        self.samples.clear();
137
138        let result = match method {
139            PpeSamplingMethod::MetropolisHastings => self.run_metropolis_hastings(),
140            PpeSamplingMethod::GibbsSampling => self.run_gibbs(),
141            PpeSamplingMethod::ImportanceSampling => self.run_importance_sampling(),
142            PpeSamplingMethod::RejectionSampling => self.run_rejection_sampling(),
143        };
144        self.last_method = Some(method);
145        self.last_result = Some(result.clone());
146        Ok(result)
147    }
148
149    // ── MH ───────────────────────────────────────────────────────────────
150
151    fn run_metropolis_hastings(&mut self) -> PpeSampleResult {
152        let n_samples = self.config.n_samples;
153        let burn_in = self.config.burn_in;
154        let thinning = self.config.thinning.max(1);
155        let total_steps = burn_in + n_samples * thinning;
156
157        // Initialise buffers.
158        let var_ids: Vec<VarId> = self.var_order.clone();
159        for &id in &var_ids {
160            self.samples.insert(id, Vec::with_capacity(n_samples));
161        }
162
163        // Current state.
164        let mut current_values: HashMap<VarId, f64> = var_ids
165            .iter()
166            .filter_map(|&id| {
167                let v = self.variables.get(&id)?.value?;
168                Some((id, v))
169            })
170            .collect();
171
172        let mut accepted = 0usize;
173        let mut collected = 0usize;
174
175        for step in 0..total_steps {
176            // Pick a variable to update (cyclic).
177            let var_id = var_ids[step % var_ids.len()];
178            let prior = {
179                match self.variables.get(&var_id) {
180                    Some(v) => v.prior.clone(),
181                    None => continue,
182                }
183            };
184
185            let current_val = *current_values.get(&var_id).unwrap_or(&0.0);
186            let proposed_val = mh_propose(current_val, &prior, &mut self.rng_state);
187
188            // Log-posterior ratio: log p(x'|prior) + logL(x') - log p(x|prior) - logL(x).
189            let log_p_current = log_density(&prior, current_val);
190            let log_p_proposed = log_density(&prior, proposed_val);
191
192            let mut proposed_values = current_values.clone();
193            proposed_values.insert(var_id, proposed_val);
194
195            let ll_current =
196                total_log_likelihood(&self.variables, &self.observations, &current_values);
197            let ll_proposed =
198                total_log_likelihood(&self.variables, &self.observations, &proposed_values);
199
200            let log_ratio = (log_p_proposed + ll_proposed) - (log_p_current + ll_current);
201            let accept = log_ratio >= 0.0 || uniform01(&mut self.rng_state) < log_ratio.exp();
202
203            if accept {
204                current_values.insert(var_id, proposed_val);
205                accepted += 1;
206            }
207
208            // Collect sample if past burn-in and on thinning stride.
209            if step >= burn_in && (step - burn_in).is_multiple_of(thinning) {
210                for &id in &var_ids {
211                    let val = *current_values.get(&id).unwrap_or(&0.0);
212                    if let Some(buf) = self.samples.get_mut(&id) {
213                        buf.push(val);
214                    }
215                }
216                collected += 1;
217            }
218        }
219
220        // Sync variable values to final state.
221        for (&id, &val) in &current_values {
222            if let Some(var) = self.variables.get_mut(&id) {
223                var.value = Some(val);
224            }
225        }
226
227        let acceptance_rate = if total_steps > 0 {
228            accepted as f64 / total_steps as f64
229        } else {
230            0.0
231        };
232
233        PpeSampleResult {
234            method: PpeSamplingMethod::MetropolisHastings,
235            total_steps,
236            accepted_samples: accepted,
237            acceptance_rate,
238            n_variables: var_ids.len(),
239            n_retained: collected,
240        }
241    }
242
243    // ── Gibbs ────────────────────────────────────────────────────────────
244
245    fn run_gibbs(&mut self) -> PpeSampleResult {
246        let n_samples = self.config.n_samples;
247        let burn_in = self.config.burn_in;
248        let thinning = self.config.thinning.max(1);
249        let total_sweeps = burn_in + n_samples * thinning;
250
251        let var_ids: Vec<VarId> = self.var_order.clone();
252        for &id in &var_ids {
253            self.samples.insert(id, Vec::with_capacity(n_samples));
254        }
255
256        let mut current_values: HashMap<VarId, f64> = var_ids
257            .iter()
258            .filter_map(|&id| {
259                let v = self.variables.get(&id)?.value?;
260                Some((id, v))
261            })
262            .collect();
263
264        let mut collected = 0usize;
265
266        for sweep in 0..total_sweeps {
267            // Update each variable in order from its conditional.
268            for &id in &var_ids {
269                // For observed variables, set to observed value.
270                if let Some(&obs) = self.observations.get(&id) {
271                    current_values.insert(id, obs);
272                    continue;
273                }
274                // Gibbs step: sample from prior (conjugate update approximation).
275                let prior = match self.variables.get(&id) {
276                    Some(v) => v.prior.clone(),
277                    None => continue,
278                };
279                // Use rejection sampling conditioned on likelihood to get the
280                // conditional.  For simplicity and efficiency, use a
281                // Metropolis-within-Gibbs step.
282                let current_val = *current_values.get(&id).unwrap_or(&0.0);
283                let proposal = mh_propose(current_val, &prior, &mut self.rng_state);
284
285                let log_p_current = log_density(&prior, current_val);
286                let log_p_proposal = log_density(&prior, proposal);
287
288                let mut proposed_values = current_values.clone();
289                proposed_values.insert(id, proposal);
290
291                let ll_cur =
292                    total_log_likelihood(&self.variables, &self.observations, &current_values);
293                let ll_prop =
294                    total_log_likelihood(&self.variables, &self.observations, &proposed_values);
295
296                let log_ratio = (log_p_proposal + ll_prop) - (log_p_current + ll_cur);
297                if log_ratio >= 0.0 || uniform01(&mut self.rng_state) < log_ratio.exp() {
298                    current_values.insert(id, proposal);
299                }
300            }
301
302            if sweep >= burn_in && (sweep - burn_in).is_multiple_of(thinning) {
303                for &id in &var_ids {
304                    let val = *current_values.get(&id).unwrap_or(&0.0);
305                    if let Some(buf) = self.samples.get_mut(&id) {
306                        buf.push(val);
307                    }
308                }
309                collected += 1;
310            }
311        }
312
313        for (&id, &val) in &current_values {
314            if let Some(var) = self.variables.get_mut(&id) {
315                var.value = Some(val);
316            }
317        }
318
319        PpeSampleResult {
320            method: PpeSamplingMethod::GibbsSampling,
321            total_steps: total_sweeps * var_ids.len(),
322            accepted_samples: total_sweeps * var_ids.len(),
323            acceptance_rate: 1.0,
324            n_variables: var_ids.len(),
325            n_retained: collected,
326        }
327    }
328
329    // ── Importance sampling ──────────────────────────────────────────────
330
331    fn run_importance_sampling(&mut self) -> PpeSampleResult {
332        let n_samples = self.config.n_samples;
333        let var_ids: Vec<VarId> = self.var_order.clone();
334        for &id in &var_ids {
335            self.samples.insert(id, Vec::with_capacity(n_samples));
336        }
337
338        // Draw many prior samples, compute log-weights, then resample.
339        let n_proposal = (n_samples * 10).max(1000);
340        let mut log_weights: Vec<f64> = Vec::with_capacity(n_proposal);
341        let mut draws: Vec<HashMap<VarId, f64>> = Vec::with_capacity(n_proposal);
342
343        for _ in 0..n_proposal {
344            let mut draw: HashMap<VarId, f64> = HashMap::new();
345            for &id in &var_ids {
346                let prior = match self.variables.get(&id) {
347                    Some(v) => v.prior.clone(),
348                    None => continue,
349                };
350                let x = if let Some(&obs) = self.observations.get(&id) {
351                    obs
352                } else {
353                    sample_prior(&prior, &mut self.rng_state)
354                };
355                draw.insert(id, x);
356            }
357            let lw = total_log_likelihood(&self.variables, &self.observations, &draw);
358            log_weights.push(lw);
359            draws.push(draw);
360        }
361
362        // Numerically stable softmax to get normalised weights.
363        let max_lw = log_weights
364            .iter()
365            .cloned()
366            .fold(f64::NEG_INFINITY, f64::max);
367        let weights: Vec<f64> = log_weights.iter().map(|&lw| (lw - max_lw).exp()).collect();
368        let total_w: f64 = weights.iter().sum();
369
370        // Systematic resampling.
371        let u_start = uniform01(&mut self.rng_state) / n_samples as f64;
372        let mut cumulative = 0.0_f64;
373        let mut draw_idx = 0usize;
374        let inv_n = 1.0 / n_samples as f64;
375
376        for s in 0..n_samples {
377            let threshold = u_start + s as f64 * inv_n;
378            // Advance draw_idx until cumulative normalised weight >= threshold.
379            while draw_idx < draws.len() - 1 {
380                let nw = if total_w > 1e-300 {
381                    weights[draw_idx] / total_w
382                } else {
383                    inv_n
384                };
385                if cumulative + nw >= threshold {
386                    break;
387                }
388                cumulative += nw;
389                draw_idx += 1;
390            }
391            for &id in &var_ids {
392                let val = draws[draw_idx].get(&id).copied().unwrap_or(0.0);
393                if let Some(buf) = self.samples.get_mut(&id) {
394                    buf.push(val);
395                }
396            }
397        }
398
399        PpeSampleResult {
400            method: PpeSamplingMethod::ImportanceSampling,
401            total_steps: n_proposal,
402            accepted_samples: n_samples,
403            acceptance_rate: n_samples as f64 / n_proposal as f64,
404            n_variables: var_ids.len(),
405            n_retained: n_samples,
406        }
407    }
408
409    // ── Rejection sampling ───────────────────────────────────────────────
410
411    fn run_rejection_sampling(&mut self) -> PpeSampleResult {
412        let n_samples = self.config.n_samples;
413        let var_ids: Vec<VarId> = self.var_order.clone();
414        for &id in &var_ids {
415            self.samples.insert(id, Vec::with_capacity(n_samples));
416        }
417
418        let mut collected = 0usize;
419        let mut total_attempts = 0usize;
420        let max_attempts = n_samples * 10_000;
421
422        // log acceptance threshold: use fixed value since we sample from prior.
423        // Accept/reject based on unnormalised likelihood.
424        while collected < n_samples && total_attempts < max_attempts {
425            total_attempts += 1;
426            let mut candidate: HashMap<VarId, f64> = HashMap::new();
427
428            for &id in &var_ids {
429                if let Some(&obs) = self.observations.get(&id) {
430                    candidate.insert(id, obs);
431                } else if let Some(var) = self.variables.get(&id) {
432                    let x = sample_prior(&var.prior, &mut self.rng_state);
433                    candidate.insert(id, x);
434                }
435            }
436
437            let ll = total_log_likelihood(&self.variables, &self.observations, &candidate);
438
439            // Accept with probability exp(ll) (assuming max likelihood = 1).
440            let accept_prob = ll.exp().min(1.0);
441            if uniform01(&mut self.rng_state) < accept_prob {
442                for &id in &var_ids {
443                    let val = candidate.get(&id).copied().unwrap_or(0.0);
444                    if let Some(buf) = self.samples.get_mut(&id) {
445                        buf.push(val);
446                    }
447                }
448                collected += 1;
449            }
450        }
451
452        // Pad with prior samples if we could not collect enough.
453        while collected < n_samples {
454            for &id in &var_ids {
455                if let Some(var) = self.variables.get(&id) {
456                    let x = if let Some(&obs) = self.observations.get(&id) {
457                        obs
458                    } else {
459                        sample_prior(&var.prior, &mut self.rng_state)
460                    };
461                    if let Some(buf) = self.samples.get_mut(&id) {
462                        buf.push(x);
463                    }
464                }
465            }
466            collected += 1;
467        }
468
469        let acceptance_rate = if total_attempts > 0 {
470            collected as f64 / total_attempts as f64
471        } else {
472            0.0
473        };
474
475        PpeSampleResult {
476            method: PpeSamplingMethod::RejectionSampling,
477            total_steps: total_attempts,
478            accepted_samples: collected,
479            acceptance_rate,
480            n_variables: var_ids.len(),
481            n_retained: collected,
482        }
483    }
484
485    // ── Posterior statistics ──────────────────────────────────────────────
486
487    /// Posterior mean of variable `var_id` from the last sampling run.
488    pub fn posterior_mean(&self, var_id: VarId) -> Option<f64> {
489        let samples = self.samples.get(&var_id)?;
490        if samples.is_empty() {
491            return None;
492        }
493        Some(samples.iter().sum::<f64>() / samples.len() as f64)
494    }
495
496    /// Posterior standard deviation of variable `var_id`.
497    pub fn posterior_std(&self, var_id: VarId) -> Option<f64> {
498        let samples = self.samples.get(&var_id)?;
499        let n = samples.len();
500        if n < 2 {
501            return None;
502        }
503        let mean = samples.iter().sum::<f64>() / n as f64;
504        let variance = samples.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (n - 1) as f64;
505        Some(variance.sqrt())
506    }
507
508    /// Central credible interval at coverage `1 - alpha`.
509    ///
510    /// E.g., `alpha = 0.05` returns the 95% CI as `(lower, upper)`.
511    pub fn credible_interval(&self, var_id: VarId, alpha: f64) -> Option<(f64, f64)> {
512        let samples = self.samples.get(&var_id)?;
513        if samples.is_empty() {
514            return None;
515        }
516        let mut sorted = samples.clone();
517        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
518        let n = sorted.len();
519        let lo_idx = ((alpha / 2.0) * n as f64) as usize;
520        let hi_idx = (((1.0 - alpha / 2.0) * n as f64) as usize).min(n - 1);
521        Some((sorted[lo_idx], sorted[hi_idx]))
522    }
523
524    /// Log-density (log prior) of `value` under the prior of `var_id`.
525    pub fn log_likelihood(&self, var_id: VarId, value: f64) -> f64 {
526        match self.variables.get(&var_id) {
527            Some(var) => log_density(&var.prior, value),
528            None => f64::NEG_INFINITY,
529        }
530    }
531
532    /// Approximate marginal distribution as a histogram with `n_bins` equal-width bins.
533    ///
534    /// Returns a `Vec<(bin_centre, frequency)>` in ascending order.
535    pub fn marginal_distribution(&self, var_id: VarId, n_bins: usize) -> Vec<(f64, f64)> {
536        let samples = match self.samples.get(&var_id) {
537            Some(s) if !s.is_empty() => s,
538            _ => return Vec::new(),
539        };
540        if n_bins == 0 {
541            return Vec::new();
542        }
543
544        let min = samples.iter().cloned().fold(f64::INFINITY, f64::min);
545        let max = samples.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
546
547        if (max - min).abs() < 1e-300 {
548            return vec![(min, samples.len() as f64)];
549        }
550
551        let bin_width = (max - min) / n_bins as f64;
552        let mut counts = vec![0u64; n_bins];
553
554        for &x in samples {
555            let idx = ((x - min) / bin_width) as usize;
556            let idx = idx.min(n_bins - 1);
557            counts[idx] += 1;
558        }
559
560        let n = samples.len() as f64;
561        counts
562            .iter()
563            .enumerate()
564            .map(|(i, &c)| {
565                let centre = min + (i as f64 + 0.5) * bin_width;
566                let freq = c as f64 / n;
567                (centre, freq)
568            })
569            .collect()
570    }
571
572    /// Diagnostics about the current state of the engine.
573    pub fn sampling_stats(&self) -> PpeSamplingStats {
574        let n_variables = self.variables.len();
575        let n_observed = self.observations.len();
576        let total_samples: usize = self.samples.values().map(Vec::len).sum();
577        let has_samples = total_samples > 0;
578
579        let min_ess = self
580            .samples
581            .values()
582            .map(|s| effective_sample_size(s))
583            .fold(f64::INFINITY, f64::min);
584        let min_ess = if min_ess.is_infinite() { 0.0 } else { min_ess };
585
586        PpeSamplingStats {
587            n_variables,
588            n_observed,
589            total_samples,
590            has_samples,
591            last_method: self.last_method,
592            min_ess,
593        }
594    }
595
596    // ── Accessors ─────────────────────────────────────────────────────────
597
598    /// Return a reference to the engine configuration.
599    pub fn config(&self) -> &PpeEngineConfig {
600        &self.config
601    }
602
603    /// Look up a variable by its id.
604    pub fn get_variable(&self, var_id: VarId) -> Option<&ProbVar> {
605        self.variables.get(&var_id)
606    }
607
608    /// Number of registered variables.
609    pub fn n_variables(&self) -> usize {
610        self.variables.len()
611    }
612
613    /// Number of retained samples for a given variable (0 if no sampling has
614    /// been run).
615    pub fn n_samples(&self, var_id: VarId) -> usize {
616        self.samples.get(&var_id).map(Vec::len).unwrap_or(0)
617    }
618
619    /// Raw posterior samples for `var_id`.
620    pub fn raw_samples(&self, var_id: VarId) -> Option<&[f64]> {
621        self.samples.get(&var_id).map(Vec::as_slice)
622    }
623
624    /// Last [`PpeSampleResult`] returned by [`sample`](Self::sample).
625    pub fn last_result(&self) -> Option<&PpeSampleResult> {
626        self.last_result.as_ref()
627    }
628}
629
630// ─── Tests ────────────────────────────────────────────────────────────────────
631
632#[cfg(test)]
633mod tests {
634    use super::ppe_sampling::{box_muller, lgamma, sample_gamma, sample_standard_normal};
635    use super::*;
636
637    // ── helpers ───────────────────────────────────────────────────────────
638
639    fn make_engine(seed: u64) -> ProbabilisticProgramEngine {
640        ProbabilisticProgramEngine::new(PpeEngineConfig {
641            n_samples: 300,
642            burn_in: 50,
643            thinning: 1,
644            seed,
645        })
646    }
647
648    fn make_small(seed: u64) -> ProbabilisticProgramEngine {
649        ProbabilisticProgramEngine::new(PpeEngineConfig {
650            n_samples: 100,
651            burn_in: 20,
652            thinning: 1,
653            seed,
654        })
655    }
656
657    // ── VarId ─────────────────────────────────────────────────────────────
658
659    #[test]
660    fn var_id_unique() {
661        let mut engine = make_engine(1);
662        let a = engine.add_variable(
663            "a".into(),
664            PpePrior::Normal {
665                mean: 0.0,
666                std: 1.0,
667            },
668        );
669        let b = engine.add_variable(
670            "b".into(),
671            PpePrior::Normal {
672                mean: 0.0,
673                std: 1.0,
674            },
675        );
676        assert_ne!(a, b);
677    }
678
679    #[test]
680    fn var_id_equality() {
681        let id = PpeVarId([1u8; 16]);
682        assert_eq!(id, PpeVarId([1u8; 16]));
683        assert_ne!(id, PpeVarId([2u8; 16]));
684    }
685
686    // ── add_variable ─────────────────────────────────────────────────────
687
688    #[test]
689    fn add_variable_returns_id() {
690        let mut e = make_engine(2);
691        let id = e.add_variable(
692            "x".into(),
693            PpePrior::Uniform {
694                low: 0.0,
695                high: 1.0,
696            },
697        );
698        assert!(e.get_variable(id).is_some());
699    }
700
701    #[test]
702    fn add_multiple_variables() {
703        let mut e = make_engine(3);
704        for i in 0..10 {
705            e.add_variable(format!("v{i}"), PpePrior::Exponential { rate: 1.0 });
706        }
707        assert_eq!(e.n_variables(), 10);
708    }
709
710    #[test]
711    fn variable_name_preserved() {
712        let mut e = make_engine(4);
713        let id = e.add_variable("my_var".into(), PpePrior::Bernoulli { p: 0.3 });
714        assert_eq!(
715            e.get_variable(id)
716                .expect("test: variable should be registered")
717                .name,
718            "my_var"
719        );
720    }
721
722    #[test]
723    fn variable_has_initial_value() {
724        let mut e = make_engine(5);
725        let id = e.add_variable(
726            "v".into(),
727            PpePrior::Normal {
728                mean: 5.0,
729                std: 1.0,
730            },
731        );
732        assert!(e
733            .get_variable(id)
734            .expect("test: variable should be registered")
735            .value
736            .is_some());
737    }
738
739    // ── observe / clear ───────────────────────────────────────────────────
740
741    #[test]
742    fn observe_sets_value() {
743        let mut e = make_engine(6);
744        let id = e.add_variable(
745            "mu".into(),
746            PpePrior::Normal {
747                mean: 0.0,
748                std: 1.0,
749            },
750        );
751        e.observe(id, 3.7);
752        assert_eq!(
753            e.get_variable(id)
754                .expect("test: variable should be registered")
755                .value,
756            Some(3.7)
757        );
758    }
759
760    #[test]
761    fn clear_observation_removes_obs() {
762        let mut e = make_engine(7);
763        let id = e.add_variable(
764            "mu".into(),
765            PpePrior::Normal {
766                mean: 0.0,
767                std: 1.0,
768            },
769        );
770        e.observe(id, 1.0);
771        e.clear_observation(id);
772        // Variable value not reset, but observation removed.
773        let stats = e.sampling_stats();
774        assert_eq!(stats.n_observed, 0);
775    }
776
777    #[test]
778    fn observe_nonexistent_var() {
779        let mut e = make_engine(8);
780        // Should not panic.
781        e.observe(PpeVarId([0u8; 16]), 1.0);
782    }
783
784    // ── sample: no variables ──────────────────────────────────────────────
785
786    #[test]
787    fn sample_no_vars_returns_error() {
788        let mut e = make_engine(9);
789        assert!(e.sample(PpeSamplingMethod::MetropolisHastings).is_err());
790    }
791
792    // ── MH sampling ───────────────────────────────────────────────────────
793
794    #[test]
795    fn mh_produces_samples() {
796        let mut e = make_engine(10);
797        e.add_variable(
798            "x".into(),
799            PpePrior::Normal {
800                mean: 0.0,
801                std: 1.0,
802            },
803        );
804        let res = e
805            .sample(PpeSamplingMethod::MetropolisHastings)
806            .expect("test: sampling should succeed");
807        assert!(res.accepted_samples > 0);
808        assert_eq!(res.method, PpeSamplingMethod::MetropolisHastings);
809    }
810
811    #[test]
812    fn mh_correct_sample_count() {
813        let mut e = make_engine(11);
814        let id = e.add_variable(
815            "x".into(),
816            PpePrior::Normal {
817                mean: 0.0,
818                std: 1.0,
819            },
820        );
821        e.sample(PpeSamplingMethod::MetropolisHastings)
822            .expect("test: sampling should succeed");
823        assert_eq!(e.n_samples(id), e.config().n_samples);
824    }
825
826    #[test]
827    fn mh_normal_mean_near_observation() {
828        let mut e = make_engine(12);
829        let id = e.add_variable(
830            "mu".into(),
831            PpePrior::Normal {
832                mean: 0.0,
833                std: 5.0,
834            },
835        );
836        e.observe(id, 3.0);
837        e.sample(PpeSamplingMethod::MetropolisHastings)
838            .expect("test: sampling should succeed");
839        let mean = e
840            .posterior_mean(id)
841            .expect("test: posterior mean should be available");
842        // With strong observation signal, posterior mean should be close to 3.
843        assert!((mean - 3.0).abs() < 1.5, "mean={mean}");
844    }
845
846    #[test]
847    fn mh_acceptance_rate_in_range() {
848        let mut e = make_engine(13);
849        e.add_variable(
850            "x".into(),
851            PpePrior::Normal {
852                mean: 0.0,
853                std: 1.0,
854            },
855        );
856        let res = e
857            .sample(PpeSamplingMethod::MetropolisHastings)
858            .expect("test: sampling should succeed");
859        assert!(res.acceptance_rate >= 0.0);
860        assert!(res.acceptance_rate <= 1.0);
861    }
862
863    // ── Gibbs sampling ────────────────────────────────────────────────────
864
865    #[test]
866    fn gibbs_produces_samples() {
867        let mut e = make_engine(14);
868        e.add_variable(
869            "y".into(),
870            PpePrior::Uniform {
871                low: -1.0,
872                high: 1.0,
873            },
874        );
875        let res = e
876            .sample(PpeSamplingMethod::GibbsSampling)
877            .expect("test: sampling should succeed");
878        assert!(res.n_retained > 0);
879        assert_eq!(res.method, PpeSamplingMethod::GibbsSampling);
880    }
881
882    #[test]
883    fn gibbs_correct_sample_count() {
884        let mut e = make_engine(15);
885        let id = e.add_variable(
886            "y".into(),
887            PpePrior::Uniform {
888                low: 0.0,
889                high: 1.0,
890            },
891        );
892        e.sample(PpeSamplingMethod::GibbsSampling)
893            .expect("test: sampling should succeed");
894        assert_eq!(e.n_samples(id), e.config().n_samples);
895    }
896
897    #[test]
898    fn gibbs_observed_var_clamped() {
899        let mut e = make_engine(16);
900        let id = e.add_variable(
901            "y".into(),
902            PpePrior::Normal {
903                mean: 0.0,
904                std: 1.0,
905            },
906        );
907        e.observe(id, 7.7);
908        e.sample(PpeSamplingMethod::GibbsSampling)
909            .expect("test: sampling should succeed");
910        // All samples for observed var should be the observation value.
911        let samples = e
912            .raw_samples(id)
913            .expect("test: raw samples should exist after sampling");
914        for &s in samples {
915            assert!((s - 7.7).abs() < 1e-9, "s={s}");
916        }
917    }
918
919    // ── Importance sampling ───────────────────────────────────────────────
920
921    #[test]
922    fn importance_produces_samples() {
923        let mut e = make_engine(17);
924        e.add_variable(
925            "z".into(),
926            PpePrior::Normal {
927                mean: 1.0,
928                std: 2.0,
929            },
930        );
931        let res = e
932            .sample(PpeSamplingMethod::ImportanceSampling)
933            .expect("test: sampling should succeed");
934        assert!(res.n_retained > 0);
935    }
936
937    #[test]
938    fn importance_sample_count_correct() {
939        let mut e = make_engine(18);
940        let id = e.add_variable(
941            "z".into(),
942            PpePrior::Normal {
943                mean: 0.0,
944                std: 1.0,
945            },
946        );
947        e.sample(PpeSamplingMethod::ImportanceSampling)
948            .expect("test: sampling should succeed");
949        assert_eq!(e.n_samples(id), e.config().n_samples);
950    }
951
952    // ── Rejection sampling ────────────────────────────────────────────────
953
954    #[test]
955    fn rejection_produces_samples() {
956        let mut e = make_small(19);
957        e.add_variable(
958            "r".into(),
959            PpePrior::Normal {
960                mean: 0.0,
961                std: 1.0,
962            },
963        );
964        let res = e
965            .sample(PpeSamplingMethod::RejectionSampling)
966            .expect("test: sampling should succeed");
967        assert!(res.n_retained > 0);
968    }
969
970    #[test]
971    fn rejection_sample_count_correct() {
972        let mut e = make_small(20);
973        let id = e.add_variable(
974            "r".into(),
975            PpePrior::Normal {
976                mean: 0.0,
977                std: 1.0,
978            },
979        );
980        e.sample(PpeSamplingMethod::RejectionSampling)
981            .expect("test: sampling should succeed");
982        assert_eq!(e.n_samples(id), e.config().n_samples);
983    }
984
985    // ── posterior_mean ────────────────────────────────────────────────────
986
987    #[test]
988    fn posterior_mean_none_before_sampling() {
989        let mut e = make_engine(21);
990        let id = e.add_variable(
991            "m".into(),
992            PpePrior::Normal {
993                mean: 0.0,
994                std: 1.0,
995            },
996        );
997        assert!(e.posterior_mean(id).is_none());
998    }
999
1000    #[test]
1001    fn posterior_mean_finite_after_mh() {
1002        let mut e = make_engine(22);
1003        let id = e.add_variable(
1004            "m".into(),
1005            PpePrior::Normal {
1006                mean: 0.0,
1007                std: 1.0,
1008            },
1009        );
1010        e.sample(PpeSamplingMethod::MetropolisHastings)
1011            .expect("test: sampling should succeed");
1012        let m = e
1013            .posterior_mean(id)
1014            .expect("test: posterior mean should be available");
1015        assert!(m.is_finite());
1016    }
1017
1018    #[test]
1019    fn posterior_mean_uniform_midpoint() {
1020        let mut e = make_engine(23);
1021        let id = e.add_variable(
1022            "u".into(),
1023            PpePrior::Uniform {
1024                low: 0.0,
1025                high: 2.0,
1026            },
1027        );
1028        e.sample(PpeSamplingMethod::MetropolisHastings)
1029            .expect("test: sampling should succeed");
1030        let m = e
1031            .posterior_mean(id)
1032            .expect("test: posterior mean should be available");
1033        // Uniform[0,2] has mean 1.0; allow ±0.3 tolerance.
1034        assert!((m - 1.0).abs() < 0.5, "mean={m}");
1035    }
1036
1037    #[test]
1038    fn posterior_mean_bernoulli() {
1039        let mut e = make_engine(24);
1040        let id = e.add_variable("b".into(), PpePrior::Bernoulli { p: 0.7 });
1041        e.sample(PpeSamplingMethod::ImportanceSampling)
1042            .expect("test: sampling should succeed");
1043        let m = e
1044            .posterior_mean(id)
1045            .expect("test: posterior mean should be available");
1046        assert!((0.0..=1.0).contains(&m), "mean={m}");
1047    }
1048
1049    // ── posterior_std ─────────────────────────────────────────────────────
1050
1051    #[test]
1052    fn posterior_std_none_before_sampling() {
1053        let mut e = make_engine(25);
1054        let id = e.add_variable(
1055            "s".into(),
1056            PpePrior::Normal {
1057                mean: 0.0,
1058                std: 1.0,
1059            },
1060        );
1061        assert!(e.posterior_std(id).is_none());
1062    }
1063
1064    #[test]
1065    fn posterior_std_non_negative() {
1066        let mut e = make_engine(26);
1067        let id = e.add_variable(
1068            "s".into(),
1069            PpePrior::Normal {
1070                mean: 0.0,
1071                std: 2.0,
1072            },
1073        );
1074        e.sample(PpeSamplingMethod::MetropolisHastings)
1075            .expect("test: sampling should succeed");
1076        let std = e
1077            .posterior_std(id)
1078            .expect("test: posterior std should be available");
1079        assert!(std >= 0.0);
1080    }
1081
1082    // ── credible_interval ────────────────────────────────────────────────
1083
1084    #[test]
1085    fn credible_interval_none_before_sampling() {
1086        let mut e = make_engine(27);
1087        let id = e.add_variable(
1088            "c".into(),
1089            PpePrior::Normal {
1090                mean: 0.0,
1091                std: 1.0,
1092            },
1093        );
1094        assert!(e.credible_interval(id, 0.05).is_none());
1095    }
1096
1097    #[test]
1098    fn credible_interval_lower_lt_upper() {
1099        let mut e = make_engine(28);
1100        let id = e.add_variable(
1101            "c".into(),
1102            PpePrior::Normal {
1103                mean: 0.0,
1104                std: 1.0,
1105            },
1106        );
1107        e.sample(PpeSamplingMethod::MetropolisHastings)
1108            .expect("test: sampling should succeed");
1109        let (lo, hi) = e
1110            .credible_interval(id, 0.05)
1111            .expect("test: credible interval should be available");
1112        assert!(lo <= hi, "lo={lo}, hi={hi}");
1113    }
1114
1115    #[test]
1116    fn credible_interval_50pct() {
1117        let mut e = make_engine(29);
1118        let id = e.add_variable(
1119            "c".into(),
1120            PpePrior::Uniform {
1121                low: 0.0,
1122                high: 1.0,
1123            },
1124        );
1125        e.sample(PpeSamplingMethod::GibbsSampling)
1126            .expect("test: sampling should succeed");
1127        let (lo, hi) = e
1128            .credible_interval(id, 0.5)
1129            .expect("test: credible interval should be available");
1130        assert!(lo >= 0.0 && hi <= 1.0);
1131        assert!(lo <= hi);
1132    }
1133
1134    // ── log_likelihood ────────────────────────────────────────────────────
1135
1136    #[test]
1137    fn log_likelihood_normal_peak_at_mean() {
1138        let mut e = make_engine(30);
1139        let id = e.add_variable(
1140            "ll".into(),
1141            PpePrior::Normal {
1142                mean: 2.0,
1143                std: 1.0,
1144            },
1145        );
1146        let at_mean = e.log_likelihood(id, 2.0);
1147        let off = e.log_likelihood(id, 5.0);
1148        assert!(at_mean > off);
1149    }
1150
1151    #[test]
1152    fn log_likelihood_uniform_constant_inside() {
1153        let mut e = make_engine(31);
1154        let id = e.add_variable(
1155            "ll".into(),
1156            PpePrior::Uniform {
1157                low: 0.0,
1158                high: 1.0,
1159            },
1160        );
1161        let a = e.log_likelihood(id, 0.2);
1162        let b = e.log_likelihood(id, 0.8);
1163        assert!((a - b).abs() < 1e-9);
1164    }
1165
1166    #[test]
1167    fn log_likelihood_uniform_neg_inf_outside() {
1168        let mut e = make_engine(32);
1169        let id = e.add_variable(
1170            "ll".into(),
1171            PpePrior::Uniform {
1172                low: 0.0,
1173                high: 1.0,
1174            },
1175        );
1176        assert_eq!(e.log_likelihood(id, -1.0), f64::NEG_INFINITY);
1177        assert_eq!(e.log_likelihood(id, 2.0), f64::NEG_INFINITY);
1178    }
1179
1180    #[test]
1181    fn log_likelihood_exponential_positive() {
1182        let mut e = make_engine(33);
1183        let id = e.add_variable("ll".into(), PpePrior::Exponential { rate: 1.0 });
1184        let v = e.log_likelihood(id, 1.0);
1185        assert!(v.is_finite());
1186    }
1187
1188    #[test]
1189    fn log_likelihood_exponential_neg_inf_outside() {
1190        let mut e = make_engine(34);
1191        let id = e.add_variable("ll".into(), PpePrior::Exponential { rate: 1.0 });
1192        assert_eq!(e.log_likelihood(id, -0.1), f64::NEG_INFINITY);
1193    }
1194
1195    #[test]
1196    fn log_likelihood_beta_inside_unit_interval() {
1197        let mut e = make_engine(35);
1198        let id = e.add_variable(
1199            "ll".into(),
1200            PpePrior::Beta {
1201                alpha: 2.0,
1202                beta: 2.0,
1203            },
1204        );
1205        let v = e.log_likelihood(id, 0.5);
1206        assert!(v.is_finite());
1207    }
1208
1209    #[test]
1210    fn log_likelihood_beta_boundary_neg_inf() {
1211        let mut e = make_engine(36);
1212        let id = e.add_variable(
1213            "ll".into(),
1214            PpePrior::Beta {
1215                alpha: 2.0,
1216                beta: 2.0,
1217            },
1218        );
1219        assert_eq!(e.log_likelihood(id, 0.0), f64::NEG_INFINITY);
1220        assert_eq!(e.log_likelihood(id, 1.0), f64::NEG_INFINITY);
1221    }
1222
1223    #[test]
1224    fn log_likelihood_nonexistent_var() {
1225        let e = make_engine(37);
1226        assert_eq!(
1227            e.log_likelihood(PpeVarId([0u8; 16]), 1.0),
1228            f64::NEG_INFINITY
1229        );
1230    }
1231
1232    // ── marginal_distribution ─────────────────────────────────────────────
1233
1234    #[test]
1235    fn marginal_empty_before_sampling() {
1236        let mut e = make_engine(38);
1237        let id = e.add_variable(
1238            "m".into(),
1239            PpePrior::Normal {
1240                mean: 0.0,
1241                std: 1.0,
1242            },
1243        );
1244        assert!(e.marginal_distribution(id, 10).is_empty());
1245    }
1246
1247    #[test]
1248    fn marginal_correct_bin_count() {
1249        let mut e = make_engine(39);
1250        let id = e.add_variable(
1251            "m".into(),
1252            PpePrior::Normal {
1253                mean: 0.0,
1254                std: 1.0,
1255            },
1256        );
1257        e.sample(PpeSamplingMethod::MetropolisHastings)
1258            .expect("test: sampling should succeed");
1259        let hist = e.marginal_distribution(id, 20);
1260        assert_eq!(hist.len(), 20);
1261    }
1262
1263    #[test]
1264    fn marginal_frequencies_sum_to_one() {
1265        let mut e = make_engine(40);
1266        let id = e.add_variable(
1267            "m".into(),
1268            PpePrior::Uniform {
1269                low: 0.0,
1270                high: 1.0,
1271            },
1272        );
1273        e.sample(PpeSamplingMethod::GibbsSampling)
1274            .expect("test: sampling should succeed");
1275        let hist = e.marginal_distribution(id, 10);
1276        let total: f64 = hist.iter().map(|(_, f)| f).sum();
1277        assert!((total - 1.0).abs() < 1e-9, "total={total}");
1278    }
1279
1280    #[test]
1281    fn marginal_zero_bins_returns_empty() {
1282        let mut e = make_engine(41);
1283        let id = e.add_variable(
1284            "m".into(),
1285            PpePrior::Normal {
1286                mean: 0.0,
1287                std: 1.0,
1288            },
1289        );
1290        e.sample(PpeSamplingMethod::MetropolisHastings)
1291            .expect("test: sampling should succeed");
1292        assert!(e.marginal_distribution(id, 0).is_empty());
1293    }
1294
1295    // ── sampling_stats ────────────────────────────────────────────────────
1296
1297    #[test]
1298    fn sampling_stats_initial_state() {
1299        let mut e = make_engine(42);
1300        e.add_variable(
1301            "x".into(),
1302            PpePrior::Normal {
1303                mean: 0.0,
1304                std: 1.0,
1305            },
1306        );
1307        let stats = e.sampling_stats();
1308        assert_eq!(stats.n_variables, 1);
1309        assert!(!stats.has_samples);
1310    }
1311
1312    #[test]
1313    fn sampling_stats_after_mh() {
1314        let mut e = make_engine(43);
1315        e.add_variable(
1316            "x".into(),
1317            PpePrior::Normal {
1318                mean: 0.0,
1319                std: 1.0,
1320            },
1321        );
1322        e.sample(PpeSamplingMethod::MetropolisHastings)
1323            .expect("test: sampling should succeed");
1324        let stats = e.sampling_stats();
1325        assert!(stats.has_samples);
1326        assert_eq!(
1327            stats.last_method,
1328            Some(PpeSamplingMethod::MetropolisHastings)
1329        );
1330    }
1331
1332    #[test]
1333    fn sampling_stats_total_samples() {
1334        let mut e = make_engine(44);
1335        e.add_variable(
1336            "a".into(),
1337            PpePrior::Normal {
1338                mean: 0.0,
1339                std: 1.0,
1340            },
1341        );
1342        e.add_variable(
1343            "b".into(),
1344            PpePrior::Normal {
1345                mean: 1.0,
1346                std: 1.0,
1347            },
1348        );
1349        e.sample(PpeSamplingMethod::GibbsSampling)
1350            .expect("test: sampling should succeed");
1351        let stats = e.sampling_stats();
1352        assert_eq!(stats.total_samples, 2 * e.config().n_samples);
1353    }
1354
1355    #[test]
1356    fn sampling_stats_min_ess_positive() {
1357        let mut e = make_engine(45);
1358        e.add_variable(
1359            "x".into(),
1360            PpePrior::Normal {
1361                mean: 0.0,
1362                std: 1.0,
1363            },
1364        );
1365        e.sample(PpeSamplingMethod::MetropolisHastings)
1366            .expect("test: sampling should succeed");
1367        let stats = e.sampling_stats();
1368        assert!(stats.min_ess >= 0.0);
1369    }
1370
1371    // ── last_result ───────────────────────────────────────────────────────
1372
1373    #[test]
1374    fn last_result_none_before_sampling() {
1375        let e = make_engine(46);
1376        assert!(e.last_result().is_none());
1377    }
1378
1379    #[test]
1380    fn last_result_after_sampling() {
1381        let mut e = make_engine(47);
1382        e.add_variable(
1383            "x".into(),
1384            PpePrior::Normal {
1385                mean: 0.0,
1386                std: 1.0,
1387            },
1388        );
1389        e.sample(PpeSamplingMethod::ImportanceSampling)
1390            .expect("test: sampling should succeed");
1391        assert!(e.last_result().is_some());
1392    }
1393
1394    // ── prior distributions ───────────────────────────────────────────────
1395
1396    #[test]
1397    fn categorical_samples_valid_indices() {
1398        let mut e = make_small(48);
1399        let probs = vec![0.2, 0.5, 0.3];
1400        let id = e.add_variable("cat".into(), PpePrior::Categorical { probs });
1401        e.sample(PpeSamplingMethod::ImportanceSampling)
1402            .expect("test: sampling should succeed");
1403        let samples = e
1404            .raw_samples(id)
1405            .expect("test: raw samples should exist after sampling");
1406        for &s in samples {
1407            assert!((0.0..3.0).contains(&s), "s={s}");
1408        }
1409    }
1410
1411    #[test]
1412    fn exponential_samples_non_negative() {
1413        let mut e = make_small(49);
1414        let id = e.add_variable("exp".into(), PpePrior::Exponential { rate: 2.0 });
1415        e.sample(PpeSamplingMethod::GibbsSampling)
1416            .expect("test: sampling should succeed");
1417        let samples = e
1418            .raw_samples(id)
1419            .expect("test: raw samples should exist after sampling");
1420        for &s in samples {
1421            assert!(s >= 0.0, "s={s}");
1422        }
1423    }
1424
1425    #[test]
1426    fn beta_samples_in_unit_interval() {
1427        let mut e = make_small(50);
1428        let id = e.add_variable(
1429            "beta".into(),
1430            PpePrior::Beta {
1431                alpha: 2.0,
1432                beta: 5.0,
1433            },
1434        );
1435        e.sample(PpeSamplingMethod::MetropolisHastings)
1436            .expect("test: sampling should succeed");
1437        let samples = e
1438            .raw_samples(id)
1439            .expect("test: raw samples should exist after sampling");
1440        for &s in samples {
1441            assert!((0.0..=1.0).contains(&s), "s={s}");
1442        }
1443    }
1444
1445    #[test]
1446    fn bernoulli_samples_zero_or_one() {
1447        let mut e = make_small(51);
1448        let id = e.add_variable("bern".into(), PpePrior::Bernoulli { p: 0.6 });
1449        e.sample(PpeSamplingMethod::MetropolisHastings)
1450            .expect("test: sampling should succeed");
1451        let samples = e
1452            .raw_samples(id)
1453            .expect("test: raw samples should exist after sampling");
1454        for &s in samples {
1455            assert!(s == 0.0 || s == 1.0, "s={s}");
1456        }
1457    }
1458
1459    // ── PRNG helpers ──────────────────────────────────────────────────────
1460
1461    #[test]
1462    fn xorshift64_not_zero() {
1463        let mut state = 12345678u64;
1464        let r = xorshift64(&mut state);
1465        assert_ne!(r, 0);
1466    }
1467
1468    #[test]
1469    fn xorshift64_different_successive_values() {
1470        let mut state = 99999u64;
1471        let a = xorshift64(&mut state);
1472        let b = xorshift64(&mut state);
1473        assert_ne!(a, b);
1474    }
1475
1476    #[test]
1477    fn uniform01_in_range() {
1478        let mut state = 777u64;
1479        for _ in 0..1000 {
1480            let u = uniform01(&mut state);
1481            assert!((0.0..1.0).contains(&u), "u={u}");
1482        }
1483    }
1484
1485    #[test]
1486    fn box_muller_finite() {
1487        let bm = box_muller(0.5, 0.3);
1488        assert!(bm.is_finite());
1489    }
1490
1491    #[test]
1492    fn sample_standard_normal_finite() {
1493        let mut state = 4242u64;
1494        for _ in 0..100 {
1495            let n = sample_standard_normal(&mut state);
1496            assert!(n.is_finite(), "n={n}");
1497        }
1498    }
1499
1500    #[test]
1501    fn lgamma_positive_values() {
1502        assert!(lgamma(1.0).is_finite());
1503        assert!(lgamma(2.0).is_finite());
1504        assert!(lgamma(0.5).is_finite());
1505    }
1506
1507    #[test]
1508    fn lgamma_negative_inf_for_zero() {
1509        // lgamma(0) = +Inf
1510        let v = lgamma(0.0);
1511        assert!(v.is_infinite() && v > 0.0, "v={v}");
1512    }
1513
1514    #[test]
1515    fn sample_gamma_positive() {
1516        let mut state = 123456u64;
1517        for shape in [0.5, 1.0, 2.0, 5.0] {
1518            let g = sample_gamma(shape, &mut state);
1519            assert!(g >= 0.0, "shape={shape}, g={g}");
1520        }
1521    }
1522
1523    // ── Thinning ─────────────────────────────────────────────────────────
1524
1525    #[test]
1526    fn thinning_respected() {
1527        let mut e = ProbabilisticProgramEngine::new(PpeEngineConfig {
1528            n_samples: 50,
1529            burn_in: 10,
1530            thinning: 3,
1531            seed: 9876,
1532        });
1533        let id = e.add_variable(
1534            "x".into(),
1535            PpePrior::Normal {
1536                mean: 0.0,
1537                std: 1.0,
1538            },
1539        );
1540        e.sample(PpeSamplingMethod::MetropolisHastings)
1541            .expect("test: sampling should succeed");
1542        assert_eq!(e.n_samples(id), 50);
1543    }
1544
1545    // ── Multiple variables ────────────────────────────────────────────────
1546
1547    #[test]
1548    fn multiple_vars_all_sampled() {
1549        let mut e = make_engine(60);
1550        let ids: Vec<_> = (0..5)
1551            .map(|i| {
1552                e.add_variable(
1553                    format!("v{i}"),
1554                    PpePrior::Normal {
1555                        mean: i as f64,
1556                        std: 1.0,
1557                    },
1558                )
1559            })
1560            .collect();
1561        e.sample(PpeSamplingMethod::GibbsSampling)
1562            .expect("test: sampling should succeed");
1563        for id in ids {
1564            assert_eq!(e.n_samples(id), e.config().n_samples);
1565        }
1566    }
1567
1568    #[test]
1569    fn multiple_observations() {
1570        let mut e = make_engine(61);
1571        let a = e.add_variable(
1572            "a".into(),
1573            PpePrior::Normal {
1574                mean: 0.0,
1575                std: 1.0,
1576            },
1577        );
1578        let b = e.add_variable(
1579            "b".into(),
1580            PpePrior::Normal {
1581                mean: 0.0,
1582                std: 1.0,
1583            },
1584        );
1585        e.observe(a, 1.0);
1586        e.observe(b, -1.0);
1587        e.sample(PpeSamplingMethod::MetropolisHastings)
1588            .expect("test: sampling should succeed");
1589        let stats = e.sampling_stats();
1590        assert_eq!(stats.n_observed, 2);
1591    }
1592
1593    // ── Edge cases ────────────────────────────────────────────────────────
1594
1595    #[test]
1596    fn credible_interval_full_alpha() {
1597        let mut e = make_engine(62);
1598        let id = e.add_variable(
1599            "x".into(),
1600            PpePrior::Normal {
1601                mean: 0.0,
1602                std: 1.0,
1603            },
1604        );
1605        e.sample(PpeSamplingMethod::MetropolisHastings)
1606            .expect("test: sampling should succeed");
1607        // alpha=0 should give the full range.
1608        let (lo, hi) = e
1609            .credible_interval(id, 0.0)
1610            .expect("test: credible interval should be available");
1611        assert!(lo <= hi);
1612    }
1613
1614    #[test]
1615    fn raw_samples_none_before_sampling() {
1616        let mut e = make_engine(63);
1617        let id = e.add_variable(
1618            "x".into(),
1619            PpePrior::Normal {
1620                mean: 0.0,
1621                std: 1.0,
1622            },
1623        );
1624        assert!(e.raw_samples(id).is_none());
1625    }
1626
1627    #[test]
1628    fn default_config() {
1629        let cfg = PpeEngineConfig::default();
1630        assert!(cfg.n_samples > 0);
1631        assert!(cfg.thinning > 0);
1632        assert!(cfg.seed > 0);
1633    }
1634
1635    #[test]
1636    fn posterior_std_single_sample_none() {
1637        let mut e = ProbabilisticProgramEngine::new(PpeEngineConfig {
1638            n_samples: 1,
1639            burn_in: 0,
1640            thinning: 1,
1641            seed: 777,
1642        });
1643        let id = e.add_variable(
1644            "x".into(),
1645            PpePrior::Normal {
1646                mean: 0.0,
1647                std: 1.0,
1648            },
1649        );
1650        e.sample(PpeSamplingMethod::RejectionSampling)
1651            .expect("test: sampling should succeed");
1652        // Std of a single sample is undefined.
1653        assert!(e.posterior_std(id).is_none());
1654    }
1655}