use crate::estimate::EstimationError;
use crate::inference::psis::pareto_smooth_weights;
use crate::solver::outer_strategy::OuterObjective;
use ndarray::{Array1, Array2};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RhoCertificate {
PlugInCertified,
ImportanceCorrect,
Escalate,
}
impl RhoCertificate {
fn from_k_hat(k_hat: f64) -> Self {
if !k_hat.is_finite() || k_hat > 0.7 {
RhoCertificate::Escalate
} else if k_hat < 0.5 {
RhoCertificate::PlugInCertified
} else {
RhoCertificate::ImportanceCorrect
}
}
}
#[derive(Debug, Clone)]
pub struct RhoPosteriorCertificate {
pub k_hat: f64,
pub certificate: RhoCertificate,
pub n_samples: usize,
pub weights: Array1<f64>,
pub effective_sample_size: f64,
}
#[derive(Debug, Clone)]
pub struct RhoQuadratureNode {
pub rho: Array1<f64>,
pub weight: f64,
pub log_weight: f64,
pub cost: f64,
pub gradient: Array1<f64>,
}
#[derive(Debug, Clone)]
pub struct RhoQuadratureMixture {
pub nodes: Vec<RhoQuadratureNode>,
pub effective_sample_size: f64,
pub max_gradient_norm: f64,
}
#[derive(Debug, Clone)]
pub struct RhoMixtureNode {
pub rho: Array1<f64>,
pub weight: f64,
pub log_weight: f64,
pub cost: f64,
}
#[derive(Debug, Clone)]
pub struct RhoPosteriorMixture {
pub nodes: Vec<RhoMixtureNode>,
pub mean: Array1<f64>,
pub covariance: Array2<f64>,
pub effective_sample_size: f64,
}
#[derive(Debug, Clone)]
pub struct RhoPosteriorSamples {
pub samples: Array2<f64>,
pub mean: Array1<f64>,
pub covariance: Array2<f64>,
pub rhat: f64,
pub ess: f64,
pub converged: bool,
}
#[derive(Debug, Clone)]
pub enum RhoPosteriorEscalation {
Quadrature(RhoPosteriorMixture),
Nuts(RhoPosteriorSamples),
Unavailable { n_params: usize, reason: String },
}
#[derive(Debug, Clone)]
pub struct MixtureCoefficientCovariance {
pub beta_bar: Array1<f64>,
pub covariance: Array2<f64>,
}
pub const TIER1_MAX_DIM: usize = 4;
pub const TIER2_MAX_DIM: usize = 16;
const ESCALATION_NUTS_SAMPLES: usize = 256;
const ESCALATION_NUTS_SEED: u64 = 0x938_5EED_0938_5EED;
const RHO_POSTERIOR_NUTS_WARMUP_FLOOR: usize = 32;
const DEFAULT_M: usize = 64;
const CERTIFICATE_SEED: u64 = 0x9E37_79B9_7F4A_7C15;
struct DetNormal {
state: u64,
}
impl DetNormal {
fn new(seed: u64) -> Self {
Self { state: seed }
}
fn uniform(&mut self) -> f64 {
self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^= z >> 31;
(((z >> 11) as f64) + 0.5) / ((1u64 << 53) as f64)
}
fn normal(&mut self) -> f64 {
let u1 = self.uniform().max(1e-300);
let u2 = self.uniform();
(-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
}
}
fn cholesky_lower(a: &Array2<f64>) -> Option<Array2<f64>> {
let n = a.nrows();
if n == 0 || a.ncols() != n {
return None;
}
let scale = (0..n)
.map(|i| a[[i, i]].abs())
.fold(0.0_f64, f64::max)
.max(1.0);
let jitter = 1e-10 * scale;
let mut l = Array2::<f64>::zeros((n, n));
for j in 0..n {
let mut d = a[[j, j]] + jitter;
for k in 0..j {
d -= l[[j, k]] * l[[j, k]];
}
if !(d.is_finite() && d > 0.0) {
return None;
}
let ljj = d.sqrt();
l[[j, j]] = ljj;
for i in (j + 1)..n {
let mut s = a[[i, j]];
for k in 0..j {
s -= l[[i, k]] * l[[j, k]];
}
l[[i, j]] = s / ljj;
}
}
Some(l)
}
fn whitening_factor_from_outer_hessian(outer_hessian: &Array2<f64>) -> Option<Array2<f64>> {
let r = cholesky_lower(outer_hessian)?;
let n = r.nrows();
let mut r_inv = Array2::<f64>::zeros((n, n));
for col in 0..n {
let mut x = Array1::<f64>::zeros(n);
for i in 0..n {
let mut acc = if i == col { 1.0 } else { 0.0 };
for k in 0..i {
acc -= r[[i, k]] * x[k];
}
let rii = r[[i, i]];
if !(rii.is_finite() && rii.abs() > 0.0) {
return None;
}
x[i] = acc / rii;
}
for i in 0..n {
r_inv[[i, col]] = x[i];
}
}
let mut l_inv = Array2::<f64>::zeros((n, n));
for i in 0..n {
for j in 0..n {
l_inv[[i, j]] = r_inv[[j, i]];
}
}
Some(l_inv)
}
fn symmetrize_in_place(a: &mut Array2<f64>) {
let n = a.nrows();
for i in 0..n {
for j in (i + 1)..n {
let v = 0.5 * (a[[i, j]] + a[[j, i]]);
a[[i, j]] = v;
a[[j, i]] = v;
}
}
}
pub fn rho_hessian_from_profiled_exact_gradient(
objective: &mut dyn OuterObjective,
rho_hat: &Array1<f64>,
) -> Result<Array2<f64>, EstimationError> {
let k = rho_hat.len();
let mut hessian = Array2::<f64>::zeros((k, k));
if k == 0 {
return Ok(hessian);
}
let base_step = 1.0e-4;
for j in 0..k {
let step = base_step * rho_hat[j].abs().max(1.0);
let mut plus = rho_hat.clone();
let mut minus = rho_hat.clone();
plus[j] += step;
minus[j] -= step;
let gp = objective.eval(&plus)?.gradient;
let gm = objective.eval(&minus)?.gradient;
if gp.len() != k || gm.len() != k {
return Err(EstimationError::RemlOptimizationFailed(format!(
"rho_hessian_from_profiled_exact_gradient: gradient length mismatch: expected {k}, got {} and {}",
gp.len(),
gm.len()
)));
}
for i in 0..k {
hessian[[i, j]] = (gp[i] - gm[i]) / (2.0 * step);
}
}
symmetrize_in_place(&mut hessian);
Ok(hessian)
}
fn standard_normal_gh_rule(nodes_per_axis: usize) -> Option<&'static [(f64, f64)]> {
match nodes_per_axis {
3 => Some(&[
(-1.732_050_807_568_877_2, 1.0 / 6.0),
(0.0, 2.0 / 3.0),
(1.732_050_807_568_877_2, 1.0 / 6.0),
]),
5 => Some(&[
(-2.856_970_013_872_805_6, 1.125_741_132_772_071_8e-2),
(-1.355_626_179_974_265_9, 2.220_759_220_056_126_4e-1),
(0.0, 5.333_333_333_333_333e-1),
(1.355_626_179_974_265_9, 2.220_759_220_056_126_4e-1),
(2.856_970_013_872_805_6, 1.125_741_132_772_071_8e-2),
]),
_ => None,
}
}
fn enumerate_gh_product(
dim: usize,
rule: &[(f64, f64)],
axis: usize,
z: &mut Array1<f64>,
log_w: f64,
out: &mut Vec<(Array1<f64>, f64)>,
) {
if axis == dim {
out.push((z.clone(), log_w));
return;
}
for &(node, weight) in rule {
z[axis] = node;
enumerate_gh_product(dim, rule, axis + 1, z, log_w + weight.ln(), out);
}
}
struct NormalizedQuadratureNode {
rho: Array1<f64>,
cost: f64,
gradient: Option<Array1<f64>>,
weight: f64,
log_weight: f64,
}
fn quadrature_nodes_core<E>(
rho_hat: &Array1<f64>,
outer_hessian: &Array2<f64>,
nodes_per_axis: usize,
cost_hat: f64,
mut eval_node: E,
) -> Result<(Vec<NormalizedQuadratureNode>, f64), EstimationError>
where
E: FnMut(&Array1<f64>) -> Result<Option<(f64, Option<Array1<f64>>)>, EstimationError>,
{
let k = rho_hat.len();
if k == 0 || outer_hessian.nrows() != k || outer_hessian.ncols() != k {
return Err(EstimationError::RemlOptimizationFailed(
"rho_posterior_quadrature: rho/Hessian shape mismatch".to_string(),
));
}
if k > TIER1_MAX_DIM {
return Err(EstimationError::RemlOptimizationFailed(format!(
"rho_posterior_quadrature: product quadrature is capped at K<={TIER1_MAX_DIM}, got {k}"
)));
}
let rule = standard_normal_gh_rule(nodes_per_axis).ok_or_else(|| {
EstimationError::RemlOptimizationFailed(format!(
"rho_posterior_quadrature: unsupported nodes_per_axis {nodes_per_axis}"
))
})?;
let l_inv = whitening_factor_from_outer_hessian(outer_hessian).ok_or_else(|| {
EstimationError::RemlOptimizationFailed(
"rho_posterior_quadrature: outer Hessian is not positive definite".to_string(),
)
})?;
if !cost_hat.is_finite() {
return Err(EstimationError::RemlOptimizationFailed(
"rho_posterior_quadrature: non-finite criterion at rho_hat".to_string(),
));
}
let mut product_nodes = Vec::new();
enumerate_gh_product(
k,
rule,
0,
&mut Array1::<f64>::zeros(k),
0.0,
&mut product_nodes,
);
let mut raw_nodes = Vec::with_capacity(product_nodes.len());
let mut max_log_weight = f64::NEG_INFINITY;
for (z, log_base_weight) in product_nodes {
let mut rho = rho_hat.clone();
for i in 0..k {
let mut acc = 0.0;
for j in 0..k {
acc += l_inv[[i, j]] * z[j];
}
rho[i] += acc;
}
let (cost, gradient, log_weight) = match eval_node(&rho)? {
Some((cost, gradient)) if cost.is_finite() => {
let half_norm_sq = 0.5 * z.iter().map(|&v| v * v).sum::<f64>();
(
cost,
gradient,
log_base_weight - cost + cost_hat + half_norm_sq,
)
}
_ => (f64::INFINITY, None, f64::NEG_INFINITY),
};
if log_weight.is_finite() {
max_log_weight = max_log_weight.max(log_weight);
}
raw_nodes.push((rho, cost, gradient, log_weight));
}
if !max_log_weight.is_finite() {
return Err(EstimationError::RemlOptimizationFailed(
"rho_posterior_quadrature: all quadrature nodes were non-finite".to_string(),
));
}
let mut total = 0.0;
let mut scaled = Vec::with_capacity(raw_nodes.len());
for (_, _, _, log_weight) in &raw_nodes {
let w = if log_weight.is_finite() {
(*log_weight - max_log_weight).exp()
} else {
0.0
};
total += w;
scaled.push(w);
}
if !(total.is_finite() && total > 0.0) {
return Err(EstimationError::RemlOptimizationFailed(
"rho_posterior_quadrature: non-positive normalized mass".to_string(),
));
}
let mut nodes = Vec::with_capacity(raw_nodes.len());
let mut sum_sq = 0.0;
for ((rho, cost, gradient, log_weight), scaled_weight) in raw_nodes.into_iter().zip(scaled) {
let weight = scaled_weight / total;
sum_sq += weight * weight;
nodes.push(NormalizedQuadratureNode {
rho,
cost,
gradient,
weight,
log_weight: log_weight - max_log_weight - total.ln(),
});
}
let ess = if sum_sq > 0.0 { 1.0 / sum_sq } else { 0.0 };
Ok((nodes, ess))
}
pub fn rho_posterior_tier1_quadrature(
objective: &mut dyn OuterObjective,
rho_hat: &Array1<f64>,
outer_hessian: &Array2<f64>,
nodes_per_axis: usize,
) -> Result<RhoQuadratureMixture, EstimationError> {
let cost_hat = objective.eval_cost(rho_hat)?;
let (core_nodes, effective_sample_size) =
quadrature_nodes_core(rho_hat, outer_hessian, nodes_per_axis, cost_hat, |rho| {
let eval = objective.eval(rho)?;
Ok(Some((eval.cost, Some(eval.gradient))))
})?;
let k = rho_hat.len();
let mut nodes = Vec::with_capacity(core_nodes.len());
let mut max_gradient_norm = 0.0_f64;
for node in core_nodes {
let gradient = node.gradient.unwrap_or_else(|| Array1::zeros(k));
let grad_norm = gradient.iter().map(|g| g * g).sum::<f64>().sqrt();
max_gradient_norm = max_gradient_norm.max(grad_norm);
nodes.push(RhoQuadratureNode {
rho: node.rho,
weight: node.weight,
log_weight: node.log_weight,
cost: node.cost,
gradient,
});
}
Ok(RhoQuadratureMixture {
nodes,
effective_sample_size,
max_gradient_norm,
})
}
fn mixture_moments(nodes: &[RhoMixtureNode], k: usize) -> (Array1<f64>, Array2<f64>) {
let mut mean = Array1::<f64>::zeros(k);
for node in nodes {
for i in 0..k {
mean[i] += node.weight * node.rho[i];
}
}
let mut covariance = Array2::<f64>::zeros((k, k));
for node in nodes {
for i in 0..k {
let di = node.rho[i] - mean[i];
for j in 0..k {
covariance[[i, j]] += node.weight * di * (node.rho[j] - mean[j]);
}
}
}
(mean, covariance)
}
pub fn rho_posterior_quadrature<F>(
rho_hat: &Array1<f64>,
outer_hessian: &Array2<f64>,
mut criterion: F,
nodes_per_axis: Option<usize>,
) -> Result<RhoPosteriorMixture, EstimationError>
where
F: FnMut(&Array1<f64>) -> Option<f64>,
{
let k = rho_hat.len();
let nodes_per_axis = nodes_per_axis.unwrap_or(if k <= 2 { 5 } else { 3 });
let cost_hat = criterion(rho_hat).ok_or_else(|| {
EstimationError::RemlOptimizationFailed(
"rho_posterior_quadrature: criterion is infeasible at rho_hat itself".to_string(),
)
})?;
let (core_nodes, effective_sample_size) =
quadrature_nodes_core(rho_hat, outer_hessian, nodes_per_axis, cost_hat, |rho| {
Ok(criterion(rho).map(|cost| (cost, None)))
})?;
let nodes: Vec<RhoMixtureNode> = core_nodes
.into_iter()
.map(|node| RhoMixtureNode {
rho: node.rho,
weight: node.weight,
log_weight: node.log_weight,
cost: node.cost,
})
.collect();
let (mean, covariance) = mixture_moments(&nodes, k);
Ok(RhoPosteriorMixture {
nodes,
mean,
covariance,
effective_sample_size,
})
}
pub fn mixture_coefficient_covariance<G>(
mixture: &RhoPosteriorMixture,
mut conditional: G,
) -> Result<MixtureCoefficientCovariance, EstimationError>
where
G: FnMut(&Array1<f64>) -> Result<(Array1<f64>, Array2<f64>), EstimationError>,
{
let mut conditionals: Vec<(f64, Array1<f64>, Array2<f64>)> = Vec::new();
let mut total_weight = 0.0;
for node in &mixture.nodes {
if node.weight <= 0.0 {
continue;
}
let (beta, vb) = conditional(&node.rho)?;
if vb.nrows() != beta.len() || vb.ncols() != beta.len() {
return Err(EstimationError::RemlOptimizationFailed(format!(
"mixture_coefficient_covariance: Vb shape {:?} does not match beta length {}",
vb.dim(),
beta.len()
)));
}
if let Some((_, first_beta, _)) = conditionals.first()
&& first_beta.len() != beta.len()
{
return Err(EstimationError::RemlOptimizationFailed(
"mixture_coefficient_covariance: inconsistent beta length across nodes".to_string(),
));
}
total_weight += node.weight;
conditionals.push((node.weight, beta, vb));
}
if conditionals.is_empty() || !(total_weight.is_finite() && total_weight > 0.0) {
return Err(EstimationError::RemlOptimizationFailed(
"mixture_coefficient_covariance: no positive-weight nodes".to_string(),
));
}
let p = conditionals[0].1.len();
let mut beta_bar = Array1::<f64>::zeros(p);
for (weight, beta, _) in &conditionals {
for i in 0..p {
beta_bar[i] += (weight / total_weight) * beta[i];
}
}
let mut covariance = Array2::<f64>::zeros((p, p));
for (weight, beta, vb) in &conditionals {
let w = weight / total_weight;
for i in 0..p {
let di = beta[i] - beta_bar[i];
for j in 0..p {
covariance[[i, j]] += w * (vb[[i, j]] + di * (beta[j] - beta_bar[j]));
}
}
}
Ok(MixtureCoefficientCovariance {
beta_bar,
covariance,
})
}
pub fn rho_posterior_nuts<F>(
rho_hat: &Array1<f64>,
outer_hessian: &Array2<f64>,
criterion_and_grad: F,
n_samples: usize,
seed: u64,
) -> Result<RhoPosteriorSamples, EstimationError>
where
F: FnMut(&Array1<f64>) -> Option<(f64, Array1<f64>)> + Send,
{
let k = rho_hat.len();
let config = crate::inference::hmc::NutsConfig {
n_samples: n_samples.max(4),
nwarmup: (n_samples / 2).max(RHO_POSTERIOR_NUTS_WARMUP_FLOOR),
n_chains: 2,
target_accept: 0.9,
seed,
};
let result = crate::inference::hmc::run_rho_criterion_nuts(
rho_hat.view(),
outer_hessian.view(),
criterion_and_grad,
&config,
)
.map_err(EstimationError::RemlOptimizationFailed)?;
let n_draws = result.samples.nrows();
if n_draws == 0 {
return Err(EstimationError::RemlOptimizationFailed(
"rho_posterior_nuts: sampler returned no draws".to_string(),
));
}
let mean = result.posterior_mean.clone();
let mut covariance = Array2::<f64>::zeros((k, k));
for row in result.samples.rows() {
for i in 0..k {
let di = row[i] - mean[i];
for j in 0..k {
covariance[[i, j]] += di * (row[j] - mean[j]);
}
}
}
covariance.mapv_inplace(|v| v / n_draws as f64);
Ok(RhoPosteriorSamples {
samples: result.samples,
mean,
covariance,
rhat: result.rhat,
ess: result.ess,
converged: result.converged,
})
}
pub fn escalate_rho_posterior<F, G>(
rho_hat: &Array1<f64>,
outer_hessian: &Array2<f64>,
criterion: F,
criterion_and_grad: G,
) -> RhoPosteriorEscalation
where
F: FnMut(&Array1<f64>) -> Option<f64>,
G: FnMut(&Array1<f64>) -> Option<(f64, Array1<f64>)> + Send,
{
let k = rho_hat.len();
if k == 0 {
return RhoPosteriorEscalation::Unavailable {
n_params: 0,
reason: "no smoothing parameters to marginalize".to_string(),
};
}
if k <= TIER1_MAX_DIM {
match rho_posterior_quadrature(rho_hat, outer_hessian, criterion, None) {
Ok(mixture) => RhoPosteriorEscalation::Quadrature(mixture),
Err(e) => RhoPosteriorEscalation::Unavailable {
n_params: k,
reason: format!("tier-1 quadrature failed: {e}"),
},
}
} else if k <= TIER2_MAX_DIM {
match rho_posterior_nuts(
rho_hat,
outer_hessian,
criterion_and_grad,
ESCALATION_NUTS_SAMPLES,
ESCALATION_NUTS_SEED,
) {
Ok(samples) => RhoPosteriorEscalation::Nuts(samples),
Err(e) => RhoPosteriorEscalation::Unavailable {
n_params: k,
reason: format!("tier-2 NUTS failed: {e}"),
},
}
} else {
RhoPosteriorEscalation::Unavailable {
n_params: k,
reason: format!(
"rho-posterior escalation is unavailable for K={k} > {TIER2_MAX_DIM} smoothing \
parameters; intervals remain plug-in with the first-order V_rho correction"
),
}
}
}
pub fn rho_posterior_certificate<F>(
rho_hat: &Array1<f64>,
outer_hessian: &Array2<f64>,
criterion: F,
n_samples: Option<usize>,
) -> Option<RhoPosteriorCertificate>
where
F: Fn(&Array1<f64>) -> Option<f64>,
{
let k = rho_hat.len();
if k == 0 || outer_hessian.nrows() != k || outer_hessian.ncols() != k {
return None;
}
let cost_hat = criterion(rho_hat)?;
if !cost_hat.is_finite() {
return None;
}
let l_inv = whitening_factor_from_outer_hessian(outer_hessian)?;
let m = n_samples
.unwrap_or(DEFAULT_M)
.max(2 * crate::inference::psis::MIN_TAIL_COUNT);
let mut rng = DetNormal::new(CERTIFICATE_SEED);
let mut raw_weights: Vec<f64> = Vec::with_capacity(m);
for _ in 0..m {
let z: Array1<f64> = Array1::from_iter((0..k).map(|_| rng.normal()));
let mut rho_m = rho_hat.clone();
for i in 0..k {
let mut acc = 0.0;
for j in 0..k {
acc += l_inv[[i, j]] * z[j];
}
rho_m[i] += acc;
}
let half_norm_sq = 0.5 * z.iter().map(|&v| v * v).sum::<f64>();
let log_w = match criterion(&rho_m) {
Some(c) if c.is_finite() => -c + cost_hat + half_norm_sq,
_ => f64::NEG_INFINITY,
};
raw_weights.push(log_w);
}
let max_lw = raw_weights
.iter()
.copied()
.filter(|v| v.is_finite())
.fold(f64::NEG_INFINITY, f64::max);
if !max_lw.is_finite() {
return None;
}
let weights: Vec<f64> = raw_weights
.iter()
.map(|&lw| {
if lw.is_finite() {
(lw - max_lw).exp()
} else {
0.0
}
})
.collect();
let psis = pareto_smooth_weights(&weights)?;
let k_hat = psis.k_hat;
let total: f64 = psis.smoothed.iter().sum();
if !(total.is_finite() && total > 0.0) {
return None;
}
let normalized: Array1<f64> = Array1::from_iter(psis.smoothed.iter().map(|&w| w / total));
let sum_sq: f64 = normalized.iter().map(|&w| w * w).sum();
let ess = if sum_sq > 0.0 { 1.0 / sum_sq } else { 0.0 };
Some(RhoPosteriorCertificate {
k_hat,
certificate: RhoCertificate::from_k_hat(k_hat),
n_samples: m,
weights: normalized,
effective_sample_size: ess,
})
}
#[cfg(test)]
mod tests {
use super::*;
use ndarray::array;
#[test]
fn exact_gaussian_target_certifies_plug_in() {
let rho_hat = array![0.3, -0.7];
let h = array![[2.0, 0.5], [0.5, 1.5]];
let crit = |rho: &Array1<f64>| {
let d = rho - &rho_hat;
let mut q = 0.0;
for i in 0..2 {
for j in 0..2 {
q += d[i] * h[[i, j]] * d[j];
}
}
Some(0.5 * q)
};
let cert = rho_posterior_certificate(&rho_hat, &h, crit, Some(256)).expect("certificate");
assert!(
(cert.effective_sample_size - cert.n_samples as f64).abs() < 1e-6,
"uniform weights must give ESS == M: ess={} M={}",
cert.effective_sample_size,
cert.n_samples
);
assert!(
cert.k_hat < 0.5,
"exact-Gaussian target must yield small k̂, got {}",
cert.k_hat
);
assert_eq!(cert.certificate, RhoCertificate::PlugInCertified);
}
#[test]
fn heavy_tailed_target_refuses_to_certify() {
let rho_hat = array![0.0];
let h = array![[4.0]]; let crit = |rho: &Array1<f64>| {
let r = rho[0];
Some((1.0 + r * r).ln())
};
let cert = rho_posterior_certificate(&rho_hat, &h, crit, Some(512)).expect("certificate");
assert!(
cert.k_hat > 0.5,
"heavy-tailed target must raise k̂ above 0.5, got {}",
cert.k_hat
);
assert_ne!(cert.certificate, RhoCertificate::PlugInCertified);
}
#[test]
fn weights_are_normalized_and_deterministic() {
let rho_hat = array![1.0];
let h = array![[1.0]];
let crit = |rho: &Array1<f64>| {
let d = rho[0] - 1.0;
Some(0.5 * d * d)
};
let a = rho_posterior_certificate(&rho_hat, &h, crit, Some(64)).expect("a");
let b = rho_posterior_certificate(&rho_hat, &h, crit, Some(64)).expect("b");
let s: f64 = a.weights.iter().sum();
assert!((s - 1.0).abs() < 1e-10, "weights must sum to 1, got {s}");
assert_eq!(a.k_hat.to_bits(), b.k_hat.to_bits());
}
#[test]
fn empty_rho_returns_none() {
let rho_hat: Array1<f64> = array![];
let h = Array2::<f64>::zeros((0, 0));
assert!(rho_posterior_certificate(&rho_hat, &h, |_| Some(0.0), None).is_none());
}
}