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 gam_math::quadrature::gauss_hermite_rule as physicists_gauss_hermite_rule;
22use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
23use std::collections::BTreeMap;
24
25/// Backward-error multiplier used when deciding whether a symmetric covariance
26/// eigenvalue is negative beyond floating-point eigensolver roundoff.
27///
28/// This is not a variance jitter: the input matrix is never modified by adding
29/// a diagonal ridge.  Eigenvalues below `-tol` are rejected, while values whose
30/// magnitude is within the backward-error envelope are treated as numerical
31/// zero.
32const PSD_BACKWARD_ERROR_MULTIPLIER: f64 = 16.0;
33
34/// Floating-point summation envelope for the signed Smolyak combination.
35const SUMMATION_ROUNDOFF_MULTIPLIER: f64 = 16.0;
36
37/// Explicit accuracy and work controls for multinomial posterior integration.
38///
39/// The production default is explicit through [`Default`] and is carried by
40/// the prediction request into this kernel. `minimum_sparse_level >= 1`
41/// guarantees at least one comparison against a preceding Smolyak level.
42#[derive(Clone, Copy, Debug)]
43pub struct MultinomialPosteriorIntegrationControl {
44    /// Per raw moment absolute tolerance.  Raw moments comprise every `E[p_c]`
45    /// and `E[p_c p_d]` for `c <= d`.
46    pub absolute_tolerance: f64,
47    /// Per raw moment relative tolerance.
48    pub relative_tolerance: f64,
49    /// Earliest Smolyak refinement level that may certify convergence.
50    pub minimum_sparse_level: usize,
51    /// Last Smolyak refinement level attempted.
52    pub maximum_sparse_level: usize,
53    /// Maximum total integrand evaluations across all attempted levels.
54    pub maximum_function_evaluations: usize,
55}
56
57impl Default for MultinomialPosteriorIntegrationControl {
58    fn default() -> Self {
59        // sqrt(machine epsilon) is the natural accuracy target for a nonlinear
60        // transform of a covariance estimated in double precision: asking for
61        // substantially more would certify quadrature noise below the input's
62        // own numerical resolution. Three sparse levels are required before a
63        // result may certify. The level ceiling is 12 (the 25-point
64        // one-dimensional Gauss–Hermite rule): a converged integrand certifies
65        // early and never visits the deeper levels, so the ceiling only
66        // matters for WIDE posteriors — the #2344 equivariant class metric
67        // honestly penalizes the class-mean logit direction at λ/K, ≈K× the
68        // variance of the old ALR-anchored penalty, and at the former ceiling
69        // of 8 such rows plateaued at a level difference ~1.6× the tolerance
70        // with only ~0.1% of the evaluation budget spent (#2350). The
71        // evaluation ceiling remains the cost guard; the streaming evaluator
72        // never stores the node set.
73        //
74        // RAISED 12 -> 16 (#2612), for the same reason #2350 raised it 8 -> 12,
75        // and the evidence is the same shape. `#2612` needed the multinomial df
76        // floor fraction `f` raised to sharpen under-confident penguin
77        // probabilities; a larger `f` caps rho lower, hence less shrinkage, hence
78        // a WIDER posterior — and at `f = 0.90` a sibling train/test split stopped
79        // PREDICTING with `did not converge through Smolyak level 12`, having
80        // spent 9633 of its 2000000 evaluations. 0.5% of the cost guard: the LEVEL
81        // bound was binding, not the budget. At 16 that split certifies and scores
82        // held-out log-loss 0.17246 against nnet's 0.76930.
83        //
84        // This costs nothing where nothing is wrong. A converged integrand
85        // certifies early and never visits the deeper levels, so the extra
86        // headroom is only ever spent by the wide posteriors that need it — which
87        // is why the ceiling, and not the evaluation budget, is the right thing to
88        // move.
89        let tolerance = f64::EPSILON.sqrt();
90        Self {
91            absolute_tolerance: tolerance,
92            relative_tolerance: tolerance,
93            minimum_sparse_level: 2,
94            // The level ceiling STAYS, and here is the measurement that says so.
95            //
96            // I removed it (45e3f1876) on the argument that there were two cost
97            // guards for one cost and only one was denominated in the cost:
98            //   8 -> 12  (#2350): plateaued having spent ~0.1% of the budget
99            //   12 -> 16 (#2612): stopped predicting at 9633/2000000  = 0.5%
100            //   16 -> ?  (#2612): stops predicting at 28033/2000000 = 1.4%
101            // Three raises, one argument, and each time the level bound really
102            // was what bound. That reasoning is still right about the DEFECT.
103            //
104            // I THEN REVERTED IT ON A BAD COMPARISON, and this paragraph is the
105            // correction. The revert cited "941 s and still running, against a
106            // refusal in seconds before". The 941 s is real; the "refusal in
107            // seconds" was a DIFFERENT TEST -- a residual-cascade fixture on
108            // #2546 -- not this path. Re-measured AT the revert on an unloaded
109            // node, the same penguins arm ran 931 s: indistinguishable from the
110            // 941 s I had blamed on removing the ceiling. So the ceiling was not
111            // the cause of the cost, and the evidence did not support the
112            // conclusion I drew from it.
113            //
114            // What is actually known, so the next reader does not inherit my
115            // error: this arm costs ~930 s in BOTH states, and no COMPLETED run
116            // was obtained in either -- so whether removing the ceiling changes
117            // the outcome, or the cost, is UNMEASURED.
118            //
119            // The original concern survives as a concern, not a measurement.
120            // With the ceiling removed, the penguins real-data arm ran **941 s
121            // and was still going**, against a refusal in seconds before. The
122            // reason is the other half of the same observation: because the
123            // level cap always bound first, the 2,000,000-evaluation budget was
124            // NEVER calibrated to bind. It was sized on the assumption that
125            // something else would stop first, so it is not a usable sole
126            // guard — it is a backstop, and reaching it costs minutes per
127            // prediction.
128            //
129            // So removing the binding guard without checking the remaining one
130            // was sized for the job traded a fast honest refusal for a slow
131            // grind. That is the same error as dropping a cost-relative
132            // stationarity band and leaving only its resolution floor (#2613):
133            // the two quantities are not interchangeable, and one of them was
134            // never a budget.
135            //
136            // What a correct fix needs, and what I did not have: a budget
137            // derived from the cost per level and the per-prediction time this
138            // path is allowed to spend, so that ONE guard bounds the cost in
139            // usable time. Until that derivation exists, a level cap that
140            // refuses in seconds is better than a budget that grinds for
141            // sixteen minutes — a refusal a caller can act on beats an answer
142            // it cannot wait for.
143            // AND IT IS NO LONGER THE THING THAT DECIDES A WIDE POSTERIOR.
144            //
145            // Every raise above was triggered by a posterior getting wider, and
146            // the cause was the RULE, not the ceiling. Measured on a rank-two
147            // covariance with posterior standard deviations (1, 5), against a
148            // converged tensor oracle:
149            //
150            //   sparse level 10 (  4961 evals): error 6.61e-4
151            //   sparse level 12 (  9633 evals): error 5.48e-4
152            //   sparse level 14 ( 17025 evals): error 3.50e-4
153            //   sparse level 16 ( 28033 evals): error 1.99e-4
154            //   tensor   33/dim (  1089 evals): error 1.84e-4
155            //   tensor   65/dim (  4225 evals): error 1.66e-6
156            //   tensor  129/dim ( 16641 evals): error 3.82e-9
157            //
158            // The sparse ladder decays algebraically -- a factor 3.3 for 5.6x
159            // the evaluations -- because a Smolyak grid gives one direction high
160            // order only by giving every other direction a single node, while a
161            // logistic-normal softmax needs order in EVERY wide direction at
162            // once. At rank two the tensor product the grid is assembled FROM is
163            // strictly better, reaching five more digits for fewer evaluations.
164            // So this ceiling is no longer a wall: `integrate_general` tries
165            // the sparse rule first and keeps it wherever it certifies -- which
166            // costs a few hundred evaluations and is why it is tried first --
167            // and reaching this ceiling now HANDS THE ROW to the tensor rule
168            // instead of refusing. Raising it again would only make the sparse
169            // rule spend longer before handing over (#2612).
170            maximum_sparse_level: 16,
171            maximum_function_evaluations: 2_000_000,
172        }
173    }
174}
175
176/// Integrated posterior means and marginal standard deviations for every row
177/// of a multinomial prediction design.
178#[derive(Clone, Debug)]
179pub struct MultinomialPosteriorRowMoments {
180    pub class_mean: Array2<f64>,
181    pub class_standard_deviation: Array2<f64>,
182}
183
184/// Integrate the logistic-normal posterior induced by a coefficient mode and
185/// its full joint covariance over every design row.
186///
187/// Coefficients have shape `(P, M)`, covariance has block-major shape
188/// `(P*M, P*M)`, and `design` has shape `(N, P)`. For row `x`, this constructs
189/// `mu_a = x' beta_a` and `V_ab = x' Sigma_ab x`, then delegates to the
190/// controlled one-row integrator. Cross-class covariance blocks are retained.
191pub fn integrate_multinomial_design_moments(
192    coefficients: ArrayView2<'_, f64>,
193    coefficient_covariance: ArrayView2<'_, f64>,
194    design: ArrayView2<'_, f64>,
195    control: &MultinomialPosteriorIntegrationControl,
196) -> Result<MultinomialPosteriorRowMoments, EstimationError> {
197    let (p, m) = coefficients.dim();
198    if p == 0 || m == 0 {
199        return Err(EstimationError::InvalidInput(format!(
200            "multinomial posterior prediction needs nonempty coefficients, got {p}x{m}"
201        )));
202    }
203    if design.ncols() != p {
204        return Err(EstimationError::InvalidInput(format!(
205            "multinomial posterior prediction design has {} columns, expected {p}",
206            design.ncols()
207        )));
208    }
209    let d = p.checked_mul(m).ok_or_else(|| {
210        EstimationError::InvalidInput(
211            "multinomial posterior prediction coefficient dimension overflowed usize".to_string(),
212        )
213    })?;
214    if coefficient_covariance.dim() != (d, d) {
215        return Err(EstimationError::InvalidInput(format!(
216            "multinomial posterior prediction covariance shape {:?} does not match (P*M, P*M) = ({d}, {d})",
217            coefficient_covariance.dim()
218        )));
219    }
220
221    let n = design.nrows();
222    let k = m + 1;
223    let mut class_mean = Array2::<f64>::zeros((n, k));
224    let mut class_standard_deviation = Array2::<f64>::zeros((n, k));
225    let mut active_mean = Array1::<f64>::zeros(m);
226    let mut active_covariance = Array2::<f64>::zeros((m, m));
227    // Every row follows the same deterministic order-doubling ladder.  A rule
228    // depends only on its order, not on the row's mean or covariance, so build
229    // each order once for this prediction call and reuse it across rows.
230    let mut conditioned_three_class_rules = ConditionedThreeClassRuleLadder::default();
231    for row in 0..n {
232        let x = design.row(row);
233        for a in 0..m {
234            active_mean[a] = x.dot(&coefficients.column(a));
235        }
236        for a in 0..m {
237            for b in 0..m {
238                let mut value = 0.0_f64;
239                let a_base = a * p;
240                let b_base = b * p;
241                for i in 0..p {
242                    let xi = x[i];
243                    if xi == 0.0 {
244                        continue;
245                    }
246                    let mut row_product = 0.0_f64;
247                    for j in 0..p {
248                        row_product += coefficient_covariance[[a_base + i, b_base + j]] * x[j];
249                    }
250                    value += xi * row_product;
251                }
252                active_covariance[[a, b]] = value;
253            }
254        }
255        let moments = integrate_logistic_normal_softmax_moments_with_rule_ladder(
256            active_mean.view(),
257            active_covariance.view(),
258            control,
259            &mut conditioned_three_class_rules,
260        )?;
261        class_mean.row_mut(row).assign(&moments.class_mean);
262        class_standard_deviation
263            .row_mut(row)
264            .assign(&moments.class_standard_deviation);
265    }
266    Ok(MultinomialPosteriorRowMoments {
267        class_mean,
268        class_standard_deviation,
269    })
270}
271
272impl MultinomialPosteriorIntegrationControl {
273    fn validate(&self) -> Result<(), EstimationError> {
274        if !(self.absolute_tolerance.is_finite() && self.absolute_tolerance >= 0.0) {
275            return Err(EstimationError::InvalidInput(format!(
276                "multinomial posterior integration absolute_tolerance must be finite and >= 0, got {}",
277                self.absolute_tolerance
278            )));
279        }
280        if !(self.relative_tolerance.is_finite() && self.relative_tolerance >= 0.0) {
281            return Err(EstimationError::InvalidInput(format!(
282                "multinomial posterior integration relative_tolerance must be finite and >= 0, got {}",
283                self.relative_tolerance
284            )));
285        }
286        if self.absolute_tolerance == 0.0 && self.relative_tolerance == 0.0 {
287            return Err(EstimationError::InvalidInput(
288                "multinomial posterior integration requires a positive absolute or relative tolerance"
289                    .to_string(),
290            ));
291        }
292        if self.minimum_sparse_level == 0 {
293            return Err(EstimationError::InvalidInput(
294                "multinomial posterior integration minimum_sparse_level must be >= 1 so a level difference exists"
295                    .to_string(),
296            ));
297        }
298        if self.maximum_sparse_level < self.minimum_sparse_level {
299            return Err(EstimationError::InvalidInput(format!(
300                "multinomial posterior integration maximum_sparse_level ({}) is below minimum_sparse_level ({})",
301                self.maximum_sparse_level, self.minimum_sparse_level
302            )));
303        }
304        if self.maximum_function_evaluations == 0 {
305            return Err(EstimationError::InvalidInput(
306                "multinomial posterior integration maximum_function_evaluations must be positive"
307                    .to_string(),
308            ));
309        }
310        Ok(())
311    }
312}
313
314/// Which deterministic rule produced a certified answer.
315///
316/// The rank of the retained posterior decides which rule can certify inside the
317/// evaluation budget, and the two are not interchangeable: a sparse grid is a
318/// saving only when the resolution a direction needs is much smaller than the
319/// number of directions, and at rank two it is strictly worse than the tensor
320/// product it is built from (#2612).
321#[derive(Clone, Debug, PartialEq, Eq)]
322pub enum MultinomialPosteriorRule {
323    /// Exact reduction: the binary logistic-normal evaluator, or a covariance
324    /// that is a point mass.
325    Exact,
326    /// Exact Gaussian conditioning reduces a three-class, rank-two posterior
327    /// to one Gauss-Hermite direction plus the controlled scalar
328    /// logistic-normal evaluator. Carries the outer rule's node count.
329    ConditionedThreeClass(usize),
330    /// Tensor-product Gauss-Hermite whose one-dimensional node count is chosen
331    /// per retained posterior direction.  Carries those node counts.
332    AnisotropicTensor(Vec<usize>),
333    /// Isotropic Smolyak sparse grid, carrying the level that certified.
334    IsotropicSparse(usize),
335}
336
337/// Integrated class-probability moments for one prediction row.
338///
339/// `class_covariance` includes the reference class and is singular in the
340/// all-ones direction, as required by `sum_c p_c = 1`.  A value of this type is
341/// only constructed after the requested level-difference certificate succeeds.
342#[derive(Clone, Debug)]
343pub struct MultinomialPosteriorMoments {
344    /// `E[p_c]`, length `K`, including the reference class last.
345    pub class_mean: Array1<f64>,
346    /// `Cov(p_c, p_d)`, shape `(K, K)`.
347    pub class_covariance: Array2<f64>,
348    /// Marginal posterior standard deviations `sqrt(Var(p_c))`.
349    pub class_standard_deviation: Array1<f64>,
350    /// Positive numerical rank of the active-logit covariance.
351    pub latent_rank: usize,
352    /// The rule that certified convergence.
353    pub rule: MultinomialPosteriorRule,
354    /// Total softmax evaluations across all attempted sparse levels.
355    pub function_evaluations: usize,
356    /// Largest absolute difference among raw first/second moments between the
357    /// certifying rule and the coarser rule it was compared against.  Zero on
358    /// the exact binary and point-mass paths.
359    pub max_raw_moment_level_difference: f64,
360    /// Bound used for positive covariance eigenmodes discarded inside the
361    /// eigensolver backward-error envelope.  Such modes are discarded only
362    /// when this bound fits inside the requested absolute tolerance.
363    pub covariance_range_projection_bound: f64,
364}
365
366/// Integrate reference-coded logistic-normal softmax moments for one row.
367///
368/// `active_mean` has length `M = K - 1`; `active_covariance` must be a finite,
369/// symmetric positive-semidefinite `(M, M)` matrix in the same active-class
370/// order.  The returned arrays include the implicit reference class as their
371/// final entry.
372pub fn integrate_logistic_normal_softmax_moments(
373    active_mean: ArrayView1<'_, f64>,
374    active_covariance: ArrayView2<'_, f64>,
375    control: &MultinomialPosteriorIntegrationControl,
376) -> Result<MultinomialPosteriorMoments, EstimationError> {
377    let mut conditioned_three_class_rules = ConditionedThreeClassRuleLadder::default();
378    integrate_logistic_normal_softmax_moments_with_rule_ladder(
379        active_mean,
380        active_covariance,
381        control,
382        &mut conditioned_three_class_rules,
383    )
384}
385
386fn integrate_logistic_normal_softmax_moments_with_rule_ladder(
387    active_mean: ArrayView1<'_, f64>,
388    active_covariance: ArrayView2<'_, f64>,
389    control: &MultinomialPosteriorIntegrationControl,
390    conditioned_three_class_rules: &mut ConditionedThreeClassRuleLadder,
391) -> Result<MultinomialPosteriorMoments, EstimationError> {
392    control.validate()?;
393    validate_inputs(active_mean, active_covariance)?;
394    // Integrate the nearest symmetric matrix. (C + Cᵀ)/2 is exact in floating
395    // point for the off-diagonal average and is what every downstream
396    // eigenroutine assumes it was handed; propagating one arbitrary triangle
397    // instead would make the result depend on which triangle happened to be
398    // read.
399    let symmetric_covariance = symmetrized_covariance(active_covariance);
400    let active_covariance = symmetric_covariance.view();
401
402    let mean = active_mean.to_vec();
403    let m = mean.len();
404    if m == 1 {
405        return integrate_binary(mean[0], active_covariance[[0, 0]]);
406    }
407
408    let maximum_covariance_entry = active_covariance
409        .iter()
410        .fold(0.0_f64, |scale, &value| scale.max(value.abs()));
411    if maximum_covariance_entry == 0.0 {
412        return point_mass_moments(&mean);
413    }
414
415    let projected = project_active_covariance(active_covariance, control.absolute_tolerance)?;
416    if projected.factor.ncols() == 0 {
417        // This arm is reachable only when every positive eigenmode lies inside
418        // the eigensolver backward-error envelope and its explicit probability
419        // bound fits within the caller's tolerance.  It is therefore a
420        // certified point-mass approximation, not a silent plug-in fallback.
421        let mut out = point_mass_moments(&mean)?;
422        out.covariance_range_projection_bound = projected.projection_bound;
423        return Ok(out);
424    }
425    if m == 2 && projected.factor.ncols() == 2 {
426        return integrate_three_class_conditionally(
427            &mean,
428            &projected,
429            control,
430            conditioned_three_class_rules,
431        );
432    }
433
434    integrate_general(&mean, &projected, control)
435}
436
437fn validate_inputs(
438    active_mean: ArrayView1<'_, f64>,
439    active_covariance: ArrayView2<'_, f64>,
440) -> Result<(), EstimationError> {
441    let m = active_mean.len();
442    if m == 0 {
443        return Err(EstimationError::InvalidInput(
444            "multinomial posterior integration needs at least one active logit (K >= 2)"
445                .to_string(),
446        ));
447    }
448    if active_covariance.dim() != (m, m) {
449        return Err(EstimationError::InvalidInput(format!(
450            "multinomial posterior integration covariance shape {:?} does not match active mean length {m}",
451            active_covariance.dim()
452        )));
453    }
454    if let Some((index, value)) = active_mean
455        .iter()
456        .copied()
457        .enumerate()
458        .find(|(_, value)| !value.is_finite())
459    {
460        return Err(EstimationError::InvalidInput(format!(
461            "multinomial posterior integration active_mean[{index}] is non-finite: {value}"
462        )));
463    }
464    if let Some(((row, column), value)) = active_covariance
465        .indexed_iter()
466        .map(|(index, &value)| (index, value))
467        .find(|(_, value)| !value.is_finite())
468    {
469        return Err(EstimationError::InvalidInput(format!(
470            "multinomial posterior integration covariance[{row},{column}] is non-finite: {value}"
471        )));
472    }
473
474    let scale = active_covariance
475        .iter()
476        .fold(0.0_f64, |acc, &value| acc.max(value.abs()));
477    // STRUCTURAL asymmetry only. This covariance is symmetric by construction,
478    // so whatever difference survives between the triangles is roundoff from
479    // the chain that assembled it — and a `c·ε·m·scale` envelope silently
480    // encodes an assumed chain length. It fired at 51·ε on a 2×2 penguins
481    // posterior (asymmetry 5.218e-15 against a 3.260e-15 bound), refusing a
482    // correct fit over noise carrying no information.
483    //
484    // A caller error that this check exists to catch — a transposed factor,
485    // the wrong triangle — shows up at O(1) RELATIVE asymmetry, so gate there,
486    // using the same √ε relative convention `outer_value_agreement_bound` uses
487    // for two lanes that should agree up to roundoff. The matrix actually
488    // integrated is the symmetrized one (see `symmetrized_covariance`), so
489    // sub-threshold asymmetry is removed rather than propagated.
490    let symmetry_tolerance = f64::EPSILON.sqrt() * scale.max(1.0);
491    let mut maximum_asymmetry = 0.0_f64;
492    for row in 0..m {
493        for column in (row + 1)..m {
494            maximum_asymmetry = maximum_asymmetry
495                .max((active_covariance[[row, column]] - active_covariance[[column, row]]).abs());
496        }
497    }
498    if maximum_asymmetry > symmetry_tolerance {
499        return Err(EstimationError::InvalidInput(format!(
500            "multinomial posterior integration covariance is not symmetric: max asymmetry {maximum_asymmetry:.6e} exceeds structural tolerance {symmetry_tolerance:.6e} (scale {scale:.6e})"
501        )));
502    }
503    Ok(())
504}
505
506/// Nearest symmetric matrix to `covariance` in the Frobenius norm.
507///
508/// The inputs to this module are symmetric by construction; this removes the
509/// roundoff-level asymmetry their assembly chain leaves behind, so the
510/// integration cannot depend on which triangle a downstream routine reads.
511fn symmetrized_covariance(covariance: ArrayView2<'_, f64>) -> Array2<f64> {
512    let m = covariance.nrows();
513    let mut out = covariance.to_owned();
514    for row in 0..m {
515        for column in (row + 1)..m {
516            let average = 0.5 * (covariance[[row, column]] + covariance[[column, row]]);
517            out[[row, column]] = average;
518            out[[column, row]] = average;
519        }
520    }
521    out
522}
523
524fn covariance_roundoff_tolerance(scale: f64, dimension: usize) -> f64 {
525    PSD_BACKWARD_ERROR_MULTIPLIER * f64::EPSILON * (dimension.max(1) as f64) * scale
526}
527
528fn integrate_binary(
529    active_mean: f64,
530    active_variance: f64,
531) -> Result<MultinomialPosteriorMoments, EstimationError> {
532    if active_variance < 0.0 {
533        return Err(EstimationError::InvalidInput(format!(
534            "binary logistic-normal variance must be non-negative, got {active_variance:.6e}"
535        )));
536    }
537    let sigma = active_variance.sqrt();
538    let (probability_mean, mean_logistic_slope) =
539        gam_solve::quadrature::logit_posterior_meanwith_deriv(active_mean, sigma)?;
540
541    // sigmoid'(eta) = p(1-p) = p-p^2, hence
542    // E[p^2] = E[p] - d/dmu E[p].  This supplies the binary probability
543    // variance from the same controlled scalar integral without a second
544    // numerical approximation.
545    let probability_second_moment = probability_mean - mean_logistic_slope;
546    let variance = (probability_second_moment - probability_mean * probability_mean).max(0.0);
547    let reference_mean = 1.0 - probability_mean;
548
549    let class_mean = Array1::from_vec(vec![probability_mean, reference_mean]);
550    let class_covariance =
551        Array2::from_shape_vec((2, 2), vec![variance, -variance, -variance, variance]).map_err(
552            |error| {
553                EstimationError::InvalidInput(format!(
554                    "binary logistic-normal covariance construction failed: {error}"
555                ))
556            },
557        )?;
558    let standard_deviation = variance.sqrt();
559    Ok(MultinomialPosteriorMoments {
560        class_mean,
561        class_covariance,
562        class_standard_deviation: Array1::from_vec(vec![standard_deviation, standard_deviation]),
563        latent_rank: if active_variance > 0.0 { 1 } else { 0 },
564        rule: MultinomialPosteriorRule::Exact,
565        function_evaluations: 0,
566        max_raw_moment_level_difference: 0.0,
567        covariance_range_projection_bound: 0.0,
568    })
569}
570
571/// Three softmax classes admit an exact one-dimensional Rao-Blackwellization.
572///
573/// Condition active logit `X` on the other active logit `Y`.  With
574///
575/// ```text
576/// L = sigmoid(X - softplus(Y)), q = sigmoid(Y),
577/// ```
578///
579/// the class probabilities are `p_x=L`, `p_y=q(1-L)`, and
580/// `p_ref=(1-q)(1-L)`.  The controlled scalar logistic-normal evaluator gives
581/// `a=E[L|Y]` and `d=E[L(1-L)|Y]`, so all conditional first and second moments
582/// are algebra:
583///
584/// ```text
585/// E[L²|Y]       = a-d,
586/// E[(1-L)²|Y]   = 1-a-d,
587/// E[L(1-L)|Y]   = d.
588/// ```
589///
590/// Only the Gaussian expectation over `Y` remains.  This changes the work for
591/// the wide rank-two posterior in #2612 from the Cartesian pair
592/// `1023² + 2047²` to two one-dimensional rules while preserving every raw
593/// moment and the same caller-tolerance convergence check.
594fn integrate_three_class_conditionally(
595    active_mean: &[f64],
596    projected: &ProjectedGaussian,
597    control: &MultinomialPosteriorIntegrationControl,
598    rules: &mut ConditionedThreeClassRuleLadder,
599) -> Result<MultinomialPosteriorMoments, EstimationError> {
600    let integrand = ThreeClassConditionalIntegrand::new(active_mean, projected)?;
601    let mut previous: Option<Vec<f64>> = None;
602    let mut total_evaluations = 0usize;
603    let mut rule_index = 1usize;
604    let mut refinement_depth = 0usize;
605    let mut last_difference = f64::INFINITY;
606    let mut last_deciding: Option<DecidingMoment> = None;
607
608    loop {
609        let node_count = rule_index
610            .checked_mul(2)
611            .and_then(|value| value.checked_sub(1))
612            .ok_or_else(|| {
613                EstimationError::InvalidInput(
614                    "three-class conditional Gauss-Hermite order overflowed usize".to_string(),
615                )
616            })?;
617        let remaining = control
618            .maximum_function_evaluations
619            .saturating_sub(total_evaluations);
620        if node_count > remaining {
621            let deciding_report = match last_deciding {
622                Some(moment) => format!(
623                    "worst raw moment {} (normalized error {:.6e} = (difference {:.6e} + projection bound {:.6e}) / tolerance {:.6e})",
624                    moment.index,
625                    moment.normalized_error,
626                    moment.difference,
627                    projected.projection_bound,
628                    moment.tolerance,
629                ),
630                None => "no two conditional rules fit, so no raw moment was compared".to_string(),
631            };
632            return Err(EstimationError::InvalidInput(format!(
633                "multinomial logistic-normal quadrature did not converge: the next one-dimensional conditioned three-class rule needs {node_count} evaluations, against {remaining} remaining; final max raw-moment difference {last_difference:.6e}, evaluations {total_evaluations}/{}; {deciding_report}",
634                control.maximum_function_evaluations
635            )));
636        }
637
638        let rule = rules.rule(refinement_depth, rule_index)?;
639        let current = integrand.raw_moments(
640            rule,
641            &mut total_evaluations,
642            control.maximum_function_evaluations,
643            control.absolute_tolerance,
644        )?;
645        if let Some(previous_moments) = previous.as_ref() {
646            let mut certified = true;
647            let mut maximum_difference = 0.0_f64;
648            let mut deciding: Option<DecidingMoment> = None;
649            for (index, (&new_value, &old_value)) in
650                current.iter().zip(previous_moments.iter()).enumerate()
651            {
652                let difference = (new_value - old_value).abs();
653                maximum_difference = maximum_difference.max(difference);
654                let tolerance = control.absolute_tolerance
655                    + control.relative_tolerance * new_value.abs().max(old_value.abs());
656                let controlled_error = difference + projected.projection_bound;
657                if controlled_error > tolerance {
658                    certified = false;
659                }
660                let normalized_error = if tolerance > 0.0 {
661                    controlled_error / tolerance
662                } else if controlled_error > 0.0 {
663                    f64::INFINITY
664                } else {
665                    0.0
666                };
667                if deciding
668                    .as_ref()
669                    .map(|current| normalized_error > current.normalized_error)
670                    .unwrap_or(true)
671                {
672                    deciding = Some(DecidingMoment {
673                        index,
674                        normalized_error,
675                        difference,
676                        tolerance,
677                    });
678                }
679            }
680            last_difference = maximum_difference;
681            last_deciding = deciding;
682            if certified {
683                return moments_from_raw(
684                    current,
685                    3,
686                    2,
687                    MultinomialPosteriorRule::ConditionedThreeClass(node_count),
688                    total_evaluations,
689                    maximum_difference,
690                    projected.projection_bound,
691                );
692            }
693        }
694        previous = Some(current);
695        rule_index = rule_index.checked_mul(2).ok_or_else(|| {
696            EstimationError::InvalidInput(
697                "three-class conditional Gauss-Hermite refinement overflowed usize".to_string(),
698            )
699        })?;
700        refinement_depth = refinement_depth.checked_add(1).ok_or_else(|| {
701            EstimationError::InvalidInput(
702                "three-class conditional Gauss-Hermite refinement depth overflowed usize"
703                    .to_string(),
704            )
705        })?;
706    }
707}
708
709/// Prediction-call-owned cache for the order-doubling rule ladder used by the
710/// exact three-class conditional reduction.
711///
712/// Depth `d` always means rule index `2^d` and therefore `2^(d+1)-1` nodes.
713/// Storing the ladder densely by depth gives direct indexing without either a
714/// global, unbounded cache or a map lookup for an order already fixed by the
715/// refinement schedule.
716#[derive(Default)]
717struct ConditionedThreeClassRuleLadder {
718    rules: Vec<GaussHermiteRule>,
719}
720
721impl ConditionedThreeClassRuleLadder {
722    fn rule(
723        &mut self,
724        refinement_depth: usize,
725        rule_index: usize,
726    ) -> Result<&GaussHermiteRule, EstimationError> {
727        if self.rules.len() == refinement_depth {
728            self.rules.push(gauss_hermite_rule(rule_index)?);
729        }
730        self.rules.get(refinement_depth).ok_or_else(|| {
731            EstimationError::InvalidInput(format!(
732                "conditioned three-class rule ladder is missing refinement depth {refinement_depth}"
733            ))
734        })
735    }
736}
737
738/// Parameters of `X | Y` and the class mapping for the exact three-class
739/// reduction above.
740struct ThreeClassConditionalIntegrand<'a> {
741    active_mean: &'a [f64],
742    conditioned_class: usize,
743    outer_class: usize,
744    outer_standard_deviation: f64,
745    conditional_regression: f64,
746    conditional_standard_deviation: f64,
747    upper_offsets: Vec<usize>,
748}
749
750impl<'a> ThreeClassConditionalIntegrand<'a> {
751    fn new(active_mean: &'a [f64], projected: &ProjectedGaussian) -> Result<Self, EstimationError> {
752        if active_mean.len() != 2 || projected.factor.dim() != (2, 2) {
753            return Err(EstimationError::InvalidInput(format!(
754                "conditioned three-class integration requires two active logits and a 2x2 retained factor, got {} logits and factor {:?}",
755                active_mean.len(),
756                projected.factor.dim()
757            )));
758        }
759        let row_variance = |row: usize| {
760            projected
761                .factor
762                .row(row)
763                .iter()
764                .map(|value| value * value)
765                .sum::<f64>()
766        };
767        let variances = [row_variance(0), row_variance(1)];
768        // One-dimensional Gauss-Hermite resolution grows with the marginal
769        // standard deviation of the remaining outer coordinate.  Conditioning
770        // the other coordinate therefore minimizes the outer rule's derived
771        // work requirement without a tuned routing threshold.
772        let outer_class = if variances[0] <= variances[1] { 0 } else { 1 };
773        let conditioned_class = 1 - outer_class;
774        let outer_variance = variances[outer_class];
775        if !(outer_variance.is_finite() && outer_variance > 0.0) {
776            return Err(EstimationError::InvalidInput(format!(
777                "conditioned three-class outer variance must be finite and positive, got {outer_variance}"
778            )));
779        }
780        let covariance = projected
781            .factor
782            .row(conditioned_class)
783            .iter()
784            .zip(projected.factor.row(outer_class).iter())
785            .map(|(left, right)| left * right)
786            .sum::<f64>();
787        let conditional_regression = covariance / outer_variance;
788        // Form the conditional residual in factor space.  Its squared norm is
789        // Var(X|Y), evaluated without the catastrophic cancellation in
790        // Var(X)-Cov(X,Y)^2/Var(Y) near a rank-one covariance.
791        let conditional_variance = projected
792            .factor
793            .row(conditioned_class)
794            .iter()
795            .zip(projected.factor.row(outer_class).iter())
796            .map(|(conditioned, outer)| {
797                let residual = conditioned - conditional_regression * outer;
798                residual * residual
799            })
800            .sum::<f64>();
801        if !(conditional_variance.is_finite() && conditional_variance >= 0.0) {
802            return Err(EstimationError::InvalidInput(format!(
803                "conditioned three-class residual variance is invalid: {conditional_variance}"
804            )));
805        }
806        Ok(Self {
807            active_mean,
808            conditioned_class,
809            outer_class,
810            outer_standard_deviation: outer_variance.sqrt(),
811            conditional_regression,
812            conditional_standard_deviation: conditional_variance.sqrt(),
813            upper_offsets: upper_triangle_offsets(3)?,
814        })
815    }
816
817    fn raw_moments(
818        &self,
819        rule: &GaussHermiteRule,
820        total_evaluations: &mut usize,
821        maximum_function_evaluations: usize,
822        absolute_tolerance: f64,
823    ) -> Result<Vec<f64>, EstimationError> {
824        let mut accumulator = QuadratureAccumulator::new(packed_moment_count(3)?)?;
825        for (&standard_normal, &weight) in rule.nodes.iter().zip(rule.weights.iter()) {
826            if *total_evaluations >= maximum_function_evaluations {
827                return Err(EstimationError::InvalidInput(format!(
828                    "multinomial conditioned three-class quadrature exhausted its function-evaluation budget ({maximum_function_evaluations}) before convergence"
829                )));
830            }
831            *total_evaluations += 1;
832
833            let outer_mean = self.active_mean[self.outer_class];
834            let outer_eta = outer_mean + self.outer_standard_deviation * standard_normal;
835            let conditioned_mean = self.active_mean[self.conditioned_class]
836                + self.conditional_regression * (outer_eta - outer_mean);
837            let scalar_location = conditioned_mean - gam_linalg::utils::stable_softplus(outer_eta);
838            let (selected_mean, selected_slope) =
839                gam_solve::quadrature::logit_posterior_meanwith_deriv(
840                    scalar_location,
841                    self.conditional_standard_deviation,
842                )
843                .map_err(|error| {
844                    EstimationError::InvalidInput(format!(
845                        "conditioned three-class scalar logistic-normal evaluation failed: {error}"
846                    ))
847                })?;
848            let outer_share = (-gam_linalg::utils::stable_softplus(-outer_eta)).exp();
849            let reference_share = 1.0 - outer_share;
850            let selected_second = selected_mean - selected_slope;
851            let remainder_second = 1.0 - selected_mean - selected_slope;
852
853            let mut means = [0.0_f64; 3];
854            means[self.conditioned_class] = selected_mean;
855            means[self.outer_class] = outer_share * (1.0 - selected_mean);
856            means[2] = reference_share * (1.0 - selected_mean);
857
858            let mut seconds = [[0.0_f64; 3]; 3];
859            seconds[self.conditioned_class][self.conditioned_class] = selected_second;
860            seconds[self.conditioned_class][self.outer_class] = outer_share * selected_slope;
861            seconds[self.outer_class][self.conditioned_class] =
862                seconds[self.conditioned_class][self.outer_class];
863            seconds[self.conditioned_class][2] = reference_share * selected_slope;
864            seconds[2][self.conditioned_class] = seconds[self.conditioned_class][2];
865            seconds[self.outer_class][self.outer_class] =
866                outer_share * outer_share * remainder_second;
867            seconds[self.outer_class][2] = outer_share * reference_share * remainder_second;
868            seconds[2][self.outer_class] = seconds[self.outer_class][2];
869            seconds[2][2] = reference_share * reference_share * remainder_second;
870
871            accumulator.add_weight(weight);
872            for (class, mean) in means.into_iter().enumerate() {
873                accumulator.add_moment(class, weight * mean);
874            }
875            let second_offset = 3;
876            for row in 0..3 {
877                for column in row..3 {
878                    let packed = second_offset + self.upper_offsets[row] + column - row;
879                    accumulator.add_moment(packed, weight * seconds[row][column]);
880                }
881            }
882        }
883        let (mut raw_moments, mass, absolute_weight_sum) = accumulator.finish();
884        normalize_by_mass(
885            &mut raw_moments,
886            mass,
887            absolute_weight_sum,
888            absolute_tolerance,
889            &format!(
890                "conditioned three-class rule with {} nodes",
891                rule.nodes.len()
892            ),
893        )?;
894        Ok(raw_moments)
895    }
896}
897
898fn point_mass_moments(active_mean: &[f64]) -> Result<MultinomialPosteriorMoments, EstimationError> {
899    let class_mean = Array1::from_vec(softmax_with_reference(active_mean)?);
900    let k = class_mean.len();
901    Ok(MultinomialPosteriorMoments {
902        class_mean,
903        class_covariance: Array2::zeros((k, k)),
904        class_standard_deviation: Array1::zeros(k),
905        latent_rank: 0,
906        rule: MultinomialPosteriorRule::Exact,
907        function_evaluations: 1,
908        max_raw_moment_level_difference: 0.0,
909        covariance_range_projection_bound: 0.0,
910    })
911}
912
913struct ProjectedGaussian {
914    /// `factor factor^T` is the retained active-logit covariance.
915    factor: Array2<f64>,
916    projection_bound: f64,
917    /// `sqrt(lambda)` per retained direction, in the column order of `factor`.
918    /// The softmax argument is `mu + F z`, so this is the scale at which
919    /// direction `d` moves the integrand and therefore the only thing that
920    /// decides how much one-dimensional resolution that direction needs.
921    standard_deviations: Vec<f64>,
922}
923
924fn project_active_covariance(
925    covariance: ArrayView2<'_, f64>,
926    absolute_tolerance: f64,
927) -> Result<ProjectedGaussian, EstimationError> {
928    let m = covariance.nrows();
929    let symmetric = (&covariance.to_owned() + &covariance.t().to_owned()) * 0.5;
930    let (eigenvalues, eigenvectors) = symmetric.eigh(faer::Side::Lower).map_err(|error| {
931        EstimationError::InvalidInput(format!(
932            "multinomial posterior covariance eigendecomposition failed: {error}"
933        ))
934    })?;
935    let eigenvalue_scale = eigenvalues
936        .iter()
937        .fold(0.0_f64, |scale, &value| scale.max(value.abs()));
938    let tolerance = covariance_roundoff_tolerance(eigenvalue_scale, m);
939    let minimum_eigenvalue = eigenvalues
940        .iter()
941        .fold(f64::INFINITY, |minimum, &value| minimum.min(value));
942    if minimum_eigenvalue < -tolerance {
943        return Err(EstimationError::InvalidInput(format!(
944            "multinomial posterior active-logit covariance is not positive semidefinite: minimum eigenvalue {minimum_eigenvalue:.6e} is below -{tolerance:.6e} (scale {eigenvalue_scale:.6e})"
945        )));
946    }
947
948    let small_positive_trace: f64 = eigenvalues
949        .iter()
950        .copied()
951        .filter(|value| *value > 0.0 && *value <= tolerance)
952        .sum();
953    // For every softmax raw moment used here, the Euclidean gradient norm is
954    // at most one.  Coupling the retained Gaussian with the full Gaussian gives
955    // |E f(full)-E f(retained)| <= E||delta|| <= sqrt(tr(V_discarded)).
956    let candidate_projection_bound = small_positive_trace.sqrt();
957    let discard_small_positive = candidate_projection_bound <= absolute_tolerance;
958
959    let retained: Vec<(usize, f64)> = eigenvalues
960        .iter()
961        .copied()
962        .enumerate()
963        .filter(|(_, value)| *value > 0.0 && (!discard_small_positive || *value > tolerance))
964        .collect();
965    let projection_bound = if discard_small_positive {
966        candidate_projection_bound
967    } else {
968        0.0
969    };
970    let mut factor = Array2::<f64>::zeros((m, retained.len()));
971    let mut standard_deviations = Vec::new();
972    standard_deviations
973        .try_reserve_exact(retained.len())
974        .map_err(|error| {
975            EstimationError::InvalidInput(format!(
976                "multinomial posterior could not allocate retained standard deviations: {error}"
977            ))
978        })?;
979    for (output_column, (eigenvector_column, eigenvalue)) in retained.into_iter().enumerate() {
980        let scale = eigenvalue.sqrt();
981        standard_deviations.push(scale);
982        for row in 0..m {
983            factor[[row, output_column]] = eigenvectors[[row, eigenvector_column]] * scale;
984        }
985    }
986    Ok(ProjectedGaussian {
987        factor,
988        projection_bound,
989        standard_deviations,
990    })
991}
992
993/// Everything about one prediction row that every rule shares.
994struct RowIntegrand<'a> {
995    active_mean: &'a [f64],
996    projected: &'a ProjectedGaussian,
997    upper_offsets: Vec<usize>,
998    moment_count: usize,
999    k: usize,
1000}
1001
1002impl<'a> RowIntegrand<'a> {
1003    fn new(
1004        active_mean: &'a [f64],
1005        projected: &'a ProjectedGaussian,
1006    ) -> Result<Self, EstimationError> {
1007        let k = active_mean.len() + 1;
1008        Ok(Self {
1009            active_mean,
1010            projected,
1011            upper_offsets: upper_triangle_offsets(k)?,
1012            moment_count: packed_moment_count(k)?,
1013            k,
1014        })
1015    }
1016
1017    /// Raw moments under the tensor-product Gauss-Hermite rule whose
1018    /// one-dimensional rule index is `orders[d]` in direction `d`.  Rule index
1019    /// `i` carries `2i - 1` nodes, and index 1 is the single node `z = 0`, so a
1020    /// direction left at 1 is evaluated at the posterior mean exactly.
1021    ///
1022    /// The cache is keyed by rule index rather than filled densely up to the
1023    /// highest one used: this path visits indices that DOUBLE, so a dense cache
1024    /// would build every rule in between, and building a rule is an
1025    /// eigendecomposition of a `(2i - 1)`-square Jacobi matrix.  Filling 1..=512
1026    /// to reach index 512 costs more than every quadrature evaluation in this
1027    /// module put together.
1028    fn tensor_moments(
1029        &self,
1030        rules: &mut BTreeMap<usize, GaussHermiteRule>,
1031        orders: &[usize],
1032        total_evaluations: &mut usize,
1033        maximum_function_evaluations: usize,
1034        absolute_tolerance: f64,
1035    ) -> Result<Vec<f64>, EstimationError> {
1036        for &order in orders {
1037            if !rules.contains_key(&order) {
1038                rules.insert(order, gauss_hermite_rule(order)?);
1039            }
1040        }
1041        let axes: Vec<&GaussHermiteRule> = orders.iter().map(|order| &rules[order]).collect();
1042        let mut workspace = QuadratureWorkspace::new(
1043            self.active_mean,
1044            self.projected,
1045            &[],
1046            &self.upper_offsets,
1047            self.moment_count,
1048            total_evaluations,
1049            maximum_function_evaluations,
1050        )?;
1051        workspace.stream_axes(0, &axes, 1.0)?;
1052        let (mut raw_moments, mass, absolute_weight_sum) = workspace.accumulator.finish();
1053        let node_counts: Vec<usize> = orders.iter().map(|order| 2 * order - 1).collect();
1054        normalize_by_mass(
1055            &mut raw_moments,
1056            mass,
1057            absolute_weight_sum,
1058            absolute_tolerance,
1059            &format!("tensor rule with node counts {node_counts:?}"),
1060        )?;
1061        Ok(raw_moments)
1062    }
1063
1064    /// One-dimensional rule index that resolves direction `direction` on its
1065    /// own, with every other direction held at the posterior mean.
1066    ///
1067    /// This chooses only the SHAPE of the tensor rule.  The certificate is taken
1068    /// on the full rule, so an optimistic reading here cannot certify anything
1069    /// -- it can only make the certified rule cheaper or dearer.  There is no
1070    /// chosen constant: the index doubles until the directional integral of
1071    /// every raw moment stops moving by more than the caller's own per-moment
1072    /// tolerance.
1073    fn directional_rule_index(
1074        &self,
1075        rules: &mut BTreeMap<usize, GaussHermiteRule>,
1076        direction: usize,
1077        control: &MultinomialPosteriorIntegrationControl,
1078        total_evaluations: &mut usize,
1079    ) -> Result<usize, EstimationError> {
1080        let rank = self.projected.factor.ncols();
1081        let mut orders = vec![1usize; rank];
1082        let mut previous: Option<Vec<f64>> = None;
1083        let mut index = 1usize;
1084        loop {
1085            orders[direction] = index;
1086            let current = self.tensor_moments(
1087                rules,
1088                &orders,
1089                total_evaluations,
1090                control.maximum_function_evaluations,
1091                control.absolute_tolerance,
1092            )?;
1093            if let Some(previous_moments) = previous.as_ref() {
1094                let resolved =
1095                    current
1096                        .iter()
1097                        .zip(previous_moments.iter())
1098                        .all(|(new_value, old_value)| {
1099                            let tolerance = control.absolute_tolerance
1100                                + control.relative_tolerance * new_value.abs().max(old_value.abs());
1101                            (new_value - old_value).abs() <= tolerance
1102                        });
1103                if resolved {
1104                    return Ok(index);
1105                }
1106            }
1107            previous = Some(current);
1108            let doubled = index.checked_mul(2).ok_or_else(|| {
1109                EstimationError::InvalidInput(
1110                    "multinomial posterior directional rule index overflowed usize".to_string(),
1111                )
1112            })?;
1113            // No direction can usefully be sized past the point where the
1114            // tensor rule carrying that order in EVERY direction would already
1115            // exceed the evaluation budget: such an order can never be part of a
1116            // rule this caller is allowed to evaluate.  That bound comes from
1117            // the budget the caller supplied, not from a chosen ceiling.
1118            if tensor_node_count(&vec![doubled; rank])
1119                .map(|count| count > control.maximum_function_evaluations)
1120                .unwrap_or(true)
1121            {
1122                return Ok(index);
1123            }
1124            index = doubled;
1125        }
1126    }
1127}
1128
1129/// Number of nodes in the tensor rule with these one-dimensional rule indices.
1130fn tensor_node_count(orders: &[usize]) -> Result<usize, EstimationError> {
1131    let mut count = 1usize;
1132    for &order in orders {
1133        let nodes = order
1134            .checked_mul(2)
1135            .and_then(|value| value.checked_sub(1))
1136            .ok_or_else(|| {
1137                EstimationError::InvalidInput(
1138                    "multinomial posterior tensor rule order overflowed usize".to_string(),
1139                )
1140            })?;
1141        count = count.checked_mul(nodes).ok_or_else(|| {
1142            EstimationError::InvalidInput(
1143                "multinomial posterior tensor node count overflowed usize".to_string(),
1144            )
1145        })?;
1146    }
1147    Ok(count)
1148}
1149
1150/// Divide accumulated moments by the rule's own total weight, after checking
1151/// that the rule integrates the constant function to one.
1152fn normalize_by_mass(
1153    raw_moments: &mut [f64],
1154    mass: f64,
1155    absolute_weight_sum: f64,
1156    absolute_tolerance: f64,
1157    context: &str,
1158) -> Result<(), EstimationError> {
1159    if !(mass.is_finite() && mass > 0.0 && absolute_weight_sum.is_finite()) {
1160        return Err(EstimationError::InvalidInput(format!(
1161            "multinomial posterior {context} produced invalid total weight {mass} (absolute sum {absolute_weight_sum})"
1162        )));
1163    }
1164    let mass_error = (mass - 1.0).abs();
1165    let summation_envelope =
1166        SUMMATION_ROUNDOFF_MULTIPLIER * f64::EPSILON * absolute_weight_sum.max(1.0);
1167    if mass_error > absolute_tolerance + summation_envelope {
1168        return Err(EstimationError::InvalidInput(format!(
1169            "multinomial posterior {context} failed constant-function exactness: total weight {mass:.17e}, error {mass_error:.6e}, allowed {:.6e}",
1170            absolute_tolerance + summation_envelope
1171        )));
1172    }
1173    for value in raw_moments.iter_mut() {
1174        *value /= mass;
1175    }
1176    Ok(())
1177}
1178
1179/// The sparse rule first, and the tensor rule for what it refuses.
1180///
1181/// The retained posterior has `rank = number of positive covariance eigenvalues`
1182/// directions, at most `K - 1`. A Smolyak grid buys its saving by giving a
1183/// direction high order only when every other direction is held at a single
1184/// node, which is the right trade when `rank` is large compared with the order
1185/// each direction needs -- and where that holds the sparse grid certifies at a
1186/// low level for a few hundred evaluations, which nothing here should make more
1187/// expensive. So it is tried first and kept wherever it certifies: every row
1188/// that predicts today predicts on the same rule, at the same cost, with the
1189/// same numbers.
1190///
1191/// What changes is the row it REFUSES. A logistic-normal softmax needs
1192/// one-dimensional order growing with a direction's own standard deviation --
1193/// `softmax` has a transition of width O(1) in the logit while the Gaussian
1194/// along that direction has width `sqrt(lambda_d)` -- so a wide posterior needs
1195/// order in EVERY wide direction at once, and that is the one thing the sparse
1196/// construction will not supply. Measured on a rank-two posterior with standard
1197/// deviations (1, 5): the sparse ladder reaches `1.99e-4` in 28033 evaluations
1198/// while the tensor product it is assembled FROM reaches `3.82e-9` in 16641
1199/// (#2612). Raising the level ceiling has been the answer three times and each
1200/// time bought a factor of three; the rule is what was wrong.
1201fn integrate_general(
1202    active_mean: &[f64],
1203    projected: &ProjectedGaussian,
1204    control: &MultinomialPosteriorIntegrationControl,
1205) -> Result<MultinomialPosteriorMoments, EstimationError> {
1206    let sparse_refusal = match integrate_isotropic_sparse(active_mean, projected, control, 0) {
1207        Ok(moments) => return Ok(moments),
1208        Err(refusal) => refusal,
1209    };
1210
1211    let integrand = RowIntegrand::new(active_mean, projected)?;
1212    let rank = projected.factor.ncols();
1213    let mut rules = BTreeMap::<usize, GaussHermiteRule>::new();
1214    let mut total_evaluations = 0usize;
1215
1216    let mut orders = vec![1usize; rank];
1217    for direction in 0..rank {
1218        orders[direction] = integrand.directional_rule_index(
1219            &mut rules,
1220            direction,
1221            control,
1222            &mut total_evaluations,
1223        )?;
1224    }
1225
1226    let refined: Vec<usize> = orders.iter().map(|order| order.saturating_mul(2)).collect();
1227    let certifying_pair_cost =
1228        tensor_node_count(&orders)?.saturating_add(tensor_node_count(&refined)?);
1229    let remaining = control
1230        .maximum_function_evaluations
1231        .saturating_sub(total_evaluations);
1232    if certifying_pair_cost > remaining {
1233        // Neither rule fits: the sparse refusal is the one the caller can act
1234        // on, and it is returned unaltered rather than restated as a tensor
1235        // refusal for a rule that was never evaluated.
1236        return Err(EstimationError::InvalidInput(format!(
1237            "{sparse_refusal}; the tensor rule sized for this posterior would need {certifying_pair_cost} evaluations to certify, against {remaining} remaining"
1238        )));
1239    }
1240
1241    integrate_anisotropic_tensor(&integrand, &mut rules, orders, control, total_evaluations)
1242}
1243
1244/// Tensor-product Gauss-Hermite, certified by comparing against the rule with
1245/// every one-dimensional order doubled.  Doubling preserves the anisotropic
1246/// profile that `directional_rule_index` measured, so refinement never undoes
1247/// the direction sizing; the returned answer is always the finer of the pair.
1248fn integrate_anisotropic_tensor(
1249    integrand: &RowIntegrand<'_>,
1250    rules: &mut BTreeMap<usize, GaussHermiteRule>,
1251    mut orders: Vec<usize>,
1252    control: &MultinomialPosteriorIntegrationControl,
1253    mut total_evaluations: usize,
1254) -> Result<MultinomialPosteriorMoments, EstimationError> {
1255    let rank = orders.len();
1256    let projection_bound = integrand.projected.projection_bound;
1257    let mut coarse = integrand.tensor_moments(
1258        rules,
1259        &orders,
1260        &mut total_evaluations,
1261        control.maximum_function_evaluations,
1262        control.absolute_tolerance,
1263    )?;
1264    loop {
1265        let refined: Vec<usize> = orders.iter().map(|order| order * 2).collect();
1266        let fine = integrand.tensor_moments(
1267            rules,
1268            &refined,
1269            &mut total_evaluations,
1270            control.maximum_function_evaluations,
1271            control.absolute_tolerance,
1272        )?;
1273
1274        let mut certified = true;
1275        let mut maximum_difference = 0.0_f64;
1276        let mut deciding: Option<DecidingMoment> = None;
1277        for (index, (&new_value, &old_value)) in fine.iter().zip(coarse.iter()).enumerate() {
1278            let difference = (new_value - old_value).abs();
1279            maximum_difference = maximum_difference.max(difference);
1280            let tolerance = control.absolute_tolerance
1281                + control.relative_tolerance * new_value.abs().max(old_value.abs());
1282            let controlled_error = difference + projection_bound;
1283            if controlled_error > tolerance {
1284                certified = false;
1285            }
1286            let normalized = if tolerance > 0.0 {
1287                controlled_error / tolerance
1288            } else if controlled_error > 0.0 {
1289                f64::INFINITY
1290            } else {
1291                0.0
1292            };
1293            let supersedes = match deciding.as_ref() {
1294                Some(current_worst) => normalized > current_worst.normalized_error,
1295                None => true,
1296            };
1297            if supersedes {
1298                deciding = Some(DecidingMoment {
1299                    index,
1300                    normalized_error: normalized,
1301                    difference,
1302                    tolerance,
1303                });
1304            }
1305        }
1306
1307        let node_counts: Vec<usize> = refined.iter().map(|order| 2 * order - 1).collect();
1308        if certified {
1309            return moments_from_raw(
1310                fine,
1311                integrand.k,
1312                rank,
1313                MultinomialPosteriorRule::AnisotropicTensor(node_counts),
1314                total_evaluations,
1315                maximum_difference,
1316                projection_bound,
1317            );
1318        }
1319
1320        orders = refined;
1321        coarse = fine;
1322        let next: Vec<usize> = orders.iter().map(|order| order * 2).collect();
1323        let next_cost = tensor_node_count(&next)?;
1324        if next_cost
1325            > control
1326                .maximum_function_evaluations
1327                .saturating_sub(total_evaluations)
1328        {
1329            let standard_deviations: Vec<String> = integrand
1330                .projected
1331                .standard_deviations
1332                .iter()
1333                .map(|value| format!("{value:.6e}"))
1334                .collect();
1335            let deciding_report = match deciding.as_ref() {
1336                Some(moment) => format!(
1337                    "worst raw moment {} (normalized error {:.6e} = (difference {:.6e} + projection bound {:.6e}) / tolerance {:.6e})",
1338                    moment.index,
1339                    moment.normalized_error,
1340                    moment.difference,
1341                    projection_bound,
1342                    moment.tolerance,
1343                ),
1344                None => "no raw moment was compared".to_string(),
1345            };
1346            return Err(EstimationError::InvalidInput(format!(
1347                "multinomial logistic-normal quadrature did not converge: tensor rule with node counts {node_counts:?} over posterior standard deviations [{}] left a raw-moment difference {maximum_difference:.6e}, and doubling it again would need {next_cost} of the {} evaluations still allowed; evaluations {total_evaluations}/{}; {deciding_report}",
1348                standard_deviations.join(", "),
1349                control
1350                    .maximum_function_evaluations
1351                    .saturating_sub(total_evaluations),
1352                control.maximum_function_evaluations,
1353            )));
1354        }
1355    }
1356}
1357
1358fn integrate_isotropic_sparse(
1359    active_mean: &[f64],
1360    projected: &ProjectedGaussian,
1361    control: &MultinomialPosteriorIntegrationControl,
1362    initial_evaluations: usize,
1363) -> Result<MultinomialPosteriorMoments, EstimationError> {
1364    let rank = projected.factor.ncols();
1365    let k = active_mean.len() + 1;
1366    let mut rules = Vec::<GaussHermiteRule>::new();
1367    let mut previous: Option<Vec<f64>> = None;
1368    let mut total_evaluations = initial_evaluations;
1369    let mut last_max_difference = f64::INFINITY;
1370    let mut last_max_normalized_error = f64::INFINITY;
1371    let mut last_deciding: Option<DecidingMoment> = None;
1372
1373    let mut last_level_attempted = 0usize;
1374    for level in 0..=control.maximum_sparse_level {
1375        last_level_attempted = level;
1376        let required_rule_count = level.checked_add(1).ok_or_else(|| {
1377            EstimationError::InvalidInput(
1378                "multinomial posterior sparse level overflowed usize".to_string(),
1379            )
1380        })?;
1381        while rules.len() < required_rule_count {
1382            let rule_index = rules.len() + 1;
1383            rules.push(gauss_hermite_rule(rule_index)?);
1384        }
1385
1386        let evaluation = evaluate_smolyak_level(
1387            active_mean,
1388            projected,
1389            &rules,
1390            level,
1391            k,
1392            &mut total_evaluations,
1393            control.maximum_function_evaluations,
1394            control.absolute_tolerance,
1395        )?;
1396        let current = evaluation.raw_moments;
1397
1398        if let Some(previous_moments) = previous.as_ref() {
1399            let mut certified = level >= control.minimum_sparse_level;
1400            let mut maximum_difference = 0.0_f64;
1401            let mut maximum_normalized_error = 0.0_f64;
1402            // The coordinate whose normalized error is the maximum -- i.e. the
1403            // one that actually refused -- carried alongside the quantities that
1404            // decided it (#2612).
1405            //
1406            // `maximum_difference` and `maximum_normalized_error` are maxima over
1407            // the SAME loop but not over the same coordinate: one is scaled by a
1408            // per-moment tolerance and the other is not, so their argmaxes differ
1409            // whenever the moments differ in magnitude. Reported side by side they
1410            // read as a pair describing one moment, and a refusal citing
1411            // `level difference 1.199663e-2, max normalized error 4.130034e5` --
1412            // seven orders apart -- invites the reading that the normalization
1413            // divides by something near zero, when in fact the two numbers simply
1414            // describe different raw moments. Naming the deciding coordinate and
1415            // printing ITS difference and ITS tolerance removes the ambiguity
1416            // instead of inviting the next reader to re-derive it.
1417            let mut deciding: Option<DecidingMoment> = None;
1418            for (index, (&new_value, &old_value)) in
1419                current.iter().zip(previous_moments.iter()).enumerate()
1420            {
1421                let difference = (new_value - old_value).abs();
1422                maximum_difference = maximum_difference.max(difference);
1423                let tolerance = control.absolute_tolerance
1424                    + control.relative_tolerance * new_value.abs().max(old_value.abs());
1425                let controlled_error = difference + projected.projection_bound;
1426                if controlled_error > tolerance {
1427                    certified = false;
1428                }
1429                // A zero tolerance cannot normalize an error, and the previous
1430                // `if tolerance > 0.0` guard SKIPPED such a coordinate entirely --
1431                // so a moment that failed certification could contribute nothing
1432                // to the reported error, and the refusal could understate the very
1433                // quantity it refused on. Infinity is the honest normalized error
1434                // when a nonzero error is measured against a zero tolerance, and
1435                // it is reported rather than dropped.
1436                let normalized = if tolerance > 0.0 {
1437                    controlled_error / tolerance
1438                } else if controlled_error > 0.0 {
1439                    f64::INFINITY
1440                } else {
1441                    0.0
1442                };
1443                let supersedes = match deciding.as_ref() {
1444                    Some(current_worst) => normalized > current_worst.normalized_error,
1445                    None => true,
1446                };
1447                if supersedes {
1448                    deciding = Some(DecidingMoment {
1449                        index,
1450                        normalized_error: normalized,
1451                        difference,
1452                        tolerance,
1453                    });
1454                }
1455                maximum_normalized_error = maximum_normalized_error.max(normalized);
1456            }
1457            last_max_difference = maximum_difference;
1458            last_max_normalized_error = maximum_normalized_error;
1459            last_deciding = deciding;
1460
1461            if certified {
1462                return moments_from_raw(
1463                    current,
1464                    k,
1465                    rank,
1466                    MultinomialPosteriorRule::IsotropicSparse(level),
1467                    total_evaluations,
1468                    maximum_difference,
1469                    projected.projection_bound,
1470                );
1471            }
1472        }
1473        previous = Some(current);
1474    }
1475
1476    // Report the level REACHED, not the configured ceiling.
1477    //
1478    // With the ceiling no longer a tuned number, the ceiling is not the fact a
1479    // reader needs; the depth actually attained before the budget ran out is.
1480    // The old message printed `control.maximum_sparse_level`, which said what
1481    // the cap was rather than what the integrand did -- and on a run that
1482    // stopped at 1.4% of its evaluation budget those are different stories.
1483    let deciding_report = match last_deciding.as_ref() {
1484        Some(moment) => format!(
1485            "worst raw moment {} (normalized error {:.6e} = (difference {:.6e} + projection bound {:.6e}) / tolerance {:.6e})",
1486            moment.index,
1487            moment.normalized_error,
1488            moment.difference,
1489            projected.projection_bound,
1490            moment.tolerance,
1491        ),
1492        // Reached only when no level after the first produced a comparison, so
1493        // there is no per-coordinate verdict to name. Said explicitly, because
1494        // an absent comparison and a passing one must not read alike (#2612).
1495        None => "no two levels were compared, so no coordinate refused".to_string(),
1496    };
1497    Err(EstimationError::InvalidInput(format!(
1498        "multinomial logistic-normal quadrature did not converge: reached Smolyak level {last_level_attempted} and exhausted the evaluation budget; final max raw-moment level difference {last_max_difference:.6e}, max normalized error {last_max_normalized_error:.6e}, projection bound {:.6e}, evaluations {total_evaluations}/{}; {deciding_report}",
1499        projected.projection_bound, control.maximum_function_evaluations
1500    )))
1501}
1502
1503/// The raw moment whose normalized error is the maximum, with the quantities
1504/// that produced it (#2612).
1505///
1506/// Exists so the refusal names WHICH moment refused and against WHAT. The
1507/// aggregate `max normalized error` and `max level difference` are maxima over
1508/// different coordinates, so neither one alone identifies the failure and the
1509/// pair actively misleads.
1510#[derive(Clone, Copy, Debug)]
1511struct DecidingMoment {
1512    index: usize,
1513    normalized_error: f64,
1514    difference: f64,
1515    tolerance: f64,
1516}
1517
1518struct SmolyakEvaluation {
1519    raw_moments: Vec<f64>,
1520}
1521
1522fn evaluate_smolyak_level(
1523    active_mean: &[f64],
1524    projected: &ProjectedGaussian,
1525    rules: &[GaussHermiteRule],
1526    level: usize,
1527    k: usize,
1528    total_evaluations: &mut usize,
1529    maximum_function_evaluations: usize,
1530    absolute_tolerance: f64,
1531) -> Result<SmolyakEvaluation, EstimationError> {
1532    let rank = projected.factor.ncols();
1533    let q = rank.checked_add(level).ok_or_else(|| {
1534        EstimationError::InvalidInput(
1535            "multinomial posterior Smolyak index overflowed usize".to_string(),
1536        )
1537    })?;
1538    let lower_total = q.saturating_sub(rank.saturating_sub(1)).max(rank);
1539    let moment_count = packed_moment_count(k)?;
1540    let upper_offsets = upper_triangle_offsets(k)?;
1541    let mut workspace = QuadratureWorkspace::new(
1542        active_mean,
1543        projected,
1544        rules,
1545        &upper_offsets,
1546        moment_count,
1547        total_evaluations,
1548        maximum_function_evaluations,
1549    )?;
1550    let mut indices = vec![1usize; rank];
1551
1552    for total in lower_total..=q {
1553        let alternating_power = q - total;
1554        let mut coefficient = binomial_as_f64(rank - 1, alternating_power)?;
1555        if alternating_power % 2 == 1 {
1556            coefficient = -coefficient;
1557        }
1558        workspace.stream_compositions(0, total, &mut indices, coefficient)?;
1559    }
1560
1561    let (mut raw_moments, mass, absolute_weight_sum) = workspace.accumulator.finish();
1562    normalize_by_mass(
1563        &mut raw_moments,
1564        mass,
1565        absolute_weight_sum,
1566        absolute_tolerance,
1567        &format!("Smolyak level {level}"),
1568    )?;
1569    Ok(SmolyakEvaluation { raw_moments })
1570}
1571
1572fn packed_moment_count(k: usize) -> Result<usize, EstimationError> {
1573    let triangular = k
1574        .checked_add(1)
1575        .and_then(|next| k.checked_mul(next))
1576        .map(|product| product / 2)
1577        .ok_or_else(|| {
1578            EstimationError::InvalidInput(
1579                "multinomial posterior moment dimension overflowed usize".to_string(),
1580            )
1581        })?;
1582    k.checked_add(triangular).ok_or_else(|| {
1583        EstimationError::InvalidInput(
1584            "multinomial posterior packed moment count overflowed usize".to_string(),
1585        )
1586    })
1587}
1588
1589fn upper_triangle_offsets(k: usize) -> Result<Vec<usize>, EstimationError> {
1590    let mut offsets = Vec::new();
1591    offsets.try_reserve_exact(k).map_err(|error| {
1592        EstimationError::InvalidInput(format!(
1593            "multinomial posterior could not allocate upper-triangle offsets: {error}"
1594        ))
1595    })?;
1596    let mut cursor = 0usize;
1597    for row in 0..k {
1598        offsets.push(cursor);
1599        cursor = cursor.checked_add(k - row).ok_or_else(|| {
1600            EstimationError::InvalidInput(
1601                "multinomial posterior upper-triangle offset overflowed usize".to_string(),
1602            )
1603        })?;
1604    }
1605    Ok(offsets)
1606}
1607
1608fn zeroed_vec(length: usize, label: &str) -> Result<Vec<f64>, EstimationError> {
1609    let mut values = Vec::new();
1610    values.try_reserve_exact(length).map_err(|error| {
1611        EstimationError::InvalidInput(format!(
1612            "multinomial posterior could not allocate {label} (length {length}): {error}"
1613        ))
1614    })?;
1615    values.resize(length, 0.0);
1616    Ok(values)
1617}
1618
1619struct CompensatedSum {
1620    sum: f64,
1621    correction: f64,
1622}
1623
1624impl CompensatedSum {
1625    fn new() -> Self {
1626        Self {
1627            sum: 0.0,
1628            correction: 0.0,
1629        }
1630    }
1631
1632    fn add(&mut self, value: f64) {
1633        let combined = self.sum + value;
1634        if self.sum.abs() >= value.abs() {
1635            self.correction += (self.sum - combined) + value;
1636        } else {
1637            self.correction += (value - combined) + self.sum;
1638        }
1639        self.sum = combined;
1640    }
1641
1642    fn value(&self) -> f64 {
1643        self.sum + self.correction
1644    }
1645}
1646
1647struct QuadratureAccumulator {
1648    sums: Vec<f64>,
1649    corrections: Vec<f64>,
1650    mass: CompensatedSum,
1651    absolute_weight_sum: f64,
1652}
1653
1654impl QuadratureAccumulator {
1655    fn new(moment_count: usize) -> Result<Self, EstimationError> {
1656        Ok(Self {
1657            sums: zeroed_vec(moment_count, "quadrature sums")?,
1658            corrections: zeroed_vec(moment_count, "quadrature corrections")?,
1659            mass: CompensatedSum::new(),
1660            absolute_weight_sum: 0.0,
1661        })
1662    }
1663
1664    fn add_moment(&mut self, index: usize, value: f64) {
1665        let combined = self.sums[index] + value;
1666        if self.sums[index].abs() >= value.abs() {
1667            self.corrections[index] += (self.sums[index] - combined) + value;
1668        } else {
1669            self.corrections[index] += (value - combined) + self.sums[index];
1670        }
1671        self.sums[index] = combined;
1672    }
1673
1674    fn add_weight(&mut self, weight: f64) {
1675        self.mass.add(weight);
1676        self.absolute_weight_sum += weight.abs();
1677    }
1678
1679    fn finish(mut self) -> (Vec<f64>, f64, f64) {
1680        for (sum, correction) in self.sums.iter_mut().zip(self.corrections.iter()) {
1681            *sum += *correction;
1682        }
1683        (self.sums, self.mass.value(), self.absolute_weight_sum)
1684    }
1685}
1686
1687struct QuadratureWorkspace<'a, 'b> {
1688    active_mean: &'a [f64],
1689    projected: &'a ProjectedGaussian,
1690    rules: &'a [GaussHermiteRule],
1691    upper_offsets: &'a [usize],
1692    z: Vec<f64>,
1693    active_eta: Vec<f64>,
1694    probabilities: Vec<f64>,
1695    accumulator: QuadratureAccumulator,
1696    total_evaluations: &'b mut usize,
1697    maximum_function_evaluations: usize,
1698}
1699
1700impl<'a, 'b> QuadratureWorkspace<'a, 'b> {
1701    fn new(
1702        active_mean: &'a [f64],
1703        projected: &'a ProjectedGaussian,
1704        rules: &'a [GaussHermiteRule],
1705        upper_offsets: &'a [usize],
1706        moment_count: usize,
1707        total_evaluations: &'b mut usize,
1708        maximum_function_evaluations: usize,
1709    ) -> Result<Self, EstimationError> {
1710        let rank = projected.factor.ncols();
1711        let m = active_mean.len();
1712        Ok(Self {
1713            active_mean,
1714            projected,
1715            rules,
1716            upper_offsets,
1717            z: zeroed_vec(rank, "standard-normal quadrature coordinate")?,
1718            active_eta: zeroed_vec(m, "active-logit quadrature buffer")?,
1719            probabilities: zeroed_vec(m + 1, "softmax quadrature buffer")?,
1720            accumulator: QuadratureAccumulator::new(moment_count)?,
1721            total_evaluations,
1722            maximum_function_evaluations,
1723        })
1724    }
1725
1726    fn stream_compositions(
1727        &mut self,
1728        position: usize,
1729        remaining: usize,
1730        indices: &mut [usize],
1731        coefficient: f64,
1732    ) -> Result<(), EstimationError> {
1733        let dimensions_left = indices.len() - position;
1734        if dimensions_left == 1 {
1735            if remaining == 0 {
1736                return Ok(());
1737            }
1738            indices[position] = remaining;
1739            return self.stream_tensor(0, indices, coefficient);
1740        }
1741        let maximum_here = remaining.saturating_sub(dimensions_left - 1);
1742        for index in 1..=maximum_here {
1743            indices[position] = index;
1744            self.stream_compositions(position + 1, remaining - index, indices, coefficient)?;
1745        }
1746        Ok(())
1747    }
1748
1749    fn stream_tensor(
1750        &mut self,
1751        axis: usize,
1752        indices: &[usize],
1753        weight: f64,
1754    ) -> Result<(), EstimationError> {
1755        if axis == indices.len() {
1756            return self.accumulate_node(weight);
1757        }
1758        let rule_index = indices[axis] - 1;
1759        let node_count = self.rules[rule_index].nodes.len();
1760        for node_index in 0..node_count {
1761            let node = self.rules[rule_index].nodes[node_index];
1762            let node_weight = self.rules[rule_index].weights[node_index];
1763            self.z[axis] = node;
1764            self.stream_tensor(axis + 1, indices, weight * node_weight)?;
1765        }
1766        Ok(())
1767    }
1768
1769    /// Tensor product over one explicitly chosen rule per direction.
1770    ///
1771    /// `stream_tensor` above resolves each axis through `rules[index - 1]`,
1772    /// which forces the rule cache to be dense; the tensor path visits indices
1773    /// that double, so it resolves its axes once and hands them over directly.
1774    fn stream_axes(
1775        &mut self,
1776        axis: usize,
1777        axes: &[&GaussHermiteRule],
1778        weight: f64,
1779    ) -> Result<(), EstimationError> {
1780        if axis == axes.len() {
1781            return self.accumulate_node(weight);
1782        }
1783        let node_count = axes[axis].nodes.len();
1784        for node_index in 0..node_count {
1785            let node = axes[axis].nodes[node_index];
1786            let node_weight = axes[axis].weights[node_index];
1787            self.z[axis] = node;
1788            self.stream_axes(axis + 1, axes, weight * node_weight)?;
1789        }
1790        Ok(())
1791    }
1792
1793    fn accumulate_node(&mut self, weight: f64) -> Result<(), EstimationError> {
1794        if *self.total_evaluations >= self.maximum_function_evaluations {
1795            return Err(EstimationError::InvalidInput(format!(
1796                "multinomial logistic-normal quadrature exhausted its function-evaluation budget ({}) before convergence",
1797                self.maximum_function_evaluations
1798            )));
1799        }
1800        *self.total_evaluations += 1;
1801
1802        for row in 0..self.active_mean.len() {
1803            let mut value = self.active_mean[row];
1804            for column in 0..self.z.len() {
1805                value += self.projected.factor[[row, column]] * self.z[column];
1806            }
1807            self.active_eta[row] = value;
1808        }
1809        softmax_with_reference_into(&self.active_eta, &mut self.probabilities)?;
1810
1811        let k = self.probabilities.len();
1812        self.accumulator.add_weight(weight);
1813        for class in 0..k {
1814            self.accumulator
1815                .add_moment(class, weight * self.probabilities[class]);
1816        }
1817        let second_offset = k;
1818        for row in 0..k {
1819            for column in row..k {
1820                let packed = second_offset + self.upper_offsets[row] + column - row;
1821                self.accumulator.add_moment(
1822                    packed,
1823                    weight * self.probabilities[row] * self.probabilities[column],
1824                );
1825            }
1826        }
1827        Ok(())
1828    }
1829}
1830
1831struct GaussHermiteRule {
1832    /// Nodes already transformed to standard-normal coordinates.
1833    nodes: Vec<f64>,
1834    /// Normalized standard-normal expectation weights (sum to one).
1835    weights: Vec<f64>,
1836}
1837
1838fn gauss_hermite_rule(index: usize) -> Result<GaussHermiteRule, EstimationError> {
1839    let node_count = index
1840        .checked_mul(2)
1841        .and_then(|value| value.checked_sub(1))
1842        .ok_or_else(|| {
1843            EstimationError::InvalidInput(
1844                "multinomial posterior Gauss-Hermite order overflowed usize".to_string(),
1845            )
1846        })?;
1847    let physicists = physicists_gauss_hermite_rule(node_count).map_err(|error| {
1848        EstimationError::InvalidInput(format!(
1849            "multinomial posterior Gauss-Hermite rule {node_count} construction failed: {error}"
1850        ))
1851    })?;
1852    let nodes = physicists
1853        .nodes
1854        .into_iter()
1855        .map(|node| std::f64::consts::SQRT_2 * node)
1856        .collect::<Vec<_>>();
1857    let mut weights = physicists.weights;
1858    let weight_sum: f64 = weights.iter().sum();
1859    if !(weight_sum.is_finite() && weight_sum > 0.0) {
1860        return Err(EstimationError::InvalidInput(format!(
1861            "multinomial posterior Gauss-Hermite rule {node_count} has invalid weight sum {weight_sum}"
1862        )));
1863    }
1864    for weight in &mut weights {
1865        *weight /= weight_sum;
1866    }
1867    Ok(GaussHermiteRule { nodes, weights })
1868}
1869
1870fn binomial_as_f64(n: usize, k: usize) -> Result<f64, EstimationError> {
1871    if k > n {
1872        return Ok(0.0);
1873    }
1874    let k = k.min(n - k);
1875    let mut value = 1.0_f64;
1876    for step in 1..=k {
1877        value *= (n - k + step) as f64 / step as f64;
1878        if !value.is_finite() {
1879            return Err(EstimationError::InvalidInput(format!(
1880                "multinomial posterior Smolyak binomial coefficient C({n},{k}) overflowed f64"
1881            )));
1882        }
1883    }
1884    Ok(value)
1885}
1886
1887/// Reference-coded softmax of one row of ACTIVE logits, with the reference
1888/// class's `η = 0` appended last — the plug-in probability `softmax(η)` at a
1889/// single point, with no posterior integration.
1890///
1891/// Shared with `multinomial::predict_multinomial_formula_plugin` rather than
1892/// re-derived there: the max-shift, the implicit reference logit and the class
1893/// ordering are all conventions this module owns, and a second copy of them is
1894/// a second place for the reference class to move.
1895pub(crate) fn softmax_with_reference(active_eta: &[f64]) -> Result<Vec<f64>, EstimationError> {
1896    let mut probabilities = zeroed_vec(active_eta.len() + 1, "softmax result")?;
1897    softmax_with_reference_into(active_eta, &mut probabilities)?;
1898    Ok(probabilities)
1899}
1900
1901fn softmax_with_reference_into(
1902    active_eta: &[f64],
1903    probabilities: &mut [f64],
1904) -> Result<(), EstimationError> {
1905    if probabilities.len() != active_eta.len() + 1 {
1906        return Err(EstimationError::InvalidInput(format!(
1907            "multinomial posterior softmax buffer length {} does not equal active-logit length {} + 1",
1908            probabilities.len(),
1909            active_eta.len()
1910        )));
1911    }
1912    let maximum = active_eta.iter().copied().fold(0.0_f64, f64::max);
1913    let reference = probabilities.len() - 1;
1914    let mut denominator = (-maximum).exp();
1915    probabilities[reference] = denominator;
1916    for (class, &eta) in active_eta.iter().enumerate() {
1917        let numerator = (eta - maximum).exp();
1918        probabilities[class] = numerator;
1919        denominator += numerator;
1920    }
1921    if !(denominator.is_finite() && denominator > 0.0) {
1922        return Err(EstimationError::InvalidInput(format!(
1923            "multinomial posterior softmax produced invalid denominator {denominator}"
1924        )));
1925    }
1926    for probability in probabilities {
1927        *probability /= denominator;
1928    }
1929    Ok(())
1930}
1931
1932fn moments_from_raw(
1933    raw_moments: Vec<f64>,
1934    k: usize,
1935    latent_rank: usize,
1936    rule: MultinomialPosteriorRule,
1937    function_evaluations: usize,
1938    max_level_difference: f64,
1939    projection_bound: f64,
1940) -> Result<MultinomialPosteriorMoments, EstimationError> {
1941    let upper_offsets = upper_triangle_offsets(k)?;
1942    let raw_error = max_level_difference + projection_bound;
1943    let covariance_error = 3.0 * raw_error + raw_error * raw_error;
1944
1945    let mut means = raw_moments[..k].to_vec();
1946    for (class, mean) in means.iter_mut().enumerate() {
1947        if *mean < -raw_error || *mean > 1.0 + raw_error || !mean.is_finite() {
1948            return Err(EstimationError::InvalidInput(format!(
1949                "multinomial posterior integrated mean for class {class} is outside its certified probability envelope: {mean} (raw error {raw_error:.6e})"
1950            )));
1951        }
1952        *mean = mean.clamp(0.0, 1.0);
1953    }
1954    let mean_sum: f64 = means.iter().sum();
1955    if !(mean_sum.is_finite() && mean_sum > 0.0) {
1956        return Err(EstimationError::InvalidInput(format!(
1957            "multinomial posterior integrated class means have invalid sum {mean_sum}"
1958        )));
1959    }
1960    let simplex_error = (mean_sum - 1.0).abs();
1961    if simplex_error > (k as f64) * raw_error + covariance_roundoff_tolerance(1.0, k) {
1962        return Err(EstimationError::InvalidInput(format!(
1963            "multinomial posterior integrated class means violate the simplex: sum {mean_sum:.17e}, error {simplex_error:.6e}, raw moment error {raw_error:.6e}"
1964        )));
1965    }
1966    for mean in &mut means {
1967        *mean /= mean_sum;
1968    }
1969
1970    let second_offset = k;
1971    let mut covariance = Array2::<f64>::zeros((k, k));
1972    for row in 0..k {
1973        for column in row..k {
1974            let packed = second_offset + upper_offsets[row] + column - row;
1975            let value = raw_moments[packed] - means[row] * means[column];
1976            covariance[[row, column]] = value;
1977            covariance[[column, row]] = value;
1978        }
1979    }
1980    covariance = project_covariance_to_simplex_tangent(&covariance);
1981    covariance = remove_covariance_roundoff(covariance, covariance_error)?;
1982    covariance = project_covariance_to_simplex_tangent(&covariance);
1983
1984    let mut standard_deviation = Array1::<f64>::zeros(k);
1985    for class in 0..k {
1986        let variance = covariance[[class, class]];
1987        if variance < -covariance_error || !variance.is_finite() {
1988            return Err(EstimationError::InvalidInput(format!(
1989                "multinomial posterior variance for class {class} is invalid: {variance:.6e} (covariance error envelope {covariance_error:.6e})"
1990            )));
1991        }
1992        standard_deviation[class] = variance.max(0.0).sqrt();
1993    }
1994
1995    Ok(MultinomialPosteriorMoments {
1996        class_mean: Array1::from_vec(means),
1997        class_covariance: covariance,
1998        class_standard_deviation: standard_deviation,
1999        latent_rank,
2000        rule,
2001        function_evaluations,
2002        max_raw_moment_level_difference: max_level_difference,
2003        covariance_range_projection_bound: projection_bound,
2004    })
2005}
2006
2007fn project_covariance_to_simplex_tangent(covariance: &Array2<f64>) -> Array2<f64> {
2008    let k = covariance.nrows();
2009    let inverse_k = 1.0 / k as f64;
2010    let row_means: Vec<f64> = (0..k)
2011        .map(|row| covariance.row(row).sum() * inverse_k)
2012        .collect();
2013    let column_means: Vec<f64> = (0..k)
2014        .map(|column| covariance.column(column).sum() * inverse_k)
2015        .collect();
2016    let grand_mean = row_means.iter().sum::<f64>() * inverse_k;
2017    Array2::from_shape_fn((k, k), |(row, column)| {
2018        covariance[[row, column]] - row_means[row] - column_means[column] + grand_mean
2019    })
2020}
2021
2022fn remove_covariance_roundoff(
2023    covariance: Array2<f64>,
2024    integration_error: f64,
2025) -> Result<Array2<f64>, EstimationError> {
2026    let symmetric = (&covariance + &covariance.t().to_owned()) * 0.5;
2027    let (eigenvalues, eigenvectors) = symmetric.eigh(faer::Side::Lower).map_err(|error| {
2028        EstimationError::InvalidInput(format!(
2029            "multinomial probability covariance eigendecomposition failed: {error}"
2030        ))
2031    })?;
2032    let scale = eigenvalues
2033        .iter()
2034        .fold(0.0_f64, |maximum, &value| maximum.max(value.abs()));
2035    let allowed_negative =
2036        integration_error + covariance_roundoff_tolerance(scale, covariance.nrows());
2037    let minimum = eigenvalues
2038        .iter()
2039        .fold(f64::INFINITY, |value, &candidate| value.min(candidate));
2040    if minimum < -allowed_negative {
2041        let negative_limit = -allowed_negative;
2042        return Err(EstimationError::InvalidInput(format!(
2043            "multinomial posterior probability covariance is indefinite beyond the integration error: min eigenvalue {minimum:.6e}, allowed {negative_limit:.6e}"
2044        )));
2045    }
2046    let mut scaled_eigenvectors = eigenvectors.clone();
2047    for (column, &eigenvalue) in eigenvalues.iter().enumerate() {
2048        let scale = eigenvalue.max(0.0);
2049        scaled_eigenvectors
2050            .column_mut(column)
2051            .mapv_inplace(|value| value * scale);
2052    }
2053    let reconstructed = scaled_eigenvectors.dot(&eigenvectors.t());
2054    Ok((&reconstructed + &reconstructed.t().to_owned()) * 0.5)
2055}
2056
2057#[cfg(test)]
2058mod tests {
2059    use super::*;
2060
2061    fn control(absolute_tolerance: f64) -> MultinomialPosteriorIntegrationControl {
2062        MultinomialPosteriorIntegrationControl {
2063            absolute_tolerance,
2064            relative_tolerance: absolute_tolerance,
2065            minimum_sparse_level: 2,
2066            maximum_sparse_level: 8,
2067            maximum_function_evaluations: 2_000_000,
2068        }
2069    }
2070
2071    fn assert_close(actual: f64, expected: f64, tolerance: f64, label: &str) {
2072        assert!(
2073            (actual - expected).abs() <= tolerance,
2074            "{label}: actual={actual:.17e}, expected={expected:.17e}, tolerance={tolerance:.3e}"
2075        );
2076    }
2077
2078    /// A refusal must name WHICH raw moment refused and against WHAT (#2612).
2079    ///
2080    /// The aggregate pair the message used to carry -- `max raw-moment level
2081    /// difference` and `max normalized error` -- are maxima over DIFFERENT
2082    /// coordinates, because one is divided by a per-moment tolerance and the
2083    /// other is not. Printed side by side they read as one moment's story, and
2084    /// the observed `1.199663e-2` beside `4.130034e5` invited the conclusion
2085    /// that the normalization divides by something near zero. It does not; they
2086    /// were simply different moments.
2087    #[test]
2088    fn a_refusal_names_the_moment_that_decided_it_2612() {
2089        let active_mean = Array1::from_vec(vec![0.7, -1.3, 2.1]);
2090        let active_covariance =
2091            Array2::from_shape_vec((3, 3), vec![2.5, 0.4, -0.3, 0.4, 3.1, 0.6, -0.3, 0.6, 2.2])
2092                .expect("covariance shape");
2093        // Two levels and a tolerance no sparse rule can reach, so the loop
2094        // exhausts its levels and falls through to the refusal cheaply.
2095        let control = MultinomialPosteriorIntegrationControl {
2096            absolute_tolerance: 1.0e-300,
2097            relative_tolerance: 1.0e-300,
2098            minimum_sparse_level: 2,
2099            maximum_sparse_level: 2,
2100            maximum_function_evaluations: 2_000_000,
2101        };
2102        let error = integrate_logistic_normal_softmax_moments(
2103            active_mean.view(),
2104            active_covariance.view(),
2105            &control,
2106        )
2107        .expect_err("a 1e-300 tolerance at level 2 cannot certify");
2108        let message = error.to_string();
2109        assert!(
2110            message.contains("worst raw moment"),
2111            "the refusal must identify the deciding coordinate: {message}"
2112        );
2113        assert!(
2114            message.contains("/ tolerance"),
2115            "the refusal must show the normalized error's denominator, so the ratio \
2116             can be checked rather than trusted: {message}"
2117        );
2118    }
2119
2120    /// The normalized error must not silently drop the coordinate that refused.
2121    ///
2122    /// `tolerance = absolute_tolerance + relative_tolerance * max(|new|, |old|)`
2123    /// is ZERO whenever `absolute_tolerance` is zero and a raw moment is exactly
2124    /// zero at both levels. The old `if tolerance > 0.0` guard skipped exactly
2125    /// those coordinates, so a moment could set `certified = false` and then
2126    /// contribute nothing to the error the refusal reports. A zero absolute
2127    /// tolerance is admissible -- `validate` only requires that ONE of the two
2128    /// be positive -- so this is a reachable state, not a defensive branch.
2129    #[test]
2130    fn a_zero_absolute_tolerance_is_admissible_so_the_zero_denominator_is_reachable_2612() {
2131        let control = MultinomialPosteriorIntegrationControl {
2132            absolute_tolerance: 0.0,
2133            relative_tolerance: 1.0e-8,
2134            minimum_sparse_level: 2,
2135            maximum_sparse_level: 4,
2136            maximum_function_evaluations: 2_000_000,
2137        };
2138        control
2139            .validate()
2140            .expect("a zero absolute tolerance with a positive relative one is admissible");
2141    }
2142
2143    #[test]
2144    fn binary_reduction_matches_controlled_logistic_normal_identity() {
2145        let active_mean = Array1::from_vec(vec![1.1]);
2146        let active_covariance = Array2::from_shape_vec((1, 1), vec![0.64]).unwrap();
2147        let result = integrate_logistic_normal_softmax_moments(
2148            active_mean.view(),
2149            active_covariance.view(),
2150            &control(1.0e-10),
2151        )
2152        .expect("binary posterior moments");
2153        let (expected_mean, expected_slope) =
2154            gam_solve::quadrature::logit_posterior_meanwith_deriv(1.1, 0.8).unwrap();
2155        let expected_variance = expected_mean - expected_slope - expected_mean * expected_mean;
2156
2157        assert_close(result.class_mean[0], expected_mean, 2.0e-14, "binary mean");
2158        assert_close(
2159            result.class_mean[1],
2160            1.0 - expected_mean,
2161            2.0e-14,
2162            "reference mean",
2163        );
2164        assert_close(
2165            result.class_covariance[[0, 0]],
2166            expected_variance,
2167            2.0e-14,
2168            "binary variance",
2169        );
2170        assert_close(
2171            result.class_covariance[[0, 1]],
2172            -expected_variance,
2173            2.0e-14,
2174            "binary covariance",
2175        );
2176        assert_eq!(result.latent_rank, 1);
2177        assert_eq!(result.rule, MultinomialPosteriorRule::Exact);
2178    }
2179
2180    #[test]
2181    fn zero_covariance_is_exact_softmax_point_mass() {
2182        let active_mean = Array1::from_vec(vec![0.7, -0.4]);
2183        let active_covariance = Array2::<f64>::zeros((2, 2));
2184        let result = integrate_logistic_normal_softmax_moments(
2185            active_mean.view(),
2186            active_covariance.view(),
2187            &control(1.0e-10),
2188        )
2189        .expect("point-mass posterior moments");
2190        let expected = softmax_with_reference(active_mean.as_slice().unwrap()).unwrap();
2191        for class in 0..3 {
2192            assert_close(
2193                result.class_mean[class],
2194                expected[class],
2195                1.0e-15,
2196                "point mean",
2197            );
2198            assert_eq!(result.class_standard_deviation[class], 0.0);
2199            for other in 0..3 {
2200                assert_eq!(result.class_covariance[[class, other]], 0.0);
2201            }
2202        }
2203        assert_eq!(result.latent_rank, 0);
2204        assert_eq!(result.rule, MultinomialPosteriorRule::Exact);
2205    }
2206
2207    #[test]
2208    fn exchangeable_full_logits_require_cross_covariance_and_integrate_to_uniform() {
2209        // If full logits gamma_c are iid N(0,s^2), reference coding gives
2210        // eta_a=gamma_a-gamma_ref, hence diag(V)=2s^2 and offdiag(V)=s^2.
2211        // Exchangeability makes E[p_c]=1/3 exactly.  Dropping the off-diagonal
2212        // covariance destroys that identity for the reference class.
2213        let variance = 0.7;
2214        let active_mean = Array1::zeros(2);
2215        let active_covariance = Array2::from_shape_vec(
2216            (2, 2),
2217            vec![2.0 * variance, variance, variance, 2.0 * variance],
2218        )
2219        .unwrap();
2220        let result = integrate_logistic_normal_softmax_moments(
2221            active_mean.view(),
2222            active_covariance.view(),
2223            &control(2.0e-7),
2224        )
2225        .expect("exchangeable posterior moments");
2226
2227        for class in 0..3 {
2228            assert_close(result.class_mean[class], 1.0 / 3.0, 8.0e-7, "uniform mean");
2229        }
2230        for class in 1..3 {
2231            assert_close(
2232                result.class_covariance[[class, class]],
2233                result.class_covariance[[0, 0]],
2234                2.0e-6,
2235                "exchangeable variance",
2236            );
2237        }
2238        for row in 0..3 {
2239            assert_close(
2240                result.class_covariance.row(row).sum(),
2241                0.0,
2242                2.0e-12,
2243                "simplex covariance row sum",
2244            );
2245        }
2246        assert_eq!(result.latent_rank, 2);
2247        assert_ne!(result.rule, MultinomialPosteriorRule::Exact);
2248    }
2249
2250    #[test]
2251    fn rank_one_general_case_matches_independent_one_dimensional_gh_oracle() {
2252        let active_mean = Array1::from_vec(vec![0.45, -0.7]);
2253        let loading = [0.8_f64, -0.35_f64];
2254        let active_covariance =
2255            Array2::from_shape_fn((2, 2), |(row, column)| loading[row] * loading[column]);
2256        let result = integrate_logistic_normal_softmax_moments(
2257            active_mean.view(),
2258            active_covariance.view(),
2259            &control(5.0e-8),
2260        )
2261        .expect("rank-one posterior moments");
2262        assert_eq!(result.latent_rank, 1);
2263
2264        // Independent high-order one-dimensional GH evaluation of the exact
2265        // rank-one representation eta=mu+loading*Z.
2266        let oracle_rule = gauss_hermite_rule(21).unwrap(); // 41 nodes
2267        let mut oracle_mean = [0.0_f64; 3];
2268        let mut oracle_second = [[0.0_f64; 3]; 3];
2269        for (&z, &weight) in oracle_rule.nodes.iter().zip(oracle_rule.weights.iter()) {
2270            let eta = [
2271                active_mean[0] + loading[0] * z,
2272                active_mean[1] + loading[1] * z,
2273            ];
2274            let probability = softmax_with_reference(&eta).unwrap();
2275            for row in 0..3 {
2276                oracle_mean[row] += weight * probability[row];
2277                for column in 0..3 {
2278                    oracle_second[row][column] += weight * probability[row] * probability[column];
2279                }
2280            }
2281        }
2282        for row in 0..3 {
2283            assert_close(
2284                result.class_mean[row],
2285                oracle_mean[row],
2286                3.0e-7,
2287                "rank-one mean",
2288            );
2289            for column in 0..3 {
2290                let oracle_covariance =
2291                    oracle_second[row][column] - oracle_mean[row] * oracle_mean[column];
2292                assert_close(
2293                    result.class_covariance[[row, column]],
2294                    oracle_covariance,
2295                    8.0e-7,
2296                    "rank-one covariance",
2297                );
2298            }
2299        }
2300        let allowed = 5.0e-8
2301            + 5.0e-8
2302                * result
2303                    .class_mean
2304                    .iter()
2305                    .fold(0.0_f64, |scale, &value| scale.max(value.abs()));
2306        assert!(
2307            result.max_raw_moment_level_difference + result.covariance_range_projection_bound
2308                <= allowed * 1.01,
2309            "returned result must carry the level-difference certificate"
2310        );
2311    }
2312
2313    #[test]
2314    fn insufficient_sparse_level_is_a_typed_error_not_a_plugin_result() {
2315        let active_mean = Array1::from_vec(vec![1.2, -0.8]);
2316        let active_covariance = Array2::from_shape_vec((2, 2), vec![2.0, 0.9, 0.9, 1.5]).unwrap();
2317        let strict_control = MultinomialPosteriorIntegrationControl {
2318            absolute_tolerance: 1.0e-14,
2319            relative_tolerance: 1.0e-14,
2320            minimum_sparse_level: 1,
2321            maximum_sparse_level: 1,
2322            maximum_function_evaluations: 100_000,
2323        };
2324        // Aimed at the sparse rule ITSELF rather than at the entry point:
2325        // the entry point now dispatches this row to the tensor rule, which
2326        // does certify it, so asserting a refusal there would be asserting the
2327        // dispatch rather than the sparse rule's own level bound (#2612).
2328        let symmetric = symmetrized_covariance(active_covariance.view());
2329        let projected =
2330            project_active_covariance(symmetric.view(), strict_control.absolute_tolerance)
2331                .expect("project the active covariance");
2332        let error = integrate_isotropic_sparse(
2333            active_mean.as_slice().expect("contiguous active mean"),
2334            &projected,
2335            &strict_control,
2336            0,
2337        )
2338        .expect_err("one sparse refinement cannot certify this nonlinear integral");
2339        assert!(
2340            error.to_string().contains("did not converge"),
2341            "unexpected error: {error}"
2342        );
2343    }
2344
2345    #[test]
2346    fn materially_indefinite_active_covariance_is_rejected() {
2347        let active_mean = Array1::from_vec(vec![0.0, 0.0]);
2348        let active_covariance = Array2::from_shape_vec((2, 2), vec![1.0, 2.0, 2.0, 1.0]).unwrap();
2349        let error = integrate_logistic_normal_softmax_moments(
2350            active_mean.view(),
2351            active_covariance.view(),
2352            &control(1.0e-7),
2353        )
2354        .expect_err("indefinite covariance must fail");
2355        assert!(error.to_string().contains("not positive semidefinite"));
2356    }
2357
2358    /// Rank-two active-logit covariance with eigenvalues `(1, 25)` in a basis
2359    /// rotated off the coordinate axes: posterior standard deviations `(1, 5)`.
2360    /// Built from its spectrum rather than from literals so the widths the test
2361    /// is about are visible in the source.
2362    fn wide_two_direction_covariance() -> Array2<f64> {
2363        let eigenvalues = [1.0_f64, 25.0_f64];
2364        let (sine, cosine) = 0.6_f64.sin_cos();
2365        let basis = [[cosine, -sine], [sine, cosine]];
2366        Array2::from_shape_fn((2, 2), |(row, column)| {
2367            (0..2)
2368                .map(|index| basis[row][index] * eigenvalues[index] * basis[column][index])
2369                .sum()
2370        })
2371    }
2372
2373    /// A WIDE two-direction posterior: exact conditioning removes one Gaussian
2374    /// direction before quadrature, because the sparse grid cannot reach the
2375    /// simultaneous order the unreduced integrand needs (#2612).
2376    ///
2377    /// Measured at `origin/main` on this exact covariance, the isotropic sparse
2378    /// ladder's error against a converged tensor oracle falls only algebraically
2379    /// -- level 10 to level 16 buys a factor 3.3 for 5.6x the evaluations, from
2380    /// `6.61e-4` to `1.99e-4` -- while the tensor product reaches `3.82e-9` at
2381    /// 129 nodes per direction for 16641 evaluations, fewer than the 28033 the
2382    /// sparse ladder spends to reach `1.99e-4`.
2383    ///
2384    /// The retained posterior here has rank two. A Smolyak grid of level `L`
2385    /// over `r` directions admits only the tensor sub-rules whose
2386    /// one-dimensional indices sum to at most `r + L`, so the only way it gives
2387    /// one direction order `2L + 1` is by giving every other direction a single
2388    /// node. Its BALANCED sub-rule -- the one that resolves both directions at
2389    /// once -- carries roughly order `L` in each. The logistic-normal softmax
2390    /// needs one-dimensional order growing with a direction's own standard
2391    /// deviation, because the softmax transition has width O(1) in the logit
2392    /// while the Gaussian along that direction has width `sqrt(lambda_d)`, so a
2393    /// posterior this wide needs high order in BOTH directions simultaneously
2394    /// and the sparse grid is the one construction that refuses to supply it.
2395    ///
2396    /// The test asserts both halves, so it cannot pass by accident:
2397    ///   * the isotropic sparse rule REFUSES this row at its level ceiling, and
2398    ///   * the conditioned entry point certifies every raw moment against an
2399    ///     independent high-order tensor Gauss-Hermite oracle.
2400    #[test]
2401    fn a_wide_two_direction_posterior_is_reduced_before_quadrature_2612() {
2402        let active_mean = Array1::from_vec(vec![2.0, -1.0]);
2403        let active_covariance = wide_two_direction_covariance();
2404        let control = MultinomialPosteriorIntegrationControl::default();
2405
2406        // Half one: the sparse rule, on its own, cannot certify this row.
2407        let symmetric = symmetrized_covariance(active_covariance.view());
2408        let projected = project_active_covariance(symmetric.view(), control.absolute_tolerance)
2409            .expect("project the active covariance");
2410        assert_eq!(projected.factor.ncols(), 2, "this row must retain rank two");
2411        let sparse_error = integrate_isotropic_sparse(
2412            active_mean.as_slice().expect("contiguous active mean"),
2413            &projected,
2414            &control,
2415            0,
2416        )
2417        .expect_err("the isotropic sparse rule must not certify this wide posterior");
2418        assert!(
2419            sparse_error.to_string().contains("did not converge"),
2420            "unexpected sparse-rule error: {sparse_error}"
2421        );
2422
2423        // Half two: the shipped entry point certifies it after exact
2424        // Rao-Blackwellization to one dimension.
2425        let result = integrate_logistic_normal_softmax_moments(
2426            active_mean.view(),
2427            active_covariance.view(),
2428            &control,
2429        )
2430        .expect("the wide posterior must be integrable");
2431        assert!(matches!(
2432            result.rule,
2433            MultinomialPosteriorRule::ConditionedThreeClass(_)
2434        ));
2435
2436        // And the certified answer is the right one: an independent tensor
2437        // Gauss-Hermite evaluation at an order nothing above chose.
2438        // 301 nodes per direction: at this covariance the 241-node and 301-node
2439        // tensor rules agree to 3.02e-12, so the oracle is converged well below
2440        // what is being asserted.
2441        let oracle_rule = gauss_hermite_rule(151).expect("oracle rule");
2442        let mut oracle_mean = vec![0.0_f64; 3];
2443        let mut oracle_second = [[0.0_f64; 3]; 3];
2444        let mut mass = 0.0_f64;
2445        for (&first_node, &first_weight) in oracle_rule.nodes.iter().zip(oracle_rule.weights.iter())
2446        {
2447            for (&second_node, &second_weight) in
2448                oracle_rule.nodes.iter().zip(oracle_rule.weights.iter())
2449            {
2450                let weight = first_weight * second_weight;
2451                mass += weight;
2452                let eta = [
2453                    active_mean[0]
2454                        + projected.factor[[0, 0]] * first_node
2455                        + projected.factor[[0, 1]] * second_node,
2456                    active_mean[1]
2457                        + projected.factor[[1, 0]] * first_node
2458                        + projected.factor[[1, 1]] * second_node,
2459                ];
2460                let probability = softmax_with_reference(&eta).expect("oracle softmax");
2461                for class in 0..3 {
2462                    oracle_mean[class] += weight * probability[class];
2463                    for other in 0..3 {
2464                        oracle_second[class][other] +=
2465                            weight * probability[class] * probability[other];
2466                    }
2467                }
2468            }
2469        }
2470        for value in &mut oracle_mean {
2471            *value /= mass;
2472        }
2473        for row in &mut oracle_second {
2474            for value in row {
2475                *value /= mass;
2476            }
2477        }
2478        for class in 0..3 {
2479            assert_close(
2480                result.class_mean[class],
2481                oracle_mean[class],
2482                1.0e-7,
2483                "wide posterior class mean",
2484            );
2485            for other in 0..3 {
2486                let oracle_covariance =
2487                    oracle_second[class][other] - oracle_mean[class] * oracle_mean[other];
2488                assert_close(
2489                    result.class_covariance[[class, other]],
2490                    oracle_covariance,
2491                    3.0e-7,
2492                    "wide posterior class covariance",
2493                );
2494            }
2495        }
2496    }
2497
2498    /// The Gauss-Hermite ladder is a property of the requested orders, not of
2499    /// a prediction row.  Reusing it across rows must avoid every repeated
2500    /// Golub-Welsch construction without changing a posterior bit.
2501    #[test]
2502    fn conditioned_three_class_rule_ladder_is_reused_across_rows_2612() {
2503        let active_mean = Array1::from_vec(vec![1.1, -0.6]);
2504        let active_covariance =
2505            Array2::from_shape_vec((2, 2), vec![3.0, 0.7, 0.7, 1.8]).expect("covariance");
2506        let control = control(2.0e-9);
2507        let symmetric = symmetrized_covariance(active_covariance.view());
2508        let projected = project_active_covariance(symmetric.view(), control.absolute_tolerance)
2509            .expect("project covariance");
2510        let mut rules = ConditionedThreeClassRuleLadder::default();
2511
2512        let first = integrate_three_class_conditionally(
2513            active_mean.as_slice().expect("contiguous mean"),
2514            &projected,
2515            &control,
2516            &mut rules,
2517        )
2518        .expect("first prediction row");
2519        let constructions_after_first_row = rules.rules.len();
2520        assert!(
2521            constructions_after_first_row >= 2,
2522            "certification must compare at least two rules"
2523        );
2524
2525        let second = integrate_three_class_conditionally(
2526            active_mean.as_slice().expect("contiguous mean"),
2527            &projected,
2528            &control,
2529            &mut rules,
2530        )
2531        .expect("identical second prediction row");
2532        assert_eq!(
2533            rules.rules.len(),
2534            constructions_after_first_row,
2535            "an identical row must reuse every constructed rule"
2536        );
2537        assert_eq!(first.rule, second.rule);
2538        assert_eq!(first.function_evaluations, second.function_evaluations);
2539        assert_eq!(
2540            first.max_raw_moment_level_difference.to_bits(),
2541            second.max_raw_moment_level_difference.to_bits()
2542        );
2543        for (&left, &right) in first.class_mean.iter().zip(second.class_mean.iter()) {
2544            assert_eq!(left.to_bits(), right.to_bits());
2545        }
2546        for (&left, &right) in first
2547            .class_covariance
2548            .iter()
2549            .zip(second.class_covariance.iter())
2550        {
2551            assert_eq!(left.to_bits(), right.to_bits());
2552        }
2553    }
2554
2555    /// Re-reference and permute all three classes, including exchanging an
2556    /// active class with the reference class.  A structural conditional
2557    /// reduction must commute with this change of coordinates: choosing which
2558    /// logit to condition is a work decision, not a statistical one.
2559    #[test]
2560    fn conditioned_three_class_moments_are_invariant_to_class_permutation_2612() {
2561        let active_mean = Array1::from_vec(vec![1.4, -0.9]);
2562        let active_covariance =
2563            Array2::from_shape_vec((2, 2), vec![7.0, -1.3, -1.3, 2.5]).expect("covariance");
2564        let control = control(2.0e-9);
2565        let original = integrate_logistic_normal_softmax_moments(
2566            active_mean.view(),
2567            active_covariance.view(),
2568            &control,
2569        )
2570        .expect("original conditioned moments");
2571        assert!(matches!(
2572            &original.rule,
2573            MultinomialPosteriorRule::ConditionedThreeClass(_)
2574        ));
2575
2576        // New class j is old class permutation[j].  The new reference is old
2577        // active class zero, so this covers the reference-coding boundary
2578        // rather than merely swapping the two stored active columns.
2579        let permutation = [2usize, 1usize, 0usize];
2580        let old_full_mean = [active_mean[0], active_mean[1], 0.0];
2581        let new_active_mean = Array1::from_vec(vec![
2582            old_full_mean[permutation[0]] - old_full_mean[permutation[2]],
2583            old_full_mean[permutation[1]] - old_full_mean[permutation[2]],
2584        ]);
2585        let old_active_coefficient = |class: usize| match class {
2586            0 => [1.0, 0.0],
2587            1 => [0.0, 1.0],
2588            2 => [0.0, 0.0],
2589            _ => unreachable!("three classes"),
2590        };
2591        let reference_coefficient = old_active_coefficient(permutation[2]);
2592        let transformation = [
2593            {
2594                let class = old_active_coefficient(permutation[0]);
2595                [
2596                    class[0] - reference_coefficient[0],
2597                    class[1] - reference_coefficient[1],
2598                ]
2599            },
2600            {
2601                let class = old_active_coefficient(permutation[1]);
2602                [
2603                    class[0] - reference_coefficient[0],
2604                    class[1] - reference_coefficient[1],
2605                ]
2606            },
2607        ];
2608        let new_active_covariance = Array2::from_shape_fn((2, 2), |(row, column)| {
2609            let mut value = 0.0;
2610            for left in 0..2 {
2611                for right in 0..2 {
2612                    value += transformation[row][left]
2613                        * active_covariance[[left, right]]
2614                        * transformation[column][right];
2615                }
2616            }
2617            value
2618        });
2619        let permuted = integrate_logistic_normal_softmax_moments(
2620            new_active_mean.view(),
2621            new_active_covariance.view(),
2622            &control,
2623        )
2624        .expect("permuted conditioned moments");
2625
2626        for new_class in 0..3 {
2627            let old_class = permutation[new_class];
2628            assert_close(
2629                permuted.class_mean[new_class],
2630                original.class_mean[old_class],
2631                2.0e-8,
2632                "permuted class mean",
2633            );
2634            for new_other in 0..3 {
2635                let old_other = permutation[new_other];
2636                assert_close(
2637                    permuted.class_covariance[[new_class, new_other]],
2638                    original.class_covariance[[old_class, old_other]],
2639                    5.0e-8,
2640                    "permuted class covariance",
2641                );
2642            }
2643        }
2644        for row in 0..3 {
2645            assert_close(
2646                permuted.class_covariance.row(row).sum(),
2647                0.0,
2648                3.0e-14,
2649                "conditioned simplex covariance row sum",
2650            );
2651        }
2652    }
2653
2654    /// The work gap is structural, not a larger allowance.  Learn the outer
2655    /// resolution this posterior needs, then rerun with one fewer evaluation
2656    /// than a square grid at that same per-direction resolution.  The exact
2657    /// conditioned rule still fits because its work is linear in the node
2658    /// count; an unreduced Cartesian rule cannot.
2659    #[test]
2660    fn conditioned_three_class_rule_fits_below_the_corresponding_tensor_cost_2612() {
2661        let active_mean = Array1::from_vec(vec![2.0, -1.0]);
2662        let active_covariance = wide_two_direction_covariance();
2663        let baseline = integrate_logistic_normal_softmax_moments(
2664            active_mean.view(),
2665            active_covariance.view(),
2666            &MultinomialPosteriorIntegrationControl::default(),
2667        )
2668        .expect("baseline conditioned moments");
2669        let nodes = match baseline.rule {
2670            MultinomialPosteriorRule::ConditionedThreeClass(nodes) => nodes,
2671            other => panic!("expected conditioned rule, got {other:?}"),
2672        };
2673        assert!(nodes > 1, "wide posterior must require a refinement");
2674        let square_cost = nodes.checked_mul(nodes).expect("tensor cost");
2675        let tight_control = MultinomialPosteriorIntegrationControl {
2676            maximum_function_evaluations: square_cost - 1,
2677            ..MultinomialPosteriorIntegrationControl::default()
2678        };
2679        let conditioned = integrate_logistic_normal_softmax_moments(
2680            active_mean.view(),
2681            active_covariance.view(),
2682            &tight_control,
2683        )
2684        .expect("conditioned rule must fit below corresponding tensor cost");
2685        assert!(
2686            conditioned.function_evaluations < square_cost,
2687            "conditioned work {} must be below {nodes}²={square_cost}",
2688            conditioned.function_evaluations
2689        );
2690        for class in 0..3 {
2691            assert_close(
2692                conditioned.class_mean[class],
2693                baseline.class_mean[class],
2694                3.0e-8,
2695                "tight-budget conditioned mean",
2696            );
2697        }
2698    }
2699}