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