1use ndarray::{Array1, Array2, ArrayView2};
49use serde::{Deserialize, Serialize};
50
51#[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#[derive(Clone, Debug)]
61pub struct ConeMode {
62 pub point: Array1<f64>,
64 pub value: f64,
66 pub gradient: Array1<f64>,
70 pub free: Vec<usize>,
72}
73
74pub 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 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 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#[derive(Clone, Debug, Serialize, Deserialize)]
163pub struct ConeProperness {
164 pub reduced: Array2<f64>,
168 pub ambient_inertia: Inertia,
171 pub reduced_inertia: Inertia,
173 pub lineality_inertia: Inertia,
178 pub copositive_minimum: Option<f64>,
183}
184
185impl ConeProperness {
186 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 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
228pub 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 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
366pub 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 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
436fn 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
495pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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}