1use crate::estimate::EstimationError;
2use faer::linalg::solvers::SolveLstsq;
3use faer::Side;
4use gam_linalg::faer_ndarray::{
5 FaerArrayView, FaerCholesky, FaerLinalgError, FaerSvd, array1_to_col_matmut,
6 default_rrqr_rank_alpha, rrqr_nullspace_basis,
7};
8use gam_linalg::utils::{KahanSum, StableSolver, array_is_finite};
9use gam_problem::{
10 ConstraintRowId, ConstraintSet, KhatriRaoConeConstraints, LinearInequalityConstraints,
11};
12use ndarray::{Array1, Array2, ArrayView1, s};
13use serde::{Deserialize, Serialize};
14use std::cell::Cell;
15use std::collections::HashSet;
16
17pub use gam_problem::PRIMAL_FEASIBILITY_TOL as ACTIVE_SET_PRIMAL_FEASIBILITY_TOL;
34
35pub const ACTIVE_SET_WORKING_FACE_TOL: f64 = 1e-10;
43
44const ACTIVE_SET_KKT_STATIONARITY_TOL: f64 = 2e-6;
50
51pub(crate) const ACTIVE_SET_KKT_COMPLEMENTARITY_TOL: f64 = 1e-6;
55
56const ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL: f64 = 1e-8;
60
61pub(crate) const ACTIVE_SET_KKT_DEGENERATE_STATIONARITY_TOL: f64 = 1e-3;
85
86#[derive(Clone, Debug, Serialize, Deserialize)]
97pub struct ConstraintKktDiagnostics {
98 pub n_constraints: usize,
100 pub n_active: usize,
102 pub primal_feasibility: f64,
104 pub dual_feasibility: f64,
106 pub complementarity: f64,
108 pub stationarity: f64,
110 pub active_tolerance: f64,
112 #[serde(default)]
127 pub working_set_rank_deficient: bool,
128 #[serde(default)]
139 pub cone_projection_refused: bool,
140 #[serde(default)]
157 pub gradient_scale: f64,
158}
159
160impl ConstraintKktDiagnostics {
161 pub fn cone_projection_note(&self) -> &'static str {
180 if self.cone_projection_refused {
181 "; cone_projection=REFUSED (no multipliers computed: stat is the UNPROJECTED gradient, \
182 not a residual)"
183 } else {
184 ""
185 }
186 }
187}
188
189fn gradient_inf_norm(gradient: &Array1<f64>) -> f64 {
193 gradient.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()))
194}
195
196fn solve_newton_direction_dense(
197 hessian: &Array2<f64>,
198 gradient: &Array1<f64>,
199 direction_out: &mut Array1<f64>,
200) -> Result<(), EstimationError> {
201 if direction_out.len() != gradient.len() {
202 *direction_out = Array1::zeros(gradient.len());
203 }
204
205 let factor = StableSolver::new()
206 .factorize(hessian)
207 .map_err(EstimationError::LinearSystemSolveFailed)?;
208 direction_out.assign(gradient);
209 let mut rhsview = array1_to_col_matmut(direction_out);
210 factor.solve_in_place(rhsview.as_mut());
211 direction_out.mapv_inplace(|v| -v);
212 if array_is_finite(direction_out) {
213 return Ok(());
214 }
215 Err(EstimationError::LinearSystemSolveFailed(
216 FaerLinalgError::FactorizationFailed {
217 context: "active-set newton direction non-finite solve",
218 },
219 ))
220}
221
222fn solve_dense_system_via_pseudoinverse(
223 matrix: &Array2<f64>,
224 rhs: &Array1<f64>,
225 out: &mut Array1<f64>,
226) -> Result<(), EstimationError> {
227 if matrix.nrows() != matrix.ncols() || rhs.len() != matrix.nrows() {
228 crate::bail_invalid_estim!("dense pseudoinverse solve dimension mismatch");
229 }
230
231 let (u_opt, singular, vt_opt) = matrix.svd(true, true).map_err(|_| {
232 EstimationError::InvalidInput("dense pseudoinverse solve SVD failed".to_string())
233 })?;
234 let (Some(u), Some(vt)) = (u_opt, vt_opt) else {
235 crate::bail_invalid_estim!("dense pseudoinverse solve missing singular vectors");
236 };
237
238 let max_singular = singular.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
239 let tol = 100.0
240 * f64::EPSILON
241 * (matrix.nrows().max(matrix.ncols()).max(1) as f64)
242 * max_singular.max(1.0);
243 let mut coeff = u.t().dot(rhs);
244 for (idx, value) in coeff.iter_mut().enumerate() {
245 let sigma = singular[idx];
246 if sigma.abs() > tol {
247 *value /= sigma;
248 } else {
249 *value = 0.0;
250 }
251 }
252 let solution = vt.t().dot(&coeff);
253 if !array_is_finite(&solution) {
254 crate::bail_invalid_estim!("dense pseudoinverse solve produced non-finite values");
255 }
256 if out.len() != solution.len() {
257 *out = Array1::zeros(solution.len());
258 }
259 out.assign(&solution);
260 Ok(())
261}
262
263fn least_squares_min_norm_any_shape(a: &Array2<f64>, b: &Array1<f64>) -> Option<Array1<f64>> {
281 let p = a.nrows();
282 let k = a.ncols();
283 if b.len() != p {
284 return None;
285 }
286 if k == 0 {
287 return Some(Array1::zeros(0));
288 }
289 if k <= p {
290 let mut rhs = Array2::<f64>::zeros((p, 1));
291 rhs.column_mut(0).assign(b);
292 let a_view = FaerArrayView::new(a);
293 let rhs_view = FaerArrayView::new(&rhs);
294 let solved = a_view.as_ref().col_piv_qr().solve_lstsq(rhs_view.as_ref());
295 let mut z = Array1::<f64>::zeros(k);
296 for c in 0..k {
297 let value = solved[(c, 0)];
298 if !value.is_finite() {
299 return None;
300 }
301 z[c] = value;
302 }
303 Some(z)
304 } else {
305 let gram = a.dot(&a.t());
309 let mut y = Array1::<f64>::zeros(p);
310 solve_dense_system_via_pseudoinverse(&gram, b, &mut y).ok()?;
311 let z = a.t().dot(&y);
312 if z.iter().any(|value| !value.is_finite()) {
313 return None;
314 }
315 Some(z)
316 }
317}
318
319pub(crate) fn stationarity_residual_reachability(
339 beta: &Array1<f64>,
340 gradient: &Array1<f64>,
341 constraints: &LinearInequalityConstraints,
342) -> Option<(f64, f64)> {
343 let p = constraints.a.ncols();
344 if beta.len() != p || gradient.len() != p {
345 return None;
346 }
347 let face = active_face(beta, constraints)?;
348 if face.active_idx.is_empty() {
349 let inf = gradient_inf_norm(gradient);
351 return Some((inf, 0.0));
352 }
353 let (_, lambda_active) =
354 project_stationarity_residual_on_constraint_cone(gradient, &face.a_active)?;
355 let mut residual = gradient.to_owned();
356 for (r, &value) in lambda_active.iter().enumerate() {
357 if value != 0.0 {
358 residual.scaled_add(-value, &face.a_active.row(r));
359 }
360 }
361
362 let mut basis: Vec<Array1<f64>> = Vec::new();
366 let drop_tol = 1e-12;
367 for r in 0..face.a_active.nrows() {
368 if basis.len() == p {
369 break;
370 }
371 let mut v = face.a_active.row(r).to_owned();
372 for q in &basis {
373 let projection = q.dot(&v);
374 v.scaled_add(-projection, q);
375 }
376 let norm = v.dot(&v).sqrt();
377 if norm > drop_tol {
378 v.mapv_inplace(|value| value / norm);
379 basis.push(v);
380 }
381 }
382
383 let mut orthogonal = residual.clone();
384 for q in &basis {
385 let projection = q.dot(&residual);
386 orthogonal.scaled_add(-projection, q);
387 }
388 let unreachable = gradient_inf_norm(&orthogonal);
389 let in_row_space = &residual - &orthogonal;
390 Some((unreachable, gradient_inf_norm(&in_row_space)))
391}
392
393struct ActiveFace {
400 a_scaled: Array2<f64>,
401 slack: Array1<f64>,
402 primal_feasibility: f64,
403 active_idx: Vec<usize>,
404 a_active: Array2<f64>,
405}
406
407pub(crate) fn binding_constraint_rows(
424 beta: &Array1<f64>,
425 gradient: &Array1<f64>,
426 constraints: &LinearInequalityConstraints,
427) -> Option<Array2<f64>> {
428 let face = active_face(beta, constraints)?;
429 let p = constraints.a.ncols();
430 if face.active_idx.is_empty() || gradient.len() != p {
431 return Some(Array2::<f64>::zeros((0, p)));
432 }
433 let (_, lambda_active) =
434 project_stationarity_residual_on_constraint_cone(gradient, &face.a_active)?;
435 let keep: Vec<usize> = (0..face.active_idx.len())
436 .filter(|&row| lambda_active.get(row).is_some_and(|value| *value > 0.0))
437 .collect();
438 let mut rows = Array2::<f64>::zeros((keep.len(), p));
439 for (position, &row) in keep.iter().enumerate() {
440 rows.row_mut(position).assign(&face.a_active.row(row));
441 }
442 Some(rows)
443}
444
445fn active_face(
446 beta: &Array1<f64>,
447 constraints: &LinearInequalityConstraints,
448) -> Option<ActiveFace> {
449 let m = constraints.a.nrows();
450 let p = constraints.a.ncols();
451 if beta.len() != p {
452 return None;
453 }
454 let mut a_scaled = constraints.a.clone();
458 let mut b_scaled = constraints.b.clone();
459 for i in 0..m {
460 let n_i = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
461 if n_i > 0.0 {
462 let inv = 1.0 / n_i;
463 a_scaled.row_mut(i).mapv_inplace(|v| v * inv);
464 b_scaled[i] *= inv;
465 }
466 }
467 let mut slack = Array1::<f64>::zeros(m);
468 let mut primal_feasibility: f64 = 0.0;
469 for i in 0..m {
470 let s_i = a_scaled.row(i).dot(beta) - b_scaled[i];
471 slack[i] = s_i;
472 primal_feasibility = primal_feasibility.max((-s_i).max(0.0));
473 }
474 let active_idx: Vec<usize> = (0..m)
475 .filter(|&i| slack[i] <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL)
476 .collect();
477 let mut a_active = Array2::<f64>::zeros((active_idx.len(), p));
478 for (r, &idx) in active_idx.iter().enumerate() {
479 a_active.row_mut(r).assign(&a_scaled.row(idx));
480 }
481 Some(ActiveFace {
482 a_scaled,
483 slack,
484 primal_feasibility,
485 active_idx,
486 a_active,
487 })
488}
489
490pub(crate) fn compute_constraint_kkt_diagnostics(
491 beta: &Array1<f64>,
492 gradient: &Array1<f64>,
493 constraints: &LinearInequalityConstraints,
494) -> ConstraintKktDiagnostics {
495 let m = constraints.a.nrows();
496 let active_tolerance = ACTIVE_SET_PRIMAL_FEASIBILITY_TOL;
497
498 let p = constraints.a.ncols();
503 let Some(ActiveFace {
504 a_scaled,
505 slack,
506 primal_feasibility,
507 active_idx,
508 a_active,
509 }) = active_face(beta, constraints)
510 else {
511 return ConstraintKktDiagnostics {
515 n_constraints: m,
516 n_active: 0,
517 primal_feasibility: f64::INFINITY,
518 dual_feasibility: 0.0,
519 complementarity: 0.0,
520 stationarity: gradient_inf_norm(gradient),
521 active_tolerance,
522 working_set_rank_deficient: false,
523 cone_projection_refused: false,
524 gradient_scale: gradient_inf_norm(gradient),
525 };
526 };
527
528 let mut lambda = Array1::<f64>::zeros(m);
529 let mut working_set_rank_deficient = false;
530 let mut cone_projection_refused = false;
545 if !active_idx.is_empty() {
546 let n_active = active_idx.len();
547 match project_stationarity_residual_on_constraint_cone(gradient, &a_active) {
548 Some((_, lambda_active)) => {
549 for (r, &idx) in active_idx.iter().enumerate() {
550 lambda[idx] = lambda_active[r];
551 }
552 }
553 None => cone_projection_refused = true,
554 }
555 working_set_rank_deficient = if n_active > p {
567 true
568 } else if n_active > 1 {
569 let groups: Vec<Vec<usize>> = (0..n_active).map(|i| vec![i]).collect();
570 let b_dummy = Array1::<f64>::zeros(n_active);
571 let (reduced_a, _, _, _) =
572 rank_reduce_rows_pivoted_qr_with_dependence(a_active, b_dummy, groups);
573 reduced_a.nrows() < n_active
574 } else {
575 false
576 };
577 }
578
579 let mut dual_feasibility: f64 = 0.0;
580 let mut complementarity: f64 = 0.0;
581 for i in 0..m {
582 dual_feasibility = dual_feasibility.max((-lambda[i]).max(0.0));
583 complementarity = complementarity.max((lambda[i] * slack[i]).abs());
584 }
585 let stationarity = {
586 let mut resid = gradient.to_owned();
587 resid -= &a_scaled.t().dot(&lambda);
588 resid.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()))
589 };
590
591 ConstraintKktDiagnostics {
592 n_constraints: m,
593 n_active: active_idx.len(),
594 primal_feasibility,
595 dual_feasibility,
596 complementarity,
597 stationarity,
598 active_tolerance,
599 working_set_rank_deficient,
600 cone_projection_refused,
601 gradient_scale: gradient_inf_norm(gradient),
602 }
603}
604
605fn nonnegative_cone_projection_by_rows<RowValues, GatherRows>(
614 row_norms: &[f64],
615 target: &Array1<f64>,
616 row_values: RowValues,
617 gather_rows: GatherRows,
618) -> Option<(Vec<(usize, f64)>, Array1<f64>)>
619where
620 RowValues: Fn(&Array1<f64>) -> Option<Array1<f64>>,
621 GatherRows: Fn(&[usize]) -> Option<Array2<f64>>,
622{
623 let p = target.len();
624 let m = row_norms.len();
625 if m == 0 {
626 return Some((Vec::new(), target.clone()));
627 }
628 if target.iter().any(|v| !v.is_finite())
629 || row_norms
630 .iter()
631 .any(|norm| !norm.is_finite() || *norm < 0.0)
632 {
633 return None;
634 }
635 let target_inf = target.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
636 if target_inf == 0.0 {
637 return Some((Vec::new(), target.clone()));
638 }
639 let tol_w = 1e-10 * target_inf;
642 let lambda_floor = 1e-14 * target_inf;
643
644 let mut lambda_unit = Array1::<f64>::zeros(m);
645 let mut passive: Vec<usize> = Vec::new();
646 let mut in_passive = vec![false; m];
647 let mut residual = target.clone();
648 let mut banned = vec![false; m];
652
653 let solve_passive = |passive: &[usize]| -> Option<Array1<f64>> {
654 let k = passive.len();
655 let mut design = Array2::<f64>::zeros((p, k));
660 let rows = gather_rows(passive)?;
661 if rows.nrows() != k || rows.ncols() != p || rows.iter().any(|value| !value.is_finite()) {
662 return None;
663 }
664 for (col, &row) in passive.iter().enumerate() {
665 let norm = row_norms[row];
666 if !(norm > 0.0) {
667 return None;
668 }
669 design
670 .column_mut(col)
671 .assign(&(&rows.row(col) / norm));
672 }
673 least_squares_min_norm_any_shape(&design, target)
674 };
675
676 let max_outer = m.saturating_mul(3).saturating_add(30);
677 for _ in 0..max_outer {
678 let values = row_values(&residual)?;
680 if values.len() != m || values.iter().any(|value| !value.is_finite()) {
681 return None;
682 }
683 let mut best: Option<(usize, f64)> = None;
684 for i in 0..m {
685 if in_passive[i] || banned[i] || row_norms[i] <= 0.0 {
686 continue;
687 }
688 let w = values[i] / row_norms[i];
689 if w > tol_w && best.map(|(_, best_w)| w > best_w).unwrap_or(true) {
690 best = Some((i, w));
691 }
692 }
693 let Some((entering, _)) = best else {
694 break;
695 };
696 passive.push(entering);
697 in_passive[entering] = true;
698
699 let mut inner_ok = false;
700 for _ in 0..(m + 2) {
701 let Some(z) = solve_passive(&passive) else {
702 return None;
703 };
704 let min_z = z.iter().copied().fold(f64::INFINITY, f64::min);
705 if min_z > lambda_floor {
706 for (pos, &row) in passive.iter().enumerate() {
707 lambda_unit[row] = z[pos];
708 }
709 inner_ok = true;
710 break;
711 }
712 let mut alpha = 1.0_f64;
715 for (pos, &row) in passive.iter().enumerate() {
716 if z[pos] <= lambda_floor {
717 let current = lambda_unit[row];
718 let denom = current - z[pos];
719 if denom > 0.0 {
720 alpha = alpha.min((current / denom).clamp(0.0, 1.0));
721 } else {
722 alpha = 0.0;
723 }
724 }
725 }
726 for (pos, &row) in passive.iter().enumerate() {
727 lambda_unit[row] += alpha * (z[pos] - lambda_unit[row]);
728 }
729 let mut retained = Vec::with_capacity(passive.len());
730 for &row in &passive {
731 if lambda_unit[row] > lambda_floor {
732 retained.push(row);
733 } else {
734 lambda_unit[row] = 0.0;
735 in_passive[row] = false;
736 banned[row] = true;
740 }
741 }
742 if retained.len() == passive.len() {
743 inner_ok = true;
746 for (pos, &row) in passive.iter().enumerate() {
747 lambda_unit[row] = z[pos].max(0.0);
748 }
749 break;
750 }
751 passive = retained;
752 if passive.is_empty() {
753 break;
754 }
755 }
756 let mut fitted = Array1::<f64>::zeros(p);
758 let passive_rows = gather_rows(&passive)?;
759 if passive_rows.nrows() != passive.len()
760 || passive_rows.ncols() != p
761 || passive_rows.iter().any(|value| !value.is_finite())
762 {
763 return None;
764 }
765 for (position, &row) in passive.iter().enumerate() {
766 fitted.scaled_add(
767 lambda_unit[row] / row_norms[row],
768 &passive_rows.row(position),
769 );
770 }
771 let new_residual = target - &fitted;
772 let moved = new_residual
773 .iter()
774 .zip(residual.iter())
775 .any(|(a, b)| (a - b).abs() > 1e-15 * target_inf);
776 residual = new_residual;
777 if moved {
778 banned.iter_mut().for_each(|b| *b = false);
779 } else if !inner_ok {
780 break;
781 }
782 }
783
784 let final_values = row_values(&residual)?;
791 if final_values.len() != m
792 || final_values.iter().any(|value| !value.is_finite())
793 || (0..m).any(|row| {
794 row_norms[row] > 0.0 && final_values[row] / row_norms[row] > tol_w
795 })
796 {
797 return None;
798 }
799
800 let multipliers: Vec<(usize, f64)> = passive
801 .into_iter()
802 .filter_map(|row| {
803 let lambda = lambda_unit[row] / row_norms[row];
804 (lambda > 0.0).then_some((row, lambda))
805 })
806 .collect();
807 if multipliers.iter().any(|(_, value)| !value.is_finite())
808 || !array_is_finite(&residual)
809 {
810 return None;
811 }
812 Some((multipliers, residual))
813}
814
815pub(crate) fn nonnegative_cone_multipliers(
839 rows: &Array2<f64>,
840 target: &Array1<f64>,
841) -> Option<(Array1<f64>, Array1<f64>)> {
842 let p = target.len();
843 let m = rows.nrows();
844 if rows.ncols() != p {
845 return None;
846 }
847 let norms: Vec<f64> = (0..m)
848 .map(|row| rows.row(row).dot(&rows.row(row)).sqrt())
849 .collect();
850 let (sparse, projected) = nonnegative_cone_projection_by_rows(
851 &norms,
852 target,
853 |residual| Some(rows.dot(residual)),
854 |ids| {
855 let mut gathered = Array2::<f64>::zeros((ids.len(), p));
856 for (position, &row) in ids.iter().enumerate() {
857 gathered.row_mut(position).assign(&rows.row(row));
858 }
859 Some(gathered)
860 },
861 )?;
862 let mut lambda = Array1::<f64>::zeros(m);
863 for (row, value) in sparse {
864 lambda[row] = value;
865 }
866 Some((lambda, projected))
867}
868
869pub fn project_stationarity_residual_on_constraint_cone(
870 residual: &Array1<f64>,
871 active_a: &Array2<f64>,
872) -> Option<(Array1<f64>, Array1<f64>)> {
873 let p = residual.len();
874 if active_a.ncols() != p {
875 return None;
876 }
877 if active_a.nrows() == 0 {
878 return Some((residual.clone(), Array1::zeros(0)));
879 }
880 nonnegative_cone_multipliers(active_a, residual).map(|(lambda, projected)| (projected, lambda))
888}
889
890pub(crate) fn feasible_point_for_linear_constraints(
891 constraints: &LinearInequalityConstraints,
892 p: usize,
893) -> Option<Array1<f64>> {
894 if constraints.a.ncols() != p
895 || constraints.a.nrows() == 0
896 || constraints.b.len() != constraints.a.nrows()
897 {
898 return None;
899 }
900 let mut all_scaled_b_tiny = true;
905 for i in 0..constraints.a.nrows() {
906 let norm = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
907 if norm > 0.0 {
908 if constraints.b[i].abs() > 1e-14 * norm {
909 all_scaled_b_tiny = false;
910 }
911 } else if constraints.b[i] > 0.0 {
912 return None;
913 }
914 }
915 if all_scaled_b_tiny {
916 return Some(Array1::zeros(p));
917 }
918
919 let gram = constraints.a.dot(&constraints.a.t());
920 let (u_opt, singular, vt_opt) = gram.svd(true, true).ok()?;
921 let (Some(u), Some(vt)) = (u_opt, vt_opt) else {
922 return None;
923 };
924 let max_singular = singular.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
925 let tol = 100.0 * f64::EPSILON * constraints.a.nrows().max(1) as f64 * max_singular;
929 let mut coeff = u.t().dot(&constraints.b);
930 for (idx, value) in coeff.iter_mut().enumerate() {
931 let sigma = singular[idx];
932 if sigma.abs() > tol {
933 *value /= sigma;
934 } else {
935 *value = 0.0;
936 }
937 }
938 let dual = vt.t().dot(&coeff);
939 let beta = constraints.a.t().dot(&dual);
940 if beta.len() != p || beta.iter().any(|v| !v.is_finite()) {
941 return None;
942 }
943 let feasible = (0..constraints.a.nrows()).all(|i| {
946 let norm = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
947 if norm > 0.0 {
948 (constraints.a.row(i).dot(&beta) - constraints.b[i]) / norm >= -1e-8
949 } else {
950 constraints.b[i] <= 0.0
951 }
952 });
953 if feasible { Some(beta) } else { None }
954}
955
956const ACTIVE_SET_INTERIOR_SEED_MARGIN: f64 = 1e-6;
966
967#[inline]
973pub(crate) fn interior_seed_margin() -> f64 {
974 ACTIVE_SET_INTERIOR_SEED_MARGIN
975}
976
977const MAX_FEASIBILITY_REPAIR_DEPTH: u32 = 16;
992
993thread_local! {
994 static FEASIBILITY_REPAIR_DEPTH: Cell<u32> = const { Cell::new(0) };
1001}
1002
1003struct FeasibilityRepairGuard;
1012
1013impl FeasibilityRepairGuard {
1014 fn enter() -> Option<Self> {
1015 FEASIBILITY_REPAIR_DEPTH.with(|depth| {
1016 let current = depth.get();
1017 if current >= MAX_FEASIBILITY_REPAIR_DEPTH {
1018 None
1019 } else {
1020 depth.set(current + 1);
1021 Some(Self)
1022 }
1023 })
1024 }
1025}
1026
1027impl Drop for FeasibilityRepairGuard {
1028 fn drop(&mut self) {
1029 FEASIBILITY_REPAIR_DEPTH.with(|depth| depth.set(depth.get().saturating_sub(1)));
1030 }
1031}
1032
1033pub fn project_point_strictly_into_feasible_cone(
1062 point: &Array1<f64>,
1063 constraints: &LinearInequalityConstraints,
1064) -> Option<Array1<f64>> {
1065 let repair_guard = FeasibilityRepairGuard::enter()?;
1071 let p = point.len();
1072 let m = constraints.a.nrows();
1073 if constraints.a.ncols() != p || m == 0 || constraints.b.len() != m {
1074 return None;
1075 }
1076 let norms: Vec<f64> = (0..m)
1077 .map(|i| constraints.a.row(i).dot(&constraints.a.row(i)).sqrt())
1078 .collect();
1079
1080 const ANTIPARALLEL_COS_TOL: f64 = -1.0 + 1e-9;
1094 const EQUALITY_WIDTH_TOL: f64 = 1e-9;
1095 let mut is_equality_member = vec![false; m];
1096 let mut equality_rows: Vec<usize> = Vec::new();
1097 let mut margin = vec![ACTIVE_SET_INTERIOR_SEED_MARGIN; m];
1098 for i in 0..m {
1099 if norms[i] == 0.0 {
1100 margin[i] = 0.0;
1101 continue;
1102 }
1103 for j in (i + 1)..m {
1104 if norms[j] == 0.0 {
1105 continue;
1106 }
1107 let cos = constraints.a.row(i).dot(&constraints.a.row(j)) / (norms[i] * norms[j]);
1108 if cos > ANTIPARALLEL_COS_TOL {
1109 continue;
1110 }
1111 let width = -constraints.b[j] / norms[j] - constraints.b[i] / norms[i];
1114 if width.abs() <= EQUALITY_WIDTH_TOL {
1115 if !is_equality_member[i] && !is_equality_member[j] {
1118 equality_rows.push(i);
1119 }
1120 is_equality_member[i] = true;
1121 is_equality_member[j] = true;
1122 } else {
1123 let cap = (width / 3.0).max(0.0);
1126 margin[i] = margin[i].min(cap);
1127 margin[j] = margin[j].min(cap);
1128 }
1129 }
1130 }
1131
1132 let ineq_rows: Vec<usize> = (0..m).filter(|&i| !is_equality_member[i]).collect();
1135 let mut a_ineq = Array2::<f64>::zeros((ineq_rows.len(), p));
1136 let mut b_ineq = Array1::<f64>::zeros(ineq_rows.len());
1137 for (r, &i) in ineq_rows.iter().enumerate() {
1138 a_ineq.row_mut(r).assign(&constraints.a.row(i));
1139 b_ineq[r] = constraints.b[i] + margin[i] * norms[i];
1140 }
1141
1142 let beta = if equality_rows.is_empty() {
1143 let interior = LinearInequalityConstraints::new(a_ineq, b_ineq)
1146 .expect("shifted interior constraint shape invariant");
1147 let identity = Array2::<f64>::eye(p);
1148 solve_quadratic_with_linear_constraints(&identity, point, point, &interior, None)
1149 .ok()?
1150 .0
1151 } else {
1152 let k = equality_rows.len();
1162 let mut e_mat = Array2::<f64>::zeros((k, p));
1163 let mut e_rhs = Array1::<f64>::zeros(k);
1164 for (r, &i) in equality_rows.iter().enumerate() {
1165 e_mat.row_mut(r).assign(&constraints.a.row(i));
1166 e_rhs[r] = constraints.b[i];
1167 }
1168 let (u_opt, sing, vt_opt) = e_mat.svd(true, true).ok()?;
1169 let (u_mat, vt) = (u_opt?, vt_opt?);
1170 let smax = sing.iter().fold(0.0_f64, |acc, &v| acc.max(v));
1171 let rank_tol = smax.max(1.0) * (k.max(p) as f64) * f64::EPSILON * 100.0;
1172 let rank = sing.iter().filter(|&&s| s > rank_tol).count();
1173 if rank == 0 || rank >= p {
1174 return None;
1175 }
1176 let mut beta_p = Array1::<f64>::zeros(p);
1177 for idx in 0..rank {
1178 let coeff = u_mat.column(idx).dot(&e_rhs) / sing[idx];
1179 beta_p.scaled_add(coeff, &vt.row(idx));
1180 }
1181 let mut basis: Vec<Array1<f64>> = (0..rank).map(|i| vt.row(i).to_owned()).collect();
1184 let mut z = Array2::<f64>::zeros((p, p - rank));
1185 let mut collected = 0usize;
1186 for axis in 0..p {
1187 if collected == p - rank {
1188 break;
1189 }
1190 let mut v = Array1::<f64>::zeros(p);
1191 v[axis] = 1.0;
1192 for q in basis.iter() {
1193 let c = q.dot(&v);
1194 v.scaled_add(-c, q);
1195 }
1196 let nrm = v.dot(&v).sqrt();
1197 if nrm > 1e-8 {
1198 v /= nrm;
1199 z.column_mut(collected).assign(&v);
1200 basis.push(v);
1201 collected += 1;
1202 }
1203 }
1204 if collected != p - rank {
1205 return None;
1206 }
1207 let a_red = a_ineq.dot(&z);
1208 let b_red = &b_ineq - &a_ineq.dot(&beta_p);
1209 let u0 = z.t().dot(&(point - &beta_p));
1210 let reduced = LinearInequalityConstraints::new(a_red, b_red)
1211 .expect("reduced constraint shape invariant");
1212 let identity = Array2::<f64>::eye(z.ncols());
1213 let (u_sol, _active) =
1214 solve_quadratic_with_linear_constraints(&identity, &u0, &u0, &reduced, None).ok()?;
1215 &beta_p + &z.dot(&u_sol)
1216 };
1217
1218 if beta.len() != p || beta.iter().any(|v| !v.is_finite()) {
1219 return None;
1220 }
1221 const SEED_FEASIBILITY_TOL: f64 = 1e-9;
1226 for i in 0..m {
1227 let s = scaled_constraint_slack(&beta, constraints, i);
1228 let lower = if is_equality_member[i] {
1229 -SEED_FEASIBILITY_TOL
1230 } else {
1231 0.5 * margin[i] - SEED_FEASIBILITY_TOL
1232 };
1233 if s < lower {
1234 return None;
1235 }
1236 }
1237 drop(repair_guard);
1242 Some(beta)
1243}
1244
1245#[inline]
1251fn scaled_constraint_slack(
1252 beta: &Array1<f64>,
1253 constraints: &LinearInequalityConstraints,
1254 i: usize,
1255) -> f64 {
1256 let norm = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
1257 if norm > 0.0 {
1258 (constraints.a.row(i).dot(beta) - constraints.b[i]) / norm
1259 } else if constraints.b[i] > 0.0 {
1260 f64::NEG_INFINITY
1261 } else {
1262 f64::INFINITY
1263 }
1264}
1265
1266struct ActiveEqualityResidualCertificate {
1267 worst_row: usize,
1268 residual: f64,
1269 allowed: f64,
1270}
1271
1272impl ActiveEqualityResidualCertificate {
1273 fn is_certified(&self) -> bool {
1274 self.residual.is_finite() && self.allowed.is_finite() && self.residual <= self.allowed
1275 }
1276}
1277
1278fn certify_active_equalities(
1304 active_a: &Array2<f64>,
1305 rhs: &Array1<f64>,
1306 direction: &Array1<f64>,
1307) -> ActiveEqualityResidualCertificate {
1308 let p = active_a.ncols();
1309 let m = active_a.nrows();
1310 let operations = p.saturating_add(1).max(1);
1311 let roundoff = operations as f64 * f64::EPSILON;
1312 let gamma = roundoff / (1.0 - roundoff);
1313 let direction_scale = direction
1314 .iter()
1315 .fold(0.0_f64, |acc, value| acc.max(value.abs()));
1316 let mut worst = ActiveEqualityResidualCertificate {
1317 worst_row: 0,
1318 residual: 0.0,
1319 allowed: f64::MIN_POSITIVE,
1320 };
1321 let mut worst_ratio = 0.0_f64;
1322 for active_row in 0..m {
1323 let mut dot = KahanSum::default();
1324 let mut magnitude = KahanSum::default();
1325 let mut row_magnitude = KahanSum::default();
1326 for column in 0..p {
1327 let entry = active_a[[active_row, column]];
1328 let product = entry * direction[column];
1329 dot.add(product);
1330 magnitude.add(product.abs());
1331 row_magnitude.add(entry.abs());
1332 }
1333 let residual = (rhs[active_row] - dot.sum()).abs();
1334 let solve_scale = row_magnitude.sum() * direction_scale;
1335 let allowed = (gamma * (magnitude.sum() + rhs[active_row].abs() + solve_scale))
1336 .max(f64::MIN_POSITIVE);
1337 if !residual.is_finite() || !allowed.is_finite() {
1338 return ActiveEqualityResidualCertificate {
1339 worst_row: active_row,
1340 residual,
1341 allowed,
1342 };
1343 }
1344 let ratio = residual / allowed;
1345 if ratio > worst_ratio {
1346 worst_ratio = ratio;
1347 worst = ActiveEqualityResidualCertificate {
1348 worst_row: active_row,
1349 residual,
1350 allowed,
1351 };
1352 }
1353 }
1354 worst
1355}
1356
1357fn compensated_active_residual(
1359 active_a: &Array2<f64>,
1360 rhs: &Array1<f64>,
1361 direction: &Array1<f64>,
1362) -> Array1<f64> {
1363 Array1::from_shape_fn(active_a.nrows(), |row| {
1364 let mut dot = KahanSum::default();
1365 for column in 0..active_a.ncols() {
1366 dot.add(active_a[[row, column]] * direction[column]);
1367 }
1368 rhs[row] - dot.sum()
1369 })
1370}
1371
1372fn minimum_norm_from_svd(
1373 u: &Array2<f64>,
1374 singular: &Array1<f64>,
1375 vt: &Array2<f64>,
1376 rank: usize,
1377 rhs: &Array1<f64>,
1378) -> Array1<f64> {
1379 let mut solution = Array1::<f64>::zeros(vt.ncols());
1380 for index in 0..rank {
1381 let coefficient = u.column(index).dot(rhs) / singular[index];
1382 solution.scaled_add(coefficient, &vt.row(index));
1383 }
1384 solution
1385}
1386
1387fn transposed_minimum_norm_from_svd(
1388 u: &Array2<f64>,
1389 singular: &Array1<f64>,
1390 vt: &Array2<f64>,
1391 rank: usize,
1392 rhs: &Array1<f64>,
1393) -> Array1<f64> {
1394 let mut solution = Array1::<f64>::zeros(u.nrows());
1395 for index in 0..rank {
1396 let coefficient = vt.row(index).dot(rhs) / singular[index];
1397 solution.scaled_add(coefficient, &u.column(index));
1398 }
1399 solution
1400}
1401
1402pub(crate) fn solve_kkt_direction(
1427 hessian: &Array2<f64>,
1428 gradient: &Array1<f64>,
1429 active_a: &Array2<f64>,
1430 active_residual: Option<&Array1<f64>>,
1431) -> Result<(Array1<f64>, Array1<f64>), EstimationError> {
1432 let p = hessian.nrows();
1433 let m = active_a.nrows();
1434 if hessian.ncols() != p || gradient.len() != p || active_a.ncols() != p {
1435 crate::bail_invalid_estim!("null-space constrained solve dimension mismatch");
1436 }
1437 if let Some(residual) = active_residual
1438 && residual.len() != m
1439 {
1440 crate::bail_invalid_estim!(
1441 "active-equality residual length mismatch: got {}, expected {}",
1442 residual.len(),
1443 m
1444 );
1445 }
1446 if m == 0 {
1447 let mut d = Array1::<f64>::zeros(p);
1448 solve_newton_direction_dense(hessian, gradient, &mut d)?;
1449 return Ok((d, Array1::zeros(0)));
1450 }
1451
1452 let mut scaled_a = active_a.clone();
1453 let mut scaled_rhs = active_residual
1454 .cloned()
1455 .unwrap_or_else(|| Array1::<f64>::zeros(m));
1456 let mut row_norms = Array1::<f64>::zeros(m);
1457 for row in 0..m {
1458 let norm = active_a.row(row).dot(&active_a.row(row)).sqrt();
1459 if !(norm.is_finite() && norm > 0.0) {
1460 crate::bail_invalid_estim!(
1461 "active equality row {row} has invalid norm {norm}"
1462 );
1463 }
1464 row_norms[row] = norm;
1465 let inverse = 1.0 / norm;
1466 scaled_a.row_mut(row).mapv_inplace(|value| value * inverse);
1467 scaled_rhs[row] *= inverse;
1468 }
1469
1470 let (u_opt, singular, vt_opt) = scaled_a.svd(true, true).map_err(|_| {
1471 EstimationError::InvalidInput(
1472 "null-space constrained quadratic active-equation SVD failed".to_string(),
1473 )
1474 })?;
1475 let (Some(u), Some(vt)) = (u_opt, vt_opt) else {
1476 crate::bail_invalid_estim!(
1477 "null-space constrained quadratic SVD omitted singular vectors"
1478 );
1479 };
1480 let (mut null_basis, rank) =
1481 rrqr_nullspace_basis(&scaled_a.t(), default_rrqr_rank_alpha()).map_err(|_| {
1482 EstimationError::InvalidInput(
1483 "null-space constrained quadratic active-equation RRQR failed".to_string(),
1484 )
1485 })?;
1486 if rank == 0 {
1487 crate::bail_invalid_estim!(
1488 "null-space constrained quadratic active equations have numerical rank zero"
1489 );
1490 }
1491 if rank > singular.len()
1492 || !singular[rank - 1].is_finite()
1493 || singular[rank - 1] <= 0.0
1494 {
1495 crate::bail_invalid_estim!(
1496 "null-space constrained quadratic RRQR rank {rank} has no positive SVD pivot"
1497 );
1498 }
1499 let nullity = p.saturating_sub(rank);
1500 if null_basis.dim() != (p, nullity) {
1501 crate::bail_invalid_estim!(
1502 "null-space constrained quadratic RRQR basis has shape {}x{}, expected {}x{}",
1503 null_basis.nrows(),
1504 null_basis.ncols(),
1505 p,
1506 nullity,
1507 );
1508 }
1509 for column in 0..nullity {
1533 for _ in 0..2 {
1534 let mut basis_column = null_basis.column(column).to_owned();
1535 for index in 0..rank {
1536 let projection = vt.row(index).dot(&basis_column);
1537 basis_column.scaled_add(-projection, &vt.row(index));
1538 }
1539 null_basis.column_mut(column).assign(&basis_column);
1540 }
1541 let norm = null_basis.column(column).dot(&null_basis.column(column)).sqrt();
1546 if norm.is_finite() && norm > 0.0 {
1547 null_basis.column_mut(column).mapv_inplace(|value| value / norm);
1548 }
1549 }
1550 if !array_is_finite(&null_basis) {
1551 crate::bail_invalid_estim!(
1552 "null-space constrained quadratic refined RRQR basis is non-finite"
1553 );
1554 }
1555
1556 let mut particular = minimum_norm_from_svd(&u, &singular, &vt, rank, &scaled_rhs);
1557 if !array_is_finite(&particular) {
1558 crate::bail_invalid_estim!(
1559 "null-space constrained quadratic affine solution is non-finite"
1560 );
1561 }
1562
1563 let initial_affine_residual =
1564 compensated_active_residual(&scaled_a, &scaled_rhs, &particular);
1565 let affine_correction =
1566 minimum_norm_from_svd(&u, &singular, &vt, rank, &initial_affine_residual);
1567 particular += &affine_correction;
1568
1569 let mut direction = particular.clone();
1570 if nullity > 0 {
1571 let mut reduced_hessian = null_basis.t().dot(hessian).dot(&null_basis);
1572 for row in 0..nullity {
1573 for column in (row + 1)..nullity {
1574 let average =
1575 0.5 * (reduced_hessian[[row, column]] + reduced_hessian[[column, row]]);
1576 reduced_hessian[[row, column]] = average;
1577 reduced_hessian[[column, row]] = average;
1578 }
1579 }
1580 let affine_gradient = gradient + &hessian.dot(&particular);
1581 let reduced_rhs = -null_basis.t().dot(&affine_gradient);
1582 let factor = reduced_hessian
1583 .cholesky(Side::Lower)
1584 .map_err(EstimationError::LinearSystemSolveFailed)?;
1585 let mut reduced_solution = factor.solvevec(&reduced_rhs);
1586 if !array_is_finite(&reduced_solution) {
1587 crate::bail_invalid_estim!(
1588 "null-space constrained quadratic reduced solve is non-finite"
1589 );
1590 }
1591 let reduced_residual = &reduced_rhs - &reduced_hessian.dot(&reduced_solution);
1616 let reduced_correction = factor.solvevec(&reduced_residual);
1617 if array_is_finite(&reduced_correction) {
1618 reduced_solution += &reduced_correction;
1619 }
1620 direction += &null_basis.dot(&reduced_solution);
1621 }
1622
1623 let initial_certificate =
1624 certify_active_equalities(&scaled_a, &scaled_rhs, &direction);
1625 if !initial_certificate.is_certified() {
1626 let affine_residual =
1627 compensated_active_residual(&scaled_a, &scaled_rhs, &direction);
1628 let correction =
1629 minimum_norm_from_svd(&u, &singular, &vt, rank, &affine_residual);
1630 if !correction.iter().all(|value| value.is_finite()) {
1631 return Err(EstimationError::ParameterConstraintViolation(format!(
1632 "null-space active-equality correction produced a non-finite value \
1633 (active_row={}, residual={:.3e}, roundoff_bound={:.3e})",
1634 initial_certificate.worst_row,
1635 initial_certificate.residual,
1636 initial_certificate.allowed,
1637 )));
1638 }
1639 direction += &correction;
1640 let refined_certificate =
1641 certify_active_equalities(&scaled_a, &scaled_rhs, &direction);
1642 if !refined_certificate.is_certified() {
1643 return Err(EstimationError::ParameterConstraintViolation(format!(
1644 "null-space active equality is unresolved after affine correction \
1645 (active_row={}, residual={:.3e}, roundoff_bound={:.3e}; \
1646 initial_active_row={}, initial_residual={:.3e}, \
1647 initial_roundoff_bound={:.3e})",
1648 refined_certificate.worst_row,
1649 refined_certificate.residual,
1650 refined_certificate.allowed,
1651 initial_certificate.worst_row,
1652 initial_certificate.residual,
1653 initial_certificate.allowed,
1654 )));
1655 }
1656 }
1657
1658 let stationarity_rhs = -(gradient + &hessian.dot(&direction));
1659 let scaled_multiplier =
1660 transposed_minimum_norm_from_svd(&u, &singular, &vt, rank, &stationarity_rhs);
1661 let multiplier = &scaled_multiplier / &row_norms;
1662 if !array_is_finite(&multiplier) {
1663 crate::bail_invalid_estim!(
1664 "null-space constrained quadratic multiplier recovery is non-finite"
1665 );
1666 }
1667 Ok((direction, multiplier))
1668}
1669
1670#[derive(Clone, Copy, Debug)]
1686pub struct ActiveRowDependence {
1687 pub active_pos: usize,
1688 pub coeff: f64,
1689}
1690
1691#[derive(Clone, Copy, Debug)]
1700pub struct ConstraintRowDependence {
1701 pub row: ConstraintRowId,
1702 pub coeff: f64,
1703}
1704
1705#[derive(Clone, Debug)]
1716pub struct ReducedFace {
1717 pub representatives: Vec<ConstraintRowId>,
1721 pub dependence: Vec<Vec<ConstraintRowDependence>>,
1726 pub tight_rows: Vec<ConstraintRowId>,
1728}
1729
1730pub fn khatri_rao_cone_reduced_face(
1753 cone: &KhatriRaoConeConstraints,
1754 beta: ndarray::ArrayView1<'_, f64>,
1755 membership_tol: f64,
1756) -> Result<ReducedFace, EstimationError> {
1757 let psi = cone.factor();
1758 let n = psi.nrows();
1759 let p_cov = psi.ncols();
1760 let coupled = cone.coupled_rows();
1761 let values = cone.values(beta).map_err(|error| {
1762 EstimationError::ParameterConstraintViolation(format!(
1763 "Khatri-Rao cone reduced-face values: {error}"
1764 ))
1765 })?;
1766
1767 let row_norms: Vec<f64> = (0..n)
1769 .map(|i| {
1770 let row = psi.row(i);
1771 row.dot(&row).sqrt()
1772 })
1773 .collect();
1774
1775 const RANK_ALPHA: f64 = 100.0;
1776 const PARALLEL_COS_TOL: f64 = 1.0 - 1e-9;
1778
1779 let mut representatives: Vec<ConstraintRowId> = Vec::new();
1780 let mut dependence: Vec<Vec<ConstraintRowDependence>> = Vec::new();
1781 let mut tight_rows: Vec<ConstraintRowId> = Vec::new();
1782
1783 for slot in 0..coupled.len() {
1784 let mut tight_obs: Vec<usize> = Vec::new();
1787 for i in 0..n {
1788 let norm_i = row_norms[i];
1789 if norm_i <= 0.0 {
1790 continue;
1791 }
1792 let scaled_slack = values[slot * n + i] / norm_i;
1793 if scaled_slack <= membership_tol {
1794 tight_rows.push(ConstraintRowId(slot * n + i));
1795 tight_obs.push(i);
1796 }
1797 }
1798 if tight_obs.is_empty() {
1799 continue;
1800 }
1801
1802 let max_norm = tight_obs
1803 .iter()
1804 .map(|&i| row_norms[i])
1805 .fold(0.0_f64, f64::max);
1806 let rank_tol =
1807 RANK_ALPHA * f64::EPSILON * (tight_obs.len().max(p_cov).max(1) as f64) * max_norm;
1808
1809 let mut ortho_basis: Vec<Array1<f64>> = Vec::new();
1810 let mut kept: Vec<(usize, Array1<f64>, usize)> = Vec::new();
1812 for &i in &tight_obs {
1813 let psi_i = psi.row(i).to_owned();
1814 let mut resid = psi_i.clone();
1815 for q in &ortho_basis {
1816 let proj = resid.dot(q);
1817 resid.scaled_add(-proj, q);
1818 }
1819 let resid_norm = resid.dot(&resid).sqrt();
1820 let flat = ConstraintRowId(slot * n + i);
1821 if resid_norm > rank_tol {
1822 ortho_basis.push(&resid / resid_norm);
1823 let out_idx = representatives.len();
1824 representatives.push(flat);
1825 dependence.push(Vec::new());
1826 kept.push((i, psi_i, out_idx));
1827 } else {
1828 let mut best_abs_cos = 0.0_f64;
1831 let mut best: Option<(usize, f64)> = None;
1832 for (rep_obs, rep_psi, rep_out_idx) in &kept {
1833 let rep_norm = row_norms[*rep_obs];
1834 let dot = psi_i.dot(rep_psi);
1835 let cos = if rep_norm > 0.0 {
1836 dot / (row_norms[i] * rep_norm)
1837 } else {
1838 0.0
1839 };
1840 if cos.abs() > best_abs_cos {
1841 best_abs_cos = cos.abs();
1842 best = Some((*rep_out_idx, dot / (rep_norm * rep_norm)));
1843 }
1844 }
1845 if best_abs_cos >= PARALLEL_COS_TOL {
1846 if let Some((out_idx, coeff)) = best {
1847 dependence[out_idx].push(ConstraintRowDependence {
1848 row: flat,
1849 coeff,
1850 });
1851 }
1852 }
1853 }
1854 }
1855 }
1856
1857 Ok(ReducedFace {
1858 representatives,
1859 dependence,
1860 tight_rows,
1861 })
1862}
1863
1864pub fn dense_reduced_face(
1873 lin: &LinearInequalityConstraints,
1874 beta: ndarray::ArrayView1<'_, f64>,
1875 membership_tol: f64,
1876) -> Result<ReducedFace, EstimationError> {
1877 let a = &lin.a;
1878 let b = &lin.b;
1879 let n = a.nrows();
1880 let p = a.ncols();
1881
1882 let row_norms: Vec<f64> = (0..n)
1883 .map(|i| {
1884 let row = a.row(i);
1885 row.dot(&row).sqrt()
1886 })
1887 .collect();
1888
1889 const RANK_ALPHA: f64 = 100.0;
1890 const PARALLEL_COS_TOL: f64 = 1.0 - 1e-9;
1891
1892 let mut tight: Vec<usize> = Vec::new();
1895 for i in 0..n {
1896 let norm_i = row_norms[i];
1897 if norm_i <= 0.0 {
1898 continue;
1899 }
1900 let scaled_slack = (a.row(i).dot(&beta) - b[i]) / norm_i;
1901 if scaled_slack <= membership_tol {
1902 tight.push(i);
1903 }
1904 }
1905
1906 let mut representatives: Vec<ConstraintRowId> = Vec::new();
1907 let mut dependence: Vec<Vec<ConstraintRowDependence>> = Vec::new();
1908 if tight.is_empty() {
1909 return Ok(ReducedFace {
1910 representatives,
1911 dependence,
1912 tight_rows: Vec::new(),
1913 });
1914 }
1915
1916 let max_norm = tight
1917 .iter()
1918 .map(|&i| row_norms[i])
1919 .fold(0.0_f64, f64::max);
1920 let rank_tol = RANK_ALPHA * f64::EPSILON * (tight.len().max(p).max(1) as f64) * max_norm;
1921
1922 let mut ortho_basis: Vec<Array1<f64>> = Vec::new();
1923 let mut kept: Vec<(usize, Array1<f64>, usize)> = Vec::new();
1925 for &i in &tight {
1926 let a_i = a.row(i).to_owned();
1927 let mut resid = a_i.clone();
1928 for q in &ortho_basis {
1929 let proj = resid.dot(q);
1930 resid.scaled_add(-proj, q);
1931 }
1932 let resid_norm = resid.dot(&resid).sqrt();
1933 if resid_norm > rank_tol {
1934 ortho_basis.push(&resid / resid_norm);
1935 let out_idx = representatives.len();
1936 representatives.push(ConstraintRowId(i));
1937 dependence.push(Vec::new());
1938 kept.push((i, a_i, out_idx));
1939 } else {
1940 let mut best_abs_cos = 0.0_f64;
1944 let mut best: Option<(usize, f64)> = None;
1945 for (rep_row, rep_a, rep_out_idx) in &kept {
1946 let rep_norm = row_norms[*rep_row];
1947 let dot = a_i.dot(rep_a);
1948 let cos = if rep_norm > 0.0 {
1949 dot / (row_norms[i] * rep_norm)
1950 } else {
1951 0.0
1952 };
1953 if cos.abs() > best_abs_cos {
1954 best_abs_cos = cos.abs();
1955 best = Some((*rep_out_idx, dot / (rep_norm * rep_norm)));
1956 }
1957 }
1958 if best_abs_cos >= PARALLEL_COS_TOL {
1959 if let Some((out_idx, coeff)) = best {
1960 dependence[out_idx].push(ConstraintRowDependence {
1961 row: ConstraintRowId(i),
1962 coeff,
1963 });
1964 }
1965 }
1966 }
1967 }
1968
1969 Ok(ReducedFace {
1970 representatives,
1971 dependence,
1972 tight_rows: tight.into_iter().map(ConstraintRowId).collect(),
1973 })
1974}
1975
1976#[inline]
1992fn lift_member_row(local: ConstraintRowId, row_offset: usize) -> ConstraintRowId {
1993 ConstraintRowId(local.index() + row_offset)
1994}
1995
1996pub trait ConstraintSetReducedFace {
2001 fn reduced_face(
2002 &self,
2003 beta: ndarray::ArrayView1<'_, f64>,
2004 membership_tol: f64,
2005 ) -> Result<ReducedFace, EstimationError>;
2006}
2007
2008impl ConstraintSetReducedFace for ConstraintSet {
2009 fn reduced_face(
2010 &self,
2011 beta: ndarray::ArrayView1<'_, f64>,
2012 membership_tol: f64,
2013 ) -> Result<ReducedFace, EstimationError> {
2014 match self {
2015 ConstraintSet::Dense(lin) => dense_reduced_face(lin, beta, membership_tol),
2016 ConstraintSet::KhatriRaoCone(cone) => {
2017 khatri_rao_cone_reduced_face(cone, beta, membership_tol)
2018 }
2019 ConstraintSet::BlockDiagonal { blocks, .. } => {
2020 let mut representatives: Vec<ConstraintRowId> = Vec::new();
2031 let mut dependence: Vec<Vec<ConstraintRowDependence>> = Vec::new();
2032 let mut tight_rows: Vec<ConstraintRowId> = Vec::new();
2033 let mut row_offset = 0usize;
2034 for block in blocks {
2035 let start = block.col_start;
2036 let end = start + block.set.ncols();
2037 let beta_block = beta.slice(ndarray::s![start..end]);
2038 let sub = block.set.reduced_face(beta_block, membership_tol)?;
2039 for r in sub.representatives {
2040 representatives.push(lift_member_row(r, row_offset));
2041 }
2042 for deps in sub.dependence {
2043 dependence.push(
2044 deps.into_iter()
2045 .map(|d| ConstraintRowDependence {
2046 row: lift_member_row(d.row, row_offset),
2047 coeff: d.coeff,
2048 })
2049 .collect(),
2050 );
2051 }
2052 for t in sub.tight_rows {
2053 tight_rows.push(lift_member_row(t, row_offset));
2054 }
2055 row_offset += block.set.nrows();
2056 }
2057 Ok(ReducedFace {
2058 representatives,
2059 dependence,
2060 tight_rows,
2061 })
2062 }
2063 }
2064 }
2065}
2066
2067fn identity_multiplier_dependence(groups: &[Vec<usize>]) -> Vec<Vec<ActiveRowDependence>> {
2068 groups
2069 .iter()
2070 .map(|group| {
2071 group
2072 .iter()
2073 .copied()
2074 .map(|active_pos| ActiveRowDependence {
2075 active_pos,
2076 coeff: 1.0,
2077 })
2078 .collect()
2079 })
2080 .collect()
2081}
2082
2083pub fn rank_reduce_rows_pivoted_qr_with_dependence(
2084 a: Array2<f64>,
2085 b: Array1<f64>,
2086 groups: Vec<Vec<usize>>,
2087) -> (
2088 Array2<f64>,
2089 Array1<f64>,
2090 Vec<Vec<usize>>,
2091 Vec<Vec<ActiveRowDependence>>,
2092) {
2093 let k = a.nrows();
2094 let p = a.ncols();
2095 if k <= 1 {
2096 let multiplier_dependence = identity_multiplier_dependence(&groups);
2097 return (a, b, groups, multiplier_dependence);
2098 }
2099
2100 const RANK_ALPHA: f64 = 100.0;
2120 let max_row_norm = (0..k)
2121 .map(|r| {
2122 let row = a.row(r);
2123 row.dot(&row).sqrt()
2124 })
2125 .fold(0.0_f64, f64::max);
2126 let tol = RANK_ALPHA * f64::EPSILON * (k.max(p).max(1) as f64) * max_row_norm;
2127
2128 let mut ortho_basis: Vec<Array1<f64>> = Vec::new();
2129 let mut kept_orig: Vec<usize> = Vec::new();
2130 let mut dropped_orig: Vec<usize> = Vec::new();
2131 for r in 0..k {
2132 let mut resid = a.row(r).to_owned();
2133 for _ in 0..2 {
2160 for q in &ortho_basis {
2161 let proj = resid.dot(q);
2162 resid.scaled_add(-proj, q);
2163 }
2164 }
2165 let resid_norm = resid.dot(&resid).sqrt();
2166 if resid_norm > tol {
2167 kept_orig.push(r);
2168 ortho_basis.push(&resid / resid_norm);
2169 } else {
2170 dropped_orig.push(r);
2171 }
2172 }
2173 let rank = kept_orig.len();
2174 if rank >= k {
2175 let multiplier_dependence = identity_multiplier_dependence(&groups);
2176 return (a, b, groups, multiplier_dependence);
2177 }
2178 if rank == 0 {
2179 log::debug!(
2180 "rank-reduced active constraints from {} to 0 rows (all active rows numerically zero)",
2181 k
2182 );
2183 return (
2184 Array2::<f64>::zeros((0, p)),
2185 Array1::<f64>::zeros(0),
2186 Vec::new(),
2187 Vec::new(),
2188 );
2189 }
2190
2191 let mut orig_to_out = std::collections::HashMap::with_capacity(rank);
2192 let mut a_out = Array2::<f64>::zeros((rank, p));
2193 let mut b_out = Array1::<f64>::zeros(rank);
2194 let mut groups_out: Vec<Vec<usize>> = Vec::with_capacity(rank);
2195 let mut multiplier_dependence: Vec<Vec<ActiveRowDependence>> = Vec::with_capacity(rank);
2196 for (out_idx, &orig_idx) in kept_orig.iter().enumerate() {
2197 a_out.row_mut(out_idx).assign(&a.row(orig_idx));
2198 b_out[out_idx] = b[orig_idx];
2199 groups_out.push(groups[orig_idx].clone());
2200 multiplier_dependence.push(
2201 groups[orig_idx]
2202 .iter()
2203 .copied()
2204 .map(|active_pos| ActiveRowDependence {
2205 active_pos,
2206 coeff: 1.0,
2207 })
2208 .collect(),
2209 );
2210 orig_to_out.insert(orig_idx, out_idx);
2211 }
2212
2213 const PARALLEL_COS_TOL: f64 = 1.0 - 1e-9;
2226 for &dropped_idx in &dropped_orig {
2227 let dropped_row = a.row(dropped_idx);
2228 let dropped_norm = dropped_row.dot(&dropped_row).sqrt();
2229 let mut best_abs_cos = 0.0_f64;
2230 let mut best_target: Option<(usize, f64)> = None;
2231 for &kept_idx in &kept_orig {
2232 let kept_row = a.row(kept_idx);
2233 let kept_norm = kept_row.dot(&kept_row).sqrt();
2234 let dot = kept_row.dot(&dropped_row);
2235 let cos = if kept_norm > 0.0 && dropped_norm > 0.0 {
2236 dot / (kept_norm * dropped_norm)
2237 } else {
2238 0.0
2239 };
2240 let coeff = if kept_norm > 0.0 {
2241 dot / (kept_norm * kept_norm)
2242 } else {
2243 0.0
2244 };
2245 if cos.abs() > best_abs_cos {
2246 best_abs_cos = cos.abs();
2247 best_target = Some((kept_idx, coeff));
2248 }
2249 }
2250 if best_abs_cos >= PARALLEL_COS_TOL {
2256 if let Some((target, coeff)) = best_target {
2257 let &out_idx = orig_to_out
2258 .get(&target)
2259 .expect("merge target must be a kept row");
2260 for &active_pos in &groups[dropped_idx] {
2261 multiplier_dependence[out_idx].push(ActiveRowDependence { active_pos, coeff });
2262 }
2263 if coeff > 0.0 {
2264 groups_out[out_idx].extend_from_slice(&groups[dropped_idx]);
2265 }
2266 }
2267 }
2268 }
2269
2270 for group in &mut groups_out {
2271 group.sort_unstable();
2272 group.dedup();
2273 }
2274 for dependencies in &mut multiplier_dependence {
2275 dependencies.sort_unstable_by_key(|dependency| dependency.active_pos);
2276 dependencies.dedup_by_key(|dependency| dependency.active_pos);
2277 }
2278
2279 let mut row_order: Vec<usize> = (0..groups_out.len()).collect();
2280 row_order.sort_by_key(|&idx| groups_out[idx].first().copied().unwrap_or(usize::MAX));
2281 if row_order.iter().enumerate().any(|(idx, &orig)| idx != orig) {
2282 let mut a_sorted = Array2::<f64>::zeros((rank, p));
2283 let mut b_sorted = Array1::<f64>::zeros(rank);
2284 let mut groups_sorted = Vec::with_capacity(rank);
2285 let mut dependence_sorted = Vec::with_capacity(rank);
2286 for (out_idx, orig_idx) in row_order.into_iter().enumerate() {
2287 a_sorted.row_mut(out_idx).assign(&a_out.row(orig_idx));
2288 b_sorted[out_idx] = b_out[orig_idx];
2289 groups_sorted.push(groups_out[orig_idx].clone());
2290 dependence_sorted.push(multiplier_dependence[orig_idx].clone());
2291 }
2292 a_out = a_sorted;
2293 b_out = b_sorted;
2294 groups_out = groups_sorted;
2295 multiplier_dependence = dependence_sorted;
2296 }
2297
2298 if rank < k {
2299 log::debug!(
2300 "rank-reduced active constraints from {} to {} rows (rank deficiency {})",
2301 k,
2302 rank,
2303 k - rank
2304 );
2305 }
2306
2307 (a_out, b_out, groups_out, multiplier_dependence)
2308}
2309
2310struct ConstraintSetOps<'a> {
2331 set: &'a ConstraintSet,
2332 norms: Vec<f64>,
2333 bounds: Vec<f64>,
2334 scaled_margin: f64,
2335}
2336
2337impl<'a> ConstraintSetOps<'a> {
2338 fn new(set: &'a ConstraintSet, scaled_margin: f64) -> Result<Self, EstimationError> {
2339 let m = set.nrows();
2340 let mut norms = Vec::with_capacity(m);
2341 let mut bounds = Vec::with_capacity(m);
2342 for row in 0..m {
2343 norms.push(set.row_norm(row).map_err(|e| {
2344 EstimationError::ParameterConstraintViolation(format!(
2345 "constraint-set row norm: {e}"
2346 ))
2347 })?);
2348 bounds.push(set.bound(row).map_err(|e| {
2349 EstimationError::ParameterConstraintViolation(format!(
2350 "constraint-set row bound: {e}"
2351 ))
2352 })?);
2353 }
2354 Ok(Self {
2355 set,
2356 norms,
2357 bounds,
2358 scaled_margin,
2359 })
2360 }
2361
2362fn tangent_face(set: &'a ConstraintSet, beta: &Array1<f64>) -> Result<Self, EstimationError> {
2368 let mut ops = Self::new(set, 0.0)?;
2369 let values = ops.values(beta)?;
2370 for row in 0..ops.nrows() {
2371 if ops.norms[row] <= 0.0 {
2372 if ops.bounds[row] > 0.0 {
2373 crate::bail_invalid_estim!(
2374 "infeasible zero-norm constraint row {} entered tangent-face projection",
2375 row
2376 );
2377 }
2378 ops.bounds[row] = 0.0;
2379 continue;
2380 }
2381 let is_tight = ops.scaled_slack(&values, row) <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL;
2382 ops.bounds[row] = 0.0;
2385 if !is_tight {
2386 ops.norms[row] = 0.0;
2387 }
2388 }
2389 Ok(ops)
2390 }
2391
2392 fn nrows(&self) -> usize {
2393 self.norms.len()
2394 }
2395
2396 fn values(&self, x: &Array1<f64>) -> Result<Array1<f64>, EstimationError> {
2397 self.set.values(x.view()).map_err(|e| {
2398 EstimationError::ParameterConstraintViolation(format!("constraint-set values: {e}"))
2399 })
2400 }
2401
2402 #[inline]
2405 fn scaled_slack(&self, values: &Array1<f64>, row: usize) -> f64 {
2406 let norm = self.norms[row];
2407 if norm > 0.0 {
2408 (values[row] - self.bounds[row]) / norm - self.scaled_margin
2409 } else if self.bounds[row] > 0.0 {
2410 f64::NEG_INFINITY
2411 } else {
2412 f64::INFINITY
2413 }
2414 }
2415
2416 fn gather_unit_rows(
2421 &self,
2422 rows: &[usize],
2423 ) -> Result<LinearInequalityConstraints, EstimationError> {
2424 let mut gathered = self.set.gather_rows(rows).map_err(|e| {
2425 EstimationError::ParameterConstraintViolation(format!(
2426 "constraint-set working-row gather: {e}"
2427 ))
2428 })?;
2429 for (out_row, &row) in rows.iter().enumerate() {
2430 let norm = self.norms[row];
2431 if norm <= 0.0 {
2432 crate::bail_invalid_estim!(
2433 "vacuous zero-norm constraint row {} entered the working set",
2434 row
2435 );
2436 }
2437 let inv = 1.0 / norm;
2438 gathered.a.row_mut(out_row).mapv_inplace(|v| v * inv);
2439 gathered.b[out_row] = self.bounds[row] * inv + self.scaled_margin;
2440 }
2441 Ok(gathered)
2442 }
2443
2444}
2445
2446fn independent_violated_operator_rows(
2464 ops: &ConstraintSetOps<'_>,
2465 values: &Array1<f64>,
2466 active: &[usize],
2467 is_active: &[bool],
2468 banned: &[bool],
2469 max_new: usize,
2470) -> Result<Vec<usize>, EstimationError> {
2471 let p = ops.set.ncols();
2472 if max_new == 0 {
2473 return Ok(Vec::new());
2474 }
2475 if values.len() != ops.nrows()
2476 || is_active.len() != ops.nrows()
2477 || banned.len() != ops.nrows()
2478 {
2479 crate::bail_invalid_estim!(
2480 "operator batch-separation dimension mismatch: values={}, active_mask={}, \
2481 banned_mask={}, constraints={}",
2482 values.len(),
2483 is_active.len(),
2484 banned.len(),
2485 ops.nrows(),
2486 );
2487 }
2488
2489 let mut candidates = Vec::<(usize, f64)>::new();
2490 for row in 0..ops.nrows() {
2491 if is_active[row] || banned[row] || ops.norms[row] <= 0.0 {
2492 continue;
2493 }
2494 let violation = (-ops.scaled_slack(values, row)).max(0.0);
2495 if violation > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
2496 candidates.push((row, violation));
2497 }
2498 }
2499 candidates.sort_unstable_by(|(left_row, left_violation), (right_row, right_violation)| {
2500 right_violation
2501 .total_cmp(left_violation)
2502 .then_with(|| left_row.cmp(right_row))
2503 });
2504 if candidates.is_empty() {
2505 return Ok(Vec::new());
2506 }
2507
2508 let rank_tolerance = 100.0 * f64::EPSILON * p.max(1) as f64;
2512 let mut basis = Vec::<Array1<f64>>::with_capacity(p);
2513 if !active.is_empty() {
2514 let active_rows = ops.gather_unit_rows(active)?;
2515 for row in active_rows.a.rows() {
2516 extend_operator_normal_basis(&mut basis, row, rank_tolerance);
2517 }
2518 }
2519
2520 let chunk_size = p.max(32);
2521 let mut selected = Vec::with_capacity(max_new.min(p.saturating_sub(basis.len())));
2522 for chunk in candidates.chunks(chunk_size) {
2523 let chunk_ids = chunk.iter().map(|(row, _)| *row).collect::<Vec<_>>();
2524 let gathered = ops.gather_unit_rows(&chunk_ids)?;
2525 for (position, &row) in chunk_ids.iter().enumerate() {
2526 if extend_operator_normal_basis(
2527 &mut basis,
2528 gathered.a.row(position),
2529 rank_tolerance,
2530 ) {
2531 selected.push(row);
2532 if selected.len() == max_new || basis.len() == p {
2533 return Ok(selected);
2534 }
2535 }
2536 }
2537 }
2538 Ok(selected)
2539}
2540
2541fn extend_operator_normal_basis(
2545 basis: &mut Vec<Array1<f64>>,
2546 row: ArrayView1<'_, f64>,
2547 rank_tolerance: f64,
2548) -> bool {
2549 let mut residual = row.to_owned();
2550 for _ in 0..2 {
2553 for direction in basis.iter() {
2554 let projection = residual.dot(direction);
2555 residual.scaled_add(-projection, direction);
2556 }
2557 }
2558 let residual_norm = residual.dot(&residual).sqrt();
2559 if !(residual_norm.is_finite() && residual_norm > rank_tolerance) {
2560 return false;
2561 }
2562 residual /= residual_norm;
2563 basis.push(residual);
2564 true
2565}
2566
2567pub fn constraint_set_rows_tight_at_point(
2576 set: &ConstraintSet,
2577 beta: &Array1<f64>,
2578 candidate_rows: &[usize],
2579) -> Result<Vec<usize>, EstimationError> {
2580 if set.ncols() != beta.len() {
2581 crate::bail_invalid_estim!(
2582 "active-face point dimension mismatch: set has {} columns, beta has {}",
2583 set.ncols(),
2584 beta.len()
2585 );
2586 }
2587 let mut seen = HashSet::with_capacity(candidate_rows.len());
2588 let mut unique = Vec::with_capacity(candidate_rows.len());
2589 for &row in candidate_rows {
2590 if row < set.nrows() && seen.insert(row) {
2591 unique.push(row);
2592 }
2593 }
2594 if unique.is_empty() {
2595 return Ok(Vec::new());
2596 }
2597 let gathered = set.gather_rows(&unique).map_err(|error| {
2598 EstimationError::ParameterConstraintViolation(format!(
2599 "active-face candidate-row gather failed: {error}"
2600 ))
2601 })?;
2602 let mut tight = Vec::with_capacity(unique.len());
2603 for (position, &row) in unique.iter().enumerate() {
2604 let constraint_row = gathered.a.row(position);
2605 let norm = constraint_row.dot(&constraint_row).sqrt();
2606 if norm > 0.0 {
2607 let scaled_slack = (constraint_row.dot(beta) - gathered.b[position]) / norm;
2608 if scaled_slack <= ACTIVE_SET_WORKING_FACE_TOL {
2609 tight.push(row);
2610 }
2611 }
2612 }
2613 Ok(tight)
2614}
2615
2616pub fn project_stationarity_residual_on_constraint_set(
2626 residual: &Array1<f64>,
2627 beta: &Array1<f64>,
2628 set: &ConstraintSet,
2629 seed_active: &[usize],
2630) -> Option<(Array1<f64>, Vec<usize>)> {
2631 let p = residual.len();
2632 if beta.len() != p || set.ncols() != p {
2633 return None;
2634 }
2635 match set {
2636 ConstraintSet::KhatriRaoCone(cone) if cone.p_left() != 1 || cone.coupled_rows() != &[0] => {
2637 let p_cov = cone.factor().ncols();
2643 let n = cone.factor().nrows();
2644 let mut projected = residual.clone();
2645 let mut active = Vec::new();
2646 for (slot, &coefficient_row) in cone.coupled_rows().iter().enumerate() {
2647 let start = coefficient_row * p_cov;
2648 let end = start + p_cov;
2649 let local_residual = residual.slice(s![start..end]).to_owned();
2650 let local_beta = beta.slice(s![start..end]).to_owned();
2651 let local_set = ConstraintSet::KhatriRaoCone(cone.single_coupled_slot(slot).ok()?);
2652 let row_start = slot * n;
2653 let row_end = row_start + n;
2654 let local_seed: Vec<usize> = seed_active
2655 .iter()
2656 .copied()
2657 .filter(|&row| row >= row_start && row < row_end)
2658 .map(|row| row - row_start)
2659 .collect();
2660 let (local_projected, local_active) =
2661 project_stationarity_residual_on_constraint_set(
2662 &local_residual,
2663 &local_beta,
2664 &local_set,
2665 &local_seed,
2666 )?;
2667 projected.slice_mut(s![start..end]).assign(&local_projected);
2668 active.extend(local_active.into_iter().map(|row| row_start + row));
2669 }
2670 Some((projected, active))
2671 }
2672 ConstraintSet::BlockDiagonal { blocks, .. } => {
2673 let mut projected = residual.clone();
2677 let mut active = Vec::new();
2678 let mut row_offset = 0usize;
2679 for block in blocks {
2680 let width = block.set.ncols();
2681 let start = block.col_start;
2682 let end = start + width;
2683 let local_residual = residual.slice(s![start..end]).to_owned();
2684 let local_beta = beta.slice(s![start..end]).to_owned();
2685 let row_end = row_offset + block.set.nrows();
2686 let local_seed: Vec<usize> = seed_active
2687 .iter()
2688 .copied()
2689 .filter(|&row| row >= row_offset && row < row_end)
2690 .map(|row| row - row_offset)
2691 .collect();
2692 let (local_projected, local_active) =
2693 project_stationarity_residual_on_constraint_set(
2694 &local_residual,
2695 &local_beta,
2696 &block.set,
2697 &local_seed,
2698 )?;
2699 projected.slice_mut(s![start..end]).assign(&local_projected);
2700 active.extend(local_active.into_iter().map(|row| row_offset + row));
2701 row_offset = row_end;
2702 }
2703 Some((projected, active))
2704 }
2705 _ => project_stationarity_residual_on_constraint_set_undivided(
2706 residual,
2707 beta,
2708 set,
2709 seed_active,
2710 ),
2711 }
2712}
2713
2714fn project_stationarity_residual_on_constraint_set_undivided(
2715 residual: &Array1<f64>,
2716 beta: &Array1<f64>,
2717 set: &ConstraintSet,
2718 seed_active: &[usize],
2719) -> Option<(Array1<f64>, Vec<usize>)> {
2720 let ops = ConstraintSetOps::tangent_face(set, beta).ok()?;
2721 let (multipliers, projected) = nonnegative_cone_projection_by_rows(
2722 &ops.norms,
2723 residual,
2724 |candidate| ops.values(candidate).ok(),
2725 |rows| ops.set.gather_rows(rows).ok().map(|gathered| gathered.a),
2726 )?;
2727 let mut active: Vec<usize> = multipliers.into_iter().map(|(row, _)| row).collect();
2728 for &row in seed_active {
2729 if row < ops.nrows() && ops.norms[row] > 0.0 && !active.contains(&row) {
2730 active.push(row);
2731 }
2732 }
2733 Some((projected, active))
2734}
2735
2736pub fn project_point_strictly_into_feasible_constraint_set(
2751 point: &Array1<f64>,
2752 set: &ConstraintSet,
2753) -> Result<Array1<f64>, EstimationError> {
2754 match set {
2755 ConstraintSet::Dense(dense) => {
2756 project_point_strictly_into_feasible_cone(point, dense).ok_or_else(|| {
2760 EstimationError::ParameterConstraintViolation(
2761 "dense strict-interior projection could not certify a feasible point"
2762 .to_string(),
2763 )
2764 })
2765 }
2766 _ => {
2767 let p = point.len();
2768 if set.ncols() != p {
2769 return Err(EstimationError::ParameterConstraintViolation(format!(
2770 "strict-interior projection dimension mismatch: point length {p} != constraint columns {}",
2771 set.ncols()
2772 )));
2773 }
2774 let ops = ConstraintSetOps::new(set, ACTIVE_SET_INTERIOR_SEED_MARGIN)?;
2775 let identity = Array2::<f64>::eye(p);
2776 let factor = identity.cholesky(Side::Lower).map_err(|error| {
2785 EstimationError::InvalidInput(format!(
2786 "strict-interior identity metric could not be factored: {error}"
2787 ))
2788 })?;
2789 let (beta, _) = solve_operator_metric_projection_dual_active_set(
2790 &identity,
2791 point,
2792 point,
2793 &factor,
2794 &ops,
2795 &[],
2796 )?;
2797 if beta.iter().any(|v| !v.is_finite()) {
2798 return Err(EstimationError::ParameterConstraintViolation(
2799 "strict-interior projection produced a non-finite iterate".to_string(),
2800 ));
2801 }
2802 const SEED_FEASIBILITY_TOL: f64 = 1e-9;
2805 let unshifted = ConstraintSetOps::new(set, 0.0)?;
2806 let values = unshifted.values(&beta)?;
2807 let half_margin = 0.5 * ACTIVE_SET_INTERIOR_SEED_MARGIN - SEED_FEASIBILITY_TOL;
2808 for row in 0..unshifted.nrows() {
2809 if unshifted.norms[row] <= 0.0 {
2810 continue;
2811 }
2812 let slack = unshifted.scaled_slack(&values, row);
2813 if slack < half_margin {
2814 return Err(EstimationError::ParameterConstraintViolation(format!(
2815 "strict-interior projection could not clear the half-margin at row {row}: \
2816 scaled slack {slack:.3e} < {half_margin:.3e}"
2817 )));
2818 }
2819 }
2820 Ok(beta)
2821 }
2822 }
2823}
2824
2825pub fn project_point_onto_constraint_set_in_metric(
2856 point: &Array1<f64>,
2857 metric_diag: Option<&Array1<f64>>,
2858 set: &ConstraintSet,
2859 warm_active_set: Option<&[usize]>,
2860) -> Result<(Array1<f64>, Vec<usize>), EstimationError> {
2861 let p = point.len();
2862 if set.ncols() != p {
2863 crate::bail_invalid_estim!(
2864 "metric projection dimension mismatch: point length {p} != constraint columns {}",
2865 set.ncols()
2866 );
2867 }
2868 if !array_is_finite(point) {
2869 crate::bail_invalid_estim!("metric projection received a non-finite point");
2870 }
2871 if let Some(diag) = metric_diag
2872 && (diag.len() != p || diag.iter().any(|value| !value.is_finite() || *value <= 0.0))
2873 {
2874 crate::bail_invalid_estim!(
2875 "metric projection needs a finite positive diagonal metric of length {p}"
2876 );
2877 }
2878 let mut hessian = Array2::<f64>::zeros((p, p));
2879 let mut rhs = Array1::<f64>::zeros(p);
2880 for index in 0..p {
2881 let weight = metric_diag.map_or(1.0, |diag| diag[index]);
2882 hessian[[index, index]] = weight;
2883 rhs[index] = weight * point[index];
2884 }
2885 solve_quadratic_with_constraint_set(&hessian, &rhs, point, set, warm_active_set)
2886}
2887
2888fn refine_operator_metric_face(
2889 hessian: &Array2<f64>,
2890 rhs: &Array1<f64>,
2891 unconstrained: &Array1<f64>,
2892 ops: &ConstraintSetOps<'_>,
2893 active: &mut Vec<usize>,
2894 is_active: &mut [bool],
2895 transitions: &mut usize,
2896) -> Result<(Array1<f64>, Array1<f64>), EstimationError> {
2897 loop {
2898 if active.is_empty() {
2899 return Ok((unconstrained.clone(), Array1::zeros(0)));
2900 }
2901 let rows = ops.gather_unit_rows(active)?;
2902 let objective_gradient = -rhs;
2915 let (candidate, system_multipliers) = solve_kkt_direction(
2916 hessian,
2917 &objective_gradient,
2918 &rows.a,
2919 Some(&rows.b),
2920 )?;
2921 let refined_multipliers = -system_multipliers;
2922 let leaving_position = refined_multipliers
2923 .iter()
2924 .enumerate()
2925 .filter(|(_, value)| {
2926 !value.is_finite() || **value < -ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL
2927 })
2928 .min_by_key(|(position, _)| active[*position])
2929 .map(|(position, _)| position);
2930 let Some(leaving_position) = leaving_position else {
2931 return Ok((candidate, refined_multipliers));
2932 };
2933 let leaving_row = active.remove(leaving_position);
2934 is_active[leaving_row] = false;
2935 *transitions += 1;
2936 }
2937}
2938
2939const ACTIVE_SET_DUAL_DEPENDENCE_TOL: f64 = 1e-11;
2951
2952fn thin_qr_reorthogonalized(
2960 columns: &[Array1<f64>],
2961 rank_tolerance: f64,
2962) -> Option<(Vec<Array1<f64>>, Array2<f64>)> {
2963 let k = columns.len();
2964 let mut q: Vec<Array1<f64>> = Vec::with_capacity(k);
2965 let mut r = Array2::<f64>::zeros((k, k));
2966 for (column_index, column) in columns.iter().enumerate() {
2967 let scale = column.dot(column).sqrt();
2968 let mut residual = column.clone();
2969 for _ in 0..2 {
2972 for (basis_index, basis) in q.iter().enumerate() {
2973 let projection = residual.dot(basis);
2974 r[[basis_index, column_index]] += projection;
2975 residual.scaled_add(-projection, basis);
2976 }
2977 }
2978 let norm = residual.dot(&residual).sqrt();
2979 if !(norm.is_finite() && scale.is_finite() && norm > rank_tolerance * scale.max(1.0)) {
2980 return None;
2981 }
2982 r[[column_index, column_index]] = norm;
2983 residual /= norm;
2984 q.push(residual);
2985 }
2986 Some((q, r))
2987}
2988
2989fn upper_triangular_back_substitution(r: &Array2<f64>, y: &Array1<f64>) -> Option<Array1<f64>> {
2991 let k = y.len();
2992 if r.nrows() != k || r.ncols() != k {
2993 return None;
2994 }
2995 let mut x = Array1::<f64>::zeros(k);
2996 for row in (0..k).rev() {
2997 let mut sum = y[row];
2998 for column in (row + 1)..k {
2999 sum -= r[[row, column]] * x[column];
3000 }
3001 let pivot = r[[row, row]];
3002 if !(pivot.is_finite() && pivot != 0.0) {
3003 return None;
3004 }
3005 x[row] = sum / pivot;
3006 }
3007 if array_is_finite(&x) { Some(x) } else { None }
3008}
3009
3010struct ViolatedConstraintRow {
3012 row: usize,
3013 violation: f64,
3014}
3015
3016struct OperatorViolationScan {
3025 worst: ViolatedConstraintRow,
3026 inactive: Vec<ViolatedConstraintRow>,
3027}
3028
3029impl OperatorViolationScan {
3030 fn is_primal_feasible(&self) -> bool {
3031 self.worst.violation <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
3032 }
3033}
3034
3035fn scan_operator_violations(
3036 ops: &ConstraintSetOps<'_>,
3037 values: &Array1<f64>,
3038 is_active: &[bool],
3039) -> Result<OperatorViolationScan, EstimationError> {
3040 if values.len() != ops.nrows() || is_active.len() != ops.nrows() {
3041 crate::bail_invalid_estim!(
3042 "operator violation scan dimension mismatch: values={}, active_mask={}, rows={}",
3043 values.len(),
3044 is_active.len(),
3045 ops.nrows(),
3046 );
3047 }
3048 let mut worst = 0.0_f64;
3049 let mut worst_row = 0usize;
3050 let mut inactive = Vec::<ViolatedConstraintRow>::new();
3051 for row in 0..ops.nrows() {
3052 if ops.norms[row] <= 0.0 {
3053 if ops.bounds[row] > 0.0 {
3056 return Err(EstimationError::ParameterConstraintViolation(format!(
3057 "operator metric projection has an infeasible zero-norm constraint row {row} \
3058 with bound {:.3e}",
3059 ops.bounds[row]
3060 )));
3061 }
3062 continue;
3063 }
3064 let violation = (-ops.scaled_slack(values, row)).max(0.0);
3065 if violation > worst {
3066 worst = violation;
3067 worst_row = row;
3068 }
3069 if violation > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL && !is_active[row] {
3070 inactive.push(ViolatedConstraintRow { row, violation });
3071 }
3072 }
3073 Ok(OperatorViolationScan {
3074 worst: ViolatedConstraintRow {
3075 row: worst_row,
3076 violation: worst,
3077 },
3078 inactive,
3079 })
3080}
3081
3082pub(crate) fn kkt_dual_channel_violations(
3142 dual_violation: f64,
3143 complementarity: f64,
3144 gradient_scale: f64,
3145) -> (bool, bool) {
3146 let dual_infeasible = dual_violation > ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL
3147 && dual_violation / gradient_scale > ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL;
3148 let complementarity_violated = complementarity > ACTIVE_SET_KKT_COMPLEMENTARITY_TOL
3149 && complementarity / gradient_scale > ACTIVE_SET_KKT_COMPLEMENTARITY_TOL;
3150 (dual_infeasible, complementarity_violated)
3151}
3152
3153fn solve_operator_metric_projection_dual_active_set(
3154 hessian: &Array2<f64>,
3155 rhs: &Array1<f64>,
3156 unconstrained: &Array1<f64>,
3157 factor: &gam_linalg::faer_ndarray::FaerCholeskyFactor,
3158 ops: &ConstraintSetOps<'_>,
3159 warm_rows: &[usize],
3160) -> Result<(Array1<f64>, Vec<usize>), EstimationError> {
3161 use gam_linalg::triangular::{
3162 back_substitution_lower_transpose, forward_substitution_lower_vector,
3163 };
3164
3165 let p = unconstrained.len();
3166 let m = ops.nrows();
3167 let lower = factor.lower_triangular();
3168 let face_rank_tolerance = 100.0 * f64::EPSILON * (p.max(1) as f64);
3169
3170 let mut beta = unconstrained.clone();
3171 let mut active = Vec::<usize>::new();
3172 let mut is_active = vec![false; m];
3173 let mut whitened_active = Vec::<Array1<f64>>::new();
3175 let mut multipliers = Vec::<f64>::new();
3176 let mut queue = std::collections::VecDeque::<usize>::new();
3177 for &row in warm_rows {
3178 if row < m && ops.norms[row] > 0.0 && !queue.contains(&row) {
3179 queue.push_back(row);
3180 }
3181 }
3182
3183 let max_transitions = 8usize
3189 .saturating_mul(p.saturating_add(2))
3190 .saturating_mul(p.saturating_add(2))
3191 .saturating_add(64);
3192 let max_refills = 4usize.saturating_mul(p).saturating_add(32);
3193 let mut transitions = 0usize;
3194 let mut refills = 0usize;
3195
3196 let (candidate, refined_multipliers) = loop {
3197 'dual: loop {
3198 let Some(entering) = queue.pop_front() else {
3199 let values = ops.values(&beta)?;
3200 let scan = scan_operator_violations(ops, &values, &is_active)?;
3201 if scan.inactive.is_empty() {
3202 break 'dual;
3207 }
3208 refills += 1;
3209 if refills > max_refills {
3210 return Err(EstimationError::ParameterConstraintViolation(format!(
3211 "operator metric projection reached its bounded-work limit of \
3212 {max_refills} separator scans with {} rows still violated \
3213 (worst {:.3e})",
3214 scan.inactive.len(),
3215 scan.inactive
3216 .iter()
3217 .map(|entry| entry.violation)
3218 .fold(0.0_f64, f64::max),
3219 )));
3220 }
3221 let no_bans = vec![false; m];
3227 let batch = independent_violated_operator_rows(
3228 ops,
3229 &values,
3230 &active,
3231 &is_active,
3232 &no_bans,
3233 p.saturating_sub(active.len()),
3234 )?;
3235 if batch.is_empty() {
3236 let mut ordered = scan.inactive;
3237 ordered.sort_unstable_by(|left, right| {
3238 right
3239 .violation
3240 .total_cmp(&left.violation)
3241 .then_with(|| left.row.cmp(&right.row))
3242 });
3243 queue.extend(
3244 ordered
3245 .iter()
3246 .take(p.saturating_add(8))
3247 .map(|entry| entry.row),
3248 );
3249 } else {
3250 queue.extend(batch);
3251 }
3252 continue 'dual;
3253 };
3254 if is_active[entering] || ops.norms[entering] <= 0.0 {
3255 continue 'dual;
3256 }
3257 let entering_rows = ops.gather_unit_rows(&[entering])?;
3258 let normal = entering_rows.a.row(0).to_owned();
3259 let bound = entering_rows.b[0];
3260 let whitened_normal = forward_substitution_lower_vector(lower.view(), normal.view());
3261 let whitened_scale = whitened_normal.dot(&whitened_normal).sqrt();
3262 if !(array_is_finite(&whitened_normal) && whitened_scale > 0.0) {
3263 crate::bail_invalid_estim!(
3264 "operator metric projection whitened entering row {entering} to a degenerate \
3265 normal (scale {whitened_scale:.3e})"
3266 );
3267 }
3268
3269 let mut remaining_violation = bound - normal.dot(&beta);
3280 if remaining_violation <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
3281 continue 'dual;
3284 }
3285 let mut entering_multiplier = 0.0_f64;
3286 loop {
3287 let (dual_direction, tangent) = if active.is_empty() {
3288 (Array1::<f64>::zeros(0), whitened_normal.clone())
3289 } else {
3290 let Some((q, r)) =
3291 thin_qr_reorthogonalized(&whitened_active, face_rank_tolerance)
3292 else {
3293 crate::bail_invalid_estim!(
3294 "operator metric projection lost independence of its {} active normals",
3295 active.len()
3296 );
3297 };
3298 let projections =
3299 Array1::from_iter(q.iter().map(|basis| basis.dot(&whitened_normal)));
3300 let Some(dual_direction) =
3301 upper_triangular_back_substitution(&r, &projections)
3302 else {
3303 crate::bail_invalid_estim!(
3304 "operator metric projection could not solve its {}-row dual direction",
3305 active.len()
3306 );
3307 };
3308 let mut tangent = whitened_normal.clone();
3309 for (basis, projection) in q.iter().zip(projections.iter()) {
3310 tangent.scaled_add(-projection, basis);
3311 }
3312 (dual_direction, tangent)
3313 };
3314
3315 let rate = tangent.dot(&tangent);
3318 let dependence_floor = ACTIVE_SET_DUAL_DEPENDENCE_TOL * whitened_scale;
3319 let full_step = if rate > dependence_floor * dependence_floor {
3320 remaining_violation / rate
3321 } else {
3322 f64::INFINITY
3323 };
3324
3325 let mut partial_step = f64::INFINITY;
3328 let mut blocking: Option<usize> = None;
3329 for (position, &direction) in dual_direction.iter().enumerate() {
3330 if !(direction > 0.0) {
3331 continue;
3332 }
3333 let ratio = (multipliers[position] / direction).max(0.0);
3334 let replaces = match blocking {
3335 None => true,
3336 Some(current) => {
3337 ratio < partial_step
3338 || (ratio == partial_step && active[position] < active[current])
3339 }
3340 };
3341 if replaces {
3342 partial_step = ratio;
3343 blocking = Some(position);
3344 }
3345 }
3346
3347 if !full_step.is_finite() && blocking.is_none() {
3348 return Err(EstimationError::ParameterConstraintViolation(format!(
3349 "operator metric projection proved its constraint set infeasible: row \
3350 {entering} is violated by {remaining_violation:.3e} and lies in the span \
3351 of the {} active normals with no releasable multiplier",
3352 active.len(),
3353 )));
3354 }
3355 let step = full_step.min(partial_step);
3356 if !step.is_finite() {
3357 crate::bail_invalid_estim!(
3358 "operator metric projection produced a non-finite dual step for row \
3359 {entering}"
3360 );
3361 }
3362 if step > 0.0 {
3363 let primal_direction =
3364 back_substitution_lower_transpose(lower.view(), tangent.view());
3365 beta.scaled_add(step, &primal_direction);
3366 if !array_is_finite(&beta) {
3367 crate::bail_invalid_estim!(
3368 "operator metric projection iterate left the finite range"
3369 );
3370 }
3371 for (multiplier, direction) in
3372 multipliers.iter_mut().zip(dual_direction.iter())
3373 {
3374 *multiplier = (*multiplier - step * direction).max(0.0);
3375 }
3376 }
3377 entering_multiplier += step;
3378 if !entering_multiplier.is_finite() {
3379 crate::bail_invalid_estim!(
3380 "operator metric projection accumulated a non-finite multiplier for \
3381 entering row {entering}"
3382 );
3383 }
3384
3385 transitions += 1;
3386 if transitions > max_transitions {
3387 return Err(EstimationError::ParameterConstraintViolation(format!(
3388 "operator metric projection reached its bounded-work limit of \
3389 {max_transitions} active-set transitions with {} active rows",
3390 active.len(),
3391 )));
3392 }
3393
3394 if full_step <= partial_step {
3395 active.push(entering);
3396 is_active[entering] = true;
3397 whitened_active.push(whitened_normal);
3398 multipliers.push(entering_multiplier);
3399 continue 'dual;
3400 }
3401 remaining_violation =
3407 (-step).mul_add(rate, remaining_violation).max(0.0);
3408 let leaving = blocking.expect("a finite partial step names a blocking row");
3409 let leaving_row = active.remove(leaving);
3410 whitened_active.remove(leaving);
3411 multipliers.remove(leaving);
3412 is_active[leaving_row] = false;
3413 }
3414 }
3415
3416 let refined = refine_operator_metric_face(
3425 hessian,
3426 rhs,
3427 unconstrained,
3428 ops,
3429 &mut active,
3430 &mut is_active,
3431 &mut transitions,
3432 )?;
3433 if transitions > max_transitions {
3434 return Err(EstimationError::ParameterConstraintViolation(format!(
3435 "operator metric projection reached its bounded-work limit of \
3436 {max_transitions} active-set transitions during original-metric face \
3437 conditioning with {} active rows",
3438 active.len(),
3439 )));
3440 }
3441 if !active.is_empty() {
3442 let active_rows = ops.gather_unit_rows(&active)?;
3443 let equality =
3444 certify_active_equalities(&active_rows.a, &active_rows.b, &refined.0);
3445 if !equality.is_certified() {
3446 return Err(EstimationError::ParameterConstraintViolation(format!(
3447 "operator metric projection conditioned face failed its active-equality \
3448 certificate at constraint row {} (active position {}): absolute residual \
3449 {:.3e} exceeds roundoff bound {:.3e} over {} active rows",
3450 active[equality.worst_row],
3451 equality.worst_row,
3452 equality.residual,
3453 equality.allowed,
3454 active.len(),
3455 )));
3456 }
3457 }
3458 let values = ops.values(&refined.0)?;
3459 let scan = scan_operator_violations(ops, &values, &is_active)?;
3460 if scan.is_primal_feasible() {
3461 break refined;
3462 }
3463 if scan.inactive.is_empty() {
3464 return Err(EstimationError::ParameterConstraintViolation(format!(
3465 "operator metric projection found all-row scaled violation {:.3e} at row {} \
3466 after certifying {} active equalities, but found no inactive separator",
3467 scan.worst.violation,
3468 scan.worst.row,
3469 active.len(),
3470 )));
3471 }
3472 beta = refined.0;
3481 multipliers = refined.1.to_vec();
3482 whitened_active.clear();
3483 if !active.is_empty() {
3484 let face_rows = ops.gather_unit_rows(&active)?;
3485 for position in 0..active.len() {
3486 whitened_active.push(forward_substitution_lower_vector(
3487 lower.view(),
3488 face_rows.a.row(position),
3489 ));
3490 }
3491 }
3492 queue.clear();
3493 queue.extend(scan.inactive.iter().map(|entry| entry.row));
3494 };
3495
3496 let active_ids = active.clone();
3497 let gradient = hessian.dot(&candidate) - rhs;
3498 let (stationarity, complementarity, dual_violation) = if active_ids.is_empty() {
3499 (gradient_inf_norm(&gradient), 0.0, 0.0)
3500 } else {
3501 let rows = ops.gather_unit_rows(&active_ids)?;
3502 let residual = &gradient - &rows.a.t().dot(&refined_multipliers);
3503 let complementarity = refined_multipliers
3504 .iter()
3505 .enumerate()
3506 .map(|(position, multiplier)| {
3507 let slack = rows.a.row(position).dot(&candidate) - rows.b[position];
3508 (multiplier * slack).abs()
3509 })
3510 .fold(0.0_f64, f64::max);
3511 let dual_violation = refined_multipliers
3512 .iter()
3513 .map(|multiplier| (-multiplier).max(0.0))
3514 .fold(0.0_f64, f64::max);
3515 (
3516 gradient_inf_norm(&residual),
3517 complementarity,
3518 dual_violation,
3519 )
3520 };
3521 let gradient_scale = gradient_inf_norm(&gradient).max(1.0);
3522 if stationarity > ACTIVE_SET_KKT_STATIONARITY_TOL
3523 && stationarity / gradient_scale > ACTIVE_SET_KKT_STATIONARITY_TOL
3524 {
3525 let achievable = if active_ids.is_empty() {
3548 Some(gradient_inf_norm(&gradient))
3549 } else {
3550 ops.gather_unit_rows(&active_ids).ok().and_then(|rows| {
3551 let gram = rows.a.dot(&rows.a.t());
3552 let ridge = 1.0e-12 * gram.diag().iter().fold(0.0_f64, |m, v| m.max(v.abs()));
3553 let mut regularized = gram;
3554 for i in 0..regularized.nrows() {
3555 regularized[[i, i]] += ridge.max(f64::MIN_POSITIVE);
3556 }
3557 regularized.cholesky(Side::Lower).ok().map(|factor| {
3558 let least_squares = factor.solvevec(&rows.a.dot(&gradient));
3559 gradient_inf_norm(&(&gradient - &rows.a.t().dot(&least_squares)))
3560 })
3561 })
3562 };
3563 let achievable_report = achievable.map_or_else(
3564 || "unmeasured".to_string(),
3565 |value| format!("{value:.3e}"),
3566 );
3567 let verdict = match achievable {
3568 Some(value) if value > 0.5 * stationarity => {
3569 "the face TANGENT carries the residual, so this point is not the \
3570 constrained minimizer of its own face"
3571 }
3572 Some(_) => {
3573 "the residual lies in the face ROW SPACE, so the point is stationary \
3574 and the recovered multipliers do not represent its gradient"
3575 }
3576 None => "achievable residual unmeasured (face gram not factorizable)",
3577 };
3578 return Err(EstimationError::ParameterConstraintViolation(format!(
3579 "operator metric projection failed stationarity certification: \
3580 residual={stationarity:.3e}, relative={:.3e}, active={}, transitions={transitions}, \
3581 achievable={achievable_report} (best over all multipliers), gradient_scale={gradient_scale:.3e}; \
3582 {verdict}",
3583 stationarity / gradient_scale,
3584 active_ids.len(),
3585 )));
3586 }
3587 let (dual_infeasible, complementarity_violated) =
3598 kkt_dual_channel_violations(dual_violation, complementarity, gradient_scale);
3599 if dual_infeasible || complementarity_violated {
3600 return Err(EstimationError::ParameterConstraintViolation(format!(
3601 "operator metric projection failed dual/complementarity certification: \
3602 dual={dual_violation:.3e}, complementarity={complementarity:.3e} \
3603 (relative to gradient_scale={gradient_scale:.3e}: dual={:.3e}, \
3604 complementarity={:.3e}), active={}",
3605 dual_violation / gradient_scale,
3606 complementarity / gradient_scale,
3607 active_ids.len(),
3608 )));
3609 }
3610 Ok((candidate, active_ids))
3611}
3612
3613fn solve_strictly_convex_quadratic_with_constraint_set_dual(
3633 hessian: &Array2<f64>,
3634 rhs: &Array1<f64>,
3635 beta_start: &Array1<f64>,
3636 set: &ConstraintSet,
3637 warm_active_set: Option<&[usize]>,
3638) -> Result<(Array1<f64>, Vec<usize>), EstimationError> {
3639 let p = rhs.len();
3640 if p == 0
3641 || hessian.nrows() != p
3642 || hessian.ncols() != p
3643 || beta_start.len() != p
3644 || set.ncols() != p
3645 || hessian.iter().any(|value| !value.is_finite())
3646 || rhs.iter().any(|value| !value.is_finite())
3647 || beta_start.iter().any(|value| !value.is_finite())
3648 {
3649 crate::bail_invalid_estim!("operator metric-projection dimension/finite contract failed");
3650 }
3651 let factor = hessian.cholesky(Side::Lower).map_err(|error| {
3652 EstimationError::InvalidInput(format!(
3653 "operator metric projection requires a strictly positive-definite Hessian: {error}"
3654 ))
3655 })?;
3656 let unconstrained = factor.solvevec(rhs);
3657 if !array_is_finite(&unconstrained) {
3658 crate::bail_invalid_estim!("operator metric-projection free solve is non-finite");
3659 }
3660
3661 let ops = ConstraintSetOps::new(set, 0.0)?;
3662 let warm_tight =
3668 constraint_set_rows_tight_at_point(set, beta_start, warm_active_set.unwrap_or(&[]))?;
3669 let (candidate, dual_basis) = solve_operator_metric_projection_dual_active_set(
3670 hessian,
3671 rhs,
3672 &unconstrained,
3673 &factor,
3674 &ops,
3675 &warm_tight,
3676 )?;
3677
3678 let dual_basis_rows = dual_basis.len();
3708 let mut face_candidates = dual_basis;
3709 for row in constraint_set_rows_tight_at_point(set, &candidate, warm_active_set.unwrap_or(&[]))?
3710 {
3711 if !face_candidates.contains(&row) {
3712 face_candidates.push(row);
3713 }
3714 }
3715 if face_candidates.len() == dual_basis_rows {
3716 return Ok((candidate, face_candidates));
3717 }
3718 let gathered = ops.gather_unit_rows(&face_candidates)?;
3721 let groups: Vec<Vec<usize>> = face_candidates.iter().map(|row| vec![*row]).collect();
3722 let (_, _, kept, _) =
3723 rank_reduce_rows_pivoted_qr_with_dependence(gathered.a, gathered.b, groups);
3724 let mut face_rows: Vec<usize> = kept
3731 .into_iter()
3732 .filter_map(|group| group.into_iter().min())
3733 .collect();
3734 face_rows.sort_unstable();
3735 face_rows.dedup();
3736 Ok((candidate, face_rows))
3737}
3738
3739pub fn solve_quadratic_with_constraint_set(
3740 hessian: &Array2<f64>,
3741 rhs: &Array1<f64>,
3742 beta_start: &Array1<f64>,
3743 set: &ConstraintSet,
3744 warm_active_set: Option<&[usize]>,
3745) -> Result<(Array1<f64>, Vec<usize>), EstimationError> {
3746 match set {
3747 ConstraintSet::Dense(dense) => solve_quadratic_with_linear_constraints(
3748 hessian,
3749 rhs,
3750 beta_start,
3751 dense,
3752 warm_active_set,
3753 ),
3754 _ => {
3755 if hessian.ncols() != hessian.nrows()
3756 || rhs.len() != hessian.nrows()
3757 || beta_start.len() != hessian.nrows()
3758 || set.ncols() != hessian.nrows()
3759 {
3760 crate::bail_invalid_estim!(
3761 "operator-constrained quadratic solve: system dimension mismatch"
3762 );
3763 }
3764 solve_strictly_convex_quadratic_with_constraint_set_dual(
3765 hessian,
3766 rhs,
3767 beta_start,
3768 set,
3769 warm_active_set,
3770 )
3771 }
3772 }
3773}
3774
3775pub(crate) fn solve_newton_direction_with_linear_constraints(
3776 hessian: &Array2<f64>,
3777 gradient: &Array1<f64>,
3778 beta: &Array1<f64>,
3779 constraints: &LinearInequalityConstraints,
3780 direction_out: &mut Array1<f64>,
3781 active_hint: Option<&mut Vec<usize>>,
3782) -> Result<(), EstimationError> {
3783 if hessian.nrows() != hessian.ncols()
3784 || gradient.len() != hessian.nrows()
3785 || beta.len() != hessian.nrows()
3786 || constraints.a.ncols() != hessian.nrows()
3787 {
3788 crate::bail_invalid_estim!("linear-constrained Newton system dimension mismatch");
3789 }
3790 let rhs = hessian.dot(beta) - gradient;
3796 let warm_active = active_hint.as_ref().map(|hint| hint.as_slice());
3797 let (candidate, active) = solve_quadratic_with_linear_constraints(
3798 hessian,
3799 &rhs,
3800 beta,
3801 constraints,
3802 warm_active,
3803 )?;
3804 if direction_out.len() != beta.len() {
3805 *direction_out = Array1::zeros(beta.len());
3806 }
3807 direction_out.assign(&(&candidate - beta));
3808 if let Some(hint) = active_hint {
3809 hint.clear();
3810 hint.extend(active);
3811 }
3812 Ok(())
3813}
3814
3815pub fn solve_quadratic_with_linear_constraints(
3816 hessian: &Array2<f64>,
3817 rhs: &Array1<f64>,
3818 beta_start: &Array1<f64>,
3819 constraints: &LinearInequalityConstraints,
3820 warm_active_set: Option<&[usize]>,
3821) -> Result<(Array1<f64>, Vec<usize>), EstimationError> {
3822 if hessian.ncols() != hessian.nrows()
3823 || rhs.len() != hessian.nrows()
3824 || beta_start.len() != hessian.nrows()
3825 || constraints.a.ncols() != hessian.nrows()
3826 {
3827 crate::bail_invalid_estim!("constrained quadratic solve: system dimension mismatch");
3828 }
3829 let constraints = constraints.canonicalized().map_err(|e| {
3835 EstimationError::ParameterConstraintViolation(format!(
3836 "constrained quadratic solve: invalid constraint system: {e}"
3837 ))
3838 })?;
3839 let set = ConstraintSet::Dense(constraints);
3848 solve_strictly_convex_quadratic_with_constraint_set_dual(
3849 hessian,
3850 rhs,
3851 beta_start,
3852 &set,
3853 warm_active_set,
3854 )
3855}
3856
3857#[cfg(test)]
3858mod tests {
3859
3860 #[test]
3866 fn dual_kkt_channels_are_judged_at_the_gradient_scale_2695() {
3867 let (dual, complementarity) = super::kkt_dual_channel_violations(0.0, 4.778e-6, 1.913580e9);
3868 assert!(!dual && !complementarity, "a roundoff slack under a 2e9 multiplier is certified");
3869 let (dual, complementarity) = super::kkt_dual_channel_violations(0.0, 4.778e-6, 1.0);
3870 assert!(!dual && complementarity, "the same product at unit gradient scale is refused");
3871 let (dual, complementarity) = super::kkt_dual_channel_violations(0.0, 1.0e-4 * 1.913580e9, 1.913580e9);
3872 assert!(!dual && complementarity, "a material relative complementarity is refused");
3873 let (dual, complementarity) = super::kkt_dual_channel_violations(0.0, 0.0, 1.0);
3874 assert!(!dual && !complementarity, "a zero dual violation is certified");
3875 let (dual, complementarity) = super::kkt_dual_channel_violations(3.0e-8, 0.0, 1.0);
3876 assert!(dual && !complementarity, "a negative multiplier at unit scale is refused");
3877 let (dual, complementarity) = super::kkt_dual_channel_violations(3.0e-8, 0.0, 1.0e9);
3878 assert!(!dual && !complementarity, "the same multiplier under a 1e9 gradient is roundoff");
3879 }
3880 #[test]
3881 fn a_metric_projection_lands_on_the_binding_face_and_names_it_2695() {
3882 use ndarray::array;
3883 let set = ConstraintSet::Dense(
3885 LinearInequalityConstraints::new(
3886 array![[1.0, 0.0], [0.0, 1.0], [1.0, 1.0]],
3887 array![0.0, 0.0, 1.0],
3888 )
3889 .expect("constraint construction"),
3890 );
3891 let (euclid, face) =
3894 super::project_point_onto_constraint_set_in_metric(&array![0.0, 0.0], None, &set, None)
3895 .expect("euclidean projection");
3896 assert!((euclid[0] - 0.5).abs() <= 1e-12 && (euclid[1] - 0.5).abs() <= 1e-12, "{euclid:?}");
3897 assert_eq!(face, vec![2], "the binding row is reported exactly");
3898 assert!((euclid[0] + euclid[1] - 1.0).abs() <= 1e-12, "the point sits ON the face");
3899 let (metric, face) = super::project_point_onto_constraint_set_in_metric(
3902 &array![0.0, 0.0],
3903 Some(&array![1.0, 4.0]),
3904 &set,
3905 None,
3906 )
3907 .expect("metric projection");
3908 assert!((metric[0] - 0.8).abs() <= 1e-12 && (metric[1] - 0.2).abs() <= 1e-12, "{metric:?}");
3909 assert_eq!(face, vec![2]);
3910 let (same, face) = super::project_point_onto_constraint_set_in_metric(
3912 &array![0.7, 0.9],
3913 Some(&array![3.0, 0.5]),
3914 &set,
3915 None,
3916 )
3917 .expect("interior projection");
3918 assert!((same[0] - 0.7).abs() <= 1e-12 && (same[1] - 0.9).abs() <= 1e-12);
3919 assert!(face.is_empty(), "no row binds at an interior point: {face:?}");
3920 let (vertex, mut face) = super::project_point_onto_constraint_set_in_metric(
3924 &array![-1.0, 0.2],
3925 None,
3926 &set,
3927 None,
3928 )
3929 .expect("vertex projection");
3930 face.sort_unstable();
3931 assert!(vertex[0].abs() <= 1e-12 && (vertex[1] - 1.0).abs() <= 1e-12, "{vertex:?}");
3932 assert_eq!(face, vec![0, 2], "both rows bind at the vertex");
3933 }
3934
3935 use super::{
3936 ACTIVE_SET_INTERIOR_SEED_MARGIN, ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL,
3937 ACTIVE_SET_PRIMAL_FEASIBILITY_TOL, ConstraintRowId, ConstraintSet, ConstraintSetOps,
3938 ConstraintSetReducedFace, LinearInequalityConstraints,
3939 array_is_finite, certify_active_equalities, compute_constraint_kkt_diagnostics,
3940 constraint_set_rows_tight_at_point,
3941 independent_violated_operator_rows,
3942 khatri_rao_cone_reduced_face, least_squares_min_norm_any_shape,
3943 nonnegative_cone_multipliers,
3944 project_point_strictly_into_feasible_cone,
3945 project_point_strictly_into_feasible_constraint_set,
3946 project_stationarity_residual_on_constraint_cone,
3947 project_stationarity_residual_on_constraint_set,
3948 rank_reduce_rows_pivoted_qr_with_dependence,
3949 scaled_constraint_slack, scan_operator_violations, solve_kkt_direction,
3950 solve_newton_direction_with_linear_constraints, solve_quadratic_with_constraint_set,
3951 solve_quadratic_with_linear_constraints,
3952
3953 };
3954 use crate::estimate::EstimationError;
3955 use approx::assert_relative_eq;
3956 use gam_problem::KhatriRaoConeConstraints;
3957 use ndarray::{Array1, Array2, array};
3958
3959 fn gather_linear_constraint_rows(
3960 constraints: &LinearInequalityConstraints,
3961 rows: &[usize],
3962 ) -> Result<LinearInequalityConstraints, EstimationError> {
3963 let p = constraints.a.ncols();
3964 let mut a = Array2::<f64>::zeros((rows.len(), p));
3965 let mut b = Array1::<f64>::zeros(rows.len());
3966 for (out, &row) in rows.iter().enumerate() {
3967 if row >= constraints.a.nrows() {
3968 crate::bail_invalid_estim!(
3969 "active constraint row {} out of bounds for {} rows",
3970 row,
3971 constraints.a.nrows()
3972 );
3973 }
3974 a.row_mut(out).assign(&constraints.a.row(row));
3975 b[out] = constraints.b[row];
3976 }
3977 LinearInequalityConstraints::new(a, b)
3978 .map_err(|error| EstimationError::ParameterConstraintViolation(error.to_string()))
3979 }
3980
3981 fn moreau_projection_via_strict_qp(
3982 residual: &Array1<f64>,
3983 active_a: &Array2<f64>,
3984 ) -> Option<(Array1<f64>, Array1<f64>)> {
3985 let p = residual.len();
3986 let m = active_a.nrows();
3987 let constraints =
3988 LinearInequalityConstraints::new(active_a.clone(), Array1::<f64>::zeros(m))
3989 .ok()?
3990 .canonicalized()
3991 .ok()?;
3992
3993 let identity = Array2::<f64>::eye(p);
3996 let origin = Array1::<f64>::zeros(p);
3997 let rhs = -residual;
3998 let (tangent_direction, tangent_active) = solve_quadratic_with_linear_constraints(
3999 &identity,
4000 &rhs,
4001 &origin,
4002 &constraints,
4003 None,
4004 )
4005 .ok()?;
4006 if !array_is_finite(&tangent_direction) {
4007 return None;
4008 }
4009 let projected = -&tangent_direction;
4010
4011 let mut lambda_canonical = Array1::<f64>::zeros(m);
4012 if !tangent_active.is_empty() {
4013 let gathered = gather_linear_constraint_rows(&constraints, &tangent_active).ok()?;
4014 let design = gathered.a.t().to_owned();
4015 let solved =
4016 least_squares_min_norm_any_shape(&design, &(residual + &tangent_direction))?;
4017 let scale = residual
4018 .iter()
4019 .fold(0.0_f64, |acc, &value| acc.max(value.abs()))
4020 .max(1.0);
4021 let tol = 100.0 * f64::EPSILON * (p.max(m) as f64) * scale;
4022 for (position, &row) in tangent_active.iter().enumerate() {
4023 let value = solved[position];
4024 if !value.is_finite() || value < -tol {
4025 return None;
4026 }
4027 lambda_canonical[row] = value.max(0.0);
4028 }
4029 }
4030 let reconstructed = residual - &constraints.a.t().dot(&lambda_canonical);
4031 let reconstruction_error = reconstructed
4032 .iter()
4033 .zip(projected.iter())
4034 .fold(0.0_f64, |acc, (&left, &right)| {
4035 acc.max((left - right).abs())
4036 });
4037 let scale = residual
4038 .iter()
4039 .fold(0.0_f64, |acc, &value| acc.max(value.abs()))
4040 .max(1.0);
4041 if reconstruction_error > 1e-8 * scale || !array_is_finite(&lambda_canonical) {
4042 return None;
4043 }
4044
4045 let mut lambda = Array1::<f64>::zeros(m);
4046 for row in 0..m {
4047 let norm = active_a.row(row).dot(&active_a.row(row)).sqrt();
4048 if norm > 0.0 {
4049 lambda[row] = lambda_canonical[row] / norm;
4050 }
4051 }
4052 Some((projected, lambda))
4053 }
4054
4055 #[test]
4056 fn active_equality_certificate_rejects_public_tolerance_band_drift() {
4057 let active_a = array![[1.0, 0.0]];
4062 let rhs = array![0.0];
4063 let direction = array![8.604942e-9, 0.0];
4064 let certificate = certify_active_equalities(&active_a, &rhs, &direction);
4065 assert!(
4066 !certificate.is_certified(),
4067 "a tolerance-band endpoint is not a roundoff-resolved active equality"
4068 );
4069 assert_eq!(certificate.worst_row, 0);
4070 assert_relative_eq!(certificate.residual, 8.604942e-9, epsilon = 0.0);
4071 assert!(certificate.residual > 1.0e6 * certificate.allowed);
4072 }
4073
4074 #[test]
4075 fn active_equality_certificate_uses_the_solve_scale_not_the_collapsed_row_scale() {
4076 let active_a = array![[0.0, 0.0, 1.0, 0.0], [1.0, 0.0, 0.0, 0.0]];
4088 let rhs = array![0.0, 0.5];
4089 let collapsed = array![0.5, 0.3, 1.0e-33, 0.0];
4090 let certificate = certify_active_equalities(&active_a, &rhs, &collapsed);
4091 assert!(
4092 certificate.is_certified(),
4093 "an equality residual {:.3e} that is 1e-33 of the solve scale is \
4094 roundoff-resolved, not a face defect (allowed {:.3e})",
4095 certificate.residual,
4096 certificate.allowed
4097 );
4098
4099 let drifted = array![0.5, 0.3, 1.0e-9, 0.0];
4103 let certificate = certify_active_equalities(&active_a, &rhs, &drifted);
4104 assert!(
4105 !certificate.is_certified(),
4106 "a 1e-9 equality drift against an O(1) solve scale is a real defect"
4107 );
4108 assert_eq!(certificate.worst_row, 0);
4109 }
4110
4111 #[test]
4112 fn stiff_null_space_solve_returns_roundoff_resolved_active_equality() {
4113 let hessian = array![[1.0e16, 1.0e8], [1.0e8, 2.0]];
4118 let gradient = array![1.0e8, -3.0];
4119 let active_a = array![[0.6, 0.8]];
4120 let active_residual = array![1.0e-4];
4121 let (direction, multiplier) =
4122 solve_kkt_direction(&hessian, &gradient, &active_a, Some(&active_residual))
4123 .expect("stiff null-space constrained solve");
4124
4125 let certificate =
4126 certify_active_equalities(&active_a, &active_residual, &direction);
4127 assert!(
4128 certificate.is_certified(),
4129 "active equality residual {:.3e} exceeds its roundoff bound {:.3e}",
4130 certificate.residual,
4131 certificate.allowed,
4132 );
4133 assert!(multiplier.iter().all(|value| value.is_finite()));
4134 }
4135
4136 #[test]
4137 fn dependent_active_equalities_share_one_null_space() {
4138 let hessian = array![
4142 [1.0e12, 0.0, 0.0],
4143 [0.0, 3.0, 0.5],
4144 [0.0, 0.5, 2.0],
4145 ];
4146 let gradient = array![2.0e5, -4.0, 1.0];
4147 let active_a = array![[1.0, 2.0, 0.0], [2.0, 4.0, 0.0]];
4148 let active_residual = array![1.0e-4, 2.0e-4];
4149 let (direction, multiplier) =
4150 solve_kkt_direction(&hessian, &gradient, &active_a, Some(&active_residual))
4151 .expect("rank-deficient active face must have one certified null space");
4152
4153 let residual = &active_a.dot(&direction) - &active_residual;
4154 assert!(
4155 residual.iter().all(|value| value.abs() <= 1.0e-14),
4156 "dependent active equations were not resolved: {residual:?}"
4157 );
4158 assert!(multiplier.iter().all(|value| value.is_finite()));
4159 }
4160
4161 #[test]
4162 fn warm_face_rows_are_point_local_for_dense_and_operator_constraints() {
4163 let hessian = array![[1.0_f64]];
4170 let rhs = array![2.0_f64];
4171 let interior = array![1.0_f64];
4172 let dense = LinearInequalityConstraints::new(array![[1.0]], array![0.0])
4173 .expect("one-dimensional half-line");
4174 let (dense_solution, dense_active) =
4175 solve_quadratic_with_linear_constraints(&hessian, &rhs, &interior, &dense, Some(&[0]))
4176 .expect("dense stale-face solve");
4177 assert_relative_eq!(dense_solution[0], 2.0, epsilon = 1e-12);
4178 assert!(dense_active.is_empty());
4179
4180 let factor = std::sync::Arc::new(array![[1.0_f64]]);
4181 let cone = KhatriRaoConeConstraints::new(factor, vec![0], 1)
4182 .expect("one-dimensional factored half-line");
4183 let operator = ConstraintSet::KhatriRaoCone(cone);
4184 let stale_terminal_face = constraint_set_rows_tight_at_point(&operator, &interior, &[0])
4185 .expect("terminal face classification");
4186 assert!(stale_terminal_face.is_empty());
4187 let (operator_solution, operator_active) =
4188 solve_quadratic_with_constraint_set(&hessian, &rhs, &interior, &operator, Some(&[0]))
4189 .expect("operator stale-face solve");
4190 assert_relative_eq!(operator_solution[0], 2.0, epsilon = 1e-12);
4191 assert!(operator_active.is_empty());
4192 }
4193
4194 #[test]
4203 fn strict_interior_projection_lifts_vertex_seed_off_every_constraint_row() {
4204 let p = 5usize;
4207 let rows = p - 2;
4208 let mut a = Array2::<f64>::zeros((rows, p));
4209 for i in 0..rows {
4210 a[[i, i]] = -1.0;
4211 a[[i, i + 1]] = 2.0;
4212 a[[i, i + 2]] = -1.0;
4213 }
4214 let constraints = LinearInequalityConstraints::new(a, Array1::zeros(rows))
4215 .expect("test constraint shape invariant");
4216
4217 let vertex = Array1::<f64>::zeros(p);
4218 for i in 0..rows {
4220 assert!(
4221 scaled_constraint_slack(&vertex, &constraints, i).abs() < 1e-12,
4222 "vertex seed should sit exactly on row {i}"
4223 );
4224 }
4225
4226 let interior = project_point_strictly_into_feasible_cone(&vertex, &constraints)
4227 .expect("strict-interior projection of the vertex must succeed");
4228 let min_slack = (0..rows)
4229 .map(|i| scaled_constraint_slack(&interior, &constraints, i))
4230 .fold(f64::INFINITY, f64::min);
4231 assert!(
4232 min_slack >= 0.5 * ACTIVE_SET_INTERIOR_SEED_MARGIN,
4233 "projected seed must be strictly interior on every row; min scaled slack = {min_slack:.3e}"
4234 );
4235 }
4236
4237 #[test]
4247 fn strict_interior_projection_keeps_equality_pairs_tight_with_shape_bounds() {
4248 let p = 5usize;
4249 let m = 3 + 2;
4252 let mut a = Array2::<f64>::zeros((m, p));
4253 a[[0, 2]] = 1.0;
4254 a[[1, 3]] = 1.0;
4255 a[[2, 4]] = 1.0;
4256 a[[3, 0]] = 1.0;
4257 a[[4, 0]] = -1.0;
4258 let constraints = LinearInequalityConstraints::new(a, Array1::zeros(m))
4259 .expect("test constraint shape invariant");
4260
4261 let point = Array1::from_vec(vec![0.7, -0.2, -0.5, -0.3, -0.1]);
4264 let seed = project_point_strictly_into_feasible_cone(&point, &constraints).expect(
4265 "strict-interior projection must succeed when an equality pair is present, \
4266 not collapse to the empty set and fall back to the vertex",
4267 );
4268
4269 for i in 0..3 {
4271 assert!(
4272 scaled_constraint_slack(&seed, &constraints, i)
4273 >= 0.4 * ACTIVE_SET_INTERIOR_SEED_MARGIN,
4274 "shape row {i} not strictly interior: scaled slack = {:.3e}",
4275 scaled_constraint_slack(&seed, &constraints, i)
4276 );
4277 }
4278 assert!(
4281 seed[0].abs() <= 1e-6,
4282 "boundary equality must be enforced, got β_0 = {:.3e}",
4283 seed[0]
4284 );
4285 }
4286
4287 #[test]
4291 fn strict_interior_projection_preserves_a_curvature_carrying_seed() {
4292 let p = 5usize;
4293 let rows = p - 2;
4294 let mut a = Array2::<f64>::zeros((rows, p));
4295 for i in 0..rows {
4296 a[[i, i]] = -1.0;
4297 a[[i, i + 1]] = 2.0;
4298 a[[i, i + 2]] = -1.0;
4299 }
4300 let constraints = LinearInequalityConstraints::new(a, Array1::zeros(rows))
4301 .expect("test constraint shape invariant");
4302 let seed = Array1::from_iter((0..p).map(|j| -((j as f64 - 2.0).powi(2))));
4306 let projected = project_point_strictly_into_feasible_cone(&seed, &constraints)
4307 .expect("already-interior seed must project");
4308 let max_move = seed
4309 .iter()
4310 .zip(projected.iter())
4311 .map(|(a, b)| (a - b).abs())
4312 .fold(0.0_f64, f64::max);
4313 assert!(
4314 max_move < 1e-3,
4315 "strictly-interior curvature-carrying seed should be preserved; max move = {max_move:.3e}"
4316 );
4317 }
4318
4319 #[test]
4320 fn dense_dual_newton_returns_the_exact_boundary_solution() {
4321 let hessian = array![[1.0]];
4322 let gradient = array![-1.0];
4323 let beta = array![0.0];
4324 let constraints = LinearInequalityConstraints {
4325 a: array![[-1.0]],
4326 b: array![-0.1],
4327 };
4328 let mut direction = Array1::zeros(1);
4329 let mut active_hint = Vec::new();
4330
4331 solve_newton_direction_with_linear_constraints(
4332 &hessian,
4333 &gradient,
4334 &beta,
4335 &constraints,
4336 &mut direction,
4337 Some(&mut active_hint),
4338 )
4339 .expect("finite dual solve should return the unique boundary solution");
4340
4341 assert_relative_eq!(direction[0], 0.1, epsilon = 1e-12);
4342 assert_eq!(active_hint, vec![0]);
4343 }
4344
4345 #[test]
4346 fn dense_dual_releases_a_boundary_with_negative_multiplier() {
4347 let hessian = array![[1.0_f64]];
4352 let beta = array![0.0_f64];
4353 let gradient = array![-1.0_f64];
4354 let constraints =
4355 LinearInequalityConstraints::new(array![[1.0]], array![0.0]).expect("one-sided bound");
4356 let mut direction = Array1::<f64>::zeros(1);
4357 let mut active = vec![0];
4358 solve_newton_direction_with_linear_constraints(
4359 &hessian,
4360 &gradient,
4361 &beta,
4362 &constraints,
4363 &mut direction,
4364 Some(&mut active),
4365 )
4366 .expect("negative-multiplier face must be released");
4367
4368 assert_relative_eq!(direction[0], 1.0, epsilon = 1e-12);
4369 assert!(gradient.dot(&direction) < 0.0);
4370 assert!(active.is_empty(), "descent moves strictly into the cone");
4371 }
4372
4373 #[test]
4374 fn rank_reduce_agrees_with_a_rank_revealing_factorization_on_an_ill_conditioned_face_2600() {
4375 use gam_linalg::faer_ndarray::FaerSvd;
4400
4401 const NODE_SPACING: f64 = 0.05;
4402 let p = 8usize;
4403 let independent = 6usize;
4404 let mut rows = Array2::<f64>::zeros((independent + 1, p));
4405 for index in 0..independent {
4406 let node = 1.0 + NODE_SPACING * index as f64;
4407 let mut power = 1.0_f64;
4408 for column in 0..p {
4409 rows[[index, column]] = power;
4410 power *= node;
4411 }
4412 let norm = rows.row(index).dot(&rows.row(index)).sqrt();
4413 let normalized = &rows.row(index).to_owned() / norm;
4414 rows.row_mut(index).assign(&normalized);
4415 }
4416 let difference_weights = [1.0_f64, -4.0, 6.0, -4.0, 1.0];
4419 let mut combination = Array1::<f64>::zeros(p);
4420 for (index, weight) in difference_weights.iter().enumerate() {
4421 combination.scaled_add(*weight, &rows.row(index));
4422 }
4423 let combination_norm = combination.dot(&combination).sqrt();
4424 rows.row_mut(independent)
4425 .assign(&(&combination / combination_norm));
4426
4427 let (_u, singular, _vt) = rows.svd(false, false).expect("face SVD");
4428 let singular_max = singular.iter().copied().fold(0.0_f64, f64::max);
4429 let svd_floor = 100.0 * f64::EPSILON * (rows.nrows().max(p) as f64) * singular_max;
4430 let svd_rank = singular.iter().filter(|&&s| s > svd_floor).count();
4431 assert_eq!(
4432 svd_rank, independent,
4433 "the fixture must be rank-deficient by construction, not by tolerance \
4434 (singular values {singular:?})"
4435 );
4436
4437 let b = Array1::<f64>::zeros(independent + 1);
4438 let groups: Vec<Vec<usize>> = (0..=independent).map(|row| vec![row]).collect();
4439 let (reduced, _b_out, groups_out, _dependence) =
4440 rank_reduce_rows_pivoted_qr_with_dependence(rows, b, groups);
4441
4442 assert_eq!(
4443 reduced.nrows(),
4444 svd_rank,
4445 "the independence scan kept {} rows where a rank-revealing factorization \
4446 of the same block at the same floor finds {svd_rank}; a face carrying a \
4447 redundant row is what #2600's affine solve then refused",
4448 reduced.nrows()
4449 );
4450 assert_eq!(groups_out.len(), svd_rank);
4451 }
4452
4453 #[test]
4454 fn rank_reduce_zero_rows_returns_empty_working_set() {
4455 let a = array![[0.0, 0.0], [0.0, 0.0],];
4456 let b = array![0.0, 0.0];
4457 let groups = vec![vec![0], vec![1]];
4458
4459 let (a_out, b_out, groups_out, _) =
4460 rank_reduce_rows_pivoted_qr_with_dependence(a, b, groups);
4461
4462 assert_eq!(a_out.nrows(), 0);
4463 assert_eq!(a_out.ncols(), 2);
4464 assert_eq!(b_out.len(), 0);
4465 assert!(groups_out.is_empty());
4466 }
4467
4468 #[test]
4469 fn cone_projection_solves_nonnegative_least_squares_not_one_way_pruning() {
4470 let active_a = array![
4471 [0.85258593, -0.77270261],
4472 [-1.22152485, 2.05129351],
4473 [0.22794844, 1.56987265],
4474 ];
4475 let residual = array![-0.50524761, -1.10104911];
4476
4477 let (projected, multipliers) =
4478 project_stationarity_residual_on_constraint_cone(&residual, &active_a)
4479 .expect("cone projection should solve");
4480
4481 let row0 = active_a.row(0);
4482 let expected_mu0 = row0.dot(&residual) / row0.dot(&row0);
4483 assert_relative_eq!(multipliers[0], expected_mu0, epsilon = 1e-8);
4484 assert_relative_eq!(multipliers[1], 0.0, epsilon = 1e-10);
4485 assert_relative_eq!(multipliers[2], 0.0, epsilon = 1e-10);
4486
4487 let raw_norm2 = residual.dot(&residual);
4488 let projected_norm2 = projected.dot(&projected);
4489 assert!(
4490 projected_norm2 < raw_norm2 - 0.1,
4491 "NNLS projection should keep the improving active row: raw={raw_norm2:.6e}, projected={projected_norm2:.6e}"
4492 );
4493 let dual = active_a.dot(&projected);
4494 for (idx, (&mu, &w)) in multipliers.iter().zip(dual.iter()).enumerate() {
4495 if mu <= 1e-10 {
4496 assert!(
4497 w <= 1e-8,
4498 "inactive cone generator {idx} has positive reduced gradient {w:.3e}"
4499 );
4500 }
4501 }
4502 }
4503
4504 #[test]
4508 fn nnls_moreau_projection_matches_strict_qp_route() {
4509 let cases: Vec<(Array2<f64>, Array1<f64>)> = vec![
4510 (
4511 array![
4512 [0.85258593, -0.77270261],
4513 [-1.22152485, 2.05129351],
4514 [0.22794844, 1.56987265],
4515 ],
4516 array![-0.50524761, -1.10104911],
4517 ),
4518 (array![[1.0, 0.0], [0.0, 1.0]], array![3.0, -2.0]),
4519 (
4520 array![[1.0, 1.0, 0.0], [1.0, -1.0, 0.0], [2.0, 2.0, 0.0]],
4521 array![1.5, 0.25, -0.75],
4522 ),
4523 ];
4524 for (rows, target) in cases {
4525 let qp = moreau_projection_via_strict_qp(&target, &rows)
4526 .expect("strict QP route must solve these well-posed instances");
4527 let (lambda, projected) = nonnegative_cone_multipliers(&rows, &target)
4528 .expect("LH route must solve the same instances");
4529 for (left, right) in qp.0.iter().zip(projected.iter()) {
4530 assert_relative_eq!(left, right, epsilon = 1e-8);
4531 }
4532 assert!(lambda.iter().all(|&v| v >= 0.0));
4534 let reconstructed = &target - &rows.t().dot(&lambda);
4535 for (left, right) in reconstructed.iter().zip(projected.iter()) {
4536 assert_relative_eq!(left, right, epsilon = 1e-12);
4537 }
4538 }
4539 }
4540
4541 #[test]
4542 fn nnls_projects_axis_cone_exactly() {
4543 let rows = array![[1.0, 0.0], [0.0, 1.0]];
4544 let target = array![3.0, -2.0];
4545 let (lambda, projected) =
4546 nonnegative_cone_multipliers(&rows, &target).expect("axis cone NNLS");
4547 assert_relative_eq!(lambda[0], 3.0, epsilon = 1e-10);
4548 assert_relative_eq!(lambda[1], 0.0, epsilon = 1e-10);
4549 assert_relative_eq!(projected[0], 0.0, epsilon = 1e-10);
4550 assert_relative_eq!(projected[1], -2.0, epsilon = 1e-10);
4551 }
4552
4553 #[test]
4559 fn nnls_closes_stationarity_on_weakly_aligned_dependent_face() {
4560 let eps = 1e-8_f64;
4561 let rows = array![[1.0, eps], [-1.0, eps], [0.0, 1.0]];
4562 let target = array![0.0, 1.0];
4563 let (lambda, projected) =
4564 nonnegative_cone_multipliers(&rows, &target).expect("dependent-face NNLS");
4565 let closure = projected.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
4566 assert!(
4567 closure <= 1e-10,
4568 "λ = e3 closes stationarity exactly; got closure {closure:.3e}"
4569 );
4570 assert!(lambda.iter().all(|&v| v >= 0.0));
4571 }
4572
4573 #[test]
4578 fn degenerate_face_with_weak_alignment_certifies_instead_of_cycling() {
4579 let eps = 1e-8_f64;
4580 let a = array![[1.0, eps], [-1.0, eps], [0.0, 1.0]];
4581 let b = array![0.0, 0.0, 0.0];
4582 let constraints = LinearInequalityConstraints::new(a.clone(), b).expect("constraints");
4583 let hessian = Array2::<f64>::eye(2);
4584 let gradient = array![0.0, 1.0];
4586 let beta = array![0.0, 0.0];
4587 let mut direction = Array1::<f64>::zeros(2);
4588 solve_newton_direction_with_linear_constraints(
4589 &hessian,
4590 &gradient,
4591 &beta,
4592 &constraints,
4593 &mut direction,
4594 None,
4595 )
4596 .expect("the vertex is a certified KKT point; refusal is the #2298 defect");
4597 let step = direction.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
4598 assert!(
4599 step <= 1e-8,
4600 "optimum is the vertex itself; got |d|∞ = {step:.3e}"
4601 );
4602 }
4603
4604 #[test]
4605 fn cone_projection_preserves_original_multiplier_units_after_row_canonicalization() {
4606 let residual = array![2.0, -1.0];
4607 let unit_row = array![[1.0, 0.0]];
4608 let scaled_row = array![[4.0, 0.0]];
4609
4610 let (projected_unit, multiplier_unit) =
4611 project_stationarity_residual_on_constraint_cone(&residual, &unit_row)
4612 .expect("unit-row cone projection should solve");
4613 let (projected_scaled, multiplier_scaled) =
4614 project_stationarity_residual_on_constraint_cone(&residual, &scaled_row)
4615 .expect("scaled-row cone projection should solve");
4616
4617 assert_relative_eq!(projected_unit[0], 0.0, epsilon = 1e-12);
4618 assert_relative_eq!(projected_unit[1], -1.0, epsilon = 1e-12);
4619 assert_relative_eq!(projected_scaled[0], projected_unit[0], epsilon = 1e-12);
4620 assert_relative_eq!(projected_scaled[1], projected_unit[1], epsilon = 1e-12);
4621 assert_relative_eq!(multiplier_unit[0], 2.0, epsilon = 1e-12);
4622 assert_relative_eq!(multiplier_scaled[0], 0.5, epsilon = 1e-12);
4623
4624 let reconstructed_unit = &residual - &unit_row.t().dot(&multiplier_unit);
4625 let reconstructed_scaled = &residual - &scaled_row.t().dot(&multiplier_scaled);
4626 assert_relative_eq!(reconstructed_unit[0], projected_unit[0], epsilon = 1e-12);
4627 assert_relative_eq!(
4628 reconstructed_scaled[0],
4629 projected_scaled[0],
4630 epsilon = 1e-12
4631 );
4632 }
4633
4634 #[test]
4643 fn a_refused_cone_projection_is_named_in_the_rendered_verdict_2601() {
4644 let beta = array![0.0, 0.0];
4647 let constraints = LinearInequalityConstraints {
4648 a: array![[1.0, 0.0], [0.0, 1.0]],
4649 b: array![0.0, 0.0],
4650 };
4651
4652 let finite = compute_constraint_kkt_diagnostics(&beta, &array![1.0, 2.0], &constraints);
4653 assert!(
4654 !finite.cone_projection_refused,
4655 "a finite gradient on a full-rank active face must be projected, not refused"
4656 );
4657 assert_eq!(
4658 finite.cone_projection_note(),
4659 "",
4660 "a projection that happened must contribute no note"
4661 );
4662
4663 let refused =
4664 compute_constraint_kkt_diagnostics(&beta, &array![f64::NAN, 2.0], &constraints);
4665 assert_eq!(
4666 refused.n_active, 2,
4667 "the fixture must reach the projector: an empty active face skips it entirely"
4668 );
4669 assert!(
4670 refused.cone_projection_refused,
4671 "a non-finite target is one of the projector's documented refusals"
4672 );
4673 assert!(
4674 refused.cone_projection_note().contains("REFUSED"),
4675 "the rendered note must name the refusal; got {:?}",
4676 refused.cone_projection_note()
4677 );
4678 assert!(
4679 refused.cone_projection_note().contains("UNPROJECTED"),
4680 "the note must say the reported stat was never projected; got {:?}",
4681 refused.cone_projection_note()
4682 );
4683 }
4684
4685 #[test]
4692 fn kkt_primal_is_per_row_scale_invariant() {
4693 let geometric_violation = 2.071e-8_f64;
4696 let gradient = Array1::<f64>::zeros(2);
4697
4698 let beta_unit = array![-geometric_violation, 0.0];
4700 let unit = LinearInequalityConstraints {
4701 a: array![[1.0, 0.0]],
4702 b: array![0.0],
4703 };
4704 let diag_unit = compute_constraint_kkt_diagnostics(&beta_unit, &gradient, &unit);
4705
4706 let beta_big = array![-geometric_violation, 0.0];
4709 let big = LinearInequalityConstraints {
4710 a: array![[1000.0, 0.0]],
4711 b: array![0.0],
4712 };
4713 let diag_big = compute_constraint_kkt_diagnostics(&beta_big, &gradient, &big);
4714
4715 assert_relative_eq!(
4716 diag_unit.primal_feasibility,
4717 geometric_violation,
4718 epsilon = 1e-14
4719 );
4720 assert_relative_eq!(
4721 diag_big.primal_feasibility,
4722 geometric_violation,
4723 epsilon = 1e-14
4724 );
4725 assert!(
4727 diag_big.primal_feasibility < 1e-7,
4728 "scaled primal {:.3e} should pass a 1e-7 gate; raw slack would be {:.3e}",
4729 diag_big.primal_feasibility,
4730 1000.0 * geometric_violation
4731 );
4732 }
4733
4734 #[test]
4742 fn opposing_inequality_pair_pins_equality_to_target() {
4743 let hessian = array![
4747 [1.0, 0.0, 0.0, 0.0],
4748 [0.0, 1.0, 0.0, 0.0],
4749 [0.0, 0.0, 1.0, 0.0],
4750 [0.0, 0.0, 0.0, 1.0],
4751 ];
4752 let rhs = array![5.0, 5.0, 0.0, 0.0];
4753 let beta_start = Array1::<f64>::zeros(4);
4754 let constraints = LinearInequalityConstraints {
4755 a: array![[1.0, 1.0, 0.0, 0.0], [-1.0, -1.0, 0.0, 0.0]],
4756 b: array![0.0, 0.0],
4757 };
4758
4759 let (beta, _active) = solve_quadratic_with_linear_constraints(
4760 &hessian,
4761 &rhs,
4762 &beta_start,
4763 &constraints,
4764 None,
4765 )
4766 .expect("opposing-inequality equality QP must solve");
4767
4768 let a_dot_beta = beta[0] + beta[1];
4769 assert!(
4770 a_dot_beta.abs() < 1e-8,
4771 "opposing inequalities must pin a·β to 0, got {a_dot_beta:.6e} (β = {beta:?})"
4772 );
4773 }
4774
4775 #[test]
4779 fn opposing_inequality_pair_pins_scaled_equality_to_nonzero_target() {
4780 let hessian = array![
4781 [1.0, 0.0, 0.0, 0.0],
4782 [0.0, 1.0, 0.0, 0.0],
4783 [0.0, 0.0, 1.0, 0.0],
4784 [0.0, 0.0, 0.0, 1.0],
4785 ];
4786 let rhs = array![5.0, 5.0, 0.0, 0.0];
4787 let beta_start = Array1::<f64>::zeros(4);
4788 let constraints = LinearInequalityConstraints {
4791 a: array![[1000.0, 1000.0, 0.0, 0.0], [-1000.0, -1000.0, 0.0, 0.0]],
4792 b: array![3000.0, -3000.0],
4793 };
4794
4795 let (beta, _active) = solve_quadratic_with_linear_constraints(
4796 &hessian,
4797 &rhs,
4798 &beta_start,
4799 &constraints,
4800 None,
4801 )
4802 .expect("scaled opposing-inequality equality QP must solve");
4803
4804 let a_dot_beta = 1000.0 * (beta[0] + beta[1]);
4805 assert!(
4806 (a_dot_beta - 3000.0).abs() < 1e-5,
4807 "opposing inequalities must pin a·β to 3000, got {a_dot_beta:.6e} (β = {beta:?})"
4808 );
4809 }
4810
4811 #[test]
4816 fn two_opposing_inequality_equalities_both_pinned() {
4817 let hessian = array![
4818 [1.0, 0.0, 0.0, 0.0],
4819 [0.0, 1.0, 0.0, 0.0],
4820 [0.0, 0.0, 1.0, 0.0],
4821 [0.0, 0.0, 0.0, 1.0],
4822 ];
4823 let rhs = array![5.0, 5.0, 5.0, 5.0];
4824 let beta_start = Array1::<f64>::zeros(4);
4825 let constraints = LinearInequalityConstraints {
4827 a: array![
4828 [1.0, 1.0, 0.0, 0.0],
4829 [-1.0, -1.0, 0.0, 0.0],
4830 [0.0, 0.0, 1.0, 1.0],
4831 [0.0, 0.0, -1.0, -1.0],
4832 ],
4833 b: array![0.0, 0.0, 0.0, 0.0],
4834 };
4835
4836 let (beta, _active) = solve_quadratic_with_linear_constraints(
4837 &hessian,
4838 &rhs,
4839 &beta_start,
4840 &constraints,
4841 None,
4842 )
4843 .expect("two-equality QP must solve");
4844
4845 assert!(
4846 (beta[0] + beta[1]).abs() < 1e-8,
4847 "equality A not pinned: β0+β1 = {:.6e}",
4848 beta[0] + beta[1]
4849 );
4850 assert!(
4851 (beta[2] + beta[3]).abs() < 1e-8,
4852 "equality B not pinned: β2+β3 = {:.6e}",
4853 beta[2] + beta[3]
4854 );
4855 }
4856
4857 #[test]
4864 fn opposing_inequality_equalities_pinned_under_ill_conditioned_penalty() {
4865 let lam = 1.0e8_f64;
4868 let hessian = array![
4869 [1.0, 0.0, 0.0, 0.0],
4870 [0.0, 1.0, 0.0, 0.0],
4871 [0.0, 0.0, lam, 0.0],
4872 [0.0, 0.0, 0.0, lam],
4873 ];
4874 let rhs = array![5.0, 5.0, 5.0, 5.0];
4875 let beta_start = Array1::<f64>::zeros(4);
4876 let constraints = LinearInequalityConstraints {
4880 a: array![
4881 [1.0, 0.0, 1.0, 0.0],
4882 [-1.0, 0.0, -1.0, 0.0],
4883 [0.0, 1.0, 0.0, 1.0],
4884 [0.0, -1.0, 0.0, -1.0],
4885 ],
4886 b: array![0.0, 0.0, 0.0, 0.0],
4887 };
4888
4889 let (beta, _active) = solve_quadratic_with_linear_constraints(
4890 &hessian,
4891 &rhs,
4892 &beta_start,
4893 &constraints,
4894 None,
4895 )
4896 .expect("ill-conditioned two-equality QP must solve");
4897
4898 assert!(
4899 (beta[0] + beta[2]).abs() < 1e-6,
4900 "equality A not pinned under ill-conditioning: β0+β2 = {:.6e}",
4901 beta[0] + beta[2]
4902 );
4903 assert!(
4904 (beta[1] + beta[3]).abs() < 1e-6,
4905 "equality B not pinned under ill-conditioning: β1+β3 = {:.6e}",
4906 beta[1] + beta[3]
4907 );
4908 }
4909
4910 fn small_cone() -> KhatriRaoConeConstraints {
4916 let psi = array![[1.0_f64, 0.2], [1.0, -0.4], [1.0, 1.3], [1.0, 0.8],];
4917 KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1, 2], 3).expect("small cone")
4918 }
4919
4920 #[test]
4923 fn cone_reduced_face_collapses_parallel_rows_to_lowest_index() {
4924 let psi = array![[1.0_f64, 0.0], [0.0, 1.0], [2.0, 0.0]];
4926 let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1], 2)
4927 .expect("parallel cone");
4928 let beta = Array1::<f64>::zeros(2 * 2);
4930 let face = khatri_rao_cone_reduced_face(&cone, beta.view(), 1e-8).expect("reduce");
4931 assert_eq!(face.tight_rows, rows(&[0, 1, 2]));
4932 assert_eq!(face.representatives, rows(&[0, 1]));
4934 assert_eq!(face.dependence.len(), 2);
4935 assert_eq!(face.dependence[0].len(), 1);
4937 assert_eq!(face.dependence[0][0].row.index(), 2);
4938 assert!((face.dependence[0][0].coeff - 2.0).abs() < 1e-12);
4939 assert!(face.dependence[1].is_empty());
4940 }
4941
4942 #[test]
4944 fn cone_reduced_face_full_rank_has_no_dependence() {
4945 let psi = array![[1.0_f64, 0.0], [0.0, 1.0]];
4946 let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1], 2)
4947 .expect("full-rank cone");
4948 let beta = Array1::<f64>::zeros(2 * 2);
4949 let face = khatri_rao_cone_reduced_face(&cone, beta.view(), 1e-8).expect("reduce");
4950 assert_eq!(face.representatives, rows(&[0, 1]));
4951 assert!(face.dependence.iter().all(|d| d.is_empty()));
4952 assert_eq!(face.tight_rows, rows(&[0, 1]));
4953 }
4954
4955 #[test]
4959 fn cone_reduced_face_general_combination_gets_no_dependence_entry() {
4960 let psi = array![[1.0_f64, 0.0], [0.0, 1.0], [1.0, 1.0]];
4962 let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1], 2)
4963 .expect("general-combo cone");
4964 let beta = Array1::<f64>::zeros(2 * 2);
4965 let face = khatri_rao_cone_reduced_face(&cone, beta.view(), 1e-8).expect("reduce");
4966 assert_eq!(face.representatives, rows(&[0, 1])); assert_eq!(face.tight_rows, rows(&[0, 1, 2])); assert!(
4969 face.dependence.iter().all(|d| d.is_empty()),
4970 "a general-position drop must carry no distributed multiplier"
4971 );
4972 }
4973
4974 #[test]
4978 fn cone_reduced_face_reduces_each_shape_block_independently() {
4979 let psi = array![[1.0_f64, 0.0], [0.0, 1.0]];
4980 let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1, 2], 3)
4981 .expect("two-block cone");
4982 let beta = Array1::<f64>::zeros(3 * 2);
4983 let face = khatri_rao_cone_reduced_face(&cone, beta.view(), 1e-8).expect("reduce");
4984 assert_eq!(face.representatives, rows(&[0, 1, 2, 3]));
4986 assert!(face.dependence.iter().all(|d| d.is_empty()));
4987 assert_eq!(face.tight_rows, rows(&[0, 1, 2, 3]));
4988 }
4989
4990 #[test]
4994 fn dense_reduced_face_via_dispatcher_collapses_parallel_rows() {
4995 let a = array![[1.0_f64, 0.0], [0.0, 1.0], [2.0, 0.0]];
4998 let set = ConstraintSet::Dense(
4999 LinearInequalityConstraints::new(a, Array1::<f64>::zeros(3)).expect("dense"),
5000 );
5001 let beta = Array1::<f64>::zeros(2);
5002 let face = set.reduced_face(beta.view(), 1e-8).expect("reduce");
5003 assert_eq!(face.tight_rows, rows(&[0, 1, 2]));
5004 assert_eq!(face.representatives, rows(&[0, 1]));
5005 assert_eq!(face.dependence[0].len(), 1);
5006 assert_eq!(face.dependence[0][0].row.index(), 2);
5007 assert!((face.dependence[0][0].coeff - 2.0).abs() < 1e-12);
5008 assert!(face.dependence[1].is_empty());
5009 }
5010
5011 fn rows(ids: &[usize]) -> Vec<ConstraintRowId> {
5013 ids.iter().copied().map(ConstraintRowId).collect()
5014 }
5015
5016 fn mixed_width_block_diagonal() -> ConstraintSet {
5024 let narrow = gam_problem::PlacedConstraintBlock {
5025 col_start: 0,
5026 set: ConstraintSet::Dense(
5027 LinearInequalityConstraints::new(
5028 array![[1.0_f64, 0.0, 0.0]],
5029 Array1::<f64>::zeros(1),
5030 )
5031 .expect("narrow block"),
5032 ),
5033 };
5034 let square = gam_problem::PlacedConstraintBlock {
5035 col_start: 3,
5036 set: ConstraintSet::Dense(
5037 LinearInequalityConstraints::new(
5038 array![[1.0_f64, 0.0], [2.0, 0.0]],
5039 Array1::<f64>::zeros(2),
5040 )
5041 .expect("square block"),
5042 ),
5043 };
5044 ConstraintSet::block_diagonal(vec![narrow, square], 5).expect("block-diagonal")
5045 }
5046
5047 #[test]
5054 fn block_diagonal_reduced_face_row_ids_address_the_joint_constraint_row_space() {
5055 let set = mixed_width_block_diagonal();
5056 let beta = Array1::<f64>::zeros(5);
5057 let values = set.values(beta.view()).expect("values");
5058 let face = set.reduced_face(beta.view(), 1e-8).expect("reduce");
5059
5060 assert_eq!(set.nrows(), 3);
5062 assert_eq!(face.tight_rows, rows(&[0, 1, 2]));
5063 assert_eq!(face.representatives, rows(&[0, 1]));
5065 assert_eq!(face.dependence[1][0].row.index(), 2);
5066
5067 for id in &face.tight_rows {
5068 let row = id.index();
5069 assert!(row < set.nrows(), "id {row} outside the joint row space");
5070 let norm = set.row_norm(row).expect("row norm resolves");
5071 let bound = set.bound(row).expect("bound resolves");
5072 assert!(
5073 (values[row] - bound) / norm <= 1e-8,
5074 "row {row} reported tight but has slack {}",
5075 (values[row] - bound) / norm
5076 );
5077 }
5078 }
5079
5080 #[test]
5084 fn block_diagonal_reduced_face_concatenates_member_row_ids() {
5085 let make = |c0: usize| gam_problem::PlacedConstraintBlock {
5088 col_start: c0,
5089 set: ConstraintSet::Dense(
5090 LinearInequalityConstraints::new(
5091 array![[1.0_f64, 0.0], [2.0, 0.0]],
5092 Array1::<f64>::zeros(2),
5093 )
5094 .expect("dense block"),
5095 ),
5096 };
5097 let set = ConstraintSet::block_diagonal(vec![make(0), make(2)], 4).expect("block-diagonal");
5098 let beta = Array1::<f64>::zeros(4);
5099 let face = set.reduced_face(beta.view(), 1e-8).expect("reduce");
5100 assert_eq!(face.tight_rows, rows(&[0, 1, 2, 3]));
5101 assert_eq!(face.representatives, rows(&[0, 2]));
5102 assert_eq!(face.dependence[0][0].row.index(), 1);
5103 assert_eq!(face.dependence[1][0].row.index(), 3);
5104 }
5105
5106 fn coupled_pd_hessian(p: usize) -> Array2<f64> {
5109 let mut h = Array2::<f64>::eye(p) * 2.0;
5110 for i in 0..p {
5111 for j in 0..p {
5112 if i != j {
5113 h[[i, j]] = 0.3 / (1.0 + (i as f64 - j as f64).abs());
5114 }
5115 }
5116 }
5117 h
5118 }
5119
5120 #[test]
5121 fn operator_cone_qp_matches_dense_oracle_when_constraints_bind() {
5122 let cone = small_cone();
5123 let set = ConstraintSet::KhatriRaoCone(cone.clone());
5124 let dense = cone.to_dense().expect("dense oracle");
5125 let p = set.ncols();
5126 let hessian = coupled_pd_hessian(p);
5127 let rhs = array![0.5_f64, -0.3, -2.0, 1.0, -1.5, -0.7];
5130 let beta_start = array![0.0_f64, 0.0, 1.0, 0.1, 1.0, 0.1];
5133
5134 let (beta_op, mut active_op) =
5135 solve_quadratic_with_constraint_set(&hessian, &rhs, &beta_start, &set, None)
5136 .expect("operator solve");
5137 let (beta_dense, mut active_dense) =
5138 solve_quadratic_with_linear_constraints(&hessian, &rhs, &beta_start, &dense, None)
5139 .expect("dense solve");
5140
5141 for j in 0..p {
5142 assert!(
5143 (beta_op[j] - beta_dense[j]).abs() < 1e-7,
5144 "operator/dense coefficient {j} mismatch: {} vs {}",
5145 beta_op[j],
5146 beta_dense[j]
5147 );
5148 }
5149 active_op.sort_unstable();
5156 active_dense.sort_unstable();
5157 let values_at_solution = set.values(beta_op.view()).expect("values at solution");
5158 let tight_at_solution: Vec<usize> = (0..set.nrows())
5159 .filter(|&row| {
5160 let norm = set.row_norm(row).expect("norm");
5161 norm > 0.0 && values_at_solution[row] / norm <= 1e-7
5162 })
5163 .collect();
5164 for &row in active_op.iter().chain(active_dense.iter()) {
5165 assert!(
5166 tight_at_solution.contains(&row),
5167 "reported active row {row} is not tight at the common solution \
5168 (op face {active_op:?}, dense face {active_dense:?}, tight {tight_at_solution:?})"
5169 );
5170 }
5171 assert_eq!(
5172 active_op.len(),
5173 active_dense.len(),
5174 "carriers disagree on the face dimension: op {active_op:?} vs dense {active_dense:?}"
5175 );
5176 assert!(
5177 !active_op.is_empty(),
5178 "fixture must actually bind at least one cone row"
5179 );
5180 let values = set.values(beta_op.view()).expect("values");
5182 let (worst, _) = set.max_scaled_violation(beta_op.view()).expect("violation");
5183 assert!(worst <= 1e-8, "operator answer infeasible: {worst:.3e}");
5184 assert_eq!(values.len(), 8);
5185 }
5186
5187 #[test]
5188 fn operator_metric_dual_solves_the_non_diagonal_projection() {
5189 let psi = array![[1.0_f64, 0.0], [0.0, 1.0]];
5198 let cone =
5199 KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![0], 1)
5200 .expect("nonnegative quadrant");
5201 let set = ConstraintSet::KhatriRaoCone(cone);
5202 let hessian = array![[4.0_f64, 1.0], [1.0, 2.0]];
5203 let rhs = array![-1.0_f64, 2.0];
5204 let beta_start = array![0.0_f64, 0.0];
5205
5206 let (candidate, active) =
5207 solve_quadratic_with_constraint_set(&hessian, &rhs, &beta_start, &set, None)
5208 .expect("strict metric projection");
5209
5210 assert_relative_eq!(candidate[0], 0.0, epsilon = 1e-12);
5211 assert_relative_eq!(candidate[1], 1.0, epsilon = 1e-12);
5212 assert_eq!(active, vec![0]);
5213 let gradient = hessian.dot(&candidate) - rhs;
5214 assert_relative_eq!(gradient[0], 2.0, epsilon = 1e-12);
5215 assert_relative_eq!(gradient[1], 0.0, epsilon = 1e-12);
5216 }
5217
5218 #[test]
5225 fn operator_metric_dual_uses_the_certificate_multiplier_cone_2432() {
5226 let psi = array![[1.0_f64, 0.0], [0.0, 1.0]];
5227 let cone =
5228 KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![0], 1)
5229 .expect("nonnegative quadrant");
5230 let set = ConstraintSet::KhatriRaoCone(cone);
5231 let hessian = Array2::<f64>::eye(2);
5232 let epsilon = 0.5 * ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL;
5233 let rhs = array![epsilon, 1.0];
5234 let beta_start = array![0.0_f64, 0.0];
5235
5236 let (candidate, active) = solve_quadratic_with_constraint_set(
5237 &hessian,
5238 &rhs,
5239 &beta_start,
5240 &set,
5241 Some(&[0]),
5242 )
5243 .expect("warm face must not perturb the unique cone projection");
5244
5245 assert!(
5246 active.is_empty(),
5247 "the exact interior optimum has no active cone row"
5248 );
5249 assert_relative_eq!(candidate[0], epsilon, epsilon = 1e-14);
5250 assert_relative_eq!(candidate[1], 1.0, epsilon = 1e-14);
5251 let gradient = hessian.dot(&candidate) - rhs;
5252 assert_relative_eq!(gradient[0], 0.0, epsilon = 1e-14);
5253 assert_relative_eq!(gradient[1], 0.0, epsilon = 1e-14);
5254 }
5255
5256 #[test]
5264 fn dense_metric_dual_leaves_feasible_nonstationary_three_of_332_face_2432() {
5265 let p = 5usize;
5266 let m = 332usize;
5267 let mut a = Array2::<f64>::zeros((m, p));
5268 let mut b = Array1::<f64>::from_elem(m, -100.0);
5269 for row in 0..3 {
5270 a[[row, row]] = 1.0;
5271 b[row] = 0.0;
5272 }
5273 for row in 3..m {
5277 a[[row, (row - 3) % p]] = 1.0;
5278 }
5279 let constraints =
5280 LinearInequalityConstraints::new(a, b).expect("332-row dense constraint system");
5281 let hessian = Array2::from_diag(&array![1.0_f64, 2.0, 3.0, 1.0, 4.0]);
5282 let rhs = array![0.6987_f64, -2.0, -3.0, -11.3, 0.0];
5283 let wrong_face_point = Array1::<f64>::zeros(p);
5284
5285 let (cold, cold_active) = solve_quadratic_with_linear_constraints(
5286 &hessian,
5287 &rhs,
5288 &wrong_face_point,
5289 &constraints,
5290 None,
5291 )
5292 .expect("cold finite dual solve");
5293 let (warm, warm_active) = solve_quadratic_with_linear_constraints(
5294 &hessian,
5295 &rhs,
5296 &wrong_face_point,
5297 &constraints,
5298 Some(&[0, 1, 2]),
5299 )
5300 .expect("wrong-face warm hint must affect ordering only");
5301
5302 assert!(
5303 cold.iter()
5304 .zip(warm.iter())
5305 .all(|(&left, &right)| left.to_bits() == right.to_bits()),
5306 "strictly-convex QP answer must be bitwise warm-history independent: \
5307 cold={cold:?}, warm={warm:?}"
5308 );
5309 assert_eq!(cold_active, vec![1, 2]);
5310 assert_eq!(warm_active, vec![1, 2]);
5311 let expected = array![0.6987_f64, 0.0, 0.0, -11.3, 0.0];
5312 for (&actual, &target) in cold.iter().zip(expected.iter()) {
5313 assert_relative_eq!(actual, target, epsilon = 1e-13);
5314 }
5315
5316 let gradient = hessian.dot(&cold) - &rhs;
5317 let active_rows = LinearInequalityConstraints::new(
5318 constraints.a.select(ndarray::Axis(0), &[1, 2]),
5319 constraints.b.select(ndarray::Axis(0), &[1, 2]),
5320 )
5321 .expect("true active face");
5322 let (_, system_multipliers) =
5323 solve_kkt_direction(&hessian, &gradient, &active_rows.a, None)
5324 .expect("true-face multiplier reconstruction");
5325 let multipliers = -system_multipliers;
5326 assert_relative_eq!(multipliers[0], 2.0, epsilon = 1e-13);
5327 assert_relative_eq!(multipliers[1], 3.0, epsilon = 1e-13);
5328 assert!(
5329 multipliers.iter().all(|&value| value > 0.0),
5330 "the returned face must carry nonnegative KKT multipliers"
5331 );
5332 let certified = compute_constraint_kkt_diagnostics(&cold, &gradient, &constraints);
5333 assert!(certified.primal_feasibility <= 1e-14);
5334 assert!(certified.dual_feasibility <= 1e-14);
5335 assert!(certified.complementarity <= 1e-14);
5336 assert!(certified.stationarity <= 1e-13);
5337 }
5338
5339 #[test]
5344 fn operator_metric_projection_batches_a_partial_warm_face_979() {
5345 let rows = 24_000;
5346 let p = 24;
5347 let psi = Array2::from_shape_fn((rows, p), |(row, column)| {
5348 if column == row % p { 1.0 } else { 0.0 }
5349 });
5350 let cone =
5351 KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![0], 1)
5352 .expect("many-row coordinate cone");
5353 let set = ConstraintSet::KhatriRaoCone(cone);
5354 let hessian = Array2::<f64>::eye(p);
5355 let rhs = Array1::<f64>::from_elem(p, -1.0);
5356 let beta_start = Array1::<f64>::zeros(p);
5357 let warm = [0usize, 1, 2, 3];
5358
5359 let ops = ConstraintSetOps::new(&set, 0.0).expect("operator geometry");
5360 let unconstrained = rhs.clone();
5361 let values = ops.values(&unconstrained).expect("free values");
5362 let mut is_active = vec![false; rows];
5363 for &row in &warm {
5364 is_active[row] = true;
5365 }
5366 let banned = vec![false; rows];
5367 let selected = independent_violated_operator_rows(
5368 &ops,
5369 &values,
5370 &warm,
5371 &is_active,
5372 &banned,
5373 p - warm.len(),
5374 )
5375 .expect("batch separation");
5376 assert_eq!(
5377 selected.len(),
5378 p - warm.len(),
5379 "one scan must recover every coefficient-space direction missing from the warm face"
5380 );
5381
5382 let (candidate, active) = solve_quadratic_with_constraint_set(
5383 &hessian,
5384 &rhs,
5385 &beta_start,
5386 &set,
5387 Some(&warm),
5388 )
5389 .expect("batched metric projection");
5390 assert!(
5391 candidate.iter().all(|value| value.abs() <= 1e-12),
5392 "projection onto the repeated coordinate cone must be the origin: {candidate:?}"
5393 );
5394 assert_eq!(
5395 active.len(),
5396 p,
5397 "the returned face must contain one representative per independent coordinate"
5398 );
5399 }
5400
5401 #[test]
5407 fn operator_strict_interior_projection_is_coefficient_bounded_on_repeated_rows_979() {
5408 let rows = 24_000;
5409 let p = 24;
5410 let psi = Array2::from_shape_fn((rows, p), |(row, column)| {
5411 if column == row % p { 1.0 } else { 0.0 }
5412 });
5413 let cone =
5414 KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![0], 1)
5415 .expect("many-row coordinate cone");
5416 let set = ConstraintSet::KhatriRaoCone(cone);
5417 let point = Array1::<f64>::from_elem(p, -1.0);
5418
5419 let projected = project_point_strictly_into_feasible_constraint_set(&point, &set)
5420 .expect("finite dual strict-interior projection");
5421 let values = set.values(projected.view()).expect("projected values");
5422 for row in 0..set.nrows() {
5423 let norm = set.row_norm(row).expect("row norm");
5424 let scaled_slack = values[row] / norm;
5425 assert!(
5426 scaled_slack >= 0.5 * ACTIVE_SET_INTERIOR_SEED_MARGIN - 1e-9,
5427 "row {row} missed the certified interior: {scaled_slack:.3e}"
5428 );
5429 }
5430 for (column, value) in projected.iter().enumerate() {
5431 assert!(
5432 *value < ACTIVE_SET_INTERIOR_SEED_MARGIN + 1e-8,
5433 "identity projection moved coordinate {column} past its nearest interior face: {value:.3e}"
5434 );
5435 }
5436 }
5437
5438 #[test]
5439 fn operator_scan_separates_primal_feasibility_from_active_equality_979() {
5440 let psi = array![[1.0_f64, 0.0], [0.0, 1.0]];
5441 let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![0], 1)
5442 .expect("two-row operator cone");
5443 let set = ConstraintSet::KhatriRaoCone(cone);
5444 let ops = ConstraintSetOps::new(&set, 0.0).expect("operator geometry");
5445 let beta = array![2.0 * ACTIVE_SET_PRIMAL_FEASIBILITY_TOL, 1.0];
5449 let values = ops.values(&beta).expect("operator values");
5450 let scan = scan_operator_violations(&ops, &values, &[true, false])
5451 .expect("full-set violation scan");
5452
5453 assert!(
5454 scan.inactive.is_empty(),
5455 "an active equality is not an admissible entering separator"
5456 );
5457 assert!(
5458 scan.is_primal_feasible(),
5459 "positive active-row slack is feasible for the one-sided public contract"
5460 );
5461 assert_relative_eq!(scan.worst.violation, 0.0, epsilon = 0.0);
5462
5463 let active_rows = ops
5464 .gather_unit_rows(&[0])
5465 .expect("one-row active equality");
5466 let equality = certify_active_equalities(&active_rows.a, &active_rows.b, &beta);
5467 assert_eq!(
5468 equality.worst_row, 0,
5469 "the only active row must own the equality residual"
5470 );
5471 assert!(
5472 !equality.is_certified(),
5473 "feasible-side drift must still fail the two-sided active-equality certificate"
5474 );
5475 assert!(
5476 equality.residual > equality.allowed,
5477 "active equality residual {:.3e} must exceed its roundoff bound {:.3e}",
5478 equality.residual,
5479 equality.allowed,
5480 );
5481 }
5482
5483 #[test]
5484 fn operator_metric_projection_finishes_a_separator_after_partial_drop_979() {
5485 let sine = 0.1_f64;
5496 let cosine = (1.0 - sine * sine).sqrt();
5497 let residual_after_drop = 0.5 * ACTIVE_SET_PRIMAL_FEASIBILITY_TOL;
5498 let psi = array![[1.0_f64, 0.0], [cosine, sine]];
5499 let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![0], 1)
5500 .expect("partial-drop operator cone");
5501 let set = ConstraintSet::KhatriRaoCone(cone);
5502 let hessian = Array2::<f64>::eye(2);
5503 let unconstrained = array![
5504 -1.0_f64,
5505 -sine / cosine - residual_after_drop / sine
5506 ];
5507 let beta_start = Array1::<f64>::zeros(2);
5508
5509 let (candidate, active) = solve_quadratic_with_constraint_set(
5510 &hessian,
5511 &unconstrained,
5512 &beta_start,
5513 &set,
5514 Some(&[0]),
5515 )
5516 .expect("a pending separator must survive its partial dual drop");
5517
5518 let normal = array![cosine, sine];
5519 let multiplier = -normal.dot(&unconstrained);
5520 let expected = &unconstrained + &(&normal * multiplier);
5521 assert!(
5522 residual_after_drop <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL,
5523 "the fixture must leave the pending separator inside the public tolerance after \
5524 its partial drop"
5525 );
5526 assert!(
5527 multiplier > 1.0,
5528 "the true multiplier must be cumulative, not the tolerance-sized final step: \
5529 {multiplier:.3e}"
5530 );
5531 for (&actual, &oracle) in candidate.iter().zip(expected.iter()) {
5532 assert_relative_eq!(actual, oracle, epsilon = 5e-14);
5533 }
5534 let gradient = &candidate - &unconstrained;
5535 let expected_gradient = &normal * multiplier;
5536 for (&actual, &oracle) in gradient.iter().zip(expected_gradient.iter()) {
5537 assert_relative_eq!(actual, oracle, epsilon = 5e-14);
5538 }
5539 assert_eq!(
5540 active,
5541 vec![1],
5542 "the unique optimum is supported by the second normal only"
5543 );
5544 let values = set.values(candidate.view()).expect("candidate values");
5545 let scan = scan_operator_violations(
5546 &ConstraintSetOps::new(&set, 0.0).expect("operator geometry"),
5547 &values,
5548 &[false, true],
5549 )
5550 .expect("candidate feasibility");
5551 assert!(scan.is_primal_feasible());
5552 assert!(
5553 candidate[0] > 0.0,
5554 "released row zero must be strictly feasible at the optimum"
5555 );
5556 }
5557
5558 #[test]
5559 fn operator_cone_qp_takes_unconstrained_path_when_interior() {
5560 let cone = small_cone();
5561 let set = ConstraintSet::KhatriRaoCone(cone);
5562 let p = set.ncols();
5563 let hessian = coupled_pd_hessian(p);
5564 let rhs = array![0.2_f64, 0.1, 3.0, 0.2, 2.5, 0.1];
5567 let beta_start = array![0.0_f64, 0.0, 1.0, 0.0, 1.0, 0.0];
5568 let (beta_op, active_op) =
5569 solve_quadratic_with_constraint_set(&hessian, &rhs, &beta_start, &set, None)
5570 .expect("operator solve");
5571 let mut beta_unconstrained = Array1::<f64>::zeros(p);
5573 super::solve_newton_direction_dense(
5574 &hessian,
5575 &(hessian.dot(&beta_start) - &rhs),
5576 &mut beta_unconstrained,
5577 )
5578 .expect("unconstrained newton");
5579 let beta_unconstrained = &beta_start + &beta_unconstrained;
5580 for j in 0..p {
5581 assert!(
5582 (beta_op[j] - beta_unconstrained[j]).abs() < 1e-8,
5583 "interior operator solve must match unconstrained optimum at {j}"
5584 );
5585 }
5586 assert!(
5587 active_op.is_empty(),
5588 "interior optimum must have empty face"
5589 );
5590 }
5591
5592 #[test]
5593 fn operator_projection_returns_strictly_interior_point() {
5594 let cone = small_cone();
5595 let set = ConstraintSet::KhatriRaoCone(cone);
5596 let point = array![0.4_f64, -0.2, -1.0, -0.5, 0.3, 0.05];
5598 let projected = project_point_strictly_into_feasible_constraint_set(&point, &set)
5599 .expect("projection must succeed on a one-sided homogeneous cone");
5600 let values = set.values(projected.view()).expect("values");
5601 for row in 0..set.nrows() {
5602 let norm = set.row_norm(row).expect("norm");
5603 if norm <= 0.0 {
5604 continue;
5605 }
5606 let slack = values[row] / norm;
5607 assert!(
5608 slack >= 0.5 * ACTIVE_SET_INTERIOR_SEED_MARGIN - 1e-9,
5609 "projected point not strictly interior on row {row}: slack {slack:.3e}"
5610 );
5611 }
5612 assert!((projected[0] - point[0]).abs() < 1e-8);
5618 assert!((projected[1] - point[1]).abs() < 1e-8);
5619 }
5620
5621 #[test]
5631 fn operator_projection_adjudicates_the_over_complete_face_2378() {
5632 let cone = small_cone();
5633 let set = ConstraintSet::KhatriRaoCone(cone.clone());
5634 let point = array![0.4_f64, -0.2, -1.0, -0.5, 0.3, 0.05];
5637 let projected = project_point_strictly_into_feasible_constraint_set(&point, &set)
5638 .expect("operator projection must certify the over-complete-face vertex");
5639
5640 let dense = ConstraintSet::Dense(cone.to_dense().expect("dense oracle"));
5643 let dense_proj = project_point_strictly_into_feasible_constraint_set(&point, &dense)
5644 .expect("dense projection oracle");
5645 for j in 0..point.len() {
5646 assert!(
5647 (projected[j] - dense_proj[j]).abs() < 1e-7,
5648 "operator projection diverged from the dense oracle at {j}: \
5649 op={:.9e} dense={:.9e}",
5650 projected[j],
5651 dense_proj[j]
5652 );
5653 }
5654
5655 let values = set.values(projected.view()).expect("values");
5659 let scaled = |row: usize| values[row] / set.row_norm(row).expect("norm");
5660 for row in [1usize, 2] {
5662 assert!(
5663 scaled(row) < ACTIVE_SET_INTERIOR_SEED_MARGIN + 1e-7,
5664 "block-1 row {row} should bind, scaled slack {:.3e}",
5665 scaled(row)
5666 );
5667 }
5668 for row in [0usize, 3] {
5670 assert!(
5671 scaled(row) > scaled(2) + 1e-9,
5672 "non-binding row {row} (slack {:.3e}) must exceed the binding \
5673 row 2 (slack {:.3e})",
5674 scaled(row),
5675 scaled(2)
5676 );
5677 }
5678 }
5679
5680 #[test]
5685 fn operator_cone_qp_over_complete_face_matches_dense_oracle_2378() {
5686 let cone = small_cone();
5687 let set = ConstraintSet::KhatriRaoCone(cone.clone());
5688 let dense = cone.to_dense().expect("dense oracle");
5689 let p = set.ncols();
5690 let hessian = coupled_pd_hessian(p);
5691 let rhs = array![0.3_f64, -0.1, -2.5, -1.2, -0.4, 0.2];
5695 let beta_start = array![0.0_f64, 0.0, 1.0, 0.1, 1.0, 0.1];
5696
5697 let (beta_op, active_op) =
5698 solve_quadratic_with_constraint_set(&hessian, &rhs, &beta_start, &set, None)
5699 .expect("operator QP solve over an over-complete face");
5700 let (beta_dense, _active_dense) =
5701 solve_quadratic_with_linear_constraints(&hessian, &rhs, &beta_start, &dense, None)
5702 .expect("dense QP oracle");
5703
5704 for j in 0..p {
5705 assert!(
5706 (beta_op[j] - beta_dense[j]).abs() < 1e-7,
5707 "operator/dense coefficient {j} mismatch: {} vs {}",
5708 beta_op[j],
5709 beta_dense[j]
5710 );
5711 }
5712 let values = set.values(beta_op.view()).expect("values");
5714 for row in 0..set.nrows() {
5715 let norm = set.row_norm(row).expect("norm");
5716 if norm > 0.0 {
5717 assert!(
5718 values[row] / norm >= -ACTIVE_SET_PRIMAL_FEASIBILITY_TOL,
5719 "row {row} violated at the operator optimum: {:.3e}",
5720 values[row] / norm
5721 );
5722 }
5723 }
5724 assert!(
5725 active_op.len() <= p,
5726 "operator passive face must contain at most one row per coefficient-space direction: \
5727 active={}, p={p}",
5728 active_op.len()
5729 );
5730 }
5731
5732 #[test]
5733 fn operator_cone_does_not_materialize_a_whole_tight_face() {
5734 let mut psi = Array2::<f64>::zeros((4096, 2));
5740 psi.column_mut(0).fill(1.0);
5741 let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1], 2)
5742 .expect("repeated-row cone");
5743 let set = ConstraintSet::KhatriRaoCone(cone);
5744 let hessian = Array2::<f64>::eye(4);
5745 let rhs = array![0.3_f64, -0.2, -1.0, 0.0];
5746 let beta_start = Array1::<f64>::zeros(4);
5747
5748 let warm_row = 2048usize;
5752 let (beta, active) = solve_quadratic_with_constraint_set(
5753 &hessian,
5754 &rhs,
5755 &beta_start,
5756 &set,
5757 Some(&[warm_row]),
5758 )
5759 .expect("vertex solve");
5760
5761 assert_eq!(
5762 active,
5763 vec![warm_row],
5764 "the compact point-tight warm representative was discarded or redundant rows entered"
5765 );
5766 assert!(beta[2].abs() <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL);
5767 assert!((beta[0] - 0.3).abs() < 1e-10);
5768 assert!((beta[1] + 0.2).abs() < 1e-10);
5769 }
5770
5771 #[test]
5795 fn the_kkt_cone_convention_is_grad_equals_a_transpose_lambda_2601() {
5796 let p = 6usize;
5797 let m = p - 2;
5798 let mut a = Array2::<f64>::zeros((m, p));
5799 for i in 0..m {
5800 a[[i, i]] = 1.0;
5801 a[[i, i + 1]] = -2.0;
5802 a[[i, i + 2]] = 1.0;
5803 }
5804 let beta = Array1::from_shape_fn(p, |j| 0.5 + 2.0 * (j as f64));
5807 let constraints =
5808 LinearInequalityConstraints::new(a.clone(), Array1::<f64>::zeros(m))
5809 .expect("second-difference constraints");
5810
5811 let row_norm = 6.0_f64.sqrt(); let a_scaled = a.mapv(|v| v / row_norm);
5815 let lambda_true = Array1::from_vec(vec![0.25, 1.5, 0.0, 3.0]);
5816 assert_eq!(lambda_true.len(), m);
5817
5818 let aligned = a_scaled.t().dot(&lambda_true);
5819 let diag = compute_constraint_kkt_diagnostics(&beta, &aligned, &constraints);
5820 assert_eq!(
5821 diag.n_active, m,
5822 "an affine β must make every second-difference row tight"
5823 );
5824 assert!(
5825 diag.stationarity <= 1e-12 * diag.gradient_scale.max(1.0),
5826 "∇f = Aᵀλ with λ ≥ 0 IS the stationarity condition for A β ≥ b; the cone \
5827 projector must absorb it entirely (stat={:.6e}, ‖g‖∞={:.6e}, active={}/{})",
5828 diag.stationarity,
5829 diag.gradient_scale,
5830 diag.n_active,
5831 diag.n_constraints,
5832 );
5833 assert!(
5834 diag.dual_feasibility <= 1e-12,
5835 "recovered multipliers must stay nonnegative (dual={:.6e})",
5836 diag.dual_feasibility,
5837 );
5838
5839 let opposed = aligned.mapv(|v| -v);
5860 let flipped = compute_constraint_kkt_diagnostics(&beta, &opposed, &constraints);
5861 let opposed_scale = opposed.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
5862 assert!(
5863 !flipped.cone_projection_refused,
5864 "the projector answered for the negated gradient; it did not refuse"
5865 );
5866 let absorbed_fraction = 1.0 - flipped.stationarity / opposed_scale;
5867 assert!(
5868 absorbed_fraction > 0.25,
5869 "a polar-cone gradient still recruits rows on this face -- it does NOT \
5870 reproduce #2601's stat_rel = 1.0, which is why a flipped sign cannot be \
5871 the explanation there (stat={:.6e}, ‖g‖∞={:.6e}, absorbed={:.6e})",
5872 flipped.stationarity,
5873 opposed_scale,
5874 absorbed_fraction,
5875 );
5876 assert_eq!(
5877 flipped.n_active, m,
5878 "the face is a property of β, not of the gradient's sign"
5879 );
5880 }
5881#[test]
5885 fn operator_nnls_certifies_pinned_degenerate_vertex_projection_979() {
5886 let a = array![
5890 [1.0_f64, 0.0, 0.0],
5891 [0.0, 1.0, 0.0],
5892 [0.0, 0.0, 1.0],
5893 [1.0, 1.0, 0.0],
5894 ];
5895 let b = array![0.0_f64, 0.0, 0.0, 0.0];
5896 let set = ConstraintSet::Dense(
5897 LinearInequalityConstraints::new(a, b).expect("degenerate vertex cone"),
5898 );
5899 let beta = array![0.0_f64, 0.0, 0.0];
5900 let residual = array![3.0_f64, 2.0, 0.0]; let (projected, active) = project_stationarity_residual_on_constraint_set(
5902 &residual,
5903 &beta,
5904 &set,
5905 &[0, 1],
5906 )
5907 .expect("operator NNLS must solve the degenerate vertex");
5908 let closure = projected.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
5909 assert!(
5910 closure <= 1e-9,
5911 "residual is in the cone; projection must close to zero, got {closure:.3e}"
5912 );
5913 assert!(!active.is_empty(), "a supported face must be reported");
5914
5915 let outside = array![1.0_f64, 0.0, -1.0];
5917 let (projected_outside, _) =
5918 project_stationarity_residual_on_constraint_set(&outside, &beta, &set, &[])
5919 .expect("operator NNLS must solve the outside-component case");
5920 assert_relative_eq!(projected_outside[0], 0.0, epsilon = 1e-9);
5921 assert_relative_eq!(projected_outside[1], 0.0, epsilon = 1e-9);
5922 assert_relative_eq!(projected_outside[2], -1.0, epsilon = 1e-9);
5923 }
5924
5925#[test]
5930 fn operator_nnls_excludes_rows_not_tight_at_beta() {
5931 let a = array![[1.0_f64, 0.0], [0.0, 1.0]];
5932 let b = array![0.0_f64, -1.0]; let set = ConstraintSet::Dense(
5934 LinearInequalityConstraints::new(a, b).expect("half-tight system"),
5935 );
5936 let beta = array![0.0_f64, 0.0];
5937 let residual = array![0.0_f64, 1.0];
5938 let (projected, active) =
5939 project_stationarity_residual_on_constraint_set(&residual, &beta, &set, &[])
5940 .expect("operator NNLS must solve the half-tight system");
5941 assert_relative_eq!(projected[1], 1.0, epsilon = 1e-12);
5942 assert!(
5943 !active.contains(&1),
5944 "slack row 1 must not appear in the certified face"
5945 );
5946 }
5947
5948#[test]
5949 fn separable_khatri_rao_tangent_projection_matches_dense_oracle() {
5950 let cone = small_cone();
5951 let set = ConstraintSet::KhatriRaoCone(cone.clone());
5952 let dense = cone.to_dense().expect("dense projection oracle");
5953 let beta = Array1::<f64>::zeros(set.ncols());
5954 let residual = array![0.4_f64, -0.2, 1.1, -0.7, -0.9, 0.8];
5955
5956 let (operator_projected, _) =
5957 project_stationarity_residual_on_constraint_set(&residual, &beta, &set, &[])
5958 .expect("separable operator projection");
5959 let (dense_projected, _) =
5960 project_stationarity_residual_on_constraint_cone(&residual, &dense.a)
5961 .expect("dense cone projection");
5962
5963 for index in 0..residual.len() {
5964 assert_relative_eq!(
5965 operator_projected[index],
5966 dense_projected[index],
5967 epsilon = 1e-8
5968 );
5969 }
5970 }
5971
5972#[test]
5977 fn operator_moreau_projection_has_coefficient_sized_support_979() {
5978 let rows = 24_000;
5979 let psi = Array2::from_shape_fn((rows, 3), |(row, column)| {
5980 let axis = (row % 6) / 2;
5981 if column == axis {
5982 if row % 2 == 0 { 1.0 } else { -1.0 }
5983 } else {
5984 0.0
5985 }
5986 });
5987 let cone =
5988 KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![0], 1)
5989 .expect("many-row low-dimensional cone");
5990 let dense = cone.to_dense().expect("dense parity oracle");
5991 let set = ConstraintSet::KhatriRaoCone(cone);
5992 let beta = Array1::<f64>::zeros(3);
5993 let residual = array![3.0_f64, -2.0, 1.0];
5994
5995 let (operator_projected, active) =
5996 project_stationarity_residual_on_constraint_set(&residual, &beta, &set, &[])
5997 .expect("operator Moreau projection");
5998 let (_, dense_projected) =
5999 nonnegative_cone_multipliers(&dense.a, &residual).expect("dense NNLS oracle");
6000
6001 for index in 0..residual.len() {
6002 assert_relative_eq!(
6003 operator_projected[index],
6004 dense_projected[index],
6005 epsilon = 1e-10
6006 );
6007 assert_relative_eq!(operator_projected[index], 0.0, epsilon = 1e-10);
6008 }
6009 assert!(
6010 active.len() <= residual.len(),
6011 "a three-dimensional cone projection gathered {} supported rows",
6012 active.len()
6013 );
6014 }
6015
6016#[test]
6017 fn operator_tangent_projection_does_not_constrain_interior_rows() {
6018 let psi = array![[1.0_f64, 0.0], [1.0, 1.0], [1.0, -1.0]];
6019 let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1], 2)
6020 .expect("interior tangent cone");
6021 let set = ConstraintSet::KhatriRaoCone(cone);
6022 let beta = array![0.0_f64, 0.0, 1.0, 0.0];
6027 let residual = array![0.0_f64, 0.0, 1.0, 0.0];
6028 let (projected, active) =
6029 project_stationarity_residual_on_constraint_set(&residual, &beta, &set, &[])
6030 .expect("interior tangent projection");
6031
6032 for index in 0..residual.len() {
6033 assert_relative_eq!(projected[index], residual[index], epsilon = 1e-12);
6034 }
6035 assert!(active.is_empty(), "interior rows entered the tangent face");
6036 }
6037
6038#[test]
6039 fn operator_tangent_projection_homogenizes_an_affine_boundary() {
6040 let set = ConstraintSet::Dense(
6041 LinearInequalityConstraints::new(array![[1.0_f64, 0.0]], array![2.0])
6042 .expect("affine half-space"),
6043 );
6044 let beta = array![2.0_f64, 0.0];
6045 let residual = array![1.0_f64, -1.0];
6046 let (projected, active) =
6047 project_stationarity_residual_on_constraint_set(&residual, &beta, &set, &[0])
6048 .expect("affine-boundary tangent projection");
6049
6050 assert_relative_eq!(projected[0], 0.0, epsilon = 1e-12);
6051 assert_relative_eq!(projected[1], -1.0, epsilon = 1e-12);
6052 assert_eq!(active, vec![0]);
6053 }
6054
6055}