Skip to main content

gam_solve/
constrained_posterior.rs

1//! First two moments of the constrained (truncated) Laplace posterior.
2//!
3//! # What the posterior is when inequality constraints are active
4//!
5//! A shape- or box-constrained fit restricts the PRIOR SUPPORT to the feasible
6//! set `C = {β : Aβ ≥ b}` (a user asserting monotonicity is asserting that the
7//! non-monotone coefficient vectors have prior mass zero). The posterior is
8//! therefore the penalized likelihood restricted to `C` and renormalized:
9//!
10//! ```text
11//! π(β | y) ∝ exp(−ℓ_p(β)) · 1_C(β)
12//! ```
13//!
14//! Expanding `ℓ_p` at the constrained mode `β̂` — where the KKT conditions give
15//! `g := ∇ℓ_p(β̂) = A_actᵀλ` with `λ ≥ 0` and `H := ∇²ℓ_p(β̂)` — and completing
16//! the square gives, to Laplace order,
17//!
18//! ```text
19//! π ≈ N(β; β_unc, Σ) truncated to C,   β_unc = β̂ − Σ·∇ℓ_p(β̂),   Σ = φ·H⁻¹
20//! ```
21//!
22//! The Gaussian is centred at the UNCONSTRAINED centre `β_unc`, not at the
23//! boundary mode: truncating a Gaussian does not move its pre-truncation mean,
24//! so a law centred at the KKT mode is a different (half-normal-shaped)
25//! distribution. This is the same law `sample_truncated_gaussian_posterior`
26//! draws from, and for the same reason.
27//!
28//! # The exact decomposition this module computes
29//!
30//! Let `A` be the retained constraint rows (`q × p`), `W = A Σ Aᵀ` and
31//! `G = Σ Aᵀ W⁻¹`, and let `P = I − G A` be the `Σ⁻¹`-orthogonal projector onto
32//! `null(A)`. Split the deviation `d = β − β_unc` as `d = P d + G(A d)` and set
33//! `t = P d`, `u = A β − b = (A β_unc − b) + A d`. Under the UNTRUNCATED law:
34//!
35//! * `Cov(t, u) = P Σ Aᵀ = Σ Aᵀ − Σ Aᵀ W⁻¹ (A Σ Aᵀ) = 0`, so `t` and `u` are
36//!   independent;
37//! * `E[t] = 0` and `Cov(t) = P Σ Pᵀ = Z(Zᵀ Σ⁻¹ Z)⁻¹Zᵀ` for any basis `Z` of
38//!   `null(A)`;
39//! * `u ~ N(A β_unc − b, W)`.
40//!
41//! Feasibility is exactly `u ≥ 0`, which constrains ONLY `u`. Because `t` is
42//! independent of `u`, conditioning leaves `t` untouched, so the truncated
43//! moments are exactly
44//!
45//! ```text
46//! E_π[β]   = β_unc + G·(E[u] − E_untrunc[u])
47//! Cov_π[β] = Σ − G·(W − Cov[u])·Gᵀ
48//! ```
49//!
50//! with `(E[u], Cov[u])` the first two moments of the `q`-dimensional Gaussian
51//! `N(A β_unc − b, W)` restricted to the orthant `u ≥ 0`.
52//!
53//! # Why the two obvious answers are both wrong
54//!
55//! Reading the covariance formula at its two endpoints:
56//!
57//! * `Cov[u] := W` (no truncation) returns `Σ` — the full unconstrained
58//!   covariance. This is the answer that ignores the constraint entirely, and
59//!   it over-states the spread along every constrained direction.
60//! * `Cov[u] := 0` returns `Σ − G W Gᵀ = P Σ Pᵀ`, the active-face reduction
61//!   `Z(ZᵀHZ)⁻¹Zᵀ`, which reports EXACTLY ZERO variance for a fully pinned
62//!   coordinate. That is the `λ → ∞` limit: it is correct only for a
63//!   constraint whose multiplier is infinite.
64//!
65//! An EQUALITY / gauge / identifiability constraint genuinely deletes a degree
66//! of freedom and zero variance is right for it — but those are absorbed into
67//! the basis in this codebase, so the deleted direction is not a coordinate at
68//! all. An INEQUALITY does not delete a direction, it halves one: the posterior
69//! along the constraint normal is supported on a half-line, its mode sits at
70//! the endpoint, and its variance does not. In the scalar case `X ~ N(μ,σ²)`
71//! truncated to `[0,∞)` with `α = −μ/σ` and `λ(α) = φ(α)/Φ̄(α)`,
72//! `Var(X) = σ²(1 + αλ − λ²)`, which is `0.3634σ²` when the mode sits exactly
73//! on the bound and decays as `σ²/α²` only as the multiplier diverges.
74//!
75//! Since truncating a Gaussian to a convex set can only shrink its covariance,
76//! `P Σ Pᵀ ≺ Cov_π[β] ≺ Σ` strictly at every finite multiplier.
77//!
78//! # No tightness predicate
79//!
80//! Nothing here classifies a row as "active". A row enters through its
81//! standardized slack `s_j = (a_jᵀβ_unc − b_j)/sd_j` and its contribution
82//! varies smoothly with it, so a row at slack `1e-9` and a row at slack `0`
83//! give nearly the same answer instead of differing by a full `σ²`. The only
84//! cut is dropping rows whose truncated mass `Φ̄(s_j)` is below double-precision
85//! resolution, a bound read off `f64::EPSILON` rather than tuned.
86
87use gam_math::probability::{
88    normal_cdf, normal_logsf, signed_probit_logcdf_and_mills_ratio, standard_normal_quantile,
89    standard_normal_quantile_from_log_cdf,
90};
91use gam_problem::LinearInequalityConstraints;
92use ndarray::{Array1, Array2, ArrayView2};
93use serde::{Deserialize, Serialize};
94
95/// Relative accuracy demanded of the orthant-moment cubature, measured against
96/// the PRE-TRUNCATION scale `sd_i = sqrt(W_ii)` so the criterion is invariant
97/// to how the constraint rows happen to be scaled.
98///
99/// The target is set by what the number is FOR. The cubature resolves `Δ`, the
100/// variance the truncation removes, and the reported variance is `Σ − GΔGᵀ`
101/// with the removed part never exceeding the total. A relative error `ε` in `Δ`
102/// therefore moves a reported variance by at most `ε` relative, and a reported
103/// standard error by at most `ε/2`. At `1e-3` no reported interval half-width
104/// can move in its fourth significant digit — far below the Laplace
105/// approximation's own error, and far below any resolution a credible interval
106/// carries. Demanding more is not free: the orthant integrand is unbounded at
107/// the cube boundary (the second moment grows like `log 1/(1−x)`), so the
108/// quasi-Monte-Carlo rate is closer to `N⁻¹` than the `N⁻²` a bounded
109/// integrand would give, and every extra digit costs two decades of nodes.
110const ORTHANT_MOMENT_RELATIVE_TOLERANCE: f64 = 1e-3;
111
112/// Cubature point count at the first pass. Each subsequent pass EXTENDS the
113/// node set to twice its length — the Kronecker sequence is a prefix sequence,
114/// so refinement reuses every node already evaluated — until the moments agree
115/// to [`ORTHANT_MOMENT_RELATIVE_TOLERANCE`]. This is a starting point rather
116/// than a budget.
117const ORTHANT_MOMENT_INITIAL_POINTS: usize = 1 << 11;
118
119/// Node count past which the cubature is declared non-convergent and the caller
120/// gets an error rather than an unconverged covariance. Silently reporting the
121/// last iterate would ship an uncertified number into every interval built from
122/// this fit.
123const ORTHANT_MOMENT_MAXIMUM_POINTS: usize = 1 << 20;
124
125/// The low-rank correction that turns the unconstrained Laplace covariance into
126/// the truncated-posterior covariance.
127///
128/// Carrying the factored form rather than a dense `p × p` matrix lets a
129/// consumer that never materializes `Σ` (the factorized inference path, the
130/// prediction backends) apply the same correction with `q` extra solves:
131/// `xᵀΣ_π x = xᵀΣx − ‖Δ^{1/2} Gᵀ x‖²`.
132#[derive(Clone, Debug, Serialize, Deserialize)]
133pub struct ConstrainedPosteriorCorrection {
134    /// `G = Σ Aᵀ W⁻¹`, `p × q`.
135    pub lift: Array2<f64>,
136    /// `Δ = W − Cov[u] ⪰ 0`, `q × q`: the variance the truncation removes from
137    /// the constraint-normal coordinates.
138    pub removed_normal_variance: Array2<f64>,
139    /// `E[u] − E_untrunc[u]`, `q`: how far truncation moves the posterior mean
140    /// in constraint-normal coordinates. Strictly positive componentwise for an
141    /// active face — the posterior mean is interior even when the mode is not.
142    pub normal_mean_shift: Array1<f64>,
143    /// Indices, into the caller's constraint system, of the rows retained.
144    pub rows: Vec<usize>,
145}
146
147impl ConstrainedPosteriorCorrection {
148    /// `Σ ← Σ − G Δ Gᵀ`, in place. The correction is rank `q`, so this never
149    /// allocates a second `p × p` matrix next to the one being corrected.
150    pub fn apply_to_covariance_in_place(&self, covariance: &mut Array2<f64>) {
151        let scaled = self.lift.dot(&self.removed_normal_variance);
152        let p = covariance.nrows();
153        for i in 0..p {
154            for j in 0..=i {
155                let removed = scaled.row(i).dot(&self.lift.row(j));
156                covariance[[i, j]] -= removed;
157                if i != j {
158                    covariance[[j, i]] = covariance[[i, j]];
159                }
160            }
161        }
162    }
163
164    /// `Σ_π = Σ − G Δ Gᵀ`.
165    pub fn apply_to_covariance(&self, covariance: &Array2<f64>) -> Array2<f64> {
166        let mut corrected = covariance.clone();
167        self.apply_to_covariance_in_place(&mut corrected);
168        corrected
169    }
170
171    /// `diag(G Δ Gᵀ)` — the per-coefficient variance the truncation removes,
172    /// for consumers that only ever build the covariance diagonal.
173    pub fn removed_variance_diagonal(&self) -> Array1<f64> {
174        let scaled = self.lift.dot(&self.removed_normal_variance);
175        let p = self.lift.nrows();
176        let mut diagonal = Array1::<f64>::zeros(p);
177        for i in 0..p {
178            diagonal[i] = scaled.row(i).dot(&self.lift.row(i));
179        }
180        diagonal
181    }
182
183    /// `E_π[β] = β_unc + G·(E[u] − E_untrunc[u])`.
184    pub fn posterior_mean(&self, unconstrained_center: &Array1<f64>) -> Array1<f64> {
185        unconstrained_center + &self.lift.dot(&self.normal_mean_shift)
186    }
187}
188
189/// Persisted identity of an inequality-truncated Laplace posterior.
190///
191/// These three objects must remain distinct:
192///
193/// * `mode` is the feasible optimizer solution and the reflective sampler's
194///   valid starting point;
195/// * `unconstrained_center` is the centre of the ambient Gaussian before
196///   truncation and therefore the reflective sampler's target centre;
197/// * the user-facing coefficient vector is the ambient centre plus the
198///   retained correction's normal-coordinate mean shift (or exactly the
199///   ambient centre when truncation is invisible at f64 resolution).
200///
201/// Keeping the two locations next to the factored moment correction prevents a
202/// saved model from re-deriving either location from row evidence or from
203/// treating the reported posterior mean as though it were the optimizer mode.
204#[derive(Clone, Debug, Serialize, Deserialize)]
205pub struct ConstrainedPosteriorGeometry {
206    /// Exact inequality system `Aβ ≥ b` in the same coefficient frame as the
207    /// locations, correction lift, and ambient precision.
208    pub constraints: LinearInequalityConstraints,
209    pub mode: Array1<f64>,
210    pub unconstrained_center: Array1<f64>,
211    /// Moment correction when at least one inequality changes the answer at
212    /// f64 resolution. `None` still records that an inequality system was
213    /// fitted; it means the ambient centre is far enough inside every row that
214    /// truncation is numerically invisible.
215    pub correction: Option<ConstrainedPosteriorCorrection>,
216}
217
218impl ConstrainedPosteriorGeometry {
219    pub fn posterior_mean(&self) -> Array1<f64> {
220        self.correction
221            .as_ref()
222            .map(|correction| correction.posterior_mean(&self.unconstrained_center))
223            .unwrap_or_else(|| self.unconstrained_center.clone())
224    }
225
226    pub fn validate_for_dimension(&self, dimension: usize) -> Result<(), String> {
227        if self.constraints.a.ncols() != dimension
228            || self.constraints.a.nrows() != self.constraints.b.len()
229        {
230            return Err(format!(
231                "constrained posterior inequalities have shape {}x{} with {} bounds, expected {dimension} columns",
232                self.constraints.a.nrows(),
233                self.constraints.a.ncols(),
234                self.constraints.b.len()
235            ));
236        }
237        if self.mode.len() != dimension || self.unconstrained_center.len() != dimension {
238            return Err(format!(
239                "constrained posterior locations have lengths mode={} and center={}, expected {dimension}",
240                self.mode.len(),
241                self.unconstrained_center.len()
242            ));
243        }
244        if self
245            .mode
246            .iter()
247            .chain(self.unconstrained_center.iter())
248            .chain(self.constraints.a.iter())
249            .chain(self.constraints.b.iter())
250            .any(|value| !value.is_finite())
251        {
252            return Err("constrained posterior geometry contains a non-finite value".to_string());
253        }
254        if let Some(correction) = self.correction.as_ref() {
255            let q = correction.lift.ncols();
256            if correction.lift.nrows() != dimension {
257                return Err(format!(
258                    "constrained posterior lift has {} rows, expected {dimension}",
259                    correction.lift.nrows()
260                ));
261            }
262            if correction.removed_normal_variance.dim() != (q, q)
263                || correction.normal_mean_shift.len() != q
264                || correction.rows.len() != q
265            {
266                return Err(format!(
267                    "constrained posterior normal geometry is inconsistent: lift={}x{q}, removed={:?}, mean={}, rows={}",
268                    correction.lift.nrows(),
269                    correction.removed_normal_variance.dim(),
270                    correction.normal_mean_shift.len(),
271                    correction.rows.len()
272                ));
273            }
274            let mut unique_rows = correction.rows.clone();
275            unique_rows.sort_unstable();
276            unique_rows.dedup();
277            if unique_rows.len() != q
278                || unique_rows
279                    .iter()
280                    .any(|&row| row >= self.constraints.a.nrows())
281            {
282                return Err(format!(
283                    "constrained posterior retained rows {:?} are not unique valid indices for {} inequalities",
284                    correction.rows,
285                    self.constraints.a.nrows()
286                ));
287            }
288            if correction
289                .lift
290                .iter()
291                .chain(correction.removed_normal_variance.iter())
292                .chain(correction.normal_mean_shift.iter())
293                .any(|value| !value.is_finite())
294            {
295                return Err(
296                    "constrained posterior correction contains a non-finite value".to_string()
297                );
298            }
299        }
300        Ok(())
301    }
302}
303
304/// Equal-tailed interval for one linear projection of an inequality-truncated
305/// Gaussian posterior.
306///
307/// `ambient_covariance` is the pre-truncation covariance `Σ` in the active
308/// coefficient frame and `contrast` defines the scalar `cᵀβ`.  The affine
309/// shift of a saved coefficient gauge is deliberately not accepted here:
310/// callers add that deterministic shift to both returned endpoints.
311///
312/// The decomposition in this module makes the projection
313///
314/// ```text
315/// cᵀβ = cᵀβ_unc + cᵀt + (Gᵀc)ᵀ(u - E_untrunc[u]),
316/// ```
317///
318/// where `cᵀt` is an independent scalar Gaussian and `u` is the retained
319/// orthant-truncated Gaussian.  The interval therefore comes from the quantiles
320/// of that convolution, not from `posterior_mean ± z·posterior_sd`.
321pub fn constrained_projection_equal_tailed_interval(
322    ambient_covariance: &Array2<f64>,
323    geometry: &ConstrainedPosteriorGeometry,
324    contrast: &Array1<f64>,
325    level: f64,
326) -> Result<(f64, f64), String> {
327    let p = contrast.len();
328    geometry.validate_for_dimension(p)?;
329    if ambient_covariance.dim() != (p, p) {
330        return Err(format!(
331            "constrained projection interval needs a {p}x{p} ambient covariance, got {:?}",
332            ambient_covariance.dim()
333        ));
334    }
335    if !(level.is_finite() && level > 0.0 && level < 1.0) {
336        return Err(format!(
337            "constrained projection interval level must lie in (0, 1), got {level}"
338        ));
339    }
340    if ambient_covariance.iter().any(|value| !value.is_finite())
341        || contrast.iter().any(|value| !value.is_finite())
342    {
343        return Err(
344            "constrained projection interval received a non-finite covariance or contrast"
345                .to_string(),
346        );
347    }
348
349    let ambient_mean = contrast.dot(&geometry.unconstrained_center);
350    let sigma_c = ambient_covariance.dot(contrast);
351    let ambient_variance = contrast.dot(&sigma_c);
352    let covariance_scale = ambient_covariance
353        .diag()
354        .iter()
355        .map(|value| value.abs())
356        .fold(f64::MIN_POSITIVE, f64::max);
357    let contrast_scale = contrast.dot(contrast).max(f64::MIN_POSITIVE);
358    let variance_floor =
359        (p.max(1) as f64) * f64::EPSILON * covariance_scale * contrast_scale;
360    if ambient_variance < -variance_floor || !ambient_variance.is_finite() {
361        return Err(format!(
362            "constrained projection interval has invalid ambient variance {ambient_variance:.6e}"
363        ));
364    }
365    let ambient_variance = ambient_variance.max(0.0);
366    let alpha = 0.5 * (1.0 - level);
367
368    let Some(correction) = geometry.correction.as_ref() else {
369        let sd = ambient_variance.sqrt();
370        if sd == 0.0 {
371            return Ok((ambient_mean, ambient_mean));
372        }
373        let z = standard_normal_quantile(1.0 - alpha)
374            .map_err(|error| format!("constrained projection normal quantile: {error}"))?;
375        return Ok((ambient_mean - z * sd, ambient_mean + z * sd));
376    };
377
378    let q = correction.rows.len();
379    let mut normal_center = Array1::<f64>::zeros(q);
380    let mut normal_covariance = Array2::<f64>::zeros((q, q));
381    let mut sigma_a = Array2::<f64>::zeros((p, q));
382    for (position, &row) in correction.rows.iter().enumerate() {
383        let a = geometry.constraints.a.row(row);
384        normal_center[position] = a.dot(&geometry.unconstrained_center)
385            - geometry.constraints.b[row];
386        sigma_a
387            .column_mut(position)
388            .assign(&ambient_covariance.dot(&a));
389    }
390    for i in 0..q {
391        let ai = geometry.constraints.a.row(correction.rows[i]);
392        for j in 0..=i {
393            let value = ai.dot(&sigma_a.column(j));
394            normal_covariance[[i, j]] = value;
395            normal_covariance[[j, i]] = value;
396        }
397    }
398
399    let projection_lift = correction.lift.t().dot(contrast);
400    let normal_component_variance =
401        projection_lift.dot(&normal_covariance.dot(&projection_lift));
402    let residual_variance = ambient_variance - normal_component_variance;
403    let residual_floor = (p.max(q).max(1) as f64)
404        * f64::EPSILON
405        * ambient_variance.max(normal_component_variance).max(f64::MIN_POSITIVE);
406    if residual_variance < -residual_floor || !residual_variance.is_finite() {
407        return Err(format!(
408            "constrained projection decomposition produced residual variance \
409             {residual_variance:.6e} from ambient {ambient_variance:.6e}"
410        ));
411    }
412    let residual_variance = residual_variance.max(0.0);
413    let posterior_mean =
414        ambient_mean + projection_lift.dot(&correction.normal_mean_shift);
415    if q == 1 && residual_variance == 0.0 && projection_lift[0] != 0.0 {
416        let scalar_quantile = |probability: f64| -> Result<f64, String> {
417            let normal_probability = if projection_lift[0] > 0.0 {
418                probability
419            } else {
420                1.0 - probability
421            };
422            let value = scalar_lower_truncated_quantile(
423                normal_center[0],
424                normal_covariance[[0, 0]],
425                normal_probability,
426            )?;
427            Ok(ambient_mean + projection_lift[0] * (value - normal_center[0]))
428        };
429        return Ok((scalar_quantile(alpha)?, scalar_quantile(1.0 - alpha)?));
430    }
431    let nodes = converged_projection_nodes(
432        &normal_center,
433        &normal_covariance,
434        &projection_lift,
435        ambient_mean,
436    )?;
437    let lower = projection_quantile(
438        &nodes,
439        residual_variance,
440        alpha,
441        posterior_mean,
442        ambient_variance.sqrt(),
443    )?;
444    let upper = projection_quantile(
445        &nodes,
446        residual_variance,
447        1.0 - alpha,
448        posterior_mean,
449        ambient_variance.sqrt(),
450    )?;
451    Ok((lower, upper))
452}
453
454fn scalar_lower_truncated_quantile(
455    mean: f64,
456    variance: f64,
457    probability: f64,
458) -> Result<f64, String> {
459    if !(variance.is_finite() && variance > 0.0) {
460        return Err(format!(
461            "scalar truncated quantile needs positive finite variance, got {variance:?}"
462        ));
463    }
464    if !(probability.is_finite() && probability > 0.0 && probability < 1.0) {
465        return Err(format!(
466            "scalar truncated quantile probability must lie in (0, 1), got {probability}"
467        ));
468    }
469    let sd = variance.sqrt();
470    let alpha = -mean / sd;
471    // P(Z > z | Z >= alpha) = (1-p) P(Z >= alpha). Work entirely in
472    // log-survival space so a deeply pinned face never forms `1-Phi(alpha)`.
473    let log_tail = (1.0 - probability).ln() + normal_logsf(alpha);
474    let z = -standard_normal_quantile_from_log_cdf(log_tail)
475        .map_err(|error| format!("scalar truncated quantile: {error}"))?;
476    Ok(mean + sd * z)
477}
478
479/// Build the truncated-posterior correction for a fit carrying linear
480/// inequality constraints, or `None` when no constraint row is close enough to
481/// the posterior centre to move the answer at double precision.
482///
483/// * `covariance` — `Σ`, the PRE-TRUNCATION posterior covariance on the same
484///   coefficient frame as `constraints` and `unconstrained_center`. This is the
485///   dispersion-scaled `φ·H⁻¹`: truncation is a statement about the posterior's
486///   own spread, so it must be applied in the scaled metric, not to `H⁻¹`.
487/// * `unconstrained_center` — `β_unc = β̂ − Σ·∇ℓ_p(β̂)`.
488/// * `constraints` — `A β ≥ b`.
489///
490/// `None` is returned when every row's standardized slack exceeds the
491/// resolution horizon, which includes the case of a fit whose constraints are
492/// all inactive. Callers must then report `Σ` unchanged, bit for bit.
493pub fn constrained_posterior_correction_from_covariance(
494    covariance: &Array2<f64>,
495    unconstrained_center: &Array1<f64>,
496    constraints: &LinearInequalityConstraints,
497) -> Result<Option<ConstrainedPosteriorCorrection>, String> {
498    let p = covariance.nrows();
499    if covariance.ncols() != p {
500        return Err(format!(
501            "constrained posterior correction needs a square covariance, got {}x{}",
502            covariance.nrows(),
503            covariance.ncols()
504        ));
505    }
506    if constraints.a.ncols() != p {
507        return Err(format!(
508            "constrained posterior correction: covariance is {p}x{p} but the constraint \
509             system has {} columns",
510            constraints.a.ncols()
511        ));
512    }
513    let sigma_times_at = covariance.dot(&constraints.a.t());
514    constrained_posterior_correction(sigma_times_at.view(), unconstrained_center, constraints)
515}
516
517/// Same correction for a caller that never materializes `Σ`.
518///
519/// Everything the decomposition needs from the covariance is the `p × m` block
520/// `Σ Aᵀ` — column `j` is `Σ a_j`, `W_ij = a_iᵀ(Σ a_j)`, and the lift is
521/// `(Σ Aᵀ)W⁻¹` — so a factorized inference path supplies `m` solves instead of
522/// a `p × p` inverse.
523pub fn constrained_posterior_correction(
524    sigma_times_constraint_transpose: ArrayView2<'_, f64>,
525    unconstrained_center: &Array1<f64>,
526    constraints: &LinearInequalityConstraints,
527) -> Result<Option<ConstrainedPosteriorCorrection>, String> {
528    let p = sigma_times_constraint_transpose.nrows();
529    if sigma_times_constraint_transpose.ncols() != constraints.a.nrows() {
530        return Err(format!(
531            "constrained posterior correction: the constraint system has {} rows but \
532             Sigma·Aᵀ has {} columns",
533            constraints.a.nrows(),
534            sigma_times_constraint_transpose.ncols()
535        ));
536    }
537    if unconstrained_center.len() != p {
538        return Err(format!(
539            "constrained posterior correction: Sigma·Aᵀ has {p} rows but the centre has \
540             length {}",
541            unconstrained_center.len()
542        ));
543    }
544    if constraints.a.ncols() != p {
545        return Err(format!(
546            "constrained posterior correction: Sigma·Aᵀ has {p} rows but the constraint \
547             system has {} columns",
548            constraints.a.ncols()
549        ));
550    }
551
552    // A row whose remaining feasible mass `Φ̄(s)` is below `f64::EPSILON` cannot
553    // change any moment at double precision, so the horizon is read off the
554    // machine epsilon rather than chosen.
555    let slack_horizon = -standard_normal_quantile(f64::EPSILON)
556        .map_err(|error| format!("resolution horizon for the constraint slack: {error}"))?;
557
558    // Order candidates by standardized slack so the greedy rank filter below
559    // keeps the rows that bind hardest when a face carries redundant rows.
560    let mut candidates: Vec<(usize, f64, Array1<f64>)> = Vec::new();
561    for row_index in 0..constraints.a.nrows() {
562        let row = constraints.a.row(row_index).to_owned();
563        let sigma_row = sigma_times_constraint_transpose
564            .column(row_index)
565            .to_owned();
566        let variance = row.dot(&sigma_row);
567        if !(variance.is_finite() && variance > 0.0) {
568            // The constraint normal has no posterior spread at all: the fit
569            // cannot move along it, so the truncation removes nothing.
570            continue;
571        }
572        let slack = (row.dot(unconstrained_center) - constraints.b[row_index]) / variance.sqrt();
573        if !slack.is_finite() {
574            return Err(format!(
575                "constraint row {row_index} produced a non-finite standardized slack"
576            ));
577        }
578        if slack < slack_horizon {
579            candidates.push((row_index, slack, sigma_row));
580        }
581    }
582    if candidates.is_empty() {
583        return Ok(None);
584    }
585    candidates.sort_by(|left, right| {
586        left.1
587            .partial_cmp(&right.1)
588            .unwrap_or(std::cmp::Ordering::Equal)
589            .then_with(|| left.0.cmp(&right.0))
590    });
591
592    // Greedy pivoted-Cholesky rank filter on `W = A Σ Aᵀ`. A row that is a
593    // linear combination of already-accepted rows adds no constraint-normal
594    // direction; keeping it would make `W` singular and `W⁻¹` meaningless.
595    let mut rows: Vec<usize> = Vec::new();
596    let mut sigma_a_columns: Vec<Array1<f64>> = Vec::new();
597    let mut offsets: Vec<f64> = Vec::new();
598    let mut w_accepted = Array2::<f64>::zeros((0, 0));
599    let mut factor = Array2::<f64>::zeros((0, 0));
600    for (row_index, _, sigma_row) in candidates {
601        let row = constraints.a.row(row_index);
602        let accepted = rows.len();
603        let diagonal = row.dot(&sigma_row);
604        let mut cross = Array1::<f64>::zeros(accepted);
605        for (position, column) in sigma_a_columns.iter().enumerate() {
606            cross[position] = row.dot(column);
607        }
608        // Forward-substitute the new column through the accepted factor.
609        let mut new_column = Array1::<f64>::zeros(accepted);
610        for i in 0..accepted {
611            let mut sum = cross[i];
612            for k in 0..i {
613                sum -= factor[[i, k]] * new_column[k];
614            }
615            new_column[i] = sum / factor[[i, i]];
616        }
617        let pivot = diagonal - new_column.dot(&new_column);
618        let rank_floor = (accepted + 1) as f64 * f64::EPSILON * diagonal;
619        if !(pivot.is_finite() && pivot > rank_floor) {
620            continue;
621        }
622        let mut grown = Array2::<f64>::zeros((accepted + 1, accepted + 1));
623        grown
624            .slice_mut(ndarray::s![..accepted, ..accepted])
625            .assign(&factor);
626        for i in 0..accepted {
627            grown[[accepted, i]] = new_column[i];
628        }
629        grown[[accepted, accepted]] = pivot.sqrt();
630        factor = grown;
631
632        let mut grown_w = Array2::<f64>::zeros((accepted + 1, accepted + 1));
633        grown_w
634            .slice_mut(ndarray::s![..accepted, ..accepted])
635            .assign(&w_accepted);
636        for i in 0..accepted {
637            grown_w[[accepted, i]] = cross[i];
638            grown_w[[i, accepted]] = cross[i];
639        }
640        grown_w[[accepted, accepted]] = diagonal;
641        w_accepted = grown_w;
642
643        rows.push(row_index);
644        sigma_a_columns.push(sigma_row);
645        offsets.push(constraints.b[row_index]);
646    }
647    if rows.is_empty() {
648        return Ok(None);
649    }
650
651    let q = rows.len();
652    let mut sigma_at = Array2::<f64>::zeros((p, q));
653    for (position, column) in sigma_a_columns.iter().enumerate() {
654        sigma_at.column_mut(position).assign(column);
655    }
656    // `G = Σ Aᵀ W⁻¹` solved through the factor built above, one column of `Gᵀ`
657    // at a time: `W Gᵀ_col = (Σ Aᵀ)ᵀ_col`.
658    let lift = cholesky_solve_right(&factor, &sigma_at)?;
659
660    let mut normal_center = Array1::<f64>::zeros(q);
661    for (position, &row_index) in rows.iter().enumerate() {
662        normal_center[position] =
663            constraints.a.row(row_index).dot(unconstrained_center) - offsets[position];
664    }
665
666    let (normal_mean, normal_covariance) = orthant_truncated_moments(&normal_center, &w_accepted)?;
667
668    let mut removed = &w_accepted - &normal_covariance;
669    symmetrize_in_place(&mut removed);
670    certify_removed_variance(&removed, &w_accepted)?;
671
672    Ok(Some(ConstrainedPosteriorCorrection {
673        lift,
674        removed_normal_variance: removed,
675        normal_mean_shift: normal_mean - normal_center,
676        rows,
677    }))
678}
679
680/// Solve `X W = B` for `X` given the lower Cholesky factor `L` of the symmetric
681/// `W = L Lᵀ`, i.e. return `B W⁻¹`. `W` is symmetric so `X = (W⁻¹ Bᵀ)ᵀ`.
682fn cholesky_solve_right(factor: &Array2<f64>, b: &Array2<f64>) -> Result<Array2<f64>, String> {
683    let q = factor.nrows();
684    if b.ncols() != q {
685        return Err(format!(
686            "constraint-normal solve: factor is {q}x{q} but the right-hand side has {} columns",
687            b.ncols()
688        ));
689    }
690    let rows = b.nrows();
691    let mut out = Array2::<f64>::zeros((rows, q));
692    let mut work = Array1::<f64>::zeros(q);
693    for r in 0..rows {
694        for i in 0..q {
695            let mut sum = b[[r, i]];
696            for k in 0..i {
697                sum -= factor[[i, k]] * work[k];
698            }
699            work[i] = sum / factor[[i, i]];
700        }
701        for i in (0..q).rev() {
702            let mut sum = work[i];
703            for k in (i + 1)..q {
704                sum -= factor[[k, i]] * out[[r, k]];
705            }
706            out[[r, i]] = sum / factor[[i, i]];
707        }
708    }
709    Ok(out)
710}
711
712/// Refuse a correction that is not a genuine variance REMOVAL. `Δ = W − Cov[u]`
713/// must be positive semidefinite (truncation cannot inflate a Gaussian's
714/// covariance) and must not exceed `W` (it cannot remove more variance than
715/// there was). Either failure means the cubature returned something that is not
716/// the moment of a distribution, which is a numerical failure and not a number
717/// to report.
718fn certify_removed_variance(removed: &Array2<f64>, w: &Array2<f64>) -> Result<(), String> {
719    let q = removed.nrows();
720    // Scale-free bound: both `Δ` and `W − Δ = Cov[u]` are checked against the
721    // cubature's own accuracy in the pre-truncation metric.
722    let slack = ORTHANT_MOMENT_RELATIVE_TOLERANCE * (q as f64);
723    for i in 0..q {
724        let scale = w[[i, i]];
725        if removed[[i, i]] < -slack * scale {
726            return Err(format!(
727                "truncated orthant moments inflated the constraint-normal variance at index {i} \
728                 (removed {:.6e} against scale {scale:.6e}); truncation cannot increase a \
729                 Gaussian covariance",
730                removed[[i, i]]
731            ));
732        }
733        if removed[[i, i]] > (1.0 + slack) * scale {
734            return Err(format!(
735                "truncated orthant moments removed more variance than exists at index {i} \
736                 (removed {:.6e} against scale {scale:.6e})",
737                removed[[i, i]]
738            ));
739        }
740        for j in 0..q {
741            if !removed[[i, j]].is_finite() {
742                return Err(format!(
743                    "truncated orthant moments produced a non-finite entry at ({i},{j})"
744                ));
745            }
746        }
747    }
748    Ok(())
749}
750
751fn symmetrize_in_place(matrix: &mut Array2<f64>) {
752    let n = matrix.nrows();
753    for i in 0..n {
754        for j in (i + 1)..n {
755            let averaged = 0.5 * (matrix[[i, j]] + matrix[[j, i]]);
756            matrix[[i, j]] = averaged;
757            matrix[[j, i]] = averaged;
758        }
759    }
760}
761
762/// First two moments of `u ~ N(mean, covariance)` restricted to the orthant
763/// `u ≥ 0`.
764///
765/// One dimension has the closed form and is evaluated exactly. Higher
766/// dimensions use the Genz separation-of-variables transformation, under which
767/// EVERY moment is an integral of the same integrand over the unit cube — so a
768/// single cubature delivers the normalizing orthant probability, the mean and
769/// the second moment together, instead of the `O(q²)` separate orthant
770/// probabilities the Tallis face/edge recursion would need.
771fn orthant_truncated_moments(
772    mean: &Array1<f64>,
773    covariance: &Array2<f64>,
774) -> Result<(Array1<f64>, Array2<f64>), String> {
775    let q = mean.len();
776    if covariance.nrows() != q || covariance.ncols() != q {
777        return Err(format!(
778            "orthant moments: mean has length {q} but the covariance is {}x{}",
779            covariance.nrows(),
780            covariance.ncols()
781        ));
782    }
783    if q == 1 {
784        return scalar_truncated_moments(mean[0], covariance[[0, 0]]);
785    }
786
787    let factor = gam_linalg::triangular::cholesky_factor_in_place(
788        covariance.view(),
789        gam_linalg::triangular::CholeskyGuard::FiniteStrict,
790    )
791    .ok_or_else(|| {
792        "orthant moments: the constraint-normal covariance W = AΣAᵀ is not numerically \
793         positive definite"
794            .to_string()
795    })?;
796
797    let generator = kronecker_generator(q);
798    let mut accumulator = OrthantAccumulator::new(q);
799    let mut evaluated = 0usize;
800    let mut previous: Option<(Array1<f64>, Array2<f64>)> = None;
801    loop {
802        let target = if evaluated == 0 {
803            ORTHANT_MOMENT_INITIAL_POINTS
804        } else {
805            evaluated * 2
806        };
807        accumulate_orthant_nodes(
808            &mut accumulator,
809            mean,
810            factor.view(),
811            &generator,
812            evaluated,
813            target,
814        )?;
815        evaluated = target;
816        let current = accumulator.moments()?;
817        if let Some(ref last) = previous
818            && moment_relative_change(last, &current, covariance)
819                <= ORTHANT_MOMENT_RELATIVE_TOLERANCE
820        {
821            return Ok(current);
822        }
823        if evaluated >= ORTHANT_MOMENT_MAXIMUM_POINTS {
824            let change = previous
825                .as_ref()
826                .map(|last| moment_relative_change(last, &current, covariance))
827                .unwrap_or(f64::INFINITY);
828            return Err(format!(
829                "orthant moments for a {q}-dimensional constraint face did not converge: \
830                 relative moment change {change:.3e} still exceeds \
831                 {ORTHANT_MOMENT_RELATIVE_TOLERANCE:.1e} at {evaluated} cubature nodes"
832            ));
833        }
834        previous = Some(current);
835    }
836}
837
838/// Running log-scaled first and second moment accumulator for the cubature.
839///
840/// Node weights span hundreds of decades between a barely-truncated face and a
841/// deeply pinned one, so the accumulators carry an explicit log scale and are
842/// rescaled whenever a heavier node arrives. Accumulating the weights directly
843/// would underflow the whole face to zero and leave the normalized moments as
844/// `0/0`.
845struct OrthantAccumulator {
846    log_scale: f64,
847    weight_sum: f64,
848    weighted_mean: Array1<f64>,
849    weighted_second: Array2<f64>,
850}
851
852trait OrthantNodeSink {
853    fn push(&mut self, log_weight: f64, point: &Array1<f64>);
854}
855
856impl OrthantAccumulator {
857    fn new(q: usize) -> Self {
858        Self {
859            log_scale: f64::NEG_INFINITY,
860            weight_sum: 0.0,
861            weighted_mean: Array1::zeros(q),
862            weighted_second: Array2::zeros((q, q)),
863        }
864    }
865
866    fn push(&mut self, log_weight: f64, point: &Array1<f64>) {
867        let q = point.len();
868        if log_weight > self.log_scale {
869            let rescale = (self.log_scale - log_weight).exp();
870            self.weight_sum *= rescale;
871            self.weighted_mean *= rescale;
872            self.weighted_second *= rescale;
873            self.log_scale = log_weight;
874        }
875        let weight = (log_weight - self.log_scale).exp();
876        self.weight_sum += weight;
877        for i in 0..q {
878            self.weighted_mean[i] += weight * point[i];
879            for j in 0..=i {
880                self.weighted_second[[i, j]] += weight * point[i] * point[j];
881            }
882        }
883    }
884
885    fn moments(&self) -> Result<(Array1<f64>, Array2<f64>), String> {
886        if !(self.weight_sum.is_finite() && self.weight_sum > 0.0) {
887            return Err(format!(
888                "orthant cubature accumulated no feasible mass (weight sum {:?}); the \
889                 constraint face has no representable interior",
890                self.weight_sum
891            ));
892        }
893        let q = self.weighted_mean.len();
894        let mean = &self.weighted_mean / self.weight_sum;
895        let mut covariance = Array2::<f64>::zeros((q, q));
896        for i in 0..q {
897            for j in 0..=i {
898                let centered = self.weighted_second[[i, j]] / self.weight_sum - mean[i] * mean[j];
899                covariance[[i, j]] = centered;
900                covariance[[j, i]] = centered;
901            }
902        }
903        Ok((mean, covariance))
904    }
905}
906
907impl OrthantNodeSink for OrthantAccumulator {
908    fn push(&mut self, log_weight: f64, point: &Array1<f64>) {
909        OrthantAccumulator::push(self, log_weight, point);
910    }
911}
912
913/// Evaluate Genz nodes `first..last` of the Kronecker sequence and fold them
914/// into `accumulator`.
915fn accumulate_orthant_nodes<S: OrthantNodeSink>(
916    accumulator: &mut S,
917    mean: &Array1<f64>,
918    factor: ArrayView2<'_, f64>,
919    generator: &[f64],
920    first: usize,
921    last: usize,
922) -> Result<(), String> {
923    let q = mean.len();
924    let mut z = Array1::<f64>::zeros(q);
925    let mut point = Array1::<f64>::zeros(q);
926    for node in first..last {
927        let offset = node as f64 + 0.5;
928        let mut log_weight = 0.0f64;
929        for i in 0..q {
930            let mut bound = -mean[i];
931            for j in 0..i {
932                bound -= factor[[i, j]] * z[j];
933            }
934            let lower = bound / factor[[i, i]];
935            let log_tail = normal_logsf(lower);
936            if !log_tail.is_finite() {
937                // The remaining feasible mass along this coordinate underflowed
938                // to zero: the node contributes nothing and cannot be
939                // renormalized, so drop it rather than propagate a NaN.
940                log_weight = f64::NEG_INFINITY;
941                break;
942            }
943            log_weight += log_tail;
944            // Tent-periodized Kronecker lattice. The raw sequence leaves the
945            // integrand non-periodic across the cube face, which costs the
946            // lattice rule most of its rate; folding `x ↦ 1 − |2x − 1|`
947            // preserves the uniform measure and periodizes it.
948            let lattice = {
949                let raw = offset * generator[i];
950                let fractional = raw - raw.floor();
951                1.0 - (2.0 * fractional - 1.0).abs()
952            };
953            // `Φ̄(z_i) = (1 − x_i)·Φ̄(lower)` inverted on the upper tail, so a
954            // deeply pinned coordinate never forms `1 − Φ(·)` in probability
955            // space. Both factors can round to one (an inactive coordinate at
956            // the very edge of the lattice cell), which would ask for `Φ̄⁻¹(1)`;
957            // the smallest representable log-probability answers that with the
958            // far-left endpoint, which is what the region actually is there.
959            let log_fraction = (1.0 - lattice).max(f64::MIN_POSITIVE).ln();
960            let log_upper_tail = log_fraction + log_tail;
961            let resolved = if log_upper_tail < 0.0 {
962                log_upper_tail
963            } else {
964                -f64::MIN_POSITIVE
965            };
966            z[i] = -standard_normal_quantile_from_log_cdf(resolved)
967                .map_err(|error| format!("orthant cubature coordinate {i}: {error}"))?;
968        }
969        if !log_weight.is_finite() {
970            continue;
971        }
972        for i in 0..q {
973            let mut value = mean[i];
974            for j in 0..=i {
975                value += factor[[i, j]] * z[j];
976            }
977            point[i] = value;
978        }
979        accumulator.push(log_weight, &point);
980    }
981    Ok(())
982}
983
984#[derive(Clone, Copy)]
985struct WeightedProjectionNode {
986    conditional_mean: f64,
987    weight: f64,
988}
989
990struct ProjectionNodeAccumulator<'a> {
991    moments: OrthantAccumulator,
992    normal_center: &'a Array1<f64>,
993    projection_lift: &'a Array1<f64>,
994    ambient_mean: f64,
995    nodes: Vec<(f64, f64)>,
996}
997
998impl<'a> ProjectionNodeAccumulator<'a> {
999    fn new(
1000        normal_center: &'a Array1<f64>,
1001        projection_lift: &'a Array1<f64>,
1002        ambient_mean: f64,
1003    ) -> Self {
1004        Self {
1005            moments: OrthantAccumulator::new(normal_center.len()),
1006            normal_center,
1007            projection_lift,
1008            ambient_mean,
1009            nodes: Vec::new(),
1010        }
1011    }
1012
1013    fn normalized_nodes(self) -> Result<Vec<WeightedProjectionNode>, String> {
1014        let max_log_weight = self
1015            .nodes
1016            .iter()
1017            .map(|(_, log_weight)| *log_weight)
1018            .fold(f64::NEG_INFINITY, f64::max);
1019        if !max_log_weight.is_finite() {
1020            return Err(
1021                "orthant projection cubature accumulated no finite node weight".to_string(),
1022            );
1023        }
1024        let weight_sum = self
1025            .nodes
1026            .iter()
1027            .map(|(_, log_weight)| (*log_weight - max_log_weight).exp())
1028            .sum::<f64>();
1029        if !(weight_sum.is_finite() && weight_sum > 0.0) {
1030            return Err(format!(
1031                "orthant projection cubature has invalid normalized weight sum {weight_sum:?}"
1032            ));
1033        }
1034        Ok(self
1035            .nodes
1036            .into_iter()
1037            .map(|(conditional_mean, log_weight)| WeightedProjectionNode {
1038                conditional_mean,
1039                weight: (log_weight - max_log_weight).exp() / weight_sum,
1040            })
1041            .collect())
1042    }
1043}
1044
1045impl OrthantNodeSink for ProjectionNodeAccumulator<'_> {
1046    fn push(&mut self, log_weight: f64, point: &Array1<f64>) {
1047        self.moments.push(log_weight, point);
1048        let conditional_mean = self.ambient_mean
1049            + self
1050                .projection_lift
1051                .iter()
1052                .zip(point.iter().zip(self.normal_center.iter()))
1053                .map(|(&lift, (&value, &center))| lift * (value - center))
1054                .sum::<f64>();
1055        self.nodes.push((conditional_mean, log_weight));
1056    }
1057}
1058
1059fn converged_projection_nodes(
1060    mean: &Array1<f64>,
1061    covariance: &Array2<f64>,
1062    projection_lift: &Array1<f64>,
1063    ambient_mean: f64,
1064) -> Result<Vec<WeightedProjectionNode>, String> {
1065    let q = mean.len();
1066    if covariance.dim() != (q, q) || projection_lift.len() != q {
1067        return Err(format!(
1068            "orthant projection geometry mismatch: mean={q}, covariance={:?}, lift={}",
1069            covariance.dim(),
1070            projection_lift.len()
1071        ));
1072    }
1073    let factor = gam_linalg::triangular::cholesky_factor_in_place(
1074        covariance.view(),
1075        gam_linalg::triangular::CholeskyGuard::FiniteStrict,
1076    )
1077    .ok_or_else(|| {
1078        "orthant projection: the constraint-normal covariance is not numerically positive definite"
1079            .to_string()
1080    })?;
1081    let generator = kronecker_generator(q);
1082    let mut accumulator = ProjectionNodeAccumulator::new(mean, projection_lift, ambient_mean);
1083    let mut evaluated = 0usize;
1084    let mut previous: Option<(Array1<f64>, Array2<f64>)> = None;
1085    loop {
1086        let target = if evaluated == 0 {
1087            ORTHANT_MOMENT_INITIAL_POINTS
1088        } else {
1089            evaluated * 2
1090        };
1091        accumulate_orthant_nodes(
1092            &mut accumulator,
1093            mean,
1094            factor.view(),
1095            &generator,
1096            evaluated,
1097            target,
1098        )?;
1099        evaluated = target;
1100        let current = accumulator.moments.moments()?;
1101        if let Some(ref last) = previous
1102            && moment_relative_change(last, &current, covariance)
1103                <= ORTHANT_MOMENT_RELATIVE_TOLERANCE
1104        {
1105            return accumulator.normalized_nodes();
1106        }
1107        if evaluated >= ORTHANT_MOMENT_MAXIMUM_POINTS {
1108            let change = previous
1109                .as_ref()
1110                .map(|last| moment_relative_change(last, &current, covariance))
1111                .unwrap_or(f64::INFINITY);
1112            return Err(format!(
1113                "orthant projection for a {q}-dimensional constraint face did not converge: \
1114                 relative moment change {change:.3e} still exceeds \
1115                 {ORTHANT_MOMENT_RELATIVE_TOLERANCE:.1e} at {evaluated} cubature nodes"
1116            ));
1117        }
1118        previous = Some(current);
1119    }
1120}
1121
1122fn projection_quantile(
1123    nodes: &[WeightedProjectionNode],
1124    residual_variance: f64,
1125    probability: f64,
1126    posterior_mean: f64,
1127    ambient_sd: f64,
1128) -> Result<f64, String> {
1129    if nodes.is_empty() {
1130        return Err("orthant projection quantile received no cubature nodes".to_string());
1131    }
1132    if residual_variance == 0.0 {
1133        let mut ordered = nodes.to_vec();
1134        ordered.sort_by(|left, right| left.conditional_mean.total_cmp(&right.conditional_mean));
1135        let mut cumulative = 0.0;
1136        for node in &ordered {
1137            cumulative += node.weight;
1138            if cumulative >= probability {
1139                return Ok(node.conditional_mean);
1140            }
1141        }
1142        return Ok(ordered
1143            .last()
1144            .expect("non-empty projection node set")
1145            .conditional_mean);
1146    }
1147
1148    let residual_sd = residual_variance.sqrt();
1149    let cdf = |value: f64| {
1150        nodes
1151            .iter()
1152            .map(|node| {
1153                node.weight * normal_cdf((value - node.conditional_mean) / residual_sd)
1154            })
1155            .sum::<f64>()
1156    };
1157    let mut step = ambient_sd.max(residual_sd).max(f64::MIN_POSITIVE);
1158    let mut lower = posterior_mean - step;
1159    let mut upper = posterior_mean + step;
1160    while cdf(lower) > probability {
1161        step *= 2.0;
1162        lower = posterior_mean - step;
1163        if !lower.is_finite() {
1164            return Err(format!(
1165                "orthant projection quantile could not bracket lower probability {probability}"
1166            ));
1167        }
1168    }
1169    step = ambient_sd.max(residual_sd).max(f64::MIN_POSITIVE);
1170    while cdf(upper) < probability {
1171        step *= 2.0;
1172        upper = posterior_mean + step;
1173        if !upper.is_finite() {
1174            return Err(format!(
1175                "orthant projection quantile could not bracket upper probability {probability}"
1176            ));
1177        }
1178    }
1179
1180    let resolution = f64::EPSILON.sqrt() * ambient_sd.max(residual_sd);
1181    loop {
1182        let midpoint = lower + 0.5 * (upper - lower);
1183        if midpoint == lower || midpoint == upper || upper - lower <= resolution {
1184            return Ok(midpoint);
1185        }
1186        if cdf(midpoint) < probability {
1187            lower = midpoint;
1188        } else {
1189            upper = midpoint;
1190        }
1191    }
1192}
1193
1194/// Closed-form moments of `N(mean, variance)` restricted to `[0, ∞)`.
1195fn scalar_truncated_moments(
1196    mean: f64,
1197    variance: f64,
1198) -> Result<(Array1<f64>, Array2<f64>), String> {
1199    if !(variance.is_finite() && variance > 0.0) {
1200        return Err(format!(
1201            "scalar truncated moments need a positive finite variance, got {variance:?}"
1202        ));
1203    }
1204    let sd = variance.sqrt();
1205    // `alpha` is the truncation point in standardized units; the feasible half
1206    // is `z ≥ alpha`, and `mills` is the inverse Mills ratio `φ(α)/Φ̄(α)`
1207    // obtained on the numerically stable `Φ` branch by reflection.
1208    let alpha = -mean / sd;
1209    let mills = signed_probit_logcdf_and_mills_ratio(-alpha).1;
1210    if !(mills.is_finite() && mills >= 0.0) {
1211        return Err(format!(
1212            "scalar truncated moments: inverse Mills ratio at {alpha} is {mills:?}"
1213        ));
1214    }
1215    let truncated_mean = mean + sd * mills;
1216    let truncated_variance = variance * (1.0 + alpha * mills - mills * mills);
1217    if !(truncated_variance.is_finite() && truncated_variance >= 0.0) {
1218        return Err(format!(
1219            "scalar truncated moments produced variance {truncated_variance:?} at \
1220             standardized truncation point {alpha}"
1221        ));
1222    }
1223    Ok((
1224        Array1::from_elem(1, truncated_mean),
1225        Array2::from_elem((1, 1), truncated_variance),
1226    ))
1227}
1228
1229/// Largest relative moment change between two cubature passes, measured in the
1230/// pre-truncation scale `sd_i = sqrt(W_ii)` so the criterion does not depend on
1231/// how the constraint rows happen to be scaled.
1232fn moment_relative_change(
1233    previous: &(Array1<f64>, Array2<f64>),
1234    current: &(Array1<f64>, Array2<f64>),
1235    w: &Array2<f64>,
1236) -> f64 {
1237    let q = current.0.len();
1238    let mut worst = 0.0f64;
1239    for i in 0..q {
1240        let sd_i = w[[i, i]].sqrt();
1241        worst = worst.max((current.0[i] - previous.0[i]).abs() / sd_i);
1242        for j in 0..q {
1243            let sd_j = w[[j, j]].sqrt();
1244            worst =
1245                worst.max((current.1[[i, j]] - previous.1[[i, j]]).abs() / (sd_i * sd_j));
1246        }
1247    }
1248    worst
1249}
1250
1251/// Kronecker (Richtmyer) lattice generator `α_i = frac(√p_i)` over the primes.
1252/// Deterministic and table-free: the sequence is reproduced from the primes
1253/// themselves, so the reported covariance does not depend on a stored vector of
1254/// magic direction numbers or on any random seed.
1255fn kronecker_generator(dimension: usize) -> Vec<f64> {
1256    let mut generator = Vec::with_capacity(dimension);
1257    let mut candidate = 2u64;
1258    while generator.len() < dimension {
1259        if is_prime(candidate) {
1260            let root = (candidate as f64).sqrt();
1261            generator.push(root - root.floor());
1262        }
1263        candidate += 1;
1264    }
1265    generator
1266}
1267
1268fn is_prime(value: u64) -> bool {
1269    if value < 2 {
1270        return false;
1271    }
1272    let mut divisor = 2u64;
1273    while divisor * divisor <= value {
1274        if value % divisor == 0 {
1275            return false;
1276        }
1277        divisor += 1;
1278    }
1279    true
1280}
1281
1282#[cfg(test)]
1283mod tests {
1284    use super::*;
1285    use ndarray::array;
1286
1287
1288    /// Independent reference: Simpson quadrature of `N(mean, variance)`
1289    /// restricted to `[0, ∞)`, with the density rescaled by its value at the
1290    /// truncation point so a deeply pinned centre does not underflow.
1291    fn quadrature_truncated_moments(mean: f64, variance: f64) -> (f64, f64) {
1292        let sd = variance.sqrt();
1293        let alpha = -mean / sd;
1294        let panels = 400_000usize;
1295        let upper = alpha + 60.0;
1296        let step = (upper - alpha) / panels as f64;
1297        let mut mass = 0.0f64;
1298        let mut first = 0.0f64;
1299        let mut second = 0.0f64;
1300        for index in 0..=panels {
1301            let z = alpha + step * index as f64;
1302            let simpson = if index == 0 || index == panels {
1303                1.0
1304            } else if index % 2 == 1 {
1305                4.0
1306            } else {
1307                2.0
1308            };
1309            let density = (-(z * z - alpha * alpha) / 2.0).exp();
1310            mass += simpson * density;
1311            first += simpson * density * z;
1312            second += simpson * density * z * z;
1313        }
1314        let m1 = first / mass;
1315        let m2 = second / mass;
1316        (mean + sd * m1, variance * (m2 - m1 * m1))
1317    }
1318
1319    /// The scalar closed form against the textbook truncated-normal moments at
1320    /// the three regimes the estimand argument turns on.
1321    #[test]
1322    fn scalar_truncated_moments_match_the_closed_form_at_every_regime() {
1323        // Mode exactly on the bound: half-normal, variance (1 - 2/pi) sigma^2.
1324        let (mean, variance) = scalar_truncated_moments(0.0, 1.0).expect("half normal");
1325        let expected_mean = (2.0 / std::f64::consts::PI).sqrt();
1326        assert!(
1327            (mean[0] - expected_mean).abs() < 1e-12,
1328            "half-normal mean {} vs {expected_mean}",
1329            mean[0]
1330        );
1331        let expected_variance = 1.0 - 2.0 / std::f64::consts::PI;
1332        assert!(
1333            (variance[[0, 0]] - expected_variance).abs() < 1e-12,
1334            "half-normal variance {} vs {expected_variance}",
1335            variance[[0, 0]]
1336        );
1337        assert!(
1338            variance[[0, 0]] > 0.36 && variance[[0, 0]] < 0.37,
1339            "a coefficient whose mode sits exactly on its bound keeps a THIRD of its \
1340             unconstrained variance, not zero: got {}",
1341            variance[[0, 0]]
1342        );
1343
1344        // Strongly pinned: checked against an INDEPENDENT quadrature of the
1345        // truncated density rather than against an asymptotic, because the
1346        // leading `sigma^2/alpha^2` term carries an O(alpha^-4) deficit that a
1347        // tolerance would have to absorb.
1348        for center in [-2.0, -4.0, -8.0] {
1349            let (deep_mean, deep) = scalar_truncated_moments(center, 1.0).expect("deep tail");
1350            let (reference_mean, reference_variance) = quadrature_truncated_moments(center, 1.0);
1351            assert!(
1352                (deep_mean[0] - reference_mean).abs() < 1e-9 * reference_mean.abs().max(1.0),
1353                "closed-form mean {} vs quadrature {reference_mean} at centre {center}",
1354                deep_mean[0]
1355            );
1356            assert!(
1357                (deep[[0, 0]] / reference_variance - 1.0).abs() < 1e-8,
1358                "closed-form variance {} vs quadrature {reference_variance} at centre {center}",
1359                deep[[0, 0]]
1360            );
1361            assert!(
1362                deep[[0, 0]] > 0.0,
1363                "a finite multiplier never gives zero variance, got {} at centre {center}",
1364                deep[[0, 0]]
1365            );
1366        }
1367        // ...and it does head to zero like sigma^2/alpha^2, which is the ONLY
1368        // limit in which the active-face answer becomes correct.
1369        let (_, at_eight) = scalar_truncated_moments(-8.0, 1.0).expect("deep tail");
1370        assert!(
1371            at_eight[[0, 0]] * 64.0 > 0.9 && at_eight[[0, 0]] * 64.0 < 1.0,
1372            "variance times alpha^2 should approach one from below, got {}",
1373            at_eight[[0, 0]] * 64.0
1374        );
1375
1376        // Constraint far away: the moments relax back to the untruncated ones,
1377        // but only to the order of the tail mass the constraint still removes —
1378        // a bound five standard deviations below the centre still moves the mean
1379        // by `sd·φ(5)/Φ(5) ≈ 3e-6`, which is exactly the smooth dependence on
1380        // slack that makes a tightness predicate unnecessary.
1381        let (far_mean, far_variance) = scalar_truncated_moments(10.0, 4.0).expect("inactive");
1382        let (reference_mean, reference_variance) = quadrature_truncated_moments(10.0, 4.0);
1383        assert!(
1384            (far_mean[0] - reference_mean).abs() < 1e-9,
1385            "inactive-bound mean {} vs quadrature {reference_mean}",
1386            far_mean[0]
1387        );
1388        assert!(
1389            (far_variance[[0, 0]] - reference_variance).abs() < 1e-9,
1390            "inactive-bound variance {} vs quadrature {reference_variance}",
1391            far_variance[[0, 0]]
1392        );
1393        assert!(
1394            (far_mean[0] - 10.0).abs() < 1e-5 && far_mean[0] > 10.0,
1395            "a bound five sd away moves the mean by the tail mass and no more, got {}",
1396            far_mean[0]
1397        );
1398        assert!(
1399            (far_variance[[0, 0]] - 4.0).abs() < 1e-4 && far_variance[[0, 0]] < 4.0,
1400            "a bound five sd away shrinks the variance by the tail mass and no more, got {}",
1401            far_variance[[0, 0]]
1402        );
1403    }
1404
1405    #[test]
1406    fn equal_tailed_projection_interval_is_asymmetric_for_a_half_normal() {
1407        let covariance = array![[1.0]];
1408        let center = array![0.0];
1409        let constraints =
1410            LinearInequalityConstraints::new(array![[1.0]], array![0.0]).expect("constraint");
1411        let correction =
1412            constrained_posterior_correction_from_covariance(&covariance, &center, &constraints)
1413                .expect("correction")
1414                .expect("active half-space");
1415        let geometry = ConstrainedPosteriorGeometry {
1416            constraints,
1417            mode: array![0.0],
1418            unconstrained_center: center,
1419            correction: Some(correction),
1420        };
1421        let (lower, upper) = constrained_projection_equal_tailed_interval(
1422            &covariance,
1423            &geometry,
1424            &array![1.0],
1425            0.95,
1426        )
1427        .expect("equal-tailed interval");
1428
1429        // For Z | Z>=0, F(z)=2 Phi(z)-1. The equal-tailed endpoints are
1430        // Phi^-1((1+p)/2), p in {0.025, 0.975}.
1431        let expected_lower = standard_normal_quantile(0.5125).expect("lower quantile");
1432        let expected_upper = standard_normal_quantile(0.9875).expect("upper quantile");
1433        assert!(
1434            (lower - expected_lower).abs() < 2e-3,
1435            "half-normal lower endpoint {lower} vs {expected_lower}"
1436        );
1437        assert!(
1438            (upper - expected_upper).abs() < 2e-3,
1439            "half-normal upper endpoint {upper} vs {expected_upper}"
1440        );
1441        let posterior_mean = (2.0 / std::f64::consts::PI).sqrt();
1442        assert!(
1443            (posterior_mean - lower) < (upper - posterior_mean),
1444            "the exact skew interval must not collapse back to mean +/- z*sd"
1445        );
1446    }
1447
1448    #[test]
1449    fn equal_tailed_projection_sweep_has_exact_mass_and_repairs_the_short_symmetric_band() {
1450        let covariance = array![[1.0]];
1451        let constraints =
1452            LinearInequalityConstraints::new(array![[1.0]], array![0.0]).expect("constraint");
1453        let alpha = 0.025;
1454        let ambient_width =
1455            2.0 * standard_normal_quantile(1.0 - alpha).expect("ambient quantile");
1456        let mut saw_repaired_short_symmetric_band = false;
1457
1458        for center_value in [0.0, 0.25, 0.5, 0.75, 1.0, 1.5, 2.0, 3.0, 5.0] {
1459            let center = array![center_value];
1460            let correction = constrained_posterior_correction_from_covariance(
1461                &covariance,
1462                &center,
1463                &constraints,
1464            )
1465            .expect("correction")
1466            .expect("finite lower truncation");
1467            let posterior_variance =
1468                1.0 - correction.removed_variance_diagonal()[0];
1469            let geometry = ConstrainedPosteriorGeometry {
1470                constraints: constraints.clone(),
1471                mode: array![center_value.max(0.0)],
1472                unconstrained_center: center,
1473                correction: Some(correction),
1474            };
1475            let (lower, upper) = constrained_projection_equal_tailed_interval(
1476                &covariance,
1477                &geometry,
1478                &array![1.0],
1479                0.95,
1480            )
1481            .expect("equal-tailed interval");
1482
1483            let mass_below_bound = normal_cdf(-center_value);
1484            let retained_mass = 1.0 - mass_below_bound;
1485            let truncated_cdf = |value: f64| {
1486                (normal_cdf(value - center_value) - mass_below_bound) / retained_mass
1487            };
1488            assert!(
1489                (truncated_cdf(lower) - alpha).abs() < 2e-8
1490                    && (truncated_cdf(upper) - (1.0 - alpha)).abs() < 2e-8,
1491                "centre {center_value}: endpoints [{lower}, {upper}] do not enclose exact \
1492                 posterior mass 0.95"
1493            );
1494            assert!(
1495                lower >= 0.0,
1496                "centre {center_value}: lower endpoint {lower} escaped the saved cone"
1497            );
1498            assert!(
1499                upper - lower <= ambient_width + 1e-10,
1500                "centre {center_value}: truncation widened [{lower}, {upper}] beyond the \
1501                 ambient Gaussian interval"
1502            );
1503
1504            if center_value == 3.0 {
1505                let symmetric_width = 2.0
1506                    * standard_normal_quantile(1.0 - alpha).expect("symmetric quantile")
1507                    * posterior_variance.sqrt();
1508                assert!(
1509                    upper - lower > symmetric_width,
1510                    "the exact 3-SE interval must repair the moment-matched symmetric interval's \
1511                     short, under-covering band: exact width {}, symmetric width {symmetric_width}",
1512                    upper - lower
1513                );
1514                saw_repaired_short_symmetric_band = true;
1515            }
1516        }
1517
1518        assert!(
1519            saw_repaired_short_symmetric_band,
1520            "the sweep must include its 3-SE regression cell"
1521        );
1522    }
1523
1524    /// The cubature must reproduce the closed form when the orthant factorizes
1525    /// into independent coordinates, which is the only multivariate case with
1526    /// an exact answer to check against. The bound is the module's own
1527    /// certified accuracy, [`ORTHANT_MOMENT_RELATIVE_TOLERANCE`], measured on
1528    /// the pre-truncation scale — asserting tighter would assert something the
1529    /// algorithm does not promise.
1530    #[test]
1531    fn cubature_reproduces_independent_coordinates_within_its_certified_accuracy() {
1532        let mean = array![-0.5, 0.25, -1.5];
1533        let covariance = array![[2.0, 0.0, 0.0], [0.0, 0.5, 0.0], [0.0, 0.0, 1.0]];
1534        let (moment_mean, moment_covariance) =
1535            orthant_truncated_moments(&mean, &covariance).expect("independent orthant");
1536        for i in 0..3 {
1537            let (exact_mean, exact_variance) =
1538                scalar_truncated_moments(mean[i], covariance[[i, i]]).expect("scalar");
1539            let scale = covariance[[i, i]].sqrt();
1540            assert!(
1541                (moment_mean[i] - exact_mean[0]).abs()
1542                    < ORTHANT_MOMENT_RELATIVE_TOLERANCE * scale,
1543                "coordinate {i} mean {} vs exact {}",
1544                moment_mean[i],
1545                exact_mean[0]
1546            );
1547            assert!(
1548                (moment_covariance[[i, i]] - exact_variance[[0, 0]]).abs()
1549                    < ORTHANT_MOMENT_RELATIVE_TOLERANCE * covariance[[i, i]],
1550                "coordinate {i} variance {} vs exact {}",
1551                moment_covariance[[i, i]],
1552                exact_variance[[0, 0]]
1553            );
1554            for j in 0..3 {
1555                if i != j {
1556                    assert!(
1557                        moment_covariance[[i, j]].abs()
1558                            < ORTHANT_MOMENT_RELATIVE_TOLERANCE
1559                                * scale
1560                                * covariance[[j, j]].sqrt(),
1561                        "independent coordinates must stay uncorrelated under an orthant \
1562                         truncation, got {} at ({i},{j})",
1563                        moment_covariance[[i, j]]
1564                    );
1565                }
1566            }
1567        }
1568    }
1569
1570    /// The whole point of the module: the correction lands strictly between the
1571    /// two answers the two fit paths ship today.
1572    #[test]
1573    fn correction_lands_strictly_between_full_space_and_active_face() {
1574        let covariance = array![[1.0, 0.4], [0.4, 1.0]];
1575        let constraints =
1576            LinearInequalityConstraints::new(array![[1.0, 0.0]], array![0.0]).expect("cone");
1577        // Unconstrained centre BELOW the bound: the constrained mode is pinned.
1578        let center = array![-0.6, 0.3];
1579        let correction = constrained_posterior_correction_from_covariance(&covariance, &center, &constraints)
1580            .expect("correction")
1581            .expect("an active row");
1582        let truncated = correction.apply_to_covariance(&covariance);
1583
1584        // Active-face answer: the same formula with the normal variance removed
1585        // in full.
1586        let mut face = covariance.clone();
1587        let full_removal = correction.lift.dot(&array![[1.0]]).dot(&correction.lift.t());
1588        face -= &full_removal;
1589
1590        assert!(
1591            truncated[[0, 0]] > face[[0, 0]] + 1e-6,
1592            "truncated variance {} must exceed the active-face answer {}",
1593            truncated[[0, 0]],
1594            face[[0, 0]]
1595        );
1596        assert!(
1597            truncated[[0, 0]] < covariance[[0, 0]] - 1e-6,
1598            "truncated variance {} must fall below the unconstrained answer {}",
1599            truncated[[0, 0]],
1600            covariance[[0, 0]]
1601        );
1602        assert!(
1603            face[[0, 0]].abs() < 1e-12,
1604            "the active-face answer for a single pinned coordinate is exactly zero, got {}",
1605            face[[0, 0]]
1606        );
1607        assert!(
1608            correction.normal_mean_shift[0] > 0.0,
1609            "truncation moves the posterior mean INTO the feasible region, shift was {}",
1610            correction.normal_mean_shift[0]
1611        );
1612    }
1613
1614    /// A constraint far from the posterior centre must leave the covariance
1615    /// untouched, so unconstrained-in-practice fits keep their exact bytes.
1616    #[test]
1617    fn inactive_constraints_produce_no_correction() {
1618        let covariance = array![[1.0, 0.0], [0.0, 1.0]];
1619        let constraints =
1620            LinearInequalityConstraints::new(array![[1.0, 0.0]], array![0.0]).expect("cone");
1621        let center = array![40.0, 0.0];
1622        let correction = constrained_posterior_correction_from_covariance(&covariance, &center, &constraints)
1623            .expect("correction");
1624        assert!(
1625            correction.is_none(),
1626            "a bound 40 posterior standard deviations away cannot move any moment at double \
1627             precision"
1628        );
1629    }
1630
1631    /// A duplicated constraint row must not make `W` singular.
1632    #[test]
1633    fn redundant_rows_are_dropped_by_the_rank_filter() {
1634        let covariance = array![[1.0, 0.2], [0.2, 1.0]];
1635        let constraints = LinearInequalityConstraints::new(
1636            array![[1.0, 0.0], [2.0, 0.0], [0.0, 1.0]],
1637            array![0.0, 0.0, 0.0],
1638        )
1639        .expect("cone");
1640        let center = array![-0.2, -0.3];
1641        let correction = constrained_posterior_correction_from_covariance(&covariance, &center, &constraints)
1642            .expect("correction")
1643            .expect("active rows");
1644        assert_eq!(
1645            correction.rows.len(),
1646            2,
1647            "the duplicated half-space must be filtered out, kept rows {:?}",
1648            correction.rows
1649        );
1650    }
1651
1652    /// The correction must never inflate a variance or drive one negative.
1653    #[test]
1654    fn corrected_covariance_stays_between_zero_and_the_unconstrained_answer() {
1655        let covariance = array![
1656            [1.0, 0.3, 0.1],
1657            [0.3, 1.2, -0.2],
1658            [0.1, -0.2, 0.8]
1659        ];
1660        let constraints = LinearInequalityConstraints::new(
1661            array![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
1662            array![0.0, 0.0],
1663        )
1664        .expect("cone");
1665        for center in [
1666            array![-2.0, -1.0, 0.5],
1667            array![0.0, 0.0, 0.0],
1668            array![-0.1, 0.4, -3.0],
1669        ] {
1670            let correction = constrained_posterior_correction_from_covariance(&covariance, &center, &constraints)
1671                .expect("correction")
1672                .expect("active rows");
1673            let truncated = correction.apply_to_covariance(&covariance);
1674            for i in 0..3 {
1675                assert!(
1676                    truncated[[i, i]] > 0.0,
1677                    "coordinate {i} lost all variance at centre {center:?}: {}",
1678                    truncated[[i, i]]
1679                );
1680                assert!(
1681                    truncated[[i, i]] <= covariance[[i, i]] + 1e-9,
1682                    "coordinate {i} gained variance at centre {center:?}: {} vs {}",
1683                    truncated[[i, i]],
1684                    covariance[[i, i]]
1685                );
1686            }
1687            let diagonal = correction.removed_variance_diagonal();
1688            for i in 0..3 {
1689                assert!(
1690                    (diagonal[i] - (covariance[[i, i]] - truncated[[i, i]])).abs() < 1e-9,
1691                    "the diagonal-only accessor must agree with the dense correction at {i}"
1692                );
1693            }
1694        }
1695    }
1696}
1697
1698/// Coverage gate for #2417.
1699///
1700/// The estimand is settled by COVERAGE against a known truth, not by the two
1701/// fit paths agreeing: two paths agreeing on a wrong covariance is not
1702/// progress. Each cell simulates from a known constrained model, refits with
1703/// the production constrained-quadratic solver, and measures what fraction of
1704/// nominal-95% intervals actually contain the truth under four procedures:
1705///
1706/// * **full space** `Σ = φH⁻¹` centred at the constrained mode — what the PIRLS
1707///   path reported before this change, with no reference to the active geometry;
1708/// * **active face** `Z(ZᵀHZ)⁻¹Zᵀ` centred at the mode — what the blockwise path
1709///   reports, reproduced here with the SAME tightness predicate it uses
1710///   (`scaled slack ≤ ACTIVE_SET_WORKING_FACE_TOL`, `covariance.rs:1583-1592`);
1711/// * **truncated** `Σ − G(W − Cov[u])Gᵀ` centred at the mode — what this module
1712///   computes and what the fit now reports;
1713/// * **truncated, mean-centred** — the same covariance around the truncated
1714///   posterior MEAN rather than the mode. Not what the fit ships (moving the
1715///   reported coefficients is out of scope for #2417); measured so the size of
1716///   the mode-vs-mean effect is on the record rather than asserted.
1717///
1718/// Among procedures that reach nominal coverage, expected interval length is
1719/// the tie-break.
1720#[cfg(test)]
1721mod coverage_gate_tests {
1722    use super::*;
1723    use gam_linalg::triangular::{CholeskyGuard, cholesky_factor_in_place, cholesky_solve_vector};
1724
1725    /// Deterministic SplitMix64 — the gate must produce the same numbers on
1726    /// every host, so no external RNG and no thread-local state.
1727    struct SplitMix64 {
1728        state: u64,
1729    }
1730
1731    impl SplitMix64 {
1732        fn new(seed: u64) -> Self {
1733            Self { state: seed }
1734        }
1735        fn next_u64(&mut self) -> u64 {
1736            self.state = self.state.wrapping_add(0x9e37_79b9_7f4a_7c15);
1737            let mut z = self.state;
1738            z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
1739            z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
1740            z ^ (z >> 31)
1741        }
1742        fn unit(&mut self) -> f64 {
1743            ((self.next_u64() >> 11) as f64 + 0.5) / (1u64 << 53) as f64
1744        }
1745        fn normal(&mut self) -> f64 {
1746            let (u1, u2) = (self.unit().max(1.0e-12), self.unit());
1747            (-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos()
1748        }
1749    }
1750
1751    /// Realized coverage and mean half-width of one interval procedure.
1752    struct CoverageTally {
1753        covered: usize,
1754        replicates: usize,
1755        total_half_width: f64,
1756    }
1757
1758    impl CoverageTally {
1759        fn new() -> Self {
1760            Self {
1761                covered: 0,
1762                replicates: 0,
1763                total_half_width: 0.0,
1764            }
1765        }
1766        fn record(&mut self, center: f64, half_width: f64, truth: f64) {
1767            self.replicates += 1;
1768            self.total_half_width += half_width;
1769            if (truth - center).abs() <= half_width {
1770                self.covered += 1;
1771            }
1772        }
1773        fn coverage(&self) -> f64 {
1774            self.covered as f64 / self.replicates as f64
1775        }
1776        fn mean_half_width(&self) -> f64 {
1777            self.total_half_width / self.replicates as f64
1778        }
1779    }
1780
1781    /// The four procedures the gate compares, in one bundle.
1782    struct CellResult {
1783        full_space: CoverageTally,
1784        active_face: CoverageTally,
1785        truncated: CoverageTally,
1786        truncated_mean_centred: CoverageTally,
1787        pinned_fraction: f64,
1788    }
1789
1790    /// Two-sided nominal level the gate reports against.
1791    const NOMINAL_HALF_WIDTH_MULTIPLIER: f64 = 1.959_963_984_540_054;
1792    const NOMINAL_COVERAGE: f64 = 0.95;
1793
1794    /// `Σ = σ²(XᵀX)⁻¹` for a fixed design.
1795    fn gaussian_posterior_covariance(gram: &Array2<f64>, noise_variance: f64) -> Array2<f64> {
1796        let p = gram.nrows();
1797        let factor = cholesky_factor_in_place(gram.view(), CholeskyGuard::FiniteStrict)
1798            .expect("simulation design is full rank");
1799        let mut covariance = Array2::<f64>::zeros((p, p));
1800        for j in 0..p {
1801            let mut unit = Array1::<f64>::zeros(p);
1802            unit[j] = 1.0;
1803            let column = cholesky_solve_vector(&factor, &unit);
1804            for i in 0..p {
1805                covariance[[i, j]] = noise_variance * column[i];
1806            }
1807        }
1808        covariance
1809    }
1810
1811    /// Rows of `A β ≥ b` that are tight at `beta`, under the SAME scaled-slack
1812    /// predicate the blockwise covariance path applies at `β̂`.
1813    fn tight_rows_at(constraints: &LinearInequalityConstraints, beta: &Array1<f64>) -> Vec<usize> {
1814        let mut tight = Vec::new();
1815        for row_index in 0..constraints.a.nrows() {
1816            let row = constraints.a.row(row_index).to_owned();
1817            let norm = row.dot(&row).sqrt();
1818            if norm > 0.0
1819                && (row.dot(beta) - constraints.b[row_index]) / norm
1820                    <= crate::active_set::ACTIVE_SET_WORKING_FACE_TOL
1821            {
1822                tight.push(row_index);
1823            }
1824        }
1825        tight
1826    }
1827
1828    /// `Σ_face = Σ − ΣA_tᵀ(A_tΣA_tᵀ)⁻¹A_tΣ` on the tight rows: the active-face
1829    /// reduction written as the same low-rank removal, so the comparator and
1830    /// the estimand under test differ ONLY in whether the constraint-normal
1831    /// variance is removed in full or only by the truncated part.
1832    fn active_face_variance(
1833        covariance: &Array2<f64>,
1834        constraints: &LinearInequalityConstraints,
1835        tight: &[usize],
1836        index: usize,
1837    ) -> f64 {
1838        if tight.is_empty() {
1839            return covariance[[index, index]];
1840        }
1841        let q = tight.len();
1842        let mut sigma_at = Array2::<f64>::zeros((covariance.nrows(), q));
1843        for (position, &row_index) in tight.iter().enumerate() {
1844            let column = covariance.dot(&constraints.a.row(row_index).to_owned());
1845            sigma_at.column_mut(position).assign(&column);
1846        }
1847        let mut normal = Array2::<f64>::zeros((q, q));
1848        for (i, &row_i) in tight.iter().enumerate() {
1849            for j in 0..q {
1850                normal[[i, j]] = constraints
1851                    .a
1852                    .row(row_i)
1853                    .to_owned()
1854                    .dot(&sigma_at.column(j).to_owned());
1855            }
1856        }
1857        let Some(factor) = cholesky_factor_in_place(normal.view(), CholeskyGuard::FiniteStrict)
1858        else {
1859            // A rank-deficient tight face pins every direction it spans; the
1860            // face answer for this coordinate is zero variance.
1861            return 0.0;
1862        };
1863        let row = sigma_at.row(index).to_owned();
1864        let solved = cholesky_solve_vector(&factor, &row);
1865        covariance[[index, index]] - row.dot(&solved)
1866    }
1867
1868    /// One simulation cell: a fixed design, a known truth strictly inside
1869    /// `A β ≥ 0`, and `replicates` refits under Gaussian noise with a KNOWN
1870    /// noise scale, so the comparison isolates the covariance question from
1871    /// dispersion estimation.
1872    fn run_cell(
1873        design: &Array2<f64>,
1874        truth: &Array1<f64>,
1875        constraints: &LinearInequalityConstraints,
1876        reported_index: usize,
1877        noise_sd: f64,
1878        replicates: usize,
1879        seed: u64,
1880    ) -> CellResult {
1881        let n = design.nrows();
1882        let p = design.ncols();
1883        let gram = design.t().dot(design);
1884        let covariance = gaussian_posterior_covariance(&gram, noise_sd * noise_sd);
1885        let mut rng = SplitMix64::new(seed);
1886        let mut result = CellResult {
1887            full_space: CoverageTally::new(),
1888            active_face: CoverageTally::new(),
1889            truncated: CoverageTally::new(),
1890            truncated_mean_centred: CoverageTally::new(),
1891            pinned_fraction: 0.0,
1892        };
1893        let mean_response = design.dot(truth);
1894        let mut pinned = 0usize;
1895
1896        for _ in 0..replicates {
1897            let mut response = Array1::<f64>::zeros(n);
1898            for i in 0..n {
1899                response[i] = mean_response[i] + noise_sd * rng.normal();
1900            }
1901            let rhs = design.t().dot(&response);
1902            let start = crate::active_set::feasible_point_for_linear_constraints(constraints, p)
1903                .expect("the simulation cone has an interior");
1904            let (beta_hat, _) = crate::active_set::solve_quadratic_with_linear_constraints(
1905                &gram,
1906                &rhs,
1907                &start,
1908                constraints,
1909                None,
1910            )
1911            .expect("constrained quadratic solve");
1912
1913            let full_half_width =
1914                NOMINAL_HALF_WIDTH_MULTIPLIER * covariance[[reported_index, reported_index]].sqrt();
1915            result.full_space.record(
1916                beta_hat[reported_index],
1917                full_half_width,
1918                truth[reported_index],
1919            );
1920
1921            let tight = tight_rows_at(constraints, &beta_hat);
1922            if !tight.is_empty() {
1923                pinned += 1;
1924            }
1925            let face_variance =
1926                active_face_variance(&covariance, constraints, &tight, reported_index);
1927            result.active_face.record(
1928                beta_hat[reported_index],
1929                NOMINAL_HALF_WIDTH_MULTIPLIER * face_variance.max(0.0).sqrt(),
1930                truth[reported_index],
1931            );
1932
1933            // `β_unc = β̂ − Σ ∇ℓ_p(β̂)` with `∇ℓ_p(β̂) = XᵀXβ̂ − Xᵀy`. For this
1934            // Gaussian cell that is exactly the unconstrained least-squares
1935            // solution — the centre a truncated Gaussian keeps.
1936            let penalized_gradient = gram.dot(&beta_hat) - &rhs;
1937            let center = &beta_hat
1938                - &(covariance.dot(&penalized_gradient) / (noise_sd * noise_sd));
1939            let correction =
1940                constrained_posterior_correction_from_covariance(&covariance, &center, constraints)
1941                    .expect("truncated correction");
1942            let (truncated_half_width, truncated_center) = match correction {
1943                None => (full_half_width, beta_hat[reported_index]),
1944                Some(ref correction) => {
1945                    let variance = covariance[[reported_index, reported_index]]
1946                        - correction.removed_variance_diagonal()[reported_index];
1947                    (
1948                        NOMINAL_HALF_WIDTH_MULTIPLIER * variance.max(0.0).sqrt(),
1949                        correction.posterior_mean(&center)[reported_index],
1950                    )
1951                }
1952            };
1953            result.truncated.record(
1954                beta_hat[reported_index],
1955                truncated_half_width,
1956                truth[reported_index],
1957            );
1958            result.truncated_mean_centred.record(
1959                truncated_center,
1960                truncated_half_width,
1961                truth[reported_index],
1962            );
1963        }
1964        result.pinned_fraction = pinned as f64 / replicates as f64;
1965        result
1966    }
1967
1968    fn report_cell(label: &str, cell: &CellResult) {
1969        eprintln!(
1970            "[#2417 coverage] {label}: nominal {NOMINAL_COVERAGE:.2}, {} replicates, mode pinned \
1971             in {:.1}% of them",
1972            cell.full_space.replicates,
1973            100.0 * cell.pinned_fraction
1974        );
1975        for (name, tally) in [
1976            ("full space          ", &cell.full_space),
1977            ("active face         ", &cell.active_face),
1978            ("truncated           ", &cell.truncated),
1979            ("truncated+mean shift", &cell.truncated_mean_centred),
1980        ] {
1981            eprintln!(
1982                "[#2417 coverage]   {name} coverage {:.4}  mean half-width {:.5}",
1983                tally.coverage(),
1984                tally.mean_half_width()
1985            );
1986        }
1987    }
1988
1989    /// A single box bound with the truth half a standard error inside the
1990    /// feasible region: the regime where the constrained mode pins in about a
1991    /// third of replicates, so the active-face answer reports a ZERO-WIDTH
1992    /// interval a third of the time and cannot possibly cover.
1993    #[test]
1994    fn box_bound_at_half_a_standard_error_separates_the_three_covariances() {
1995        let n = 60;
1996        let mut rng = SplitMix64::new(20_417);
1997        let mut design = Array2::<f64>::zeros((n, 2));
1998        for i in 0..n {
1999            design[[i, 0]] = 1.0;
2000            design[[i, 1]] = rng.normal();
2001        }
2002        let gram = design.t().dot(&design);
2003        let noise_sd = 1.0;
2004        let covariance = gaussian_posterior_covariance(&gram, noise_sd * noise_sd);
2005        let standard_error = covariance[[1, 1]].sqrt();
2006        let truth = Array1::from_vec(vec![0.3, 0.5 * standard_error]);
2007        let constraints =
2008            LinearInequalityConstraints::new(ndarray::array![[0.0, 1.0]], ndarray::array![0.0])
2009                .expect("nonnegativity bound");
2010
2011        let cell = run_cell(&design, &truth, &constraints, 1, noise_sd, 4000, 91_137);
2012        report_cell("box bound, truth 0.5 se", &cell);
2013
2014        // The mode pins often enough that a zero-width interval is not a corner
2015        // case; if it stopped pinning the cell would stop testing anything.
2016        assert!(
2017            cell.pinned_fraction > 0.2,
2018            "the cell must actually exercise the boundary, pinned fraction {:.3}",
2019            cell.pinned_fraction
2020        );
2021        assert!(
2022            cell.active_face.coverage() < 0.80,
2023            "the active-face covariance must under-cover catastrophically here — it reports a \
2024             zero-width interval whenever the mode pins — but coverage was {:.4}",
2025            cell.active_face.coverage()
2026        );
2027        assert!(
2028            cell.truncated.coverage() >= NOMINAL_COVERAGE - 0.01,
2029            "the truncated covariance must reach nominal coverage, got {:.4}",
2030            cell.truncated.coverage()
2031        );
2032        assert!(
2033            cell.truncated_mean_centred.coverage() >= NOMINAL_COVERAGE - 0.01,
2034            "and it must still reach it once the centre moves to the truncated posterior \
2035             mean, got {:.4}",
2036            cell.truncated_mean_centred.coverage()
2037        );
2038        assert!(
2039            cell.truncated.mean_half_width() < 0.85 * cell.full_space.mean_half_width(),
2040            "the truncated covariance must buy its coverage with materially SHORTER intervals \
2041             than the full-space answer: {:.5} vs {:.5}",
2042            cell.truncated.mean_half_width(),
2043            cell.full_space.mean_half_width()
2044        );
2045        assert!(
2046            cell.full_space.coverage() >= NOMINAL_COVERAGE,
2047            "the full-space covariance over-covers by construction, got {:.4}",
2048            cell.full_space.coverage()
2049        );
2050    }
2051
2052    /// The truth pushed further from the bound, where the mode is pinned less
2053    /// often but is much further from the truth when it is. This cell is the
2054    /// counterexample to narrowing the covariance ALONE.
2055    ///
2056    /// Measured here: the truncated covariance around the constrained MODE
2057    /// covers 0.873 against a nominal 0.95 — worse than both the full-space
2058    /// answer (0.976) and the active face (0.910) — while the SAME covariance
2059    /// around the truncated posterior MEAN covers 0.966 at an interval 16%
2060    /// shorter than full space. Truncating the spread without moving the
2061    /// location keeps the interval centred on a point the posterior says is its
2062    /// least-likely feasible value, then makes it narrower. The two halves of
2063    /// the estimand are not separable, and this test exists so that fact cannot
2064    /// be lost: a covariance-only change is a REGRESSION here.
2065    #[test]
2066    fn narrowing_the_covariance_without_moving_the_mean_is_a_regression() {
2067        let n = 60;
2068        let mut rng = SplitMix64::new(31_417);
2069        let mut design = Array2::<f64>::zeros((n, 2));
2070        for i in 0..n {
2071            design[[i, 0]] = 1.0;
2072            design[[i, 1]] = rng.normal();
2073        }
2074        let gram = design.t().dot(&design);
2075        let noise_sd = 1.0;
2076        let covariance = gaussian_posterior_covariance(&gram, noise_sd * noise_sd);
2077        let standard_error = covariance[[1, 1]].sqrt();
2078        let truth = Array1::from_vec(vec![-0.2, 1.5 * standard_error]);
2079        let constraints =
2080            LinearInequalityConstraints::new(ndarray::array![[0.0, 1.0]], ndarray::array![0.0])
2081                .expect("nonnegativity bound");
2082
2083        let cell = run_cell(&design, &truth, &constraints, 1, noise_sd, 4000, 47_903);
2084        report_cell("box bound, truth 1.5 se", &cell);
2085
2086        assert!(
2087            cell.truncated.coverage() < NOMINAL_COVERAGE - 0.02,
2088            "this cell exists BECAUSE the mode-centred truncated interval under-covers here; \
2089             if it stopped doing so the counterexample would no longer be testing anything, \
2090             got {:.4}",
2091            cell.truncated.coverage()
2092        );
2093        assert!(
2094            cell.truncated.coverage() < cell.active_face.coverage(),
2095            "the point of the cell: narrowing the covariance while leaving the interval \
2096             centred on the mode is worse than the active-face answer it replaces, {:.4} vs \
2097             {:.4}",
2098            cell.truncated.coverage(),
2099            cell.active_face.coverage()
2100        );
2101        assert!(
2102            cell.truncated_mean_centred.coverage() >= NOMINAL_COVERAGE - 0.01,
2103            "moving the centre to the truncated posterior mean recovers nominal coverage with \
2104             the same covariance, got {:.4}",
2105            cell.truncated_mean_centred.coverage()
2106        );
2107        assert!(
2108            cell.truncated_mean_centred.mean_half_width() < cell.full_space.mean_half_width(),
2109            "and it does so with shorter intervals than the full-space answer: {:.5} vs {:.5}",
2110            cell.truncated_mean_centred.mean_half_width(),
2111            cell.full_space.mean_half_width()
2112        );
2113    }
2114
2115    /// Two coupled bounds, so the correction runs through the multivariate
2116    /// orthant cubature rather than the scalar closed form.
2117    #[test]
2118    fn two_coupled_bounds_exercise_the_orthant_cubature() {
2119        let n = 80;
2120        let mut rng = SplitMix64::new(74_211);
2121        let mut design = Array2::<f64>::zeros((n, 3));
2122        for i in 0..n {
2123            design[[i, 0]] = 1.0;
2124            let shared = rng.normal();
2125            design[[i, 1]] = shared;
2126            // Correlated with column 1, so the two bounds are coupled and the
2127            // constraint-normal covariance `W` is not diagonal.
2128            design[[i, 2]] = 0.7 * shared + 0.7 * rng.normal();
2129        }
2130        let gram = design.t().dot(&design);
2131        let noise_sd = 1.0;
2132        let covariance = gaussian_posterior_covariance(&gram, noise_sd * noise_sd);
2133        let truth = Array1::from_vec(vec![
2134            0.25,
2135            0.5 * covariance[[1, 1]].sqrt(),
2136            0.5 * covariance[[2, 2]].sqrt(),
2137        ]);
2138        let constraints = LinearInequalityConstraints::new(
2139            ndarray::array![[0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
2140            ndarray::array![0.0, 0.0],
2141        )
2142        .expect("two nonnegativity bounds");
2143
2144        let cell = run_cell(&design, &truth, &constraints, 1, noise_sd, 600, 55_301);
2145        report_cell("two coupled bounds, truth 0.5 se", &cell);
2146
2147        assert!(
2148            cell.active_face.coverage() < 0.85,
2149            "the active-face covariance must under-cover here too, got {:.4}",
2150            cell.active_face.coverage()
2151        );
2152        assert!(
2153            cell.truncated.coverage() >= NOMINAL_COVERAGE - 0.03,
2154            "the truncated covariance must reach nominal coverage through the orthant \
2155             cubature, got {:.4}",
2156            cell.truncated.coverage()
2157        );
2158        assert!(
2159            cell.truncated.mean_half_width() < cell.full_space.mean_half_width(),
2160            "shorter intervals at nominal coverage: {:.5} vs {:.5}",
2161            cell.truncated.mean_half_width(),
2162            cell.full_space.mean_half_width()
2163        );
2164    }
2165}