Skip to main content

gam_models/
multinomial_posterior.rs

1//! Deterministic posterior moments for logistic-normal softmax probabilities.
2//!
3//! A reference-coded multinomial model has `M = K - 1` active logits.  At one
4//! prediction row the Laplace posterior induces
5//!
6//! ```text
7//! eta ~ Normal(mu, V),
8//! p(eta) = softmax(eta_0, ..., eta_{M-1}, 0).
9//! ```
10//!
11//! This module computes `E[p]` and `Cov(p)` rather than the plug-in quantity
12//! `softmax(E[eta])`.  The binary case is reduced to the controlled scalar
13//! logistic-normal evaluator in `gam-solve`.  For `K > 2`, the covariance is
14//! eigendecomposed and quadrature is performed only over its positive range.
15//! Successive Smolyak levels built from odd-order Gauss-Hermite rules provide a
16//! deterministic error check.  Failure to establish the requested tolerance is
17//! an error; there is deliberately no Monte Carlo or plug-in fallback.
18
19use crate::model_types::EstimationError;
20use gam_linalg::faer_ndarray::FaerEigh;
21use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
22
23/// Backward-error multiplier used when deciding whether a symmetric covariance
24/// eigenvalue is negative beyond floating-point eigensolver roundoff.
25///
26/// This is not a variance jitter: the input matrix is never modified by adding
27/// a diagonal ridge.  Eigenvalues below `-tol` are rejected, while values whose
28/// magnitude is within the backward-error envelope are treated as numerical
29/// zero.
30const PSD_BACKWARD_ERROR_MULTIPLIER: f64 = 16.0;
31
32/// Floating-point summation envelope for the signed Smolyak combination.
33const SUMMATION_ROUNDOFF_MULTIPLIER: f64 = 16.0;
34
35/// Explicit accuracy and work controls for multinomial posterior integration.
36///
37/// The production default is explicit through [`Default`] and is carried by
38/// the prediction request into this kernel. `minimum_sparse_level >= 1`
39/// guarantees at least one comparison against a preceding Smolyak level.
40#[derive(Clone, Copy, Debug)]
41pub struct MultinomialPosteriorIntegrationControl {
42    /// Per raw moment absolute tolerance.  Raw moments comprise every `E[p_c]`
43    /// and `E[p_c p_d]` for `c <= d`.
44    pub absolute_tolerance: f64,
45    /// Per raw moment relative tolerance.
46    pub relative_tolerance: f64,
47    /// Earliest Smolyak refinement level that may certify convergence.
48    pub minimum_sparse_level: usize,
49    /// Last Smolyak refinement level attempted.
50    pub maximum_sparse_level: usize,
51    /// Maximum total integrand evaluations across all attempted levels.
52    pub maximum_function_evaluations: usize,
53}
54
55impl Default for MultinomialPosteriorIntegrationControl {
56    fn default() -> Self {
57        // sqrt(machine epsilon) is the natural accuracy target for a nonlinear
58        // transform of a covariance estimated in double precision: asking for
59        // substantially more would certify quadrature noise below the input's
60        // own numerical resolution. Three sparse levels are required before a
61        // result may certify; level eight reaches the 17-point one-dimensional
62        // Gauss-Hermite rule. The streaming evaluator never stores the node
63        // set, and the evaluation ceiling bounds work on high-rank rows.
64        let tolerance = f64::EPSILON.sqrt();
65        Self {
66            absolute_tolerance: tolerance,
67            relative_tolerance: tolerance,
68            minimum_sparse_level: 2,
69            maximum_sparse_level: 8,
70            maximum_function_evaluations: 2_000_000,
71        }
72    }
73}
74
75/// Integrated posterior means and marginal standard deviations for every row
76/// of a multinomial prediction design.
77#[derive(Clone, Debug)]
78pub struct MultinomialPosteriorRowMoments {
79    pub class_mean: Array2<f64>,
80    pub class_standard_deviation: Array2<f64>,
81}
82
83/// Integrate the logistic-normal posterior induced by a coefficient mode and
84/// its full joint covariance over every design row.
85///
86/// Coefficients have shape `(P, M)`, covariance has block-major shape
87/// `(P*M, P*M)`, and `design` has shape `(N, P)`. For row `x`, this constructs
88/// `mu_a = x' beta_a` and `V_ab = x' Sigma_ab x`, then delegates to the
89/// controlled one-row integrator. Cross-class covariance blocks are retained.
90pub fn integrate_multinomial_design_moments(
91    coefficients: ArrayView2<'_, f64>,
92    coefficient_covariance: ArrayView2<'_, f64>,
93    design: ArrayView2<'_, f64>,
94    control: &MultinomialPosteriorIntegrationControl,
95) -> Result<MultinomialPosteriorRowMoments, EstimationError> {
96    let (p, m) = coefficients.dim();
97    if p == 0 || m == 0 {
98        return Err(EstimationError::InvalidInput(format!(
99            "multinomial posterior prediction needs nonempty coefficients, got {p}x{m}"
100        )));
101    }
102    if design.ncols() != p {
103        return Err(EstimationError::InvalidInput(format!(
104            "multinomial posterior prediction design has {} columns, expected {p}",
105            design.ncols()
106        )));
107    }
108    let d = p.checked_mul(m).ok_or_else(|| {
109        EstimationError::InvalidInput(
110            "multinomial posterior prediction coefficient dimension overflowed usize".to_string(),
111        )
112    })?;
113    if coefficient_covariance.dim() != (d, d) {
114        return Err(EstimationError::InvalidInput(format!(
115            "multinomial posterior prediction covariance shape {:?} does not match (P*M, P*M) = ({d}, {d})",
116            coefficient_covariance.dim()
117        )));
118    }
119
120    let n = design.nrows();
121    let k = m + 1;
122    let mut class_mean = Array2::<f64>::zeros((n, k));
123    let mut class_standard_deviation = Array2::<f64>::zeros((n, k));
124    let mut active_mean = Array1::<f64>::zeros(m);
125    let mut active_covariance = Array2::<f64>::zeros((m, m));
126    for row in 0..n {
127        let x = design.row(row);
128        for a in 0..m {
129            active_mean[a] = x.dot(&coefficients.column(a));
130        }
131        for a in 0..m {
132            for b in 0..m {
133                let mut value = 0.0_f64;
134                let a_base = a * p;
135                let b_base = b * p;
136                for i in 0..p {
137                    let xi = x[i];
138                    if xi == 0.0 {
139                        continue;
140                    }
141                    let mut row_product = 0.0_f64;
142                    for j in 0..p {
143                        row_product += coefficient_covariance[[a_base + i, b_base + j]] * x[j];
144                    }
145                    value += xi * row_product;
146                }
147                active_covariance[[a, b]] = value;
148            }
149        }
150        let moments = integrate_logistic_normal_softmax_moments(
151            active_mean.view(),
152            active_covariance.view(),
153            control,
154        )?;
155        class_mean.row_mut(row).assign(&moments.class_mean);
156        class_standard_deviation
157            .row_mut(row)
158            .assign(&moments.class_standard_deviation);
159    }
160    Ok(MultinomialPosteriorRowMoments {
161        class_mean,
162        class_standard_deviation,
163    })
164}
165
166impl MultinomialPosteriorIntegrationControl {
167    fn validate(&self) -> Result<(), EstimationError> {
168        if !(self.absolute_tolerance.is_finite() && self.absolute_tolerance >= 0.0) {
169            return Err(EstimationError::InvalidInput(format!(
170                "multinomial posterior integration absolute_tolerance must be finite and >= 0, got {}",
171                self.absolute_tolerance
172            )));
173        }
174        if !(self.relative_tolerance.is_finite() && self.relative_tolerance >= 0.0) {
175            return Err(EstimationError::InvalidInput(format!(
176                "multinomial posterior integration relative_tolerance must be finite and >= 0, got {}",
177                self.relative_tolerance
178            )));
179        }
180        if self.absolute_tolerance == 0.0 && self.relative_tolerance == 0.0 {
181            return Err(EstimationError::InvalidInput(
182                "multinomial posterior integration requires a positive absolute or relative tolerance"
183                    .to_string(),
184            ));
185        }
186        if self.minimum_sparse_level == 0 {
187            return Err(EstimationError::InvalidInput(
188                "multinomial posterior integration minimum_sparse_level must be >= 1 so a level difference exists"
189                    .to_string(),
190            ));
191        }
192        if self.maximum_sparse_level < self.minimum_sparse_level {
193            return Err(EstimationError::InvalidInput(format!(
194                "multinomial posterior integration maximum_sparse_level ({}) is below minimum_sparse_level ({})",
195                self.maximum_sparse_level, self.minimum_sparse_level
196            )));
197        }
198        if self.maximum_function_evaluations == 0 {
199            return Err(EstimationError::InvalidInput(
200                "multinomial posterior integration maximum_function_evaluations must be positive"
201                    .to_string(),
202            ));
203        }
204        Ok(())
205    }
206}
207
208/// Integrated class-probability moments for one prediction row.
209///
210/// `class_covariance` includes the reference class and is singular in the
211/// all-ones direction, as required by `sum_c p_c = 1`.  A value of this type is
212/// only constructed after the requested level-difference certificate succeeds.
213#[derive(Clone, Debug)]
214pub struct MultinomialPosteriorMoments {
215    /// `E[p_c]`, length `K`, including the reference class last.
216    pub class_mean: Array1<f64>,
217    /// `Cov(p_c, p_d)`, shape `(K, K)`.
218    pub class_covariance: Array2<f64>,
219    /// Marginal posterior standard deviations `sqrt(Var(p_c))`.
220    pub class_standard_deviation: Array1<f64>,
221    /// Positive numerical rank of the active-logit covariance.
222    pub latent_rank: usize,
223    /// Smolyak level that certified convergence.  `None` denotes an exact
224    /// binary reduction or an exact point-mass covariance.
225    pub sparse_level: Option<usize>,
226    /// Total softmax evaluations across all attempted sparse levels.
227    pub function_evaluations: usize,
228    /// Largest absolute difference among raw first/second moments at the final
229    /// two sparse levels.  Zero on the exact binary and point-mass paths.
230    pub max_raw_moment_level_difference: f64,
231    /// Bound used for positive covariance eigenmodes discarded inside the
232    /// eigensolver backward-error envelope.  Such modes are discarded only
233    /// when this bound fits inside the requested absolute tolerance.
234    pub covariance_range_projection_bound: f64,
235}
236
237/// Integrate reference-coded logistic-normal softmax moments for one row.
238///
239/// `active_mean` has length `M = K - 1`; `active_covariance` must be a finite,
240/// symmetric positive-semidefinite `(M, M)` matrix in the same active-class
241/// order.  The returned arrays include the implicit reference class as their
242/// final entry.
243pub fn integrate_logistic_normal_softmax_moments(
244    active_mean: ArrayView1<'_, f64>,
245    active_covariance: ArrayView2<'_, f64>,
246    control: &MultinomialPosteriorIntegrationControl,
247) -> Result<MultinomialPosteriorMoments, EstimationError> {
248    control.validate()?;
249    validate_inputs(active_mean, active_covariance)?;
250
251    let mean = active_mean.to_vec();
252    let m = mean.len();
253    if m == 1 {
254        return integrate_binary(mean[0], active_covariance[[0, 0]]);
255    }
256
257    let maximum_covariance_entry = active_covariance
258        .iter()
259        .fold(0.0_f64, |scale, &value| scale.max(value.abs()));
260    if maximum_covariance_entry == 0.0 {
261        return point_mass_moments(&mean);
262    }
263
264    let projected = project_active_covariance(active_covariance, control.absolute_tolerance)?;
265    if projected.factor.ncols() == 0 {
266        // This arm is reachable only when every positive eigenmode lies inside
267        // the eigensolver backward-error envelope and its explicit probability
268        // bound fits within the caller's tolerance.  It is therefore a
269        // certified point-mass approximation, not a silent plug-in fallback.
270        let mut out = point_mass_moments(&mean)?;
271        out.covariance_range_projection_bound = projected.projection_bound;
272        return Ok(out);
273    }
274
275    integrate_general(&mean, &projected, control)
276}
277
278fn validate_inputs(
279    active_mean: ArrayView1<'_, f64>,
280    active_covariance: ArrayView2<'_, f64>,
281) -> Result<(), EstimationError> {
282    let m = active_mean.len();
283    if m == 0 {
284        return Err(EstimationError::InvalidInput(
285            "multinomial posterior integration needs at least one active logit (K >= 2)"
286                .to_string(),
287        ));
288    }
289    if active_covariance.dim() != (m, m) {
290        return Err(EstimationError::InvalidInput(format!(
291            "multinomial posterior integration covariance shape {:?} does not match active mean length {m}",
292            active_covariance.dim()
293        )));
294    }
295    if let Some((index, value)) = active_mean
296        .iter()
297        .copied()
298        .enumerate()
299        .find(|(_, value)| !value.is_finite())
300    {
301        return Err(EstimationError::InvalidInput(format!(
302            "multinomial posterior integration active_mean[{index}] is non-finite: {value}"
303        )));
304    }
305    if let Some(((row, column), value)) = active_covariance
306        .indexed_iter()
307        .map(|(index, &value)| (index, value))
308        .find(|(_, value)| !value.is_finite())
309    {
310        return Err(EstimationError::InvalidInput(format!(
311            "multinomial posterior integration covariance[{row},{column}] is non-finite: {value}"
312        )));
313    }
314
315    let scale = active_covariance
316        .iter()
317        .fold(0.0_f64, |acc, &value| acc.max(value.abs()));
318    let symmetry_tolerance = covariance_roundoff_tolerance(scale, m);
319    let mut maximum_asymmetry = 0.0_f64;
320    for row in 0..m {
321        for column in (row + 1)..m {
322            maximum_asymmetry = maximum_asymmetry
323                .max((active_covariance[[row, column]] - active_covariance[[column, row]]).abs());
324        }
325    }
326    if maximum_asymmetry > symmetry_tolerance {
327        return Err(EstimationError::InvalidInput(format!(
328            "multinomial posterior integration covariance is not symmetric: max asymmetry {maximum_asymmetry:.6e} exceeds backward-error tolerance {symmetry_tolerance:.6e}"
329        )));
330    }
331    Ok(())
332}
333
334fn covariance_roundoff_tolerance(scale: f64, dimension: usize) -> f64 {
335    PSD_BACKWARD_ERROR_MULTIPLIER * f64::EPSILON * (dimension.max(1) as f64) * scale
336}
337
338fn integrate_binary(
339    active_mean: f64,
340    active_variance: f64,
341) -> Result<MultinomialPosteriorMoments, EstimationError> {
342    if active_variance < 0.0 {
343        return Err(EstimationError::InvalidInput(format!(
344            "binary logistic-normal variance must be non-negative, got {active_variance:.6e}"
345        )));
346    }
347    let sigma = active_variance.sqrt();
348    let (probability_mean, mean_logistic_slope) =
349        gam_solve::quadrature::logit_posterior_meanwith_deriv(active_mean, sigma)?;
350
351    // sigmoid'(eta) = p(1-p) = p-p^2, hence
352    // E[p^2] = E[p] - d/dmu E[p].  This supplies the binary probability
353    // variance from the same controlled scalar integral without a second
354    // numerical approximation.
355    let probability_second_moment = probability_mean - mean_logistic_slope;
356    let variance = (probability_second_moment - probability_mean * probability_mean).max(0.0);
357    let reference_mean = 1.0 - probability_mean;
358
359    let class_mean = Array1::from_vec(vec![probability_mean, reference_mean]);
360    let class_covariance =
361        Array2::from_shape_vec((2, 2), vec![variance, -variance, -variance, variance]).map_err(
362            |error| {
363                EstimationError::InvalidInput(format!(
364                    "binary logistic-normal covariance construction failed: {error}"
365                ))
366            },
367        )?;
368    let standard_deviation = variance.sqrt();
369    Ok(MultinomialPosteriorMoments {
370        class_mean,
371        class_covariance,
372        class_standard_deviation: Array1::from_vec(vec![standard_deviation, standard_deviation]),
373        latent_rank: if active_variance > 0.0 { 1 } else { 0 },
374        sparse_level: None,
375        function_evaluations: 0,
376        max_raw_moment_level_difference: 0.0,
377        covariance_range_projection_bound: 0.0,
378    })
379}
380
381fn point_mass_moments(active_mean: &[f64]) -> Result<MultinomialPosteriorMoments, EstimationError> {
382    let class_mean = Array1::from_vec(softmax_with_reference(active_mean)?);
383    let k = class_mean.len();
384    Ok(MultinomialPosteriorMoments {
385        class_mean,
386        class_covariance: Array2::zeros((k, k)),
387        class_standard_deviation: Array1::zeros(k),
388        latent_rank: 0,
389        sparse_level: None,
390        function_evaluations: 1,
391        max_raw_moment_level_difference: 0.0,
392        covariance_range_projection_bound: 0.0,
393    })
394}
395
396struct ProjectedGaussian {
397    /// `factor factor^T` is the retained active-logit covariance.
398    factor: Array2<f64>,
399    projection_bound: f64,
400}
401
402fn project_active_covariance(
403    covariance: ArrayView2<'_, f64>,
404    absolute_tolerance: f64,
405) -> Result<ProjectedGaussian, EstimationError> {
406    let m = covariance.nrows();
407    let symmetric = (&covariance.to_owned() + &covariance.t().to_owned()) * 0.5;
408    let (eigenvalues, eigenvectors) = symmetric.eigh(faer::Side::Lower).map_err(|error| {
409        EstimationError::InvalidInput(format!(
410            "multinomial posterior covariance eigendecomposition failed: {error}"
411        ))
412    })?;
413    let eigenvalue_scale = eigenvalues
414        .iter()
415        .fold(0.0_f64, |scale, &value| scale.max(value.abs()));
416    let tolerance = covariance_roundoff_tolerance(eigenvalue_scale, m);
417    let minimum_eigenvalue = eigenvalues
418        .iter()
419        .fold(f64::INFINITY, |minimum, &value| minimum.min(value));
420    if minimum_eigenvalue < -tolerance {
421        return Err(EstimationError::InvalidInput(format!(
422            "multinomial posterior active-logit covariance is not positive semidefinite: minimum eigenvalue {minimum_eigenvalue:.6e} is below -{tolerance:.6e} (scale {eigenvalue_scale:.6e})"
423        )));
424    }
425
426    let small_positive_trace: f64 = eigenvalues
427        .iter()
428        .copied()
429        .filter(|value| *value > 0.0 && *value <= tolerance)
430        .sum();
431    // For every softmax raw moment used here, the Euclidean gradient norm is
432    // at most one.  Coupling the retained Gaussian with the full Gaussian gives
433    // |E f(full)-E f(retained)| <= E||delta|| <= sqrt(tr(V_discarded)).
434    let candidate_projection_bound = small_positive_trace.sqrt();
435    let discard_small_positive = candidate_projection_bound <= absolute_tolerance;
436
437    let retained: Vec<(usize, f64)> = eigenvalues
438        .iter()
439        .copied()
440        .enumerate()
441        .filter(|(_, value)| *value > 0.0 && (!discard_small_positive || *value > tolerance))
442        .collect();
443    let projection_bound = if discard_small_positive {
444        candidate_projection_bound
445    } else {
446        0.0
447    };
448    let mut factor = Array2::<f64>::zeros((m, retained.len()));
449    for (output_column, (eigenvector_column, eigenvalue)) in retained.into_iter().enumerate() {
450        let scale = eigenvalue.sqrt();
451        for row in 0..m {
452            factor[[row, output_column]] = eigenvectors[[row, eigenvector_column]] * scale;
453        }
454    }
455    Ok(ProjectedGaussian {
456        factor,
457        projection_bound,
458    })
459}
460
461fn integrate_general(
462    active_mean: &[f64],
463    projected: &ProjectedGaussian,
464    control: &MultinomialPosteriorIntegrationControl,
465) -> Result<MultinomialPosteriorMoments, EstimationError> {
466    let rank = projected.factor.ncols();
467    let k = active_mean.len() + 1;
468    let mut rules = Vec::<GaussHermiteRule>::new();
469    let mut previous: Option<Vec<f64>> = None;
470    let mut total_evaluations = 0usize;
471    let mut last_max_difference = f64::INFINITY;
472    let mut last_max_normalized_error = f64::INFINITY;
473
474    for level in 0..=control.maximum_sparse_level {
475        let required_rule_count = level.checked_add(1).ok_or_else(|| {
476            EstimationError::InvalidInput(
477                "multinomial posterior sparse level overflowed usize".to_string(),
478            )
479        })?;
480        while rules.len() < required_rule_count {
481            let rule_index = rules.len() + 1;
482            rules.push(gauss_hermite_rule(rule_index)?);
483        }
484
485        let evaluation = evaluate_smolyak_level(
486            active_mean,
487            projected,
488            &rules,
489            level,
490            k,
491            &mut total_evaluations,
492            control.maximum_function_evaluations,
493            control.absolute_tolerance,
494        )?;
495        let current = evaluation.raw_moments;
496
497        if let Some(previous_moments) = previous.as_ref() {
498            let mut certified = level >= control.minimum_sparse_level;
499            let mut maximum_difference = 0.0_f64;
500            let mut maximum_normalized_error = 0.0_f64;
501            for (&new_value, &old_value) in current.iter().zip(previous_moments.iter()) {
502                let difference = (new_value - old_value).abs();
503                maximum_difference = maximum_difference.max(difference);
504                let tolerance = control.absolute_tolerance
505                    + control.relative_tolerance * new_value.abs().max(old_value.abs());
506                let controlled_error = difference + projected.projection_bound;
507                if controlled_error > tolerance {
508                    certified = false;
509                }
510                if tolerance > 0.0 {
511                    maximum_normalized_error =
512                        maximum_normalized_error.max(controlled_error / tolerance);
513                }
514            }
515            last_max_difference = maximum_difference;
516            last_max_normalized_error = maximum_normalized_error;
517
518            if certified {
519                return moments_from_raw(
520                    current,
521                    k,
522                    rank,
523                    level,
524                    total_evaluations,
525                    maximum_difference,
526                    projected.projection_bound,
527                );
528            }
529        }
530        previous = Some(current);
531    }
532
533    Err(EstimationError::InvalidInput(format!(
534        "multinomial logistic-normal quadrature did not converge through Smolyak level {}: final max raw-moment level difference {last_max_difference:.6e}, max normalized error {last_max_normalized_error:.6e}, projection bound {:.6e}, evaluations {total_evaluations}/{}",
535        control.maximum_sparse_level,
536        projected.projection_bound,
537        control.maximum_function_evaluations
538    )))
539}
540
541struct SmolyakEvaluation {
542    raw_moments: Vec<f64>,
543}
544
545fn evaluate_smolyak_level(
546    active_mean: &[f64],
547    projected: &ProjectedGaussian,
548    rules: &[GaussHermiteRule],
549    level: usize,
550    k: usize,
551    total_evaluations: &mut usize,
552    maximum_function_evaluations: usize,
553    absolute_tolerance: f64,
554) -> Result<SmolyakEvaluation, EstimationError> {
555    let rank = projected.factor.ncols();
556    let q = rank.checked_add(level).ok_or_else(|| {
557        EstimationError::InvalidInput(
558            "multinomial posterior Smolyak index overflowed usize".to_string(),
559        )
560    })?;
561    let lower_total = q.saturating_sub(rank.saturating_sub(1)).max(rank);
562    let moment_count = packed_moment_count(k)?;
563    let upper_offsets = upper_triangle_offsets(k)?;
564    let mut workspace = QuadratureWorkspace::new(
565        active_mean,
566        projected,
567        rules,
568        &upper_offsets,
569        moment_count,
570        total_evaluations,
571        maximum_function_evaluations,
572    )?;
573    let mut indices = vec![1usize; rank];
574
575    for total in lower_total..=q {
576        let alternating_power = q - total;
577        let mut coefficient = binomial_as_f64(rank - 1, alternating_power)?;
578        if alternating_power % 2 == 1 {
579            coefficient = -coefficient;
580        }
581        workspace.stream_compositions(0, total, &mut indices, coefficient)?;
582    }
583
584    let (mut raw_moments, mass, absolute_weight_sum) = workspace.accumulator.finish();
585    if !(mass.is_finite() && mass > 0.0 && absolute_weight_sum.is_finite()) {
586        return Err(EstimationError::InvalidInput(format!(
587            "multinomial posterior Smolyak level {level} produced invalid total weight {mass} (absolute sum {absolute_weight_sum})"
588        )));
589    }
590    let mass_error = (mass - 1.0).abs();
591    let summation_envelope =
592        SUMMATION_ROUNDOFF_MULTIPLIER * f64::EPSILON * absolute_weight_sum.max(1.0);
593    if mass_error > absolute_tolerance + summation_envelope {
594        return Err(EstimationError::InvalidInput(format!(
595            "multinomial posterior Smolyak level {level} failed constant-function exactness: total weight {mass:.17e}, error {mass_error:.6e}, allowed {:.6e}",
596            absolute_tolerance + summation_envelope
597        )));
598    }
599    for value in &mut raw_moments {
600        *value /= mass;
601    }
602    Ok(SmolyakEvaluation { raw_moments })
603}
604
605fn packed_moment_count(k: usize) -> Result<usize, EstimationError> {
606    let triangular = k
607        .checked_add(1)
608        .and_then(|next| k.checked_mul(next))
609        .map(|product| product / 2)
610        .ok_or_else(|| {
611            EstimationError::InvalidInput(
612                "multinomial posterior moment dimension overflowed usize".to_string(),
613            )
614        })?;
615    k.checked_add(triangular).ok_or_else(|| {
616        EstimationError::InvalidInput(
617            "multinomial posterior packed moment count overflowed usize".to_string(),
618        )
619    })
620}
621
622fn upper_triangle_offsets(k: usize) -> Result<Vec<usize>, EstimationError> {
623    let mut offsets = Vec::new();
624    offsets.try_reserve_exact(k).map_err(|error| {
625        EstimationError::InvalidInput(format!(
626            "multinomial posterior could not allocate upper-triangle offsets: {error}"
627        ))
628    })?;
629    let mut cursor = 0usize;
630    for row in 0..k {
631        offsets.push(cursor);
632        cursor = cursor.checked_add(k - row).ok_or_else(|| {
633            EstimationError::InvalidInput(
634                "multinomial posterior upper-triangle offset overflowed usize".to_string(),
635            )
636        })?;
637    }
638    Ok(offsets)
639}
640
641fn zeroed_vec(length: usize, label: &str) -> Result<Vec<f64>, EstimationError> {
642    let mut values = Vec::new();
643    values.try_reserve_exact(length).map_err(|error| {
644        EstimationError::InvalidInput(format!(
645            "multinomial posterior could not allocate {label} (length {length}): {error}"
646        ))
647    })?;
648    values.resize(length, 0.0);
649    Ok(values)
650}
651
652struct CompensatedSum {
653    sum: f64,
654    correction: f64,
655}
656
657impl CompensatedSum {
658    fn new() -> Self {
659        Self {
660            sum: 0.0,
661            correction: 0.0,
662        }
663    }
664
665    fn add(&mut self, value: f64) {
666        let combined = self.sum + value;
667        if self.sum.abs() >= value.abs() {
668            self.correction += (self.sum - combined) + value;
669        } else {
670            self.correction += (value - combined) + self.sum;
671        }
672        self.sum = combined;
673    }
674
675    fn value(&self) -> f64 {
676        self.sum + self.correction
677    }
678}
679
680struct QuadratureAccumulator {
681    sums: Vec<f64>,
682    corrections: Vec<f64>,
683    mass: CompensatedSum,
684    absolute_weight_sum: f64,
685}
686
687impl QuadratureAccumulator {
688    fn new(moment_count: usize) -> Result<Self, EstimationError> {
689        Ok(Self {
690            sums: zeroed_vec(moment_count, "quadrature sums")?,
691            corrections: zeroed_vec(moment_count, "quadrature corrections")?,
692            mass: CompensatedSum::new(),
693            absolute_weight_sum: 0.0,
694        })
695    }
696
697    fn add_moment(&mut self, index: usize, value: f64) {
698        let combined = self.sums[index] + value;
699        if self.sums[index].abs() >= value.abs() {
700            self.corrections[index] += (self.sums[index] - combined) + value;
701        } else {
702            self.corrections[index] += (value - combined) + self.sums[index];
703        }
704        self.sums[index] = combined;
705    }
706
707    fn add_weight(&mut self, weight: f64) {
708        self.mass.add(weight);
709        self.absolute_weight_sum += weight.abs();
710    }
711
712    fn finish(mut self) -> (Vec<f64>, f64, f64) {
713        for (sum, correction) in self.sums.iter_mut().zip(self.corrections.iter()) {
714            *sum += *correction;
715        }
716        (self.sums, self.mass.value(), self.absolute_weight_sum)
717    }
718}
719
720struct QuadratureWorkspace<'a, 'b> {
721    active_mean: &'a [f64],
722    projected: &'a ProjectedGaussian,
723    rules: &'a [GaussHermiteRule],
724    upper_offsets: &'a [usize],
725    z: Vec<f64>,
726    active_eta: Vec<f64>,
727    probabilities: Vec<f64>,
728    accumulator: QuadratureAccumulator,
729    total_evaluations: &'b mut usize,
730    maximum_function_evaluations: usize,
731}
732
733impl<'a, 'b> QuadratureWorkspace<'a, 'b> {
734        fn new(
735        active_mean: &'a [f64],
736        projected: &'a ProjectedGaussian,
737        rules: &'a [GaussHermiteRule],
738        upper_offsets: &'a [usize],
739        moment_count: usize,
740        total_evaluations: &'b mut usize,
741        maximum_function_evaluations: usize,
742    ) -> Result<Self, EstimationError> {
743        let rank = projected.factor.ncols();
744        let m = active_mean.len();
745        Ok(Self {
746            active_mean,
747            projected,
748            rules,
749            upper_offsets,
750            z: zeroed_vec(rank, "standard-normal quadrature coordinate")?,
751            active_eta: zeroed_vec(m, "active-logit quadrature buffer")?,
752            probabilities: zeroed_vec(m + 1, "softmax quadrature buffer")?,
753            accumulator: QuadratureAccumulator::new(moment_count)?,
754            total_evaluations,
755            maximum_function_evaluations,
756        })
757    }
758
759    fn stream_compositions(
760        &mut self,
761        position: usize,
762        remaining: usize,
763        indices: &mut [usize],
764        coefficient: f64,
765    ) -> Result<(), EstimationError> {
766        let dimensions_left = indices.len() - position;
767        if dimensions_left == 1 {
768            if remaining == 0 {
769                return Ok(());
770            }
771            indices[position] = remaining;
772            return self.stream_tensor(0, indices, coefficient);
773        }
774        let maximum_here = remaining.saturating_sub(dimensions_left - 1);
775        for index in 1..=maximum_here {
776            indices[position] = index;
777            self.stream_compositions(position + 1, remaining - index, indices, coefficient)?;
778        }
779        Ok(())
780    }
781
782    fn stream_tensor(
783        &mut self,
784        axis: usize,
785        indices: &[usize],
786        weight: f64,
787    ) -> Result<(), EstimationError> {
788        if axis == indices.len() {
789            return self.accumulate_node(weight);
790        }
791        let rule_index = indices[axis] - 1;
792        let node_count = self.rules[rule_index].nodes.len();
793        for node_index in 0..node_count {
794            let node = self.rules[rule_index].nodes[node_index];
795            let node_weight = self.rules[rule_index].weights[node_index];
796            self.z[axis] = node;
797            self.stream_tensor(axis + 1, indices, weight * node_weight)?;
798        }
799        Ok(())
800    }
801
802    fn accumulate_node(&mut self, weight: f64) -> Result<(), EstimationError> {
803        if *self.total_evaluations >= self.maximum_function_evaluations {
804            return Err(EstimationError::InvalidInput(format!(
805                "multinomial logistic-normal quadrature exhausted its function-evaluation budget ({}) before convergence",
806                self.maximum_function_evaluations
807            )));
808        }
809        *self.total_evaluations += 1;
810
811        for row in 0..self.active_mean.len() {
812            let mut value = self.active_mean[row];
813            for column in 0..self.z.len() {
814                value += self.projected.factor[[row, column]] * self.z[column];
815            }
816            self.active_eta[row] = value;
817        }
818        softmax_with_reference_into(&self.active_eta, &mut self.probabilities)?;
819
820        let k = self.probabilities.len();
821        self.accumulator.add_weight(weight);
822        for class in 0..k {
823            self.accumulator
824                .add_moment(class, weight * self.probabilities[class]);
825        }
826        let second_offset = k;
827        for row in 0..k {
828            for column in row..k {
829                let packed = second_offset + self.upper_offsets[row] + column - row;
830                self.accumulator.add_moment(
831                    packed,
832                    weight * self.probabilities[row] * self.probabilities[column],
833                );
834            }
835        }
836        Ok(())
837    }
838}
839
840struct GaussHermiteRule {
841    /// Nodes already transformed to standard-normal coordinates.
842    nodes: Vec<f64>,
843    /// Normalized standard-normal expectation weights (sum to one).
844    weights: Vec<f64>,
845}
846
847fn gauss_hermite_rule(index: usize) -> Result<GaussHermiteRule, EstimationError> {
848    let node_count = index
849        .checked_mul(2)
850        .and_then(|value| value.checked_sub(1))
851        .ok_or_else(|| {
852            EstimationError::InvalidInput(
853                "multinomial posterior Gauss-Hermite order overflowed usize".to_string(),
854            )
855        })?;
856    let mut jacobi = Array2::<f64>::zeros((node_count, node_count));
857    for diagonal in 0..node_count.saturating_sub(1) {
858        // Physicists' Hermite weight exp(-x^2): Jacobi off-diagonal sqrt(i/2).
859        let value = (((diagonal + 1) as f64) * 0.5).sqrt();
860        jacobi[[diagonal, diagonal + 1]] = value;
861        jacobi[[diagonal + 1, diagonal]] = value;
862    }
863    let (eigenvalues, eigenvectors) = jacobi.eigh(faer::Side::Lower).map_err(|error| {
864        EstimationError::InvalidInput(format!(
865            "multinomial posterior Gauss-Hermite rule {node_count} eigendecomposition failed: {error}"
866        ))
867    })?;
868    let mut nodes = Vec::new();
869    let mut weights = Vec::new();
870    nodes.try_reserve_exact(node_count).map_err(|error| {
871        EstimationError::InvalidInput(format!(
872            "multinomial posterior could not allocate Gauss-Hermite nodes: {error}"
873        ))
874    })?;
875    weights.try_reserve_exact(node_count).map_err(|error| {
876        EstimationError::InvalidInput(format!(
877            "multinomial posterior could not allocate Gauss-Hermite weights: {error}"
878        ))
879    })?;
880    for column in 0..node_count {
881        nodes.push(std::f64::consts::SQRT_2 * eigenvalues[column]);
882        weights.push(eigenvectors[[0, column]] * eigenvectors[[0, column]]);
883    }
884    let weight_sum: f64 = weights.iter().sum();
885    if !(weight_sum.is_finite() && weight_sum > 0.0) {
886        return Err(EstimationError::InvalidInput(format!(
887            "multinomial posterior Gauss-Hermite rule {node_count} has invalid weight sum {weight_sum}"
888        )));
889    }
890    for weight in &mut weights {
891        *weight /= weight_sum;
892    }
893    Ok(GaussHermiteRule { nodes, weights })
894}
895
896fn binomial_as_f64(n: usize, k: usize) -> Result<f64, EstimationError> {
897    if k > n {
898        return Ok(0.0);
899    }
900    let k = k.min(n - k);
901    let mut value = 1.0_f64;
902    for step in 1..=k {
903        value *= (n - k + step) as f64 / step as f64;
904        if !value.is_finite() {
905            return Err(EstimationError::InvalidInput(format!(
906                "multinomial posterior Smolyak binomial coefficient C({n},{k}) overflowed f64"
907            )));
908        }
909    }
910    Ok(value)
911}
912
913fn softmax_with_reference(active_eta: &[f64]) -> Result<Vec<f64>, EstimationError> {
914    let mut probabilities = zeroed_vec(active_eta.len() + 1, "softmax result")?;
915    softmax_with_reference_into(active_eta, &mut probabilities)?;
916    Ok(probabilities)
917}
918
919fn softmax_with_reference_into(
920    active_eta: &[f64],
921    probabilities: &mut [f64],
922) -> Result<(), EstimationError> {
923    if probabilities.len() != active_eta.len() + 1 {
924        return Err(EstimationError::InvalidInput(format!(
925            "multinomial posterior softmax buffer length {} does not equal active-logit length {} + 1",
926            probabilities.len(),
927            active_eta.len()
928        )));
929    }
930    let maximum = active_eta.iter().copied().fold(0.0_f64, f64::max);
931    let reference = probabilities.len() - 1;
932    let mut denominator = (-maximum).exp();
933    probabilities[reference] = denominator;
934    for (class, &eta) in active_eta.iter().enumerate() {
935        let numerator = (eta - maximum).exp();
936        probabilities[class] = numerator;
937        denominator += numerator;
938    }
939    if !(denominator.is_finite() && denominator > 0.0) {
940        return Err(EstimationError::InvalidInput(format!(
941            "multinomial posterior softmax produced invalid denominator {denominator}"
942        )));
943    }
944    for probability in probabilities {
945        *probability /= denominator;
946    }
947    Ok(())
948}
949
950fn moments_from_raw(
951    raw_moments: Vec<f64>,
952    k: usize,
953    latent_rank: usize,
954    sparse_level: usize,
955    function_evaluations: usize,
956    max_level_difference: f64,
957    projection_bound: f64,
958) -> Result<MultinomialPosteriorMoments, EstimationError> {
959    let upper_offsets = upper_triangle_offsets(k)?;
960    let raw_error = max_level_difference + projection_bound;
961    let covariance_error = 3.0 * raw_error + raw_error * raw_error;
962
963    let mut means = raw_moments[..k].to_vec();
964    for (class, mean) in means.iter_mut().enumerate() {
965        if *mean < -raw_error || *mean > 1.0 + raw_error || !mean.is_finite() {
966            return Err(EstimationError::InvalidInput(format!(
967                "multinomial posterior integrated mean for class {class} is outside its certified probability envelope: {mean} (raw error {raw_error:.6e})"
968            )));
969        }
970        *mean = mean.clamp(0.0, 1.0);
971    }
972    let mean_sum: f64 = means.iter().sum();
973    if !(mean_sum.is_finite() && mean_sum > 0.0) {
974        return Err(EstimationError::InvalidInput(format!(
975            "multinomial posterior integrated class means have invalid sum {mean_sum}"
976        )));
977    }
978    let simplex_error = (mean_sum - 1.0).abs();
979    if simplex_error > (k as f64) * raw_error + covariance_roundoff_tolerance(1.0, k) {
980        return Err(EstimationError::InvalidInput(format!(
981            "multinomial posterior integrated class means violate the simplex: sum {mean_sum:.17e}, error {simplex_error:.6e}, raw moment error {raw_error:.6e}"
982        )));
983    }
984    for mean in &mut means {
985        *mean /= mean_sum;
986    }
987
988    let second_offset = k;
989    let mut covariance = Array2::<f64>::zeros((k, k));
990    for row in 0..k {
991        for column in row..k {
992            let packed = second_offset + upper_offsets[row] + column - row;
993            let value = raw_moments[packed] - means[row] * means[column];
994            covariance[[row, column]] = value;
995            covariance[[column, row]] = value;
996        }
997    }
998    covariance = project_covariance_to_simplex_tangent(&covariance);
999    covariance = remove_covariance_roundoff(covariance, covariance_error)?;
1000    covariance = project_covariance_to_simplex_tangent(&covariance);
1001
1002    let mut standard_deviation = Array1::<f64>::zeros(k);
1003    for class in 0..k {
1004        let variance = covariance[[class, class]];
1005        if variance < -covariance_error || !variance.is_finite() {
1006            return Err(EstimationError::InvalidInput(format!(
1007                "multinomial posterior variance for class {class} is invalid: {variance:.6e} (covariance error envelope {covariance_error:.6e})"
1008            )));
1009        }
1010        standard_deviation[class] = variance.max(0.0).sqrt();
1011    }
1012
1013    Ok(MultinomialPosteriorMoments {
1014        class_mean: Array1::from_vec(means),
1015        class_covariance: covariance,
1016        class_standard_deviation: standard_deviation,
1017        latent_rank,
1018        sparse_level: Some(sparse_level),
1019        function_evaluations,
1020        max_raw_moment_level_difference: max_level_difference,
1021        covariance_range_projection_bound: projection_bound,
1022    })
1023}
1024
1025fn project_covariance_to_simplex_tangent(covariance: &Array2<f64>) -> Array2<f64> {
1026    let k = covariance.nrows();
1027    let inverse_k = 1.0 / k as f64;
1028    let row_means: Vec<f64> = (0..k)
1029        .map(|row| covariance.row(row).sum() * inverse_k)
1030        .collect();
1031    let column_means: Vec<f64> = (0..k)
1032        .map(|column| covariance.column(column).sum() * inverse_k)
1033        .collect();
1034    let grand_mean = row_means.iter().sum::<f64>() * inverse_k;
1035    Array2::from_shape_fn((k, k), |(row, column)| {
1036        covariance[[row, column]] - row_means[row] - column_means[column] + grand_mean
1037    })
1038}
1039
1040fn remove_covariance_roundoff(
1041    covariance: Array2<f64>,
1042    integration_error: f64,
1043) -> Result<Array2<f64>, EstimationError> {
1044    let symmetric = (&covariance + &covariance.t().to_owned()) * 0.5;
1045    let (eigenvalues, eigenvectors) = symmetric.eigh(faer::Side::Lower).map_err(|error| {
1046        EstimationError::InvalidInput(format!(
1047            "multinomial probability covariance eigendecomposition failed: {error}"
1048        ))
1049    })?;
1050    let scale = eigenvalues
1051        .iter()
1052        .fold(0.0_f64, |maximum, &value| maximum.max(value.abs()));
1053    let allowed_negative =
1054        integration_error + covariance_roundoff_tolerance(scale, covariance.nrows());
1055    let minimum = eigenvalues
1056        .iter()
1057        .fold(f64::INFINITY, |value, &candidate| value.min(candidate));
1058    if minimum < -allowed_negative {
1059        let negative_limit = -allowed_negative;
1060        return Err(EstimationError::InvalidInput(format!(
1061            "multinomial posterior probability covariance is indefinite beyond the integration error: min eigenvalue {minimum:.6e}, allowed {negative_limit:.6e}"
1062        )));
1063    }
1064    let mut scaled_eigenvectors = eigenvectors.clone();
1065    for (column, &eigenvalue) in eigenvalues.iter().enumerate() {
1066        let scale = eigenvalue.max(0.0);
1067        scaled_eigenvectors
1068            .column_mut(column)
1069            .mapv_inplace(|value| value * scale);
1070    }
1071    let reconstructed = scaled_eigenvectors.dot(&eigenvectors.t());
1072    Ok((&reconstructed + &reconstructed.t().to_owned()) * 0.5)
1073}
1074
1075#[cfg(test)]
1076mod tests {
1077    use super::*;
1078
1079    fn control(absolute_tolerance: f64) -> MultinomialPosteriorIntegrationControl {
1080        MultinomialPosteriorIntegrationControl {
1081            absolute_tolerance,
1082            relative_tolerance: absolute_tolerance,
1083            minimum_sparse_level: 2,
1084            maximum_sparse_level: 8,
1085            maximum_function_evaluations: 2_000_000,
1086        }
1087    }
1088
1089    fn assert_close(actual: f64, expected: f64, tolerance: f64, label: &str) {
1090        assert!(
1091            (actual - expected).abs() <= tolerance,
1092            "{label}: actual={actual:.17e}, expected={expected:.17e}, tolerance={tolerance:.3e}"
1093        );
1094    }
1095
1096    #[test]
1097    fn binary_reduction_matches_controlled_logistic_normal_identity() {
1098        let active_mean = Array1::from_vec(vec![1.1]);
1099        let active_covariance = Array2::from_shape_vec((1, 1), vec![0.64]).unwrap();
1100        let result = integrate_logistic_normal_softmax_moments(
1101            active_mean.view(),
1102            active_covariance.view(),
1103            &control(1.0e-10),
1104        )
1105        .expect("binary posterior moments");
1106        let (expected_mean, expected_slope) =
1107            gam_solve::quadrature::logit_posterior_meanwith_deriv(1.1, 0.8).unwrap();
1108        let expected_variance = expected_mean - expected_slope - expected_mean * expected_mean;
1109
1110        assert_close(result.class_mean[0], expected_mean, 2.0e-14, "binary mean");
1111        assert_close(
1112            result.class_mean[1],
1113            1.0 - expected_mean,
1114            2.0e-14,
1115            "reference mean",
1116        );
1117        assert_close(
1118            result.class_covariance[[0, 0]],
1119            expected_variance,
1120            2.0e-14,
1121            "binary variance",
1122        );
1123        assert_close(
1124            result.class_covariance[[0, 1]],
1125            -expected_variance,
1126            2.0e-14,
1127            "binary covariance",
1128        );
1129        assert_eq!(result.latent_rank, 1);
1130        assert_eq!(result.sparse_level, None);
1131    }
1132
1133    #[test]
1134    fn zero_covariance_is_exact_softmax_point_mass() {
1135        let active_mean = Array1::from_vec(vec![0.7, -0.4]);
1136        let active_covariance = Array2::<f64>::zeros((2, 2));
1137        let result = integrate_logistic_normal_softmax_moments(
1138            active_mean.view(),
1139            active_covariance.view(),
1140            &control(1.0e-10),
1141        )
1142        .expect("point-mass posterior moments");
1143        let expected = softmax_with_reference(active_mean.as_slice().unwrap()).unwrap();
1144        for class in 0..3 {
1145            assert_close(
1146                result.class_mean[class],
1147                expected[class],
1148                1.0e-15,
1149                "point mean",
1150            );
1151            assert_eq!(result.class_standard_deviation[class], 0.0);
1152            for other in 0..3 {
1153                assert_eq!(result.class_covariance[[class, other]], 0.0);
1154            }
1155        }
1156        assert_eq!(result.latent_rank, 0);
1157        assert_eq!(result.sparse_level, None);
1158    }
1159
1160    #[test]
1161    fn exchangeable_full_logits_require_cross_covariance_and_integrate_to_uniform() {
1162        // If full logits gamma_c are iid N(0,s^2), reference coding gives
1163        // eta_a=gamma_a-gamma_ref, hence diag(V)=2s^2 and offdiag(V)=s^2.
1164        // Exchangeability makes E[p_c]=1/3 exactly.  Dropping the off-diagonal
1165        // covariance destroys that identity for the reference class.
1166        let variance = 0.7;
1167        let active_mean = Array1::zeros(2);
1168        let active_covariance = Array2::from_shape_vec(
1169            (2, 2),
1170            vec![2.0 * variance, variance, variance, 2.0 * variance],
1171        )
1172        .unwrap();
1173        let result = integrate_logistic_normal_softmax_moments(
1174            active_mean.view(),
1175            active_covariance.view(),
1176            &control(2.0e-7),
1177        )
1178        .expect("exchangeable posterior moments");
1179
1180        for class in 0..3 {
1181            assert_close(result.class_mean[class], 1.0 / 3.0, 8.0e-7, "uniform mean");
1182        }
1183        for class in 1..3 {
1184            assert_close(
1185                result.class_covariance[[class, class]],
1186                result.class_covariance[[0, 0]],
1187                2.0e-6,
1188                "exchangeable variance",
1189            );
1190        }
1191        for row in 0..3 {
1192            assert_close(
1193                result.class_covariance.row(row).sum(),
1194                0.0,
1195                2.0e-12,
1196                "simplex covariance row sum",
1197            );
1198        }
1199        assert_eq!(result.latent_rank, 2);
1200        assert!(result.sparse_level.is_some());
1201    }
1202
1203    #[test]
1204    fn rank_one_general_case_matches_independent_one_dimensional_gh_oracle() {
1205        let active_mean = Array1::from_vec(vec![0.45, -0.7]);
1206        let loading = [0.8_f64, -0.35_f64];
1207        let active_covariance =
1208            Array2::from_shape_fn((2, 2), |(row, column)| loading[row] * loading[column]);
1209        let result = integrate_logistic_normal_softmax_moments(
1210            active_mean.view(),
1211            active_covariance.view(),
1212            &control(5.0e-8),
1213        )
1214        .expect("rank-one posterior moments");
1215        assert_eq!(result.latent_rank, 1);
1216
1217        // Independent high-order one-dimensional GH evaluation of the exact
1218        // rank-one representation eta=mu+loading*Z.
1219        let oracle_rule = gauss_hermite_rule(21).unwrap(); // 41 nodes
1220        let mut oracle_mean = [0.0_f64; 3];
1221        let mut oracle_second = [[0.0_f64; 3]; 3];
1222        for (&z, &weight) in oracle_rule.nodes.iter().zip(oracle_rule.weights.iter()) {
1223            let eta = [
1224                active_mean[0] + loading[0] * z,
1225                active_mean[1] + loading[1] * z,
1226            ];
1227            let probability = softmax_with_reference(&eta).unwrap();
1228            for row in 0..3 {
1229                oracle_mean[row] += weight * probability[row];
1230                for column in 0..3 {
1231                    oracle_second[row][column] += weight * probability[row] * probability[column];
1232                }
1233            }
1234        }
1235        for row in 0..3 {
1236            assert_close(
1237                result.class_mean[row],
1238                oracle_mean[row],
1239                3.0e-7,
1240                "rank-one mean",
1241            );
1242            for column in 0..3 {
1243                let oracle_covariance =
1244                    oracle_second[row][column] - oracle_mean[row] * oracle_mean[column];
1245                assert_close(
1246                    result.class_covariance[[row, column]],
1247                    oracle_covariance,
1248                    8.0e-7,
1249                    "rank-one covariance",
1250                );
1251            }
1252        }
1253        let allowed = 5.0e-8
1254            + 5.0e-8
1255                * result
1256                    .class_mean
1257                    .iter()
1258                    .fold(0.0_f64, |scale, &value| scale.max(value.abs()));
1259        assert!(
1260            result.max_raw_moment_level_difference + result.covariance_range_projection_bound
1261                <= allowed * 1.01,
1262            "returned result must carry the level-difference certificate"
1263        );
1264    }
1265
1266    #[test]
1267    fn insufficient_sparse_level_is_a_typed_error_not_a_plugin_result() {
1268        let active_mean = Array1::from_vec(vec![1.2, -0.8]);
1269        let active_covariance = Array2::from_shape_vec((2, 2), vec![2.0, 0.9, 0.9, 1.5]).unwrap();
1270        let strict_control = MultinomialPosteriorIntegrationControl {
1271            absolute_tolerance: 1.0e-14,
1272            relative_tolerance: 1.0e-14,
1273            minimum_sparse_level: 1,
1274            maximum_sparse_level: 1,
1275            maximum_function_evaluations: 100_000,
1276        };
1277        let error = integrate_logistic_normal_softmax_moments(
1278            active_mean.view(),
1279            active_covariance.view(),
1280            &strict_control,
1281        )
1282        .expect_err("one sparse refinement cannot certify this nonlinear integral");
1283        assert!(
1284            error.to_string().contains("did not converge"),
1285            "unexpected error: {error}"
1286        );
1287    }
1288
1289    #[test]
1290    fn materially_indefinite_active_covariance_is_rejected() {
1291        let active_mean = Array1::from_vec(vec![0.0, 0.0]);
1292        let active_covariance = Array2::from_shape_vec((2, 2), vec![1.0, 2.0, 2.0, 1.0]).unwrap();
1293        let error = integrate_logistic_normal_softmax_moments(
1294            active_mean.view(),
1295            active_covariance.view(),
1296            &control(1.0e-7),
1297        )
1298        .expect_err("indefinite covariance must fail");
1299        assert!(error.to_string().contains("not positive semidefinite"));
1300    }
1301}