Skip to main content

gam_solve/
cone_reduction.rs

1//! Geometry of a cone-truncated Laplace posterior whose reduced precision is
2//! INDEFINITE.
3//!
4//! # The object
5//!
6//! After the `#2442` reparameterization a location-scale fit with an indefinite
7//! ambient Hessian leaves
8//!
9//! ```text
10//! π(w) ∝ exp(−½ wᵀ M w) · 1{w ≥ ℓ},     In(M) = (n−1, 0, 1)
11//! ```
12//!
13//! `M` is not a precision matrix: it has exactly one negative eigenvalue, so
14//! `M⁻¹` is not a covariance and this law is NOT a truncated Gaussian. It is
15//! normalizable exactly when `M` is strictly copositive on the nonnegative
16//! orthant, because along every feasible ray `d ≥ 0` the exponent grows like
17//! `½ t² dᵀMd` and copositivity is the statement that `dᵀMd > 0` there.
18//!
19//! # Why the origin has to move
20//!
21//! Everything downstream is expressed as an offset from the CONSTRAINED MODE,
22//! not from `w = 0` or from `w = ℓ`. That is not presentation. On the fixture
23//! this module was built against, the ambient centre lies outside the feasible
24//! set and the integrand's peak over that set is `exp(−513.82)`; carried in the
25//! reduction's natural origin, every downstream conditional sits about thirty
26//! posterior standard deviations outside the feasible region, and a cubature
27//! asked for such a probability returns a number that climbs monotonically with
28//! its node count instead of converging (measured: +74 log units from `2¹⁰` to
29//! `2¹⁸` nodes, still climbing). Re-centred, the same quantities are ordinary.
30//!
31//! # Everything here is exact
32//!
33//! Both searches are finite face enumerations rather than iterative solves:
34//!
35//! * `min wᵀMw` over the simplex is attained at a stationary point in the
36//!   relative interior of some face, so enumerating all `2ⁿ − 1` supports plus
37//!   the vertices decides copositivity exactly;
38//! * the constrained minimiser of `½xᵀMx + cᵀx` over `x ≥ 0` satisfies, on its
39//!   free set `F`, `M_FF x_F = −c_F` with `x_F ≥ 0`, `(Mx + c)_A ≥ 0` on the
40//!   active set, and `M_FF ⪰ 0`; enumerating supports and keeping the feasible
41//!   KKT point of least value is therefore exact.
42//!
43//! Exactness is worth the `2ⁿ` because `n` is the number of RETAINED constraint
44//! rows — six on the motivating fixture — and because the multiplier vector
45//! `g = Mx* + c` is consumed downstream, where an optimiser's tolerance would
46//! become the quadrature's error floor.
47
48use ndarray::{Array1, Array2, ArrayView2};
49use serde::{Deserialize, Serialize};
50
51/// Inertia `(positive, zero, negative)` of a symmetric matrix.
52#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
53pub struct Inertia {
54    pub positive: usize,
55    pub zero: usize,
56    pub negative: usize,
57}
58
59/// The constrained mode of `½xᵀMx + cᵀx` over the nonnegative orthant.
60#[derive(Clone, Debug)]
61pub struct ConeMode {
62    /// The minimiser `x*`.
63    pub point: Array1<f64>,
64    /// `φ* = ½x*ᵀMx* + cᵀx*`.
65    pub value: f64,
66    /// `g = Mx* + c`. Zero on the free set and non-negative on the active set —
67    /// these are the KKT multipliers, and the downstream quadrature integrates
68    /// `exp(−½dᵀMd − gᵀd)` over `{d ≥ −x*}`.
69    pub gradient: Array1<f64>,
70    /// Indices with `x*_j > 0`.
71    pub free: Vec<usize>,
72}
73
74/// Symmetric inertia by `LDLᵀ` with symmetric pivoting.
75///
76/// Sylvester's law of inertia makes the pivot signs the inertia, so this needs
77/// no eigensolver. Diagonal pivoting keeps it well posed for the indefinite
78/// case, which is the case this module exists for.
79pub fn symmetric_inertia(matrix: ArrayView2<'_, f64>, tolerance: f64) -> Result<Inertia, String> {
80    let n = matrix.nrows();
81    if matrix.ncols() != n {
82        return Err(format!(
83            "inertia needs a square matrix, got {}x{}",
84            matrix.nrows(),
85            matrix.ncols()
86        ));
87    }
88    let mut work = matrix.to_owned();
89    let scale = work
90        .iter()
91        .fold(0.0f64, |worst, value| worst.max(value.abs()))
92        .max(1.0);
93    let floor = tolerance * scale;
94    let mut remaining: Vec<usize> = (0..n).collect();
95    let mut inertia = Inertia {
96        positive: 0,
97        zero: 0,
98        negative: 0,
99    };
100    while !remaining.is_empty() {
101        // Pivot on the largest-magnitude remaining diagonal entry.
102        let (position, &pivot_index) = remaining
103            .iter()
104            .enumerate()
105            .max_by(|left, right| {
106                work[[*left.1, *left.1]]
107                    .abs()
108                    .partial_cmp(&work[[*right.1, *right.1]].abs())
109                    .unwrap_or(std::cmp::Ordering::Equal)
110            })
111            .ok_or_else(|| "inertia pivot selection found no candidate".to_string())?;
112        let pivot = work[[pivot_index, pivot_index]];
113        if !pivot.is_finite() {
114            return Err(format!("inertia pivot {pivot_index} is not finite"));
115        }
116        if pivot.abs() <= floor {
117            // The whole remaining block is numerically zero on its diagonal. A
118            // nonzero off-diagonal here would be a 2x2 block; refuse rather
119            // than guess, since callers of this module treat the inertia as a
120            // certificate.
121            for &i in &remaining {
122                for &j in &remaining {
123                    if i != j && work[[i, j]].abs() > floor {
124                        return Err(format!(
125                            "inertia needs a 2x2 pivot at ({i},{j}); the matrix is not \
126                             diagonally pivotable at tolerance {tolerance:.3e}"
127                        ));
128                    }
129                }
130            }
131            inertia.zero += remaining.len();
132            break;
133        }
134        if pivot > 0.0 {
135            inertia.positive += 1;
136        } else {
137            inertia.negative += 1;
138        }
139        remaining.remove(position);
140        let rest = remaining.clone();
141        for &i in &rest {
142            let factor = work[[i, pivot_index]] / pivot;
143            if factor == 0.0 {
144                continue;
145            }
146            for &j in &rest {
147                work[[i, j]] -= factor * work[[pivot_index, j]];
148            }
149        }
150        for &i in &rest {
151            work[[i, pivot_index]] = 0.0;
152            work[[pivot_index, i]] = 0.0;
153        }
154    }
155    Ok(inertia)
156}
157
158/// What a cone-truncated posterior's properness was decided against.
159///
160/// Every field is a measured quantity rather than a summary, because the point
161/// of this type is that a decline can name what it declined on.
162#[derive(Clone, Debug, Serialize, Deserialize)]
163pub struct ConeProperness {
164    /// The reduced precision `M` on the recession cone's normal coordinates
165    /// `w = Ad`: `wᵀMw` is the STATIONARY value of `dᵀHd` on `{d : Ad = w}`, which
166    /// is its minimum exactly when `H` is positive definite on `null(A)`.
167    pub reduced: Array2<f64>,
168    /// `In(H)` — the ambient precision's inertia. A constrained mode is not
169    /// obliged to make this all-positive.
170    pub ambient_inertia: Inertia,
171    /// `In(M)`.
172    pub reduced_inertia: Inertia,
173    /// `In(ZᵀHZ)` for `Z` a basis of `null(A)`, obtained from Haynsworth
174    /// additivity `In(H) = In(ZᵀHZ) + In(M)` rather than by forming `Z`.
175    /// `null(A)` is the recession cone's LINEALITY space — both `±d` are
176    /// feasible there — so anything but all-positive here is impropriety.
177    pub lineality_inertia: Inertia,
178    /// `min wᵀMw` over the unit simplex. `Some(v)` with `v > 0` is a proof that
179    /// the cone-truncated posterior is proper; `Some(v)` with `v <= 0` is a
180    /// proof that it is improper. `None` means the face is too wide for the
181    /// exact `2^q` enumeration, so properness is undecided — never assumed.
182    pub copositive_minimum: Option<f64>,
183}
184
185impl ConeProperness {
186    /// `Some(true)`/`Some(false)` when properness is PROVED either way, `None`
187    /// when it is undecided. Undecided is deliberately not folded into either
188    /// answer.
189    pub fn is_proper(&self) -> Option<bool> {
190        if self.lineality_inertia.negative > 0 || self.lineality_inertia.zero > 0 {
191            return Some(false);
192        }
193        self.copositive_minimum.map(|minimum| minimum > 0.0)
194    }
195
196    /// One line naming every quantity the verdict was decided against, for a
197    /// refusal or a decline to carry.
198    pub fn summary(&self) -> String {
199        let verdict = match self.is_proper() {
200            Some(true) => "PROPER".to_string(),
201            Some(false) => "IMPROPER".to_string(),
202            None => format!(
203                "UNDECIDED (the exact enumeration is out of range at q = {})",
204                self.reduced.nrows()
205            ),
206        };
207        let copositive = match self.copositive_minimum {
208            Some(minimum) => format!("{minimum:.6e}"),
209            None => "not enumerated".to_string(),
210        };
211        format!(
212            "cone-truncated posterior is {verdict}: In(H) = ({}, {}, {}), \
213             In(M) = ({}, {}, {}), In(ZᵀHZ) = ({}, {}, {}) on null(A), \
214             min wᵀMw over the simplex = {copositive}",
215            self.ambient_inertia.positive,
216            self.ambient_inertia.zero,
217            self.ambient_inertia.negative,
218            self.reduced_inertia.positive,
219            self.reduced_inertia.zero,
220            self.reduced_inertia.negative,
221            self.lineality_inertia.positive,
222            self.lineality_inertia.zero,
223            self.lineality_inertia.negative,
224        )
225    }
226}
227
228/// The reduced precision `M` on the recession cone's normal coordinates.
229///
230/// For a feasible set `{d : Ad ≥ b}` the recession cone is `{d : Ad ≥ 0}`, and
231/// splitting `d = Zt + Nw` with `Z` a basis of `null(A)` and `w = Ad` leaves the
232/// `w`-marginal precision as the Schur complement
233/// `M = NᵀHN − NᵀHZ(ZᵀHZ)⁻¹ZᵀHN`. This computes it WITHOUT forming `Z`, `N`, or
234/// `H⁻¹`, from the defining variational identity
235///
236/// ```text
237/// wᵀMw = stat{ dᵀHd : Ad = w }
238/// ```
239///
240/// — the stationary value, which is the MINIMUM exactly when `ZᵀHZ ≻ 0` and is
241/// the algebraic Schur complement either way, so this route does not presuppose
242/// the condition the certificate above it goes on to test. Its stationarity
243/// system is the symmetric saddle point
244///
245/// ```text
246/// [ H  Aᵀ ] [ d ]   [ 0 ]
247/// [ A  0  ] [ ν ] = [ w ],        M w = −ν
248/// ```
249///
250/// so one solve per constraint row gives `M` exactly. That matters here: the
251/// reason this module exists is that `H` is INDEFINITE, so `Σ = H⁻¹` may not be
252/// a covariance and `M = (AH⁻¹Aᵀ)⁻¹` — the identity that holds when `H ≻ 0` —
253/// cannot be evaluated by inverting anything. The saddle system is indefinite by
254/// construction and needs no positive definiteness anywhere.
255///
256/// The system is nonsingular exactly when `A` has full row rank and `H` is
257/// nonsingular on `null(A)`; a failed pivot therefore refuses by naming which of
258/// those two the face broke, rather than returning a matrix built on neither.
259pub fn reduced_cone_precision(
260    hessian: ArrayView2<'_, f64>,
261    constraints: ArrayView2<'_, f64>,
262) -> Result<Array2<f64>, String> {
263    let p = hessian.nrows();
264    if hessian.ncols() != p {
265        return Err(format!(
266            "cone reduction needs a square ambient precision, got {}x{}",
267            hessian.nrows(),
268            hessian.ncols()
269        ));
270    }
271    let q = constraints.nrows();
272    if constraints.ncols() != p {
273        return Err(format!(
274            "cone reduction: the ambient precision is {p}x{p} but the constraint rows have \
275             {} columns",
276            constraints.ncols()
277        ));
278    }
279    if q == 0 {
280        return Err(
281            "cone reduction needs at least one inequality row; with none the recession cone \
282             is all of R^p and properness is just positive definiteness of H"
283                .to_string(),
284        );
285    }
286    if q > p {
287        return Err(format!(
288            "cone reduction: {q} constraint rows in {p} dimensions cannot be independent, so \
289             the reduction's coordinates are not well defined; canonicalize the face to an \
290             independent row basis first"
291        ));
292    }
293    let size = p + q;
294    let mut saddle = Array2::<f64>::zeros((size, size));
295    saddle
296        .slice_mut(ndarray::s![0..p, 0..p])
297        .assign(&hessian);
298    saddle
299        .slice_mut(ndarray::s![0..p, p..size])
300        .assign(&constraints.t());
301    saddle
302        .slice_mut(ndarray::s![p..size, 0..p])
303        .assign(&constraints);
304    if saddle.iter().any(|value| !value.is_finite()) {
305        return Err(
306            "cone reduction: the saddle system carries a non-finite entry, so neither the \
307             ambient precision nor the constraint rows can be trusted"
308                .to_string(),
309        );
310    }
311    let scale = saddle
312        .iter()
313        .fold(0.0f64, |worst, value| worst.max(value.abs()))
314        .max(1.0);
315    let floor = 1e-12 * scale;
316    let mut reduced = Array2::<f64>::zeros((q, q));
317    for column in 0..q {
318        let mut rhs = Array1::<f64>::zeros(size);
319        rhs[p + column] = 1.0;
320        let Some(solution) = symmetric_solve(&saddle, &rhs, floor) else {
321            return Err(format!(
322                "cone reduction: the saddle system [[H, Aᵀ],[A, 0]] is singular at pivot floor \
323                 {floor:.3e} while eliminating constraint row {column}. Either the {q} \
324                 constraint rows are dependent, or H is singular on null(A) — and the second \
325                 case is itself impropriety, since null(A) is the recession cone's lineality \
326                 space"
327            ));
328        };
329        for row in 0..q {
330            reduced[[row, column]] = -solution[p + row];
331        }
332    }
333    // `M` is symmetric in exact arithmetic (it is a Schur complement of a
334    // symmetric matrix); the elimination is not symmetry preserving, so the
335    // asymmetry it leaves is measured and then removed rather than assumed
336    // absent.
337    let mut worst_asymmetry = 0.0f64;
338    for row in 0..q {
339        for column in 0..q {
340            let gap = (reduced[[row, column]] - reduced[[column, row]]).abs();
341            worst_asymmetry = worst_asymmetry.max(gap);
342        }
343    }
344    let reduced_scale = reduced
345        .iter()
346        .fold(0.0f64, |worst, value| worst.max(value.abs()))
347        .max(1.0);
348    if worst_asymmetry > 1e-6 * reduced_scale {
349        return Err(format!(
350            "cone reduction: the reduced precision came back asymmetric by \
351             {worst_asymmetry:.3e} against a scale of {reduced_scale:.3e}, which a Schur \
352             complement of a symmetric matrix cannot be — the saddle solve lost the face's \
353             conditioning"
354        ));
355    }
356    for row in 0..q {
357        for column in (row + 1)..q {
358            let averaged = 0.5 * (reduced[[row, column]] + reduced[[column, row]]);
359            reduced[[row, column]] = averaged;
360            reduced[[column, row]] = averaged;
361        }
362    }
363    Ok(reduced)
364}
365
366/// Decide whether a cone-truncated Laplace posterior is proper, exactly.
367///
368/// The feasible set is `{d : Ad ≥ b}`, so `exp(−½dᵀHd − …)` is normalizable over
369/// it exactly when `dᵀHd > 0` for every nonzero `d` in the recession cone
370/// `{Ad ≥ 0}` — strict copositivity of `H` on that cone, NOT `H ≻ 0`. In the
371/// `d = Zt + Nw` coordinates that separates into two conditions, and this
372/// returns both:
373///
374/// * `ZᵀHZ ≻ 0`, i.e. properness along the cone's lineality space `null(A)`,
375///   where both `±d` are feasible so there is nothing for a constraint to do;
376/// * `M` strictly copositive on `{w ≥ 0}`, decided exactly by face enumeration.
377///
378/// `In(ZᵀHZ)` comes from Haynsworth additivity — `In(H) = In(ZᵀHZ) + In(M)` —
379/// so no null-space basis is ever formed.
380pub fn cone_properness_certificate(
381    hessian: ArrayView2<'_, f64>,
382    constraints: ArrayView2<'_, f64>,
383    tolerance: f64,
384) -> Result<ConeProperness, String> {
385    let reduced = reduced_cone_precision(hessian, constraints)?;
386    let ambient_inertia = symmetric_inertia(hessian, tolerance)
387        .map_err(|error| format!("ambient precision inertia: {error}"))?;
388    let reduced_inertia = symmetric_inertia(reduced.view(), tolerance)
389        .map_err(|error| format!("reduced precision inertia: {error}"))?;
390    let (positive, zero, negative) = (
391        ambient_inertia.positive.checked_sub(reduced_inertia.positive),
392        ambient_inertia.zero.checked_sub(reduced_inertia.zero),
393        ambient_inertia.negative.checked_sub(reduced_inertia.negative),
394    );
395    let (Some(positive), Some(zero), Some(negative)) = (positive, zero, negative) else {
396        return Err(format!(
397            "Haynsworth additivity In(H) = In(ZᵀHZ) + In(M) is violated: In(H) = ({}, {}, {}) \
398             cannot contain In(M) = ({}, {}, {}). One of the two inertias is wrong, so the \
399             lineality verdict has no basis",
400            ambient_inertia.positive,
401            ambient_inertia.zero,
402            ambient_inertia.negative,
403            reduced_inertia.positive,
404            reduced_inertia.zero,
405            reduced_inertia.negative,
406        ));
407    };
408    let lineality_inertia = Inertia {
409        positive,
410        zero,
411        negative,
412    };
413    let expected = hessian.nrows() - reduced.nrows();
414    let realized = positive + zero + negative;
415    if realized != expected {
416        return Err(format!(
417            "the lineality inertia has {realized} directions where null(A) has {expected}; \
418             In(H) − In(M) is not an inertia of the right dimension"
419        ));
420    }
421    // Only enumerate when the answer would be exact. `copositive_simplex_minimum`
422    // owns that range, and an out-of-range face reports UNDECIDED rather than
423    // borrowing a cheaper sufficient condition and calling it a proof.
424    let copositive_minimum = copositive_simplex_minimum(reduced.view())
425        .ok()
426        .map(|(minimum, _)| minimum);
427    Ok(ConeProperness {
428        reduced,
429        ambient_inertia,
430        reduced_inertia,
431        lineality_inertia,
432        copositive_minimum,
433    })
434}
435
436/// Solve `A y = b` for a small dense `A` by Gaussian elimination with partial
437/// pivoting. Returns `None` when a pivot falls below the floor, which the
438/// callers read as "this face is degenerate, skip it" rather than as an error —
439/// a singular face carries no isolated stationary point to compare.
440///
441/// The name records where it is used, not a requirement: the elimination is a
442/// general LU with row pivoting, and `reduced_cone_precision` deliberately feeds
443/// it an indefinite symmetric saddle matrix.
444fn symmetric_solve(a: &Array2<f64>, b: &Array1<f64>, floor: f64) -> Option<Array1<f64>> {
445    let n = a.nrows();
446    let mut work = a.clone();
447    let mut rhs = b.clone();
448    for column in 0..n {
449        let mut pivot_row = column;
450        let mut best = work[[column, column]].abs();
451        for row in (column + 1)..n {
452            let candidate = work[[row, column]].abs();
453            if candidate > best {
454                best = candidate;
455                pivot_row = row;
456            }
457        }
458        if !best.is_finite() || best <= floor {
459            return None;
460        }
461        if pivot_row != column {
462            for j in 0..n {
463                let swap = work[[column, j]];
464                work[[column, j]] = work[[pivot_row, j]];
465                work[[pivot_row, j]] = swap;
466            }
467            rhs.swap(column, pivot_row);
468        }
469        let pivot = work[[column, column]];
470        for row in (column + 1)..n {
471            let factor = work[[row, column]] / pivot;
472            if factor == 0.0 {
473                continue;
474            }
475            for j in column..n {
476                work[[row, j]] -= factor * work[[column, j]];
477            }
478            rhs[row] -= factor * rhs[column];
479        }
480    }
481    let mut solution = Array1::<f64>::zeros(n);
482    for row in (0..n).rev() {
483        let mut total = rhs[row];
484        for column in (row + 1)..n {
485            total -= work[[row, column]] * solution[column];
486        }
487        solution[row] = total / work[[row, row]];
488    }
489    if solution.iter().any(|value| !value.is_finite()) {
490        return None;
491    }
492    Some(solution)
493}
494
495/// Exact minimum of `wᵀMw` over the unit simplex `{w ≥ 0, 1ᵀw = 1}`.
496///
497/// Strictly positive iff `M` is strictly copositive, which is exactly the
498/// condition for `exp(−½wᵀMw)` to be normalizable on a shifted orthant. On the
499/// face with support `S` the stationary value is `1/(1ᵀM_SS⁻¹1)`, so enumerating
500/// all `2ⁿ − 1` supports and the vertices `M_jj` decides it — no nonconvex QP,
501/// and a non-positive answer is a PROOF of impropriety rather than an
502/// inconclusive bound.
503pub fn copositive_simplex_minimum(
504    matrix: ArrayView2<'_, f64>,
505) -> Result<(f64, Array1<f64>), String> {
506    let n = matrix.nrows();
507    if matrix.ncols() != n {
508        return Err(format!(
509            "copositivity needs a square matrix, got {}x{}",
510            matrix.nrows(),
511            matrix.ncols()
512        ));
513    }
514    if n == 0 || n > 20 {
515        return Err(format!(
516            "exact copositivity enumerates 2^n faces and is meant for a retained \
517             constraint face; n = {n} is out of range"
518        ));
519    }
520    let owned = matrix.to_owned();
521    let scale = owned
522        .iter()
523        .fold(0.0f64, |worst, value| worst.max(value.abs()))
524        .max(1.0);
525    let floor = 1e-12 * scale;
526    let mut best = f64::INFINITY;
527    let mut best_point = Array1::<f64>::zeros(n);
528    for mask in 1u32..(1u32 << n) {
529        let support: Vec<usize> = (0..n).filter(|j| mask & (1 << j) != 0).collect();
530        let size = support.len();
531        let mut block = Array2::<f64>::zeros((size, size));
532        for (i, &row) in support.iter().enumerate() {
533            for (j, &column) in support.iter().enumerate() {
534                block[[i, j]] = owned[[row, column]];
535            }
536        }
537        let ones = Array1::<f64>::ones(size);
538        let Some(solution) = symmetric_solve(&block, &ones, floor) else {
539            continue;
540        };
541        let total: f64 = solution.sum();
542        if !total.is_finite() || total.abs() <= floor {
543            continue;
544        }
545        let weights = &solution / total;
546        if weights.iter().any(|value| *value <= 0.0) {
547            continue;
548        }
549        let value = weights.dot(&block.dot(&weights));
550        if value.is_finite() && value < best {
551            best = value;
552            best_point = Array1::zeros(n);
553            for (i, &row) in support.iter().enumerate() {
554                best_point[row] = weights[i];
555            }
556        }
557    }
558    for j in 0..n {
559        if owned[[j, j]] < best {
560            best = owned[[j, j]];
561            best_point = Array1::zeros(n);
562            best_point[j] = 1.0;
563        }
564    }
565    if !best.is_finite() {
566        return Err("copositivity enumeration produced no finite face value".to_string());
567    }
568    Ok((best, best_point))
569}
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574    use ndarray::array;
575
576    /// The reduced precision `M` of the refusing location-scale fixture on
577    /// #2529, taken from the probe dump at `e23e674633b` (`p = 9`, `m = 6`,
578    /// blocks `MU ⊕ LOG_SIGMA ⊕ WIGGLE`). Every constant asserted against it
579    /// below was produced twice by two lanes on two independent methods.
580    const FIXTURE_M: [[f64; 6]; 6] = [
581        [2144.265169679624, 1715.134178122592, 1747.5745584612605, 935.098928788, -2.7864165543774675, -0.20985105745649374],
582        [1715.134178122592, 2085.4964662263064, 1875.9766836439958, 759.8968208234021, -39.68741458861115, -0.2501447114808116],
583        [1747.5745584612605, 1875.9766836439958, 1822.1414523216163, 1123.054947333127, 109.2026621607369, -0.17598452900630168],
584        [935.098928788, 759.8968208234021, 1123.054947333127, 938.363436676176, 106.59121181068619, -4.890252117554146],
585        [-2.7864165543774675, -39.68741458861115, 109.2026621607369, 106.59121181068619, 23.64370794528972, -21.728482419069984],
586        [-0.20985105745649374, -0.2501447114808116, -0.17598452900630168, -4.890252117554146, -21.728482419069984, 57.945174065326796],
587    ];
588    const FIXTURE_ELL: [f64; 6] = [
589        0.41517285129090653,
590        -1.8692500719946608,
591        2.765160237666297,
592        -3.8165670131467633,
593        6.59422728766729,
594        4.190338688011645,
595    ];
596
597    fn fixture() -> (Array2<f64>, Array1<f64>) {
598        let mut matrix = Array2::<f64>::zeros((6, 6));
599        for (i, row) in FIXTURE_M.iter().enumerate() {
600            for (j, value) in row.iter().enumerate() {
601                matrix[[i, j]] = *value;
602            }
603        }
604        (matrix, Array1::from_vec(FIXTURE_ELL.to_vec()))
605    }
606
607    #[test]
608    fn inertia_counts_pivot_signs_rather_than_solving_an_eigenproblem() {
609        // Diagonal: the inertia is read straight off.
610        let diagonal = array![[3.0, 0.0, 0.0], [0.0, -2.0, 0.0], [0.0, 0.0, 5.0]];
611        assert_eq!(
612            symmetric_inertia(diagonal.view(), 1e-12).expect("diagonal inertia"),
613            Inertia { positive: 2, zero: 0, negative: 1 }
614        );
615        // A congruence transform must leave the inertia alone — that is
616        // Sylvester's law, and it is the whole reason pivot signs are a
617        // certificate. `C A Cᵀ` with `C` invertible.
618        let c = array![[1.0, 2.0, 0.0], [0.0, 1.0, 3.0], [4.0, 0.0, 1.0]];
619        let congruent = c.dot(&diagonal).dot(&c.t());
620        assert_eq!(
621            symmetric_inertia(congruent.view(), 1e-12).expect("congruent inertia"),
622            Inertia { positive: 2, zero: 0, negative: 1 },
623            "congruence preserves inertia"
624        );
625    }
626
627    /// `H⁻¹Aᵀ` one column at a time, for the tests that need an independent
628    /// route to `W = AΣAᵀ`. Only ever called on a positive definite `H`.
629    fn ambient_solve_against_rows(hessian: &Array2<f64>, constraints: &Array2<f64>) -> Array2<f64> {
630        let p = hessian.nrows();
631        let q = constraints.nrows();
632        let scale = hessian
633            .iter()
634            .fold(0.0f64, |worst, value| worst.max(value.abs()))
635            .max(1.0);
636        let mut lifted = Array2::<f64>::zeros((p, q));
637        for row in 0..q {
638            let rhs = constraints.row(row).to_owned();
639            let solution =
640                symmetric_solve(hessian, &rhs, 1e-12 * scale).expect("a PD ambient solve");
641            for i in 0..p {
642                lifted[[i, row]] = solution[i];
643            }
644        }
645        lifted
646    }
647
648    #[test]
649    fn the_reduced_precision_inverts_the_constraint_normal_covariance_when_the_ambient_is_pd() {
650        // `M = (A H⁻¹ Aᵀ)⁻¹` is the identity the #2417 decomposition uses, and it
651        // holds only when `H ≻ 0`. So it is exactly the right independent check
652        // on the saddle route, which never forms `H⁻¹`: on a PD ambient the two
653        // must agree, and the saddle route is then used on ambients where the
654        // identity's right-hand side does not exist at all.
655        let hessian = array![
656            [7.0, 1.0, 0.5, 0.0],
657            [1.0, 5.0, -1.0, 0.25],
658            [0.5, -1.0, 6.0, 1.5],
659            [0.0, 0.25, 1.5, 4.0],
660        ];
661        let constraints = array![[1.0, 0.0, -1.0, 0.0], [0.0, 2.0, 1.0, -0.5]];
662        let reduced = reduced_cone_precision(hessian.view(), constraints.view())
663            .expect("the saddle reduction on a PD ambient");
664        let lifted = ambient_solve_against_rows(&hessian, &constraints);
665        let normal_covariance = constraints.dot(&lifted);
666        let product = normal_covariance.dot(&reduced);
667        for i in 0..2 {
668            for j in 0..2 {
669                let expected = if i == j { 1.0 } else { 0.0 };
670                assert!(
671                    (product[[i, j]] - expected).abs() < 1e-10,
672                    "(A H⁻¹ Aᵀ) M should be the identity, entry ({i},{j}) was {:.6e}",
673                    product[[i, j]]
674                );
675            }
676        }
677    }
678
679    #[test]
680    fn the_live_reduction_reproduces_the_fixture_reduced_precision_and_its_minimum() {
681        // Until now `M` existed here only as 36 constants dumped by a Python
682        // probe. This builds an ambient `H` whose reduction IS that matrix and
683        // checks that the production route recovers it — so the published
684        // copositivity minimum becomes a property of the code, not of a paste.
685        //
686        // With `A = [I_6 | 0]` the normal coordinates are the first six, the
687        // lineality space is the last three, and the reduction is the ordinary
688        // Schur complement `H₁₁ − H₁₂H₂₂⁻¹H₂₁`. Choosing `H₂₂ ≻ 0` (its min
689        // eigenvalue echoes the measured `+51.4` on `null(A)`) and a nonzero
690        // coupling `H₁₂` makes the reduction do real work rather than copy a
691        // block.
692        let (target, _) = fixture();
693        let lineality = array![[51.4, 3.0, -1.0], [3.0, 60.0, 2.0], [-1.0, 2.0, 70.0]];
694        let mut coupling = Array2::<f64>::zeros((6, 3));
695        for i in 0..6 {
696            for j in 0..3 {
697                coupling[[i, j]] = ((i + 1) as f64) * 0.5 - ((j + 1) as f64) * 1.25;
698            }
699        }
700        // `H₁₁ = M + H₁₂H₂₂⁻¹H₂₁` reverses the Schur complement exactly.
701        let mut lineality_solve = Array2::<f64>::zeros((3, 6));
702        for column in 0..6 {
703            let rhs = coupling.row(column).to_owned();
704            let solution = symmetric_solve(&lineality, &rhs, 1e-12 * 70.0)
705                .expect("the PD lineality block is invertible");
706            for i in 0..3 {
707                lineality_solve[[i, column]] = solution[i];
708            }
709        }
710        let correction = coupling.dot(&lineality_solve);
711        let mut hessian = Array2::<f64>::zeros((9, 9));
712        hessian
713            .slice_mut(ndarray::s![0..6, 0..6])
714            .assign(&(&target + &correction));
715        hessian.slice_mut(ndarray::s![0..6, 6..9]).assign(&coupling);
716        hessian
717            .slice_mut(ndarray::s![6..9, 0..6])
718            .assign(&coupling.t());
719        hessian.slice_mut(ndarray::s![6..9, 6..9]).assign(&lineality);
720        let mut constraints = Array2::<f64>::zeros((6, 9));
721        for j in 0..6 {
722            constraints[[j, j]] = 1.0;
723        }
724
725        let certificate = cone_properness_certificate(hessian.view(), constraints.view(), 1e-12)
726            .expect("a certificate on an indefinite ambient with a PD lineality block");
727        let scale = target
728            .iter()
729            .fold(0.0f64, |worst, value| worst.max(value.abs()));
730        for i in 0..6 {
731            for j in 0..6 {
732                assert!(
733                    (certificate.reduced[[i, j]] - target[[i, j]]).abs() < 1e-8 * scale,
734                    "recovered M[{i},{j}] = {:.9e}, expected {:.9e}",
735                    certificate.reduced[[i, j]],
736                    target[[i, j]]
737                );
738            }
739        }
740        assert_eq!(
741            certificate.reduced_inertia,
742            Inertia {
743                positive: 5,
744                zero: 0,
745                negative: 1
746            },
747            "In(M) = (5,0,1) survives the round trip through the ambient"
748        );
749        // The whole point of the Haynsworth route: `null(A)` never gets a basis,
750        // yet its inertia comes out right. The ambient built here is indefinite,
751        // so this is not the PD case in disguise.
752        assert_eq!(
753            certificate.lineality_inertia,
754            Inertia {
755                positive: 3,
756                zero: 0,
757                negative: 0
758            },
759            "H is PD on null(A), which is what licenses marginalizing the tangent"
760        );
761        assert_eq!(certificate.ambient_inertia.negative, 1);
762        let minimum = certificate
763            .copositive_minimum
764            .expect("q = 6 is inside the exact enumeration range");
765        assert!(
766            (minimum - 6.683215003061817).abs() < 1e-6,
767            "the live reduction's copositivity minimum was {minimum:.12e}, expected \
768             6.683215003061817"
769        );
770        assert_eq!(
771            certificate.is_proper(),
772            Some(true),
773            "a copositive M with a PD lineality block is a PROOF of properness"
774        );
775        let summary = certificate.summary();
776        assert!(
777            summary.contains("PROPER") && summary.contains("min wᵀMw"),
778            "the summary must name the quantity it decided on, got: {summary}"
779        );
780    }
781
782    #[test]
783    fn a_negative_direction_inside_null_a_is_reported_as_impropriety() {
784        // The constraint touches only the first coordinate, so `null(A)` carries
785        // the other two — and a negative curvature there is a direction along
786        // which BOTH `±d` are feasible. No inequality can make that proper, and
787        // copositivity of `M` cannot see it, so the lineality inertia has to be
788        // the thing that decides.
789        let hessian = array![[1.0, 0.0, 0.0], [0.0, -1.0, 0.0], [0.0, 0.0, 1.0]];
790        let constraints = array![[1.0, 0.0, 0.0]];
791        let certificate = cone_properness_certificate(hessian.view(), constraints.view(), 1e-12)
792            .expect("a certificate on a lineality-improper ambient");
793        assert_eq!(
794            certificate.lineality_inertia.negative, 1,
795            "the negative direction lands in null(A), not in the normal coordinates"
796        );
797        assert_eq!(
798            certificate.copositive_minimum,
799            Some(1.0),
800            "M is the 1x1 block [1], so copositivity alone would have said PROPER"
801        );
802        assert_eq!(
803            certificate.is_proper(),
804            Some(false),
805            "impropriety along the cone's lineality space outranks a copositive M"
806        );
807        assert!(certificate.summary().contains("IMPROPER"));
808    }
809
810    #[test]
811    fn dependent_constraint_rows_are_refused_by_name_rather_than_reduced() {
812        // Two copies of one row make the saddle system singular. The reduction
813        // has no coordinates in that case, and the refusal has to say so — a
814        // silently pseudo-inverted `M` would be a matrix built on neither of the
815        // two conditions the certificate reports.
816        let hessian = array![[4.0, 0.0, 0.0], [0.0, 3.0, 0.0], [0.0, 0.0, 2.0]];
817        let constraints = array![[1.0, 1.0, 0.0], [1.0, 1.0, 0.0]];
818        let message = reduced_cone_precision(hessian.view(), constraints.view())
819            .expect_err("dependent rows have no reduction");
820        assert!(
821            message.contains("dependent") && message.contains("lineality"),
822            "the refusal must name both readings of a singular saddle, got: {message}"
823        );
824        // More rows than dimensions cannot be independent at all, and that is
825        // decidable without a solve.
826        let wide = Array2::<f64>::ones((4, 3));
827        let message = reduced_cone_precision(hessian.view(), wide.view())
828            .expect_err("q > p has no independent reduction");
829        assert!(
830            message.contains("cannot be independent"),
831            "got: {message}"
832        );
833    }
834
835    #[test]
836    fn a_face_too_wide_for_the_exact_enumeration_reports_undecided_rather_than_proper() {
837        // `copositive_simplex_minimum` is exact because it enumerates `2^q`
838        // faces, and it owns the range where that is affordable. Past it the
839        // certificate must decline to answer: a diagonally dominant `M` here is
840        // OBVIOUSLY copositive, and reporting PROPER from that would be a
841        // sufficient condition wearing a proof's clothes.
842        let width = 21usize;
843        let mut hessian = Array2::<f64>::eye(width);
844        for j in 0..width {
845            hessian[[j, j]] = 2.0 + (j as f64);
846        }
847        let constraints = Array2::<f64>::eye(width);
848        let certificate = cone_properness_certificate(hessian.view(), constraints.view(), 1e-12)
849            .expect("a certificate on a wide face");
850        assert_eq!(
851            certificate.copositive_minimum, None,
852            "q = {width} is outside the exact range"
853        );
854        assert_eq!(
855            certificate.is_proper(),
856            None,
857            "undecided must not collapse into either verdict"
858        );
859        assert!(
860            certificate.summary().contains("UNDECIDED"),
861            "got: {}",
862            certificate.summary()
863        );
864    }
865
866    #[test]
867    fn the_fixture_reduced_precision_has_exactly_one_negative_direction() {
868        let (matrix, _) = fixture();
869        assert_eq!(
870            symmetric_inertia(matrix.view(), 1e-12).expect("fixture inertia"),
871            Inertia { positive: 5, zero: 0, negative: 1 },
872            "In(M) = (5,0,1) is what makes this a cone problem rather than a truncated Gaussian"
873        );
874    }
875
876    #[test]
877    fn copositivity_is_decided_exactly_and_matches_the_published_minimum() {
878        let (matrix, _) = fixture();
879        let (minimum, point) =
880            copositive_simplex_minimum(matrix.view()).expect("simplex minimum");
881        // Published on #2529 step 1 by the constrained-posterior lane
882        // (face enumeration cross-checked by 4000-start projected gradient to
883        // 3.02e-13 relative); reproduced here by an independent enumeration.
884        assert!(
885            (minimum - 6.683215003061817).abs() < 1e-9,
886            "min wᵀMw over the simplex was {minimum:.15e}, expected 6.683215003061817"
887        );
888        assert!(minimum > 0.0, "strict copositivity ⇒ the cone-truncated law is proper");
889        let total: f64 = point.sum();
890        assert!((total - 1.0).abs() < 1e-9, "the argmin lies on the simplex, sum was {total}");
891        assert!(point.iter().all(|value| *value >= 0.0), "the argmin is nonnegative");
892    }
893
894}