1use crate::estimate::EstimationError;
2use faer::linalg::solvers::{Lblt as FaerLblt, Solve as FaerSolve, SolveLstsq};
3use faer::Side;
4use gam_linalg::faer_ndarray::{FaerArrayView, FaerLinalgError, FaerSvd, array1_to_col_matmut};
5use gam_linalg::utils::{StableSolver, array_is_finite, boundary_hit_step_fraction};
6use gam_problem::{
7 ConstraintRowId, ConstraintSet, KhatriRaoConeConstraints, LinearInequalityConstraints,
8};
9use ndarray::{Array1, Array2, s};
10use serde::{Deserialize, Serialize};
11use std::cell::Cell;
12use std::collections::HashSet;
13
14pub const ACTIVE_SET_PRIMAL_FEASIBILITY_TOL: f64 = 1e-8;
27
28pub const ACTIVE_SET_WORKING_FACE_TOL: f64 = 1e-10;
36
37#[inline]
54fn active_set_boundary_hit_step_fraction(
55 scaled_slack: f64,
56 scaled_directional_change: f64,
57 current_step_limit: f64,
58) -> Option<f64> {
59 boundary_hit_step_fraction(
60 scaled_slack.max(0.0),
61 scaled_directional_change,
62 current_step_limit,
63 )
64}
65
66const ACTIVE_SET_KKT_STATIONARITY_TOL: f64 = 2e-6;
72
73const ACTIVE_SET_KKT_COMPLEMENTARITY_TOL: f64 = 1e-6;
77
78const ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL: f64 = 1e-8;
82
83pub(crate) const ACTIVE_SET_KKT_DEGENERATE_STATIONARITY_TOL: f64 = 1e-3;
107
108const ACTIVE_SET_MODEL_DESCENT_REL_TOL: f64 = 1e-10;
113
114#[derive(Clone, Debug, Serialize, Deserialize)]
125pub struct ConstraintKktDiagnostics {
126 pub n_constraints: usize,
128 pub n_active: usize,
130 pub primal_feasibility: f64,
132 pub dual_feasibility: f64,
134 pub complementarity: f64,
136 pub stationarity: f64,
138 pub active_tolerance: f64,
140 #[serde(default)]
155 pub working_set_rank_deficient: bool,
156 #[serde(default)]
173 pub gradient_scale: f64,
174}
175
176fn gradient_inf_norm(gradient: &Array1<f64>) -> f64 {
180 gradient.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()))
181}
182
183fn solve_newton_direction_dense(
184 hessian: &Array2<f64>,
185 gradient: &Array1<f64>,
186 direction_out: &mut Array1<f64>,
187) -> Result<(), EstimationError> {
188 if direction_out.len() != gradient.len() {
189 *direction_out = Array1::zeros(gradient.len());
190 }
191
192 let factor = StableSolver::new()
193 .factorize(hessian)
194 .map_err(EstimationError::LinearSystemSolveFailed)?;
195 direction_out.assign(gradient);
196 let mut rhsview = array1_to_col_matmut(direction_out);
197 factor.solve_in_place(rhsview.as_mut());
198 direction_out.mapv_inplace(|v| -v);
199 if array_is_finite(direction_out) {
200 return Ok(());
201 }
202 Err(EstimationError::LinearSystemSolveFailed(
203 FaerLinalgError::FactorizationFailed {
204 context: "active-set newton direction non-finite solve",
205 },
206 ))
207}
208
209fn solve_dense_system_via_pseudoinverse(
210 matrix: &Array2<f64>,
211 rhs: &Array1<f64>,
212 out: &mut Array1<f64>,
213) -> Result<(), EstimationError> {
214 if matrix.nrows() != matrix.ncols() || rhs.len() != matrix.nrows() {
215 crate::bail_invalid_estim!("dense pseudoinverse solve dimension mismatch");
216 }
217
218 let (u_opt, singular, vt_opt) = matrix.svd(true, true).map_err(|_| {
219 EstimationError::InvalidInput("dense pseudoinverse solve SVD failed".to_string())
220 })?;
221 let (Some(u), Some(vt)) = (u_opt, vt_opt) else {
222 crate::bail_invalid_estim!("dense pseudoinverse solve missing singular vectors");
223 };
224
225 let max_singular = singular.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
226 let tol = 100.0
227 * f64::EPSILON
228 * (matrix.nrows().max(matrix.ncols()).max(1) as f64)
229 * max_singular.max(1.0);
230 let mut coeff = u.t().dot(rhs);
231 for (idx, value) in coeff.iter_mut().enumerate() {
232 let sigma = singular[idx];
233 if sigma.abs() > tol {
234 *value /= sigma;
235 } else {
236 *value = 0.0;
237 }
238 }
239 let solution = vt.t().dot(&coeff);
240 if !array_is_finite(&solution) {
241 crate::bail_invalid_estim!("dense pseudoinverse solve produced non-finite values");
242 }
243 if out.len() != solution.len() {
244 *out = Array1::zeros(solution.len());
245 }
246 out.assign(&solution);
247 Ok(())
248}
249
250fn least_squares_min_norm_any_shape(a: &Array2<f64>, b: &Array1<f64>) -> Option<Array1<f64>> {
268 let p = a.nrows();
269 let k = a.ncols();
270 if b.len() != p {
271 return None;
272 }
273 if k == 0 {
274 return Some(Array1::zeros(0));
275 }
276 if k <= p {
277 let mut rhs = Array2::<f64>::zeros((p, 1));
278 rhs.column_mut(0).assign(b);
279 let a_view = FaerArrayView::new(a);
280 let rhs_view = FaerArrayView::new(&rhs);
281 let solved = a_view.as_ref().col_piv_qr().solve_lstsq(rhs_view.as_ref());
282 let mut z = Array1::<f64>::zeros(k);
283 for c in 0..k {
284 let value = solved[(c, 0)];
285 if !value.is_finite() {
286 return None;
287 }
288 z[c] = value;
289 }
290 Some(z)
291 } else {
292 let gram = a.dot(&a.t());
296 let mut y = Array1::<f64>::zeros(p);
297 solve_dense_system_via_pseudoinverse(&gram, b, &mut y).ok()?;
298 let z = a.t().dot(&y);
299 if z.iter().any(|value| !value.is_finite()) {
300 return None;
301 }
302 Some(z)
303 }
304}
305
306pub(crate) fn compute_constraint_kkt_diagnostics(
307 beta: &Array1<f64>,
308 gradient: &Array1<f64>,
309 constraints: &LinearInequalityConstraints,
310) -> ConstraintKktDiagnostics {
311 let m = constraints.a.nrows();
312 let active_tolerance = ACTIVE_SET_PRIMAL_FEASIBILITY_TOL;
313
314 let p = constraints.a.ncols();
329 let mut a_scaled = constraints.a.clone();
330 let mut b_scaled = constraints.b.clone();
331 for i in 0..m {
332 let n_i = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
333 if n_i > 0.0 {
334 let inv = 1.0 / n_i;
335 a_scaled.row_mut(i).mapv_inplace(|v| v * inv);
336 b_scaled[i] *= inv;
337 }
338 }
339
340 let mut slack = Array1::<f64>::zeros(m);
341 let mut primal_feasibility: f64 = 0.0;
342 for i in 0..m {
343 let s_i = a_scaled.row(i).dot(beta) - b_scaled[i];
344 slack[i] = s_i;
345 primal_feasibility = primal_feasibility.max((-s_i).max(0.0));
346 }
347
348 let active_idx: Vec<usize> = (0..m).filter(|&i| slack[i] <= active_tolerance).collect();
349 let mut lambda = Array1::<f64>::zeros(m);
350 let mut working_set_rank_deficient = false;
351 if !active_idx.is_empty() {
352 let n_active = active_idx.len();
353 let mut a_active = Array2::<f64>::zeros((n_active, p));
354 for (r, &idx) in active_idx.iter().enumerate() {
355 a_active.row_mut(r).assign(&a_scaled.row(idx));
356 }
357 if let Some((_, lambda_active)) =
358 project_stationarity_residual_on_constraint_cone(gradient, &a_active)
359 {
360 for (r, &idx) in active_idx.iter().enumerate() {
361 lambda[idx] = lambda_active[r];
362 }
363 }
364 working_set_rank_deficient = if n_active > p {
376 true
377 } else if n_active > 1 {
378 let groups: Vec<Vec<usize>> = (0..n_active).map(|i| vec![i]).collect();
379 let b_dummy = Array1::<f64>::zeros(n_active);
380 let (reduced_a, _, _, _) =
381 rank_reduce_rows_pivoted_qr_with_dependence(a_active, b_dummy, groups);
382 reduced_a.nrows() < n_active
383 } else {
384 false
385 };
386 }
387
388 let mut dual_feasibility: f64 = 0.0;
389 let mut complementarity: f64 = 0.0;
390 for i in 0..m {
391 dual_feasibility = dual_feasibility.max((-lambda[i]).max(0.0));
392 complementarity = complementarity.max((lambda[i] * slack[i]).abs());
393 }
394 let stationarity = {
395 let mut resid = gradient.to_owned();
396 resid -= &a_scaled.t().dot(&lambda);
397 resid.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()))
398 };
399
400 ConstraintKktDiagnostics {
401 n_constraints: m,
402 n_active: active_idx.len(),
403 primal_feasibility,
404 dual_feasibility,
405 complementarity,
406 stationarity,
407 active_tolerance,
408 working_set_rank_deficient,
409 gradient_scale: gradient_inf_norm(gradient),
410 }
411}
412
413pub(crate) fn nonnegative_cone_multipliers(
436 rows: &Array2<f64>,
437 target: &Array1<f64>,
438) -> Option<(Array1<f64>, Array1<f64>)> {
439 let p = target.len();
440 let m = rows.nrows();
441 if rows.ncols() != p {
442 return None;
443 }
444 if m == 0 {
445 return Some((Array1::zeros(0), target.clone()));
446 }
447 if target.iter().any(|v| !v.is_finite()) || rows.iter().any(|v| !v.is_finite()) {
448 return None;
449 }
450 let mut norms = Array1::<f64>::zeros(m);
451 let mut unit = Array2::<f64>::zeros((m, p));
452 for i in 0..m {
453 let norm = rows.row(i).dot(&rows.row(i)).sqrt();
454 norms[i] = norm;
455 if norm > 0.0 {
456 unit.row_mut(i).assign(&(&rows.row(i) / norm));
457 }
458 }
459 let target_inf = target.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
460 if target_inf == 0.0 {
461 return Some((Array1::zeros(m), target.clone()));
462 }
463 let tol_w = 1e-10 * target_inf;
466 let lambda_floor = 1e-14 * target_inf;
467
468 let mut lambda_unit = Array1::<f64>::zeros(m);
469 let mut passive: Vec<usize> = Vec::new();
470 let mut in_passive = vec![false; m];
471 let mut residual = target.clone();
472 let mut banned = vec![false; m];
476
477 let solve_passive = |passive: &[usize]| -> Option<Array1<f64>> {
478 let k = passive.len();
479 let mut design = Array2::<f64>::zeros((p, k));
484 for (col, &row) in passive.iter().enumerate() {
485 design.column_mut(col).assign(&unit.row(row));
486 }
487 least_squares_min_norm_any_shape(&design, target)
488 };
489
490 let max_outer = 3 * m + 30;
491 for _ in 0..max_outer {
492 let mut best: Option<(usize, f64)> = None;
494 for i in 0..m {
495 if in_passive[i] || banned[i] || norms[i] <= 0.0 {
496 continue;
497 }
498 let w = unit.row(i).dot(&residual);
499 if w > tol_w && best.map(|(_, bw)| w > bw).unwrap_or(true) {
500 best = Some((i, w));
501 }
502 }
503 let Some((entering, _)) = best else {
504 break;
505 };
506 passive.push(entering);
507 in_passive[entering] = true;
508
509 let mut inner_ok = false;
510 for _ in 0..(m + 2) {
511 let Some(z) = solve_passive(&passive) else {
512 return None;
513 };
514 let min_z = z.iter().copied().fold(f64::INFINITY, f64::min);
515 if min_z > lambda_floor {
516 for (pos, &row) in passive.iter().enumerate() {
517 lambda_unit[row] = z[pos];
518 }
519 inner_ok = true;
520 break;
521 }
522 let mut alpha = 1.0_f64;
525 for (pos, &row) in passive.iter().enumerate() {
526 if z[pos] <= lambda_floor {
527 let current = lambda_unit[row];
528 let denom = current - z[pos];
529 if denom > 0.0 {
530 alpha = alpha.min((current / denom).clamp(0.0, 1.0));
531 } else {
532 alpha = 0.0;
533 }
534 }
535 }
536 for (pos, &row) in passive.iter().enumerate() {
537 lambda_unit[row] += alpha * (z[pos] - lambda_unit[row]);
538 }
539 let mut retained = Vec::with_capacity(passive.len());
540 for &row in &passive {
541 if lambda_unit[row] > lambda_floor {
542 retained.push(row);
543 } else {
544 lambda_unit[row] = 0.0;
545 in_passive[row] = false;
546 banned[row] = true;
550 }
551 }
552 if retained.len() == passive.len() {
553 inner_ok = true;
556 for (pos, &row) in passive.iter().enumerate() {
557 lambda_unit[row] = z[pos].max(0.0);
558 }
559 break;
560 }
561 passive = retained;
562 if passive.is_empty() {
563 break;
564 }
565 }
566 let mut fitted = Array1::<f64>::zeros(p);
568 for &row in &passive {
569 fitted.scaled_add(lambda_unit[row], &unit.row(row));
570 }
571 let new_residual = target - &fitted;
572 let moved = new_residual
573 .iter()
574 .zip(residual.iter())
575 .any(|(a, b)| (a - b).abs() > 1e-15 * target_inf);
576 residual = new_residual;
577 if moved {
578 banned.iter_mut().for_each(|b| *b = false);
579 } else if !inner_ok {
580 break;
581 }
582 }
583
584 let mut lambda = Array1::<f64>::zeros(m);
585 for i in 0..m {
586 if norms[i] > 0.0 {
587 lambda[i] = lambda_unit[i] / norms[i];
588 }
589 }
590 if !array_is_finite(&lambda) || !array_is_finite(&residual) {
591 return None;
592 }
593 Some((lambda, residual))
594}
595
596pub fn project_stationarity_residual_on_constraint_cone(
597 residual: &Array1<f64>,
598 active_a: &Array2<f64>,
599) -> Option<(Array1<f64>, Array1<f64>)> {
600 let p = residual.len();
601 if active_a.ncols() != p {
602 return None;
603 }
604 if active_a.nrows() == 0 {
605 return Some((residual.clone(), Array1::zeros(0)));
606 }
607 if let Some(result) = moreau_projection_via_primal_qp(residual, active_a) {
608 return Some(result);
609 }
610 nonnegative_cone_multipliers(active_a, residual).map(|(lambda, projected)| (projected, lambda))
617}
618
619fn moreau_projection_via_primal_qp(
620 residual: &Array1<f64>,
621 active_a: &Array2<f64>,
622) -> Option<(Array1<f64>, Array1<f64>)> {
623 let p = residual.len();
624
625 let m = active_a.nrows();
626 let constraints = LinearInequalityConstraints::new(active_a.clone(), Array1::<f64>::zeros(m))
627 .ok()?
628 .canonicalized()
629 .ok()?;
630
631 let identity = Array2::<f64>::eye(p);
644 let origin = Array1::<f64>::zeros(p);
645 let mut tangent_direction = Array1::<f64>::zeros(p);
646 let mut tangent_active = Vec::new();
647 let max_iterations = (p + m + 8) * 4;
648 solve_newton_direction_with_linear_constraints_impl(
649 &identity,
650 residual,
651 &origin,
652 &constraints,
653 &mut tangent_direction,
654 Some(&mut tangent_active),
655 max_iterations,
656 false,
657 )
658 .ok()?;
659 if !array_is_finite(&tangent_direction) {
660 return None;
661 }
662 let projected = -&tangent_direction;
663
664 let mut lambda_canonical = Array1::<f64>::zeros(m);
669 if !tangent_active.is_empty() {
670 let gathered = gather_linear_constraint_rows(&constraints, &tangent_active).ok()?;
671 let design = gathered.a.t().to_owned();
676 let solved = least_squares_min_norm_any_shape(&design, &(residual + &tangent_direction))?;
677 let scale = residual
678 .iter()
679 .fold(0.0_f64, |acc, &value| acc.max(value.abs()))
680 .max(1.0);
681 let tol = 100.0 * f64::EPSILON * (p.max(m) as f64) * scale;
682 for (position, &row) in tangent_active.iter().enumerate() {
683 let value = solved[position];
684 if !value.is_finite() || value < -tol {
685 return None;
686 }
687 lambda_canonical[row] = value.max(0.0);
688 }
689 }
690 let reconstructed = residual - &constraints.a.t().dot(&lambda_canonical);
691 let reconstruction_error = reconstructed
692 .iter()
693 .zip(projected.iter())
694 .fold(0.0_f64, |acc, (&left, &right)| {
695 acc.max((left - right).abs())
696 });
697 let scale = residual
698 .iter()
699 .fold(0.0_f64, |acc, &value| acc.max(value.abs()))
700 .max(1.0);
701 if reconstruction_error > 1e-8 * scale || !array_is_finite(&lambda_canonical) {
702 return None;
703 }
704
705 let mut lambda = Array1::<f64>::zeros(m);
710 for row in 0..m {
711 let norm = active_a.row(row).dot(&active_a.row(row)).sqrt();
712 if norm > 0.0 {
713 lambda[row] = lambda_canonical[row] / norm;
714 }
715 }
716 Some((projected, lambda))
717}
718
719pub(crate) fn feasible_point_for_linear_constraints(
720 constraints: &LinearInequalityConstraints,
721 p: usize,
722) -> Option<Array1<f64>> {
723 if constraints.a.ncols() != p
724 || constraints.a.nrows() == 0
725 || constraints.b.len() != constraints.a.nrows()
726 {
727 return None;
728 }
729 let mut all_scaled_b_tiny = true;
734 for i in 0..constraints.a.nrows() {
735 let norm = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
736 if norm > 0.0 {
737 if constraints.b[i].abs() > 1e-14 * norm {
738 all_scaled_b_tiny = false;
739 }
740 } else if constraints.b[i] > 0.0 {
741 return None;
742 }
743 }
744 if all_scaled_b_tiny {
745 return Some(Array1::zeros(p));
746 }
747
748 let gram = constraints.a.dot(&constraints.a.t());
749 let (u_opt, singular, vt_opt) = gram.svd(true, true).ok()?;
750 let (Some(u), Some(vt)) = (u_opt, vt_opt) else {
751 return None;
752 };
753 let max_singular = singular.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
754 let tol = 100.0 * f64::EPSILON * constraints.a.nrows().max(1) as f64 * max_singular;
758 let mut coeff = u.t().dot(&constraints.b);
759 for (idx, value) in coeff.iter_mut().enumerate() {
760 let sigma = singular[idx];
761 if sigma.abs() > tol {
762 *value /= sigma;
763 } else {
764 *value = 0.0;
765 }
766 }
767 let dual = vt.t().dot(&coeff);
768 let beta = constraints.a.t().dot(&dual);
769 if beta.len() != p || beta.iter().any(|v| !v.is_finite()) {
770 return None;
771 }
772 let feasible = (0..constraints.a.nrows()).all(|i| {
775 let norm = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
776 if norm > 0.0 {
777 (constraints.a.row(i).dot(&beta) - constraints.b[i]) / norm >= -1e-8
778 } else {
779 constraints.b[i] <= 0.0
780 }
781 });
782 if feasible { Some(beta) } else { None }
783}
784
785const ACTIVE_SET_INTERIOR_SEED_MARGIN: f64 = 1e-6;
795
796#[inline]
802pub(crate) fn interior_seed_margin() -> f64 {
803 ACTIVE_SET_INTERIOR_SEED_MARGIN
804}
805
806const MAX_FEASIBILITY_REPAIR_DEPTH: u32 = 16;
825
826thread_local! {
827 static FEASIBILITY_REPAIR_DEPTH: Cell<u32> = const { Cell::new(0) };
835}
836
837struct FeasibilityRepairGuard;
846
847impl FeasibilityRepairGuard {
848 fn enter() -> Option<Self> {
849 FEASIBILITY_REPAIR_DEPTH.with(|depth| {
850 let current = depth.get();
851 if current >= MAX_FEASIBILITY_REPAIR_DEPTH {
852 None
853 } else {
854 depth.set(current + 1);
855 Some(Self)
856 }
857 })
858 }
859}
860
861impl Drop for FeasibilityRepairGuard {
862 fn drop(&mut self) {
863 FEASIBILITY_REPAIR_DEPTH.with(|depth| depth.set(depth.get().saturating_sub(1)));
864 }
865}
866
867pub fn project_point_strictly_into_feasible_cone(
896 point: &Array1<f64>,
897 constraints: &LinearInequalityConstraints,
898) -> Option<Array1<f64>> {
899 let repair_guard = FeasibilityRepairGuard::enter()?;
905 let p = point.len();
906 let m = constraints.a.nrows();
907 if constraints.a.ncols() != p || m == 0 || constraints.b.len() != m {
908 return None;
909 }
910 let norms: Vec<f64> = (0..m)
911 .map(|i| constraints.a.row(i).dot(&constraints.a.row(i)).sqrt())
912 .collect();
913
914 const ANTIPARALLEL_COS_TOL: f64 = -1.0 + 1e-9;
928 const EQUALITY_WIDTH_TOL: f64 = 1e-9;
929 let mut is_equality_member = vec![false; m];
930 let mut equality_rows: Vec<usize> = Vec::new();
931 let mut margin = vec![ACTIVE_SET_INTERIOR_SEED_MARGIN; m];
932 for i in 0..m {
933 if norms[i] == 0.0 {
934 margin[i] = 0.0;
935 continue;
936 }
937 for j in (i + 1)..m {
938 if norms[j] == 0.0 {
939 continue;
940 }
941 let cos = constraints.a.row(i).dot(&constraints.a.row(j)) / (norms[i] * norms[j]);
942 if cos > ANTIPARALLEL_COS_TOL {
943 continue;
944 }
945 let width = -constraints.b[j] / norms[j] - constraints.b[i] / norms[i];
948 if width.abs() <= EQUALITY_WIDTH_TOL {
949 if !is_equality_member[i] && !is_equality_member[j] {
952 equality_rows.push(i);
953 }
954 is_equality_member[i] = true;
955 is_equality_member[j] = true;
956 } else {
957 let cap = (width / 3.0).max(0.0);
960 margin[i] = margin[i].min(cap);
961 margin[j] = margin[j].min(cap);
962 }
963 }
964 }
965
966 let ineq_rows: Vec<usize> = (0..m).filter(|&i| !is_equality_member[i]).collect();
969 let mut a_ineq = Array2::<f64>::zeros((ineq_rows.len(), p));
970 let mut b_ineq = Array1::<f64>::zeros(ineq_rows.len());
971 for (r, &i) in ineq_rows.iter().enumerate() {
972 a_ineq.row_mut(r).assign(&constraints.a.row(i));
973 b_ineq[r] = constraints.b[i] + margin[i] * norms[i];
974 }
975
976 let beta = if equality_rows.is_empty() {
977 let interior = LinearInequalityConstraints::new(a_ineq, b_ineq)
980 .expect("shifted interior constraint shape invariant");
981 let identity = Array2::<f64>::eye(p);
982 solve_quadratic_with_linear_constraints(&identity, point, point, &interior, None)
983 .ok()?
984 .0
985 } else {
986 let k = equality_rows.len();
996 let mut e_mat = Array2::<f64>::zeros((k, p));
997 let mut e_rhs = Array1::<f64>::zeros(k);
998 for (r, &i) in equality_rows.iter().enumerate() {
999 e_mat.row_mut(r).assign(&constraints.a.row(i));
1000 e_rhs[r] = constraints.b[i];
1001 }
1002 let (u_opt, sing, vt_opt) = e_mat.svd(true, true).ok()?;
1003 let (u_mat, vt) = (u_opt?, vt_opt?);
1004 let smax = sing.iter().fold(0.0_f64, |acc, &v| acc.max(v));
1005 let rank_tol = smax.max(1.0) * (k.max(p) as f64) * f64::EPSILON * 100.0;
1006 let rank = sing.iter().filter(|&&s| s > rank_tol).count();
1007 if rank == 0 || rank >= p {
1008 return None;
1009 }
1010 let mut beta_p = Array1::<f64>::zeros(p);
1011 for idx in 0..rank {
1012 let coeff = u_mat.column(idx).dot(&e_rhs) / sing[idx];
1013 beta_p.scaled_add(coeff, &vt.row(idx));
1014 }
1015 let mut basis: Vec<Array1<f64>> = (0..rank).map(|i| vt.row(i).to_owned()).collect();
1018 let mut z = Array2::<f64>::zeros((p, p - rank));
1019 let mut collected = 0usize;
1020 for axis in 0..p {
1021 if collected == p - rank {
1022 break;
1023 }
1024 let mut v = Array1::<f64>::zeros(p);
1025 v[axis] = 1.0;
1026 for q in basis.iter() {
1027 let c = q.dot(&v);
1028 v.scaled_add(-c, q);
1029 }
1030 let nrm = v.dot(&v).sqrt();
1031 if nrm > 1e-8 {
1032 v /= nrm;
1033 z.column_mut(collected).assign(&v);
1034 basis.push(v);
1035 collected += 1;
1036 }
1037 }
1038 if collected != p - rank {
1039 return None;
1040 }
1041 let a_red = a_ineq.dot(&z);
1042 let b_red = &b_ineq - &a_ineq.dot(&beta_p);
1043 let u0 = z.t().dot(&(point - &beta_p));
1044 let reduced = LinearInequalityConstraints::new(a_red, b_red)
1045 .expect("reduced constraint shape invariant");
1046 let identity = Array2::<f64>::eye(z.ncols());
1047 let (u_sol, _active) =
1048 solve_quadratic_with_linear_constraints(&identity, &u0, &u0, &reduced, None).ok()?;
1049 &beta_p + &z.dot(&u_sol)
1050 };
1051
1052 if beta.len() != p || beta.iter().any(|v| !v.is_finite()) {
1053 return None;
1054 }
1055 const SEED_FEASIBILITY_TOL: f64 = 1e-9;
1060 for i in 0..m {
1061 let s = scaled_constraint_slack(&beta, constraints, i);
1062 let lower = if is_equality_member[i] {
1063 -SEED_FEASIBILITY_TOL
1064 } else {
1065 0.5 * margin[i] - SEED_FEASIBILITY_TOL
1066 };
1067 if s < lower {
1068 return None;
1069 }
1070 }
1071 drop(repair_guard);
1076 Some(beta)
1077}
1078
1079fn max_linear_constraint_violation(
1091 beta: &Array1<f64>,
1092 constraints: &LinearInequalityConstraints,
1093) -> (f64, usize) {
1094 let mut worst = 0.0_f64;
1095 let mut worst_row = 0usize;
1096 for i in 0..constraints.a.nrows() {
1097 let slack = scaled_constraint_slack(beta, constraints, i);
1098 let viol = (-slack).max(0.0);
1099 if viol > worst {
1100 worst = viol;
1101 worst_row = i;
1102 }
1103 }
1104 (worst, worst_row)
1105}
1106
1107#[inline]
1113fn scaled_constraint_slack(
1114 beta: &Array1<f64>,
1115 constraints: &LinearInequalityConstraints,
1116 i: usize,
1117) -> f64 {
1118 let norm = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
1119 if norm > 0.0 {
1120 (constraints.a.row(i).dot(beta) - constraints.b[i]) / norm
1121 } else if constraints.b[i] > 0.0 {
1122 f64::NEG_INFINITY
1123 } else {
1124 f64::INFINITY
1125 }
1126}
1127
1128pub(crate) fn solve_kkt_direction(
1129 hessian: &Array2<f64>,
1130 gradient: &Array1<f64>,
1131 active_a: &Array2<f64>,
1132 active_residual: Option<&Array1<f64>>,
1133) -> Result<(Array1<f64>, Array1<f64>), EstimationError> {
1134 let p = hessian.nrows();
1135 let m = active_a.nrows();
1136 if hessian.ncols() != p || gradient.len() != p || active_a.ncols() != p {
1137 crate::bail_invalid_estim!("KKT solve dimension mismatch");
1138 }
1139 if let Some(residual) = active_residual
1140 && residual.len() != m
1141 {
1142 crate::bail_invalid_estim!(
1143 "KKT active residual length mismatch: got {}, expected {}",
1144 residual.len(),
1145 m
1146 );
1147 }
1148 if m == 0 {
1149 let mut d = Array1::<f64>::zeros(p);
1150 solve_newton_direction_dense(hessian, gradient, &mut d)?;
1151 return Ok((d, Array1::zeros(0)));
1152 }
1153 let mut kkt = Array2::<f64>::zeros((p + m, p + m));
1154 kkt.slice_mut(s![0..p, 0..p]).assign(hessian);
1155 kkt.slice_mut(s![0..p, p..(p + m)]).assign(&active_a.t());
1156 kkt.slice_mut(s![p..(p + m), 0..p]).assign(active_a);
1157
1158 let mut rhs = Array1::<f64>::zeros(p + m);
1159 for i in 0..p {
1160 rhs[i] = -gradient[i];
1161 }
1162 if let Some(residual) = active_residual {
1163 for i in 0..m {
1164 rhs[p + i] = residual[i];
1165 }
1166 }
1167 let rhs_target = rhs.clone();
1168
1169 let kkt_view = FaerArrayView::new(&kkt);
1170 let factor = FaerLblt::new(kkt_view.as_ref(), Side::Lower);
1171 let mut rhs_col = array1_to_col_matmut(&mut rhs);
1172 factor.solve_in_place(rhs_col.as_mut());
1173 if !rhs.iter().all(|v| v.is_finite()) {
1174 solve_dense_system_via_pseudoinverse(&kkt, &rhs_target, &mut rhs)?;
1175 }
1176 let d = rhs.slice(s![0..p]).to_owned();
1177 let lambda = rhs.slice(s![p..(p + m)]).to_owned();
1178 Ok((d, lambda))
1179}
1180
1181#[derive(Clone, Debug)]
1182pub(crate) struct CompressedActiveWorkingSet {
1183 pub(crate) constraints: LinearInequalityConstraints,
1184 pub(crate) groups: Vec<Vec<usize>>,
1189 pub(crate) original_active_count: usize,
1190}
1191
1192#[derive(Clone, Copy, Debug)]
1208pub struct ActiveRowDependence {
1209 pub active_pos: usize,
1210 pub coeff: f64,
1211}
1212
1213#[derive(Clone, Copy, Debug)]
1222pub struct ConstraintRowDependence {
1223 pub row: ConstraintRowId,
1224 pub coeff: f64,
1225}
1226
1227#[derive(Clone, Debug)]
1238pub struct ReducedFace {
1239 pub representatives: Vec<ConstraintRowId>,
1243 pub dependence: Vec<Vec<ConstraintRowDependence>>,
1248 pub tight_rows: Vec<ConstraintRowId>,
1250}
1251
1252pub fn khatri_rao_cone_reduced_face(
1275 cone: &KhatriRaoConeConstraints,
1276 beta: ndarray::ArrayView1<'_, f64>,
1277 membership_tol: f64,
1278) -> Result<ReducedFace, EstimationError> {
1279 let psi = cone.factor();
1280 let n = psi.nrows();
1281 let p_cov = psi.ncols();
1282 let coupled = cone.coupled_rows();
1283 let values = cone.values(beta).map_err(|error| {
1284 EstimationError::ParameterConstraintViolation(format!(
1285 "Khatri-Rao cone reduced-face values: {error}"
1286 ))
1287 })?;
1288
1289 let row_norms: Vec<f64> = (0..n)
1291 .map(|i| {
1292 let row = psi.row(i);
1293 row.dot(&row).sqrt()
1294 })
1295 .collect();
1296
1297 const RANK_ALPHA: f64 = 100.0;
1298 const PARALLEL_COS_TOL: f64 = 1.0 - 1e-9;
1300
1301 let mut representatives: Vec<ConstraintRowId> = Vec::new();
1302 let mut dependence: Vec<Vec<ConstraintRowDependence>> = Vec::new();
1303 let mut tight_rows: Vec<ConstraintRowId> = Vec::new();
1304
1305 for slot in 0..coupled.len() {
1306 let mut tight_obs: Vec<usize> = Vec::new();
1309 for i in 0..n {
1310 let norm_i = row_norms[i];
1311 if norm_i <= 0.0 {
1312 continue;
1313 }
1314 let scaled_slack = values[slot * n + i] / norm_i;
1315 if scaled_slack <= membership_tol {
1316 tight_rows.push(ConstraintRowId(slot * n + i));
1317 tight_obs.push(i);
1318 }
1319 }
1320 if tight_obs.is_empty() {
1321 continue;
1322 }
1323
1324 let max_norm = tight_obs
1325 .iter()
1326 .map(|&i| row_norms[i])
1327 .fold(0.0_f64, f64::max);
1328 let rank_tol =
1329 RANK_ALPHA * f64::EPSILON * (tight_obs.len().max(p_cov).max(1) as f64) * max_norm;
1330
1331 let mut ortho_basis: Vec<Array1<f64>> = Vec::new();
1332 let mut kept: Vec<(usize, Array1<f64>, usize)> = Vec::new();
1334 for &i in &tight_obs {
1335 let psi_i = psi.row(i).to_owned();
1336 let mut resid = psi_i.clone();
1337 for q in &ortho_basis {
1338 let proj = resid.dot(q);
1339 resid.scaled_add(-proj, q);
1340 }
1341 let resid_norm = resid.dot(&resid).sqrt();
1342 let flat = ConstraintRowId(slot * n + i);
1343 if resid_norm > rank_tol {
1344 ortho_basis.push(&resid / resid_norm);
1345 let out_idx = representatives.len();
1346 representatives.push(flat);
1347 dependence.push(Vec::new());
1348 kept.push((i, psi_i, out_idx));
1349 } else {
1350 let mut best_abs_cos = 0.0_f64;
1353 let mut best: Option<(usize, f64)> = None;
1354 for (rep_obs, rep_psi, rep_out_idx) in &kept {
1355 let rep_norm = row_norms[*rep_obs];
1356 let dot = psi_i.dot(rep_psi);
1357 let cos = if rep_norm > 0.0 {
1358 dot / (row_norms[i] * rep_norm)
1359 } else {
1360 0.0
1361 };
1362 if cos.abs() > best_abs_cos {
1363 best_abs_cos = cos.abs();
1364 best = Some((*rep_out_idx, dot / (rep_norm * rep_norm)));
1365 }
1366 }
1367 if best_abs_cos >= PARALLEL_COS_TOL {
1368 if let Some((out_idx, coeff)) = best {
1369 dependence[out_idx].push(ConstraintRowDependence {
1370 row: flat,
1371 coeff,
1372 });
1373 }
1374 }
1375 }
1376 }
1377 }
1378
1379 Ok(ReducedFace {
1380 representatives,
1381 dependence,
1382 tight_rows,
1383 })
1384}
1385
1386pub fn dense_reduced_face(
1395 lin: &LinearInequalityConstraints,
1396 beta: ndarray::ArrayView1<'_, f64>,
1397 membership_tol: f64,
1398) -> Result<ReducedFace, EstimationError> {
1399 let a = &lin.a;
1400 let b = &lin.b;
1401 let n = a.nrows();
1402 let p = a.ncols();
1403
1404 let row_norms: Vec<f64> = (0..n)
1405 .map(|i| {
1406 let row = a.row(i);
1407 row.dot(&row).sqrt()
1408 })
1409 .collect();
1410
1411 const RANK_ALPHA: f64 = 100.0;
1412 const PARALLEL_COS_TOL: f64 = 1.0 - 1e-9;
1413
1414 let mut tight: Vec<usize> = Vec::new();
1417 for i in 0..n {
1418 let norm_i = row_norms[i];
1419 if norm_i <= 0.0 {
1420 continue;
1421 }
1422 let scaled_slack = (a.row(i).dot(&beta) - b[i]) / norm_i;
1423 if scaled_slack <= membership_tol {
1424 tight.push(i);
1425 }
1426 }
1427
1428 let mut representatives: Vec<ConstraintRowId> = Vec::new();
1429 let mut dependence: Vec<Vec<ConstraintRowDependence>> = Vec::new();
1430 if tight.is_empty() {
1431 return Ok(ReducedFace {
1432 representatives,
1433 dependence,
1434 tight_rows: Vec::new(),
1435 });
1436 }
1437
1438 let max_norm = tight
1439 .iter()
1440 .map(|&i| row_norms[i])
1441 .fold(0.0_f64, f64::max);
1442 let rank_tol = RANK_ALPHA * f64::EPSILON * (tight.len().max(p).max(1) as f64) * max_norm;
1443
1444 let mut ortho_basis: Vec<Array1<f64>> = Vec::new();
1445 let mut kept: Vec<(usize, Array1<f64>, usize)> = Vec::new();
1447 for &i in &tight {
1448 let a_i = a.row(i).to_owned();
1449 let mut resid = a_i.clone();
1450 for q in &ortho_basis {
1451 let proj = resid.dot(q);
1452 resid.scaled_add(-proj, q);
1453 }
1454 let resid_norm = resid.dot(&resid).sqrt();
1455 if resid_norm > rank_tol {
1456 ortho_basis.push(&resid / resid_norm);
1457 let out_idx = representatives.len();
1458 representatives.push(ConstraintRowId(i));
1459 dependence.push(Vec::new());
1460 kept.push((i, a_i, out_idx));
1461 } else {
1462 let mut best_abs_cos = 0.0_f64;
1466 let mut best: Option<(usize, f64)> = None;
1467 for (rep_row, rep_a, rep_out_idx) in &kept {
1468 let rep_norm = row_norms[*rep_row];
1469 let dot = a_i.dot(rep_a);
1470 let cos = if rep_norm > 0.0 {
1471 dot / (row_norms[i] * rep_norm)
1472 } else {
1473 0.0
1474 };
1475 if cos.abs() > best_abs_cos {
1476 best_abs_cos = cos.abs();
1477 best = Some((*rep_out_idx, dot / (rep_norm * rep_norm)));
1478 }
1479 }
1480 if best_abs_cos >= PARALLEL_COS_TOL {
1481 if let Some((out_idx, coeff)) = best {
1482 dependence[out_idx].push(ConstraintRowDependence {
1483 row: ConstraintRowId(i),
1484 coeff,
1485 });
1486 }
1487 }
1488 }
1489 }
1490
1491 Ok(ReducedFace {
1492 representatives,
1493 dependence,
1494 tight_rows: tight.into_iter().map(ConstraintRowId).collect(),
1495 })
1496}
1497
1498#[inline]
1514fn lift_member_row(local: ConstraintRowId, row_offset: usize) -> ConstraintRowId {
1515 ConstraintRowId(local.index() + row_offset)
1516}
1517
1518pub trait ConstraintSetReducedFace {
1523 fn reduced_face(
1524 &self,
1525 beta: ndarray::ArrayView1<'_, f64>,
1526 membership_tol: f64,
1527 ) -> Result<ReducedFace, EstimationError>;
1528}
1529
1530impl ConstraintSetReducedFace for ConstraintSet {
1531 fn reduced_face(
1532 &self,
1533 beta: ndarray::ArrayView1<'_, f64>,
1534 membership_tol: f64,
1535 ) -> Result<ReducedFace, EstimationError> {
1536 match self {
1537 ConstraintSet::Dense(lin) => dense_reduced_face(lin, beta, membership_tol),
1538 ConstraintSet::KhatriRaoCone(cone) => {
1539 khatri_rao_cone_reduced_face(cone, beta, membership_tol)
1540 }
1541 ConstraintSet::BlockDiagonal { blocks, .. } => {
1542 let mut representatives: Vec<ConstraintRowId> = Vec::new();
1553 let mut dependence: Vec<Vec<ConstraintRowDependence>> = Vec::new();
1554 let mut tight_rows: Vec<ConstraintRowId> = Vec::new();
1555 let mut row_offset = 0usize;
1556 for block in blocks {
1557 let start = block.col_start;
1558 let end = start + block.set.ncols();
1559 let beta_block = beta.slice(ndarray::s![start..end]);
1560 let sub = block.set.reduced_face(beta_block, membership_tol)?;
1561 for r in sub.representatives {
1562 representatives.push(lift_member_row(r, row_offset));
1563 }
1564 for deps in sub.dependence {
1565 dependence.push(
1566 deps.into_iter()
1567 .map(|d| ConstraintRowDependence {
1568 row: lift_member_row(d.row, row_offset),
1569 coeff: d.coeff,
1570 })
1571 .collect(),
1572 );
1573 }
1574 for t in sub.tight_rows {
1575 tight_rows.push(lift_member_row(t, row_offset));
1576 }
1577 row_offset += block.set.nrows();
1578 }
1579 Ok(ReducedFace {
1580 representatives,
1581 dependence,
1582 tight_rows,
1583 })
1584 }
1585 }
1586 }
1587}
1588
1589impl CompressedActiveWorkingSet {
1590 fn is_degenerate_face(&self) -> bool {
1591 self.constraints.a.nrows() < self.original_active_count
1592 || self.groups.iter().any(|group| group.len() > 1)
1593 }
1594
1595 fn negative_representative_group(
1615 &self,
1616 lambda_system: &Array1<f64>,
1617 tol_dual: f64,
1618 active: &[usize],
1619 ) -> Option<Vec<usize>> {
1620 self.groups
1621 .iter()
1622 .enumerate()
1623 .filter(|&(group_pos, _)| {
1624 lambda_system
1626 .get(group_pos)
1627 .is_some_and(|&value| -value < -tol_dual)
1628 })
1629 .min_by_key(|&(_, group)| {
1630 let first = group.first().copied().unwrap_or(usize::MAX);
1631 (active.get(first).copied().unwrap_or(usize::MAX), first)
1632 })
1633 .map(|(_, group)| group.clone())
1634 }
1635
1636 fn position_enforced(&self, pos: usize) -> bool {
1643 self.groups.iter().any(|group| group.contains(&pos))
1644 }
1645
1646 fn over_complete_release_group(
1661 &self,
1662 violated: ndarray::ArrayView1<'_, f64>,
1663 active: &[usize],
1664 ) -> Option<Vec<usize>> {
1665 let v_norm = violated.dot(&violated).sqrt();
1666 if !(v_norm > 0.0) {
1667 return None;
1668 }
1669 const COS_TIE_TOL: f64 = 1e-12;
1670 let mut best: Option<(f64, (usize, usize), usize)> = None;
1671 for (group_pos, group) in self.groups.iter().enumerate() {
1672 let rep = self.constraints.a.row(group_pos);
1673 let rep_norm = rep.dot(&rep).sqrt();
1674 if !(rep_norm > 0.0) {
1675 continue;
1676 }
1677 let cos = rep.dot(&violated) / (rep_norm * v_norm);
1678 if cos <= 0.0 {
1679 continue;
1680 }
1681 let first = group.first().copied().unwrap_or(usize::MAX);
1682 let key = (active.get(first).copied().unwrap_or(usize::MAX), first);
1683 let take = match &best {
1684 None => true,
1685 Some((best_cos, best_key, _)) => {
1686 cos > best_cos + COS_TIE_TOL
1687 || ((cos - best_cos).abs() <= COS_TIE_TOL && key < *best_key)
1688 }
1689 };
1690 if take {
1691 best = Some((cos, key, group_pos));
1692 }
1693 }
1694 best.map(|(_, _, group_pos)| self.groups[group_pos].clone())
1695 }
1696}
1697
1698pub(crate) fn compress_active_working_set(
1699 x: &Array1<f64>,
1700 constraints: &LinearInequalityConstraints,
1701 active: &[usize],
1702) -> Result<CompressedActiveWorkingSet, EstimationError> {
1703 let p = constraints.a.ncols();
1704 if x.len() != p {
1705 crate::bail_invalid_estim!("active working-set compression dimension mismatch");
1706 }
1707
1708 let mut a_out = Array2::<f64>::zeros((active.len(), p));
1709 let mut b_out = Array1::<f64>::zeros(active.len());
1710 let mut groups_out: Vec<Vec<usize>> = Vec::with_capacity(active.len());
1711 for (pos, &idx) in active.iter().enumerate() {
1712 if idx >= constraints.a.nrows() {
1713 crate::bail_invalid_estim!(
1714 "active working-set index {} out of bounds for {} constraints",
1715 idx,
1716 constraints.a.nrows()
1717 );
1718 }
1719 a_out.row_mut(pos).assign(&constraints.a.row(idx));
1720 b_out[pos] = constraints.b[idx];
1721 groups_out.push(vec![pos]);
1722 }
1723
1724 let (a_out, b_out, groups_out, _) =
1728 rank_reduce_rows_pivoted_qr_with_dependence(a_out, b_out, groups_out);
1729
1730 Ok(CompressedActiveWorkingSet {
1731 constraints: LinearInequalityConstraints::new(a_out, b_out)
1732 .expect("compressed active constraint shape invariant"),
1733 groups: groups_out,
1734 original_active_count: active.len(),
1735 })
1736}
1737
1738fn identity_multiplier_dependence(groups: &[Vec<usize>]) -> Vec<Vec<ActiveRowDependence>> {
1739 groups
1740 .iter()
1741 .map(|group| {
1742 group
1743 .iter()
1744 .copied()
1745 .map(|active_pos| ActiveRowDependence {
1746 active_pos,
1747 coeff: 1.0,
1748 })
1749 .collect()
1750 })
1751 .collect()
1752}
1753
1754pub fn rank_reduce_rows_pivoted_qr_with_dependence(
1755 a: Array2<f64>,
1756 b: Array1<f64>,
1757 groups: Vec<Vec<usize>>,
1758) -> (
1759 Array2<f64>,
1760 Array1<f64>,
1761 Vec<Vec<usize>>,
1762 Vec<Vec<ActiveRowDependence>>,
1763) {
1764 let k = a.nrows();
1765 let p = a.ncols();
1766 if k <= 1 {
1767 let multiplier_dependence = identity_multiplier_dependence(&groups);
1768 return (a, b, groups, multiplier_dependence);
1769 }
1770
1771 const RANK_ALPHA: f64 = 100.0;
1791 let max_row_norm = (0..k)
1792 .map(|r| {
1793 let row = a.row(r);
1794 row.dot(&row).sqrt()
1795 })
1796 .fold(0.0_f64, f64::max);
1797 let tol = RANK_ALPHA * f64::EPSILON * (k.max(p).max(1) as f64) * max_row_norm;
1798
1799 let mut ortho_basis: Vec<Array1<f64>> = Vec::new();
1800 let mut kept_orig: Vec<usize> = Vec::new();
1801 let mut dropped_orig: Vec<usize> = Vec::new();
1802 for r in 0..k {
1803 let mut resid = a.row(r).to_owned();
1804 for q in &ortho_basis {
1805 let proj = resid.dot(q);
1806 resid.scaled_add(-proj, q);
1807 }
1808 let resid_norm = resid.dot(&resid).sqrt();
1809 if resid_norm > tol {
1810 kept_orig.push(r);
1811 ortho_basis.push(&resid / resid_norm);
1812 } else {
1813 dropped_orig.push(r);
1814 }
1815 }
1816 let rank = kept_orig.len();
1817 if rank >= k {
1818 let multiplier_dependence = identity_multiplier_dependence(&groups);
1819 return (a, b, groups, multiplier_dependence);
1820 }
1821 if rank == 0 {
1822 log::debug!(
1823 "rank-reduced active constraints from {} to 0 rows (all active rows numerically zero)",
1824 k
1825 );
1826 return (
1827 Array2::<f64>::zeros((0, p)),
1828 Array1::<f64>::zeros(0),
1829 Vec::new(),
1830 Vec::new(),
1831 );
1832 }
1833
1834 let mut orig_to_out = std::collections::HashMap::with_capacity(rank);
1835 let mut a_out = Array2::<f64>::zeros((rank, p));
1836 let mut b_out = Array1::<f64>::zeros(rank);
1837 let mut groups_out: Vec<Vec<usize>> = Vec::with_capacity(rank);
1838 let mut multiplier_dependence: Vec<Vec<ActiveRowDependence>> = Vec::with_capacity(rank);
1839 for (out_idx, &orig_idx) in kept_orig.iter().enumerate() {
1840 a_out.row_mut(out_idx).assign(&a.row(orig_idx));
1841 b_out[out_idx] = b[orig_idx];
1842 groups_out.push(groups[orig_idx].clone());
1843 multiplier_dependence.push(
1844 groups[orig_idx]
1845 .iter()
1846 .copied()
1847 .map(|active_pos| ActiveRowDependence {
1848 active_pos,
1849 coeff: 1.0,
1850 })
1851 .collect(),
1852 );
1853 orig_to_out.insert(orig_idx, out_idx);
1854 }
1855
1856 const PARALLEL_COS_TOL: f64 = 1.0 - 1e-9;
1869 for &dropped_idx in &dropped_orig {
1870 let dropped_row = a.row(dropped_idx);
1871 let dropped_norm = dropped_row.dot(&dropped_row).sqrt();
1872 let mut best_abs_cos = 0.0_f64;
1873 let mut best_target: Option<(usize, f64)> = None;
1874 for &kept_idx in &kept_orig {
1875 let kept_row = a.row(kept_idx);
1876 let kept_norm = kept_row.dot(&kept_row).sqrt();
1877 let dot = kept_row.dot(&dropped_row);
1878 let cos = if kept_norm > 0.0 && dropped_norm > 0.0 {
1879 dot / (kept_norm * dropped_norm)
1880 } else {
1881 0.0
1882 };
1883 let coeff = if kept_norm > 0.0 {
1884 dot / (kept_norm * kept_norm)
1885 } else {
1886 0.0
1887 };
1888 if cos.abs() > best_abs_cos {
1889 best_abs_cos = cos.abs();
1890 best_target = Some((kept_idx, coeff));
1891 }
1892 }
1893 if best_abs_cos >= PARALLEL_COS_TOL {
1899 if let Some((target, coeff)) = best_target {
1900 let &out_idx = orig_to_out
1901 .get(&target)
1902 .expect("merge target must be a kept row");
1903 for &active_pos in &groups[dropped_idx] {
1904 multiplier_dependence[out_idx].push(ActiveRowDependence { active_pos, coeff });
1905 }
1906 if coeff > 0.0 {
1907 groups_out[out_idx].extend_from_slice(&groups[dropped_idx]);
1908 }
1909 }
1910 }
1911 }
1912
1913 for group in &mut groups_out {
1914 group.sort_unstable();
1915 group.dedup();
1916 }
1917 for dependencies in &mut multiplier_dependence {
1918 dependencies.sort_unstable_by_key(|dependency| dependency.active_pos);
1919 dependencies.dedup_by_key(|dependency| dependency.active_pos);
1920 }
1921
1922 let mut row_order: Vec<usize> = (0..groups_out.len()).collect();
1923 row_order.sort_by_key(|&idx| groups_out[idx].first().copied().unwrap_or(usize::MAX));
1924 if row_order.iter().enumerate().any(|(idx, &orig)| idx != orig) {
1925 let mut a_sorted = Array2::<f64>::zeros((rank, p));
1926 let mut b_sorted = Array1::<f64>::zeros(rank);
1927 let mut groups_sorted = Vec::with_capacity(rank);
1928 let mut dependence_sorted = Vec::with_capacity(rank);
1929 for (out_idx, orig_idx) in row_order.into_iter().enumerate() {
1930 a_sorted.row_mut(out_idx).assign(&a_out.row(orig_idx));
1931 b_sorted[out_idx] = b_out[orig_idx];
1932 groups_sorted.push(groups_out[orig_idx].clone());
1933 dependence_sorted.push(multiplier_dependence[orig_idx].clone());
1934 }
1935 a_out = a_sorted;
1936 b_out = b_sorted;
1937 groups_out = groups_sorted;
1938 multiplier_dependence = dependence_sorted;
1939 }
1940
1941 if rank < k {
1942 log::debug!(
1943 "rank-reduced active constraints from {} to {} rows (rank deficiency {})",
1944 k,
1945 rank,
1946 k - rank
1947 );
1948 }
1949
1950 (a_out, b_out, groups_out, multiplier_dependence)
1951}
1952
1953pub(crate) fn working_set_kkt_diagnostics_from_multipliers(
1954 x: &Array1<f64>,
1955 gradient: &Array1<f64>,
1956 working_constraints: &LinearInequalityConstraints,
1957 lambda_active_true: &Array1<f64>,
1958 n_total_constraints: usize,
1959) -> Result<ConstraintKktDiagnostics, EstimationError> {
1960 let p = working_constraints.a.ncols();
1961 if x.len() != p || gradient.len() != p {
1962 crate::bail_invalid_estim!("working-set KKT diagnostic dimension mismatch");
1963 }
1964 if lambda_active_true.len() != working_constraints.a.nrows() {
1965 crate::bail_invalid_estim!(
1966 "working-set KKT multiplier length mismatch: got {}, expected {}",
1967 lambda_active_true.len(),
1968 working_constraints.a.nrows()
1969 );
1970 }
1971 let m = working_constraints.a.nrows();
1982 let mut slack = Array1::<f64>::zeros(m);
1983 let mut primal_feasibility: f64 = 0.0;
1984 for i in 0..m {
1985 let s_i = scaled_constraint_slack(x, working_constraints, i);
1986 slack[i] = s_i;
1987 primal_feasibility = primal_feasibility.max((-s_i).max(0.0));
1988 }
1989
1990 let lambda = lambda_active_true.to_owned();
1991
1992 let mut dual_feasibility: f64 = 0.0;
1993 let mut complementarity: f64 = 0.0;
1994 for i in 0..m {
1995 dual_feasibility = dual_feasibility.max((-lambda[i]).max(0.0));
1996 let norm_i = working_constraints
2004 .a
2005 .row(i)
2006 .dot(&working_constraints.a.row(i))
2007 .sqrt();
2008 complementarity = complementarity.max((norm_i * lambda[i] * slack[i]).abs());
2009 }
2010 let stationarity = {
2011 let mut resid = gradient.to_owned();
2012 resid -= &working_constraints.a.t().dot(&lambda);
2013 resid.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()))
2014 };
2015
2016 Ok(ConstraintKktDiagnostics {
2017 n_constraints: n_total_constraints,
2018 n_active: m,
2019 primal_feasibility,
2020 dual_feasibility,
2021 complementarity,
2022 stationarity,
2023 active_tolerance: ACTIVE_SET_PRIMAL_FEASIBILITY_TOL,
2024 working_set_rank_deficient: false,
2031 gradient_scale: gradient_inf_norm(gradient),
2032 })
2033}
2034
2035fn canonicalize_active_constraint_ids(
2036 x: &Array1<f64>,
2037 constraints: &LinearInequalityConstraints,
2038 active: &[usize],
2039) -> Result<Vec<usize>, EstimationError> {
2040 if active.is_empty() {
2041 return Ok(Vec::new());
2042 }
2043 let compressed_working = compress_active_working_set(x, constraints, active)?;
2044 let mut canonical = Vec::with_capacity(compressed_working.groups.len());
2045 for group in &compressed_working.groups {
2046 if let Some(&active_pos) = group.first() {
2047 canonical.push(active[active_pos]);
2048 }
2049 }
2050 Ok(canonical)
2051}
2052
2053fn gather_linear_constraint_rows(
2054 constraints: &LinearInequalityConstraints,
2055 rows: &[usize],
2056) -> Result<LinearInequalityConstraints, EstimationError> {
2057 let p = constraints.a.ncols();
2058 let mut a = Array2::<f64>::zeros((rows.len(), p));
2059 let mut b = Array1::<f64>::zeros(rows.len());
2060 for (out, &row) in rows.iter().enumerate() {
2061 if row >= constraints.a.nrows() {
2062 crate::bail_invalid_estim!(
2063 "active constraint row {} out of bounds for {} rows",
2064 row,
2065 constraints.a.nrows()
2066 );
2067 }
2068 a.row_mut(out).assign(&constraints.a.row(row));
2069 b[out] = constraints.b[row];
2070 }
2071 LinearInequalityConstraints::new(a, b)
2072 .map_err(|error| EstimationError::ParameterConstraintViolation(error.to_string()))
2073}
2074
2075fn fallback_projected_gradient_direction(
2076 beta: &Array1<f64>,
2077 x: &Array1<f64>,
2078 d_total: &Array1<f64>,
2079 gradient: &Array1<f64>,
2080 working_constraints: &LinearInequalityConstraints,
2081 constraints: &LinearInequalityConstraints,
2082) -> Result<Option<(Array1<f64>, Vec<usize>)>, EstimationError> {
2083 let p = gradient.len();
2084 if x.len() != p || d_total.len() != p || beta.len() != p || constraints.a.ncols() != p {
2085 crate::bail_invalid_estim!("projected-gradient fallback dimension mismatch");
2086 }
2087
2088 let tangent_direction = if working_constraints.a.nrows() == 0 {
2097 -gradient
2098 } else {
2099 let Some((stationarity_residual, _multipliers)) =
2100 project_stationarity_residual_on_constraint_cone(gradient, &working_constraints.a)
2101 else {
2102 return Ok(None);
2103 };
2104 -stationarity_residual
2105 };
2106
2107 if !array_is_finite(&tangent_direction) {
2108 return Ok(None);
2109 }
2110
2111 let step_inf = tangent_direction
2112 .iter()
2113 .fold(0.0_f64, |acc, &value| acc.max(value.abs()));
2114 if step_inf <= 1e-12 {
2115 let (worst, _) = max_linear_constraint_violation(x, constraints);
2131 if worst > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
2132 let projected = project_point_strictly_into_feasible_cone(x, constraints)
2133 .or_else(|| {
2134 let identity = Array2::<f64>::eye(p);
2135 solve_quadratic_with_linear_constraints(&identity, x, x, constraints, None)
2136 .ok()
2137 .map(|(beta, _active)| beta)
2138 })
2139 .filter(|p_candidate| {
2140 max_linear_constraint_violation(p_candidate, constraints).0
2141 <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
2142 });
2143 let Some(projected) = projected else {
2144 return Ok(None);
2147 };
2148 let repair = &projected - x;
2149 let new_direction = d_total + &repair;
2150 let candidate = beta + &new_direction;
2154 if max_linear_constraint_violation(&candidate, constraints).0
2155 > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
2156 {
2157 return Ok(None);
2158 }
2159 let active = canonicalize_active_constraint_ids(&candidate, constraints, &[])?;
2160 return Ok(Some((new_direction, active)));
2161 }
2162 let active = canonicalize_active_constraint_ids(x, constraints, &[])?;
2163 return Ok(Some((d_total.clone(), active)));
2164 }
2165
2166 let directional_derivative = gradient.dot(&tangent_direction);
2167 if !directional_derivative.is_finite() || directional_derivative >= 0.0 {
2168 return Ok(None);
2169 }
2170
2171 let mut alpha = 1.0_f64;
2172 for i in 0..constraints.a.nrows() {
2173 let norm = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
2177 let inv = if norm > 0.0 { 1.0 / norm } else { 0.0 };
2178 let slack = (constraints.a.row(i).dot(x) - constraints.b[i]) * inv;
2179 let ai_d = constraints.a.row(i).dot(&tangent_direction) * inv;
2180 if let Some(candidate) = active_set_boundary_hit_step_fraction(slack, ai_d, alpha) {
2181 alpha = candidate;
2182 }
2183 }
2184 if !alpha.is_finite() || alpha <= 0.0 {
2185 return Ok(None);
2186 }
2187
2188 let fallback_step = tangent_direction * alpha;
2189 let new_direction = d_total + &fallback_step;
2190 let new_x = beta + &new_direction;
2193 let (worst, _) = max_linear_constraint_violation(&new_x, constraints);
2195 if worst > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
2196 return Ok(None);
2197 }
2198 let active = (0..constraints.a.nrows())
2199 .filter(|&i| scaled_constraint_slack(&new_x, constraints, i) <= 1e-10)
2200 .collect::<Vec<_>>();
2201 let active = canonicalize_active_constraint_ids(&new_x, constraints, &active)?;
2202 Ok(Some((new_direction, active)))
2203}
2204
2205fn log_active_set_transition(
2206 event: &str,
2207 iteration: usize,
2208 active_len: usize,
2209 constraint: Option<usize>,
2210) {
2211 log::debug!(
2212 "[active-set/QP] iter={} event={} active={} constraint={}",
2213 iteration,
2214 event,
2215 active_len,
2216 constraint
2217 .map(|idx| idx.to_string())
2218 .unwrap_or_else(|| "NA".to_string()),
2219 );
2220}
2221
2222fn record_active_working_set(
2233 visited: &mut HashSet<(Vec<usize>, Vec<u64>)>,
2234 active: &[usize],
2235 x: &Array1<f64>,
2236 iteration: usize,
2237) -> bool {
2238 let mut active_key = active.to_vec();
2239 active_key.sort_unstable();
2240 let point_key = x.iter().map(|value| value.to_bits()).collect::<Vec<_>>();
2241 if visited.insert((active_key.clone(), point_key)) {
2242 return true;
2243 }
2244 log::debug!(
2245 "[active-set/QP] iter={iteration} repeated working set at the identical primal point ({} rows); \
2246 deferring to the post-loop KKT exit gate",
2247 active_key.len()
2248 );
2249 false
2250}
2251
2252fn solve_newton_direction_with_linear_constraints_impl(
2253 hessian: &Array2<f64>,
2254 gradient: &Array1<f64>,
2255 beta: &Array1<f64>,
2256 constraints: &LinearInequalityConstraints,
2257 direction_out: &mut Array1<f64>,
2258 mut active_hint: Option<&mut Vec<usize>>,
2259 max_iterations: usize,
2260 allow_projected_gradient_fallback: bool,
2261) -> Result<(), EstimationError> {
2262 let p = gradient.len();
2263 if direction_out.len() != p {
2264 *direction_out = Array1::zeros(p);
2265 }
2266 let m = constraints.a.nrows();
2267 if constraints.a.ncols() != p || constraints.b.len() != m || beta.len() != p {
2268 crate::bail_invalid_estim!(
2269 "linear constraint shape mismatch: A={}x{}, b={}, p={}",
2270 constraints.a.nrows(),
2271 constraints.a.ncols(),
2272 constraints.b.len(),
2273 p
2274 );
2275 }
2276
2277 let tol_active = ACTIVE_SET_WORKING_FACE_TOL;
2278 let tol_step = 1e-12;
2279 let tol_dual = 1e-10;
2280 let mut x = beta.to_owned();
2281 let mut d_total = Array1::<f64>::zeros(p);
2282 let mut g_cur = gradient.to_owned();
2283
2284 if let Some(hint) = active_hint.as_mut() {
2293 hint.retain(|&idx| idx < m && scaled_constraint_slack(&x, constraints, idx) <= tol_active);
2294 }
2295
2296 let has_active_hint = active_hint
2297 .as_ref()
2298 .map(|hint| !hint.is_empty())
2299 .unwrap_or(false);
2300 if !has_active_hint && solve_newton_direction_dense(hessian, gradient, direction_out).is_ok() {
2301 let candidate = beta + &*direction_out;
2302 let mut feasible = true;
2303 for i in 0..m {
2304 let slack = scaled_constraint_slack(&candidate, constraints, i);
2307 if slack < -tol_active {
2308 feasible = false;
2309 break;
2310 }
2311 }
2312 if feasible {
2313 if let Some(hint) = active_hint.as_mut() {
2325 let mut tight: Vec<usize> = Vec::new();
2326 for i in 0..m {
2327 if scaled_constraint_slack(&candidate, constraints, i) <= tol_active {
2328 tight.push(i);
2329 }
2330 }
2331 hint.clear();
2332 hint.extend(canonicalize_active_constraint_ids(
2333 &candidate,
2334 constraints,
2335 &tight,
2336 )?);
2337 }
2338 return Ok(());
2339 }
2340 }
2341
2342 let mut active: Vec<usize> = Vec::new();
2343 let mut is_active = vec![false; m];
2344 if let Some(hint) = active_hint.as_ref() {
2345 for &idx in hint.iter() {
2346 if idx < m && !is_active[idx] {
2347 active.push(idx);
2348 is_active[idx] = true;
2349 log_active_set_transition("warm-add", 0, active.len(), Some(idx));
2350 }
2351 }
2352 }
2353 for i in 0..m {
2354 let slack = scaled_constraint_slack(&x, constraints, i);
2360 if slack <= tol_active && !is_active[i] {
2361 active.push(i);
2362 is_active[i] = true;
2363 log_active_set_transition("initial-boundary-add", 0, active.len(), Some(i));
2364 }
2365 }
2366 let mut visited_working_sets: HashSet<(Vec<usize>, Vec<u64>)> = HashSet::new();
2367 record_active_working_set(&mut visited_working_sets, &active, &x, 0);
2368
2369 let mut face_minimized = false;
2374
2375 for iteration in 0..max_iterations {
2376 let adjudicate_face = face_minimized;
2377 face_minimized = false;
2378 let compressed_working = compress_active_working_set(&x, constraints, &active)?;
2379 let mut residualw = Array1::<f64>::zeros(compressed_working.constraints.a.nrows());
2380 for r in 0..compressed_working.constraints.a.nrows() {
2381 residualw[r] = compressed_working.constraints.b[r]
2382 - compressed_working.constraints.a.row(r).dot(&x);
2383 }
2384 let (d, lambdaw) = solve_kkt_direction(
2385 hessian,
2386 &g_cur,
2387 &compressed_working.constraints.a,
2388 Some(&residualw),
2389 )?;
2390 let step_norm = d.iter().map(|v| v * v).sum::<f64>().sqrt();
2391 if step_norm <= tol_step || adjudicate_face {
2392 let (worst, worst_row) = max_linear_constraint_violation(&x, constraints);
2403 if worst > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL && !is_active[worst_row] {
2404 active.push(worst_row);
2405 is_active[worst_row] = true;
2406 log_active_set_transition(
2407 "stationary-infeasible-add",
2408 iteration,
2409 active.len(),
2410 Some(worst_row),
2411 );
2412 if !record_active_working_set(&mut visited_working_sets, &active, &x, iteration) {
2413 break;
2414 }
2415 continue;
2416 }
2417 if worst > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
2418 let worst_pos = active.iter().position(|&idx| idx == worst_row);
2421 let enforced = worst_pos.is_some_and(|pos| compressed_working.position_enforced(pos));
2422 if !enforced {
2423 if let Some(mut group) = compressed_working
2432 .over_complete_release_group(constraints.a.row(worst_row), &active)
2433 {
2434 group.sort_unstable_by(|a, b| b.cmp(a));
2435 let mut released = None;
2436 for active_pos in group {
2437 let idx = active.remove(active_pos);
2438 is_active[idx] = false;
2439 released = Some(idx);
2440 }
2441 log_active_set_transition(
2442 "release-over-complete-face",
2443 iteration,
2444 active.len(),
2445 released,
2446 );
2447 if !record_active_working_set(
2448 &mut visited_working_sets,
2449 &active,
2450 &x,
2451 iteration,
2452 ) {
2453 break;
2454 }
2455 continue;
2456 }
2457 }
2458 break;
2466 }
2467 if compressed_working.groups.is_empty() {
2468 direction_out.assign(&d_total);
2469 return Ok(());
2470 }
2471 let remove_group =
2472 compressed_working.negative_representative_group(&lambdaw, tol_dual, &active);
2473 if let Some(mut group) = remove_group {
2474 group.sort_unstable_by(|a, b| b.cmp(a));
2478 let mut released = None;
2479 for active_pos in group {
2480 let idx = active.remove(active_pos);
2481 is_active[idx] = false;
2482 released = Some(idx);
2483 }
2484 log_active_set_transition(
2485 "release-negative-representative",
2486 iteration,
2487 active.len(),
2488 released,
2489 );
2490 if !record_active_working_set(&mut visited_working_sets, &active, &x, iteration) {
2491 break;
2492 }
2493 continue;
2494 }
2495 if let Some(hint) = active_hint {
2496 hint.clear();
2497 hint.extend(canonicalize_active_constraint_ids(
2498 &x,
2499 constraints,
2500 &active,
2501 )?);
2502 }
2503 direction_out.assign(&d_total);
2504 return Ok(());
2505 }
2506
2507 let mut alpha = 1.0_f64;
2508 for i in 0..m {
2509 if is_active[i] {
2510 continue;
2511 }
2512 let norm = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
2519 let inv = if norm > 0.0 { 1.0 / norm } else { 0.0 };
2520 let slack = (constraints.a.row(i).dot(&x) - constraints.b[i]) * inv;
2521 let ai_d = constraints.a.row(i).dot(&d) * inv;
2522 if let Some(cand) = active_set_boundary_hit_step_fraction(slack, ai_d, alpha) {
2523 alpha = cand;
2524 }
2525 }
2526
2527 ndarray::Zip::from(&mut d_total)
2528 .and(&d)
2529 .for_each(|dt_i, &d_i| {
2530 *dt_i += alpha * d_i;
2531 });
2532 x = beta + &d_total;
2539 g_cur = gradient + &hessian.dot(&d_total);
2540
2541 let mut added_new_active = false;
2542 let mut working_set_repeated = false;
2543 for i in 0..m {
2544 if is_active[i] {
2545 continue;
2546 }
2547 let slack = scaled_constraint_slack(&x, constraints, i);
2548 if slack <= tol_active {
2549 active.push(i);
2550 is_active[i] = true;
2551 added_new_active = true;
2552 log_active_set_transition("blocking-add", iteration, active.len(), Some(i));
2553 working_set_repeated =
2554 !record_active_working_set(&mut visited_working_sets, &active, &x, iteration);
2555 break;
2556 }
2557 }
2558 if !added_new_active {
2559 face_minimized = true;
2562 }
2563 if working_set_repeated {
2564 break;
2565 }
2566
2567 if active.is_empty() && !added_new_active {
2568 if let Some(hint) = active_hint {
2569 hint.clear();
2570 }
2571 direction_out.assign(&d_total);
2572 return Ok(());
2573 }
2574 }
2575
2576 let compressed_working = compress_active_working_set(&x, constraints, &active)?;
2577 let mut residualw = Array1::<f64>::zeros(compressed_working.constraints.a.nrows());
2578 for r in 0..compressed_working.constraints.a.nrows() {
2579 residualw[r] =
2580 compressed_working.constraints.b[r] - compressed_working.constraints.a.row(r).dot(&x);
2581 }
2582 let (_, lambdaw) = solve_kkt_direction(
2583 hessian,
2584 &g_cur,
2585 &compressed_working.constraints.a,
2586 Some(&residualw),
2587 )?;
2588 let lambda_true = lambdaw.mapv(|lam_sys| -lam_sys);
2589 let (worst, row) = max_linear_constraint_violation(&x, constraints);
2590 let working_kkt = working_set_kkt_diagnostics_from_multipliers(
2591 &x,
2592 &g_cur,
2593 &compressed_working.constraints,
2594 &lambda_true,
2595 m,
2596 )?;
2597 let grad_inf = gradient_inf_norm(&g_cur);
2598 let stationarity_rel = working_kkt.stationarity / grad_inf.max(1.0);
2599 let step_inf = d_total.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
2600 let hd_total = hessian.dot(&d_total);
2601 let predicted_delta = gradient.dot(&d_total)
2602 + 0.5
2603 * d_total
2604 .iter()
2605 .zip(hd_total.iter())
2606 .map(|(a, b)| a * b)
2607 .sum::<f64>();
2608 let kkt_strong_ok = (working_kkt.stationarity <= ACTIVE_SET_KKT_STATIONARITY_TOL
2609 || stationarity_rel <= ACTIVE_SET_KKT_STATIONARITY_TOL)
2610 && working_kkt.complementarity <= ACTIVE_SET_KKT_COMPLEMENTARITY_TOL;
2611 let model_descent_ok =
2612 predicted_delta <= -ACTIVE_SET_MODEL_DESCENT_REL_TOL * (1.0 + grad_inf * step_inf);
2613 let degenerate_boundary_ok = compressed_working.is_degenerate_face()
2614 && worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
2615 && working_kkt.primal_feasibility <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
2616 && working_kkt.complementarity <= ACTIVE_SET_KKT_COMPLEMENTARITY_TOL
2617 && (working_kkt.stationarity <= ACTIVE_SET_KKT_DEGENERATE_STATIONARITY_TOL
2618 || stationarity_rel <= ACTIVE_SET_KKT_STATIONARITY_TOL);
2619 let strong_path_accepts =
2633 kkt_strong_ok && working_kkt.dual_feasibility <= ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL;
2634 let nnls_certified = worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL && !strong_path_accepts && {
2635 let tight: Vec<usize> = (0..m)
2636 .filter(|&i| scaled_constraint_slack(&x, constraints, i) <= tol_active)
2637 .collect();
2638 match gather_linear_constraint_rows(constraints, &tight) {
2639 Ok(gathered) => nonnegative_cone_multipliers(&gathered.a, &g_cur)
2640 .map(|(_, projected)| {
2641 let closure = projected.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
2642 closure <= ACTIVE_SET_KKT_STATIONARITY_TOL
2643 || closure / grad_inf.max(1.0) <= ACTIVE_SET_KKT_STATIONARITY_TOL
2644 })
2645 .unwrap_or(false),
2646 Err(_) => false,
2647 }
2648 };
2649 if worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
2650 && ((working_kkt.dual_feasibility <= ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL
2651 && (kkt_strong_ok || (allow_projected_gradient_fallback && model_descent_ok)))
2652 || degenerate_boundary_ok
2653 || nnls_certified)
2654 {
2655 if let Some(hint) = active_hint {
2656 hint.clear();
2657 hint.extend(canonicalize_active_constraint_ids(
2658 &x,
2659 constraints,
2660 &active,
2661 )?);
2662 }
2663 direction_out.assign(&d_total);
2664 return Ok(());
2665 }
2666 if !allow_projected_gradient_fallback {
2667 return Err(EstimationError::ParameterConstraintViolation(format!(
2668 "linear-constrained Newton active-set did not certify the strict-convex projection QP; max(Aβ-b violation)={worst:.3e} at row {row}; KKT[primal={:.3e}, dual={:.3e}, comp={:.3e}, stat={:.3e}, active={}/{}]",
2669 working_kkt.primal_feasibility,
2670 working_kkt.dual_feasibility,
2671 working_kkt.complementarity,
2672 working_kkt.stationarity,
2673 working_kkt.n_active,
2674 working_kkt.n_constraints,
2675 )));
2676 }
2677 let kkt = compute_constraint_kkt_diagnostics(&x, &g_cur, constraints);
2678 let fallback_working = gather_linear_constraint_rows(constraints, &active)?;
2679 if let Some((fallback_direction, fallback_active)) = fallback_projected_gradient_direction(
2680 beta,
2681 &x,
2682 &d_total,
2683 &g_cur,
2684 &fallback_working,
2685 constraints,
2686 )? {
2687 if let Some(hint) = active_hint {
2688 hint.clear();
2689 hint.extend(fallback_active);
2690 }
2691 direction_out.assign(&fallback_direction);
2692 return Ok(());
2693 }
2694 Err(EstimationError::ParameterConstraintViolation(format!(
2695 "linear-constrained Newton active-set failed to converge; max(Aβ-b violation)={worst:.3e} at row {row}; KKT[primal={:.3e}, dual={:.3e}, comp={:.3e}, stat={:.3e}, active={}/{}]; diagnostic-reconstruction[dual={:.3e}, stat={:.3e}]",
2696 working_kkt.primal_feasibility,
2697 working_kkt.dual_feasibility,
2698 working_kkt.complementarity,
2699 working_kkt.stationarity,
2700 working_kkt.n_active,
2701 working_kkt.n_constraints,
2702 kkt.dual_feasibility,
2703 kkt.stationarity
2704 )))
2705}
2706
2707struct ConstraintSetOps<'a> {
2729 set: &'a ConstraintSet,
2730 norms: Vec<f64>,
2731 bounds: Vec<f64>,
2732 scaled_margin: f64,
2733}
2734
2735impl<'a> ConstraintSetOps<'a> {
2736 fn new(set: &'a ConstraintSet, scaled_margin: f64) -> Result<Self, EstimationError> {
2737 let m = set.nrows();
2738 let mut norms = Vec::with_capacity(m);
2739 let mut bounds = Vec::with_capacity(m);
2740 for row in 0..m {
2741 norms.push(set.row_norm(row).map_err(|e| {
2742 EstimationError::ParameterConstraintViolation(format!(
2743 "constraint-set row norm: {e}"
2744 ))
2745 })?);
2746 bounds.push(set.bound(row).map_err(|e| {
2747 EstimationError::ParameterConstraintViolation(format!(
2748 "constraint-set row bound: {e}"
2749 ))
2750 })?);
2751 }
2752 Ok(Self {
2753 set,
2754 norms,
2755 bounds,
2756 scaled_margin,
2757 })
2758 }
2759
2760 fn tangent_face(set: &'a ConstraintSet, beta: &Array1<f64>) -> Result<Self, EstimationError> {
2766 let mut ops = Self::new(set, 0.0)?;
2767 let values = ops.values(beta)?;
2768 for row in 0..ops.nrows() {
2769 if ops.norms[row] <= 0.0 {
2770 if ops.bounds[row] > 0.0 {
2771 crate::bail_invalid_estim!(
2772 "infeasible zero-norm constraint row {} entered tangent-face projection",
2773 row
2774 );
2775 }
2776 ops.bounds[row] = 0.0;
2777 continue;
2778 }
2779 let is_tight = ops.scaled_slack(&values, row) <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL;
2780 ops.bounds[row] = 0.0;
2783 if !is_tight {
2784 ops.norms[row] = 0.0;
2785 }
2786 }
2787 Ok(ops)
2788 }
2789
2790 fn nrows(&self) -> usize {
2791 self.norms.len()
2792 }
2793
2794 fn values(&self, x: &Array1<f64>) -> Result<Array1<f64>, EstimationError> {
2795 self.set.values(x.view()).map_err(|e| {
2796 EstimationError::ParameterConstraintViolation(format!("constraint-set values: {e}"))
2797 })
2798 }
2799
2800 #[inline]
2803 fn scaled_slack(&self, values: &Array1<f64>, row: usize) -> f64 {
2804 let norm = self.norms[row];
2805 if norm > 0.0 {
2806 (values[row] - self.bounds[row]) / norm - self.scaled_margin
2807 } else if self.bounds[row] > 0.0 {
2808 f64::NEG_INFINITY
2809 } else {
2810 f64::INFINITY
2811 }
2812 }
2813
2814 fn max_violation(&self, values: &Array1<f64>) -> (f64, usize) {
2815 let mut worst = 0.0_f64;
2816 let mut worst_row = 0usize;
2817 for row in 0..self.nrows() {
2818 let violation = (-self.scaled_slack(values, row)).max(0.0);
2819 if violation > worst {
2820 worst = violation;
2821 worst_row = row;
2822 }
2823 }
2824 (worst, worst_row)
2825 }
2826
2827 fn gather_unit_rows(
2832 &self,
2833 rows: &[usize],
2834 ) -> Result<LinearInequalityConstraints, EstimationError> {
2835 let mut gathered = self.set.gather_rows(rows).map_err(|e| {
2836 EstimationError::ParameterConstraintViolation(format!(
2837 "constraint-set working-row gather: {e}"
2838 ))
2839 })?;
2840 for (out_row, &row) in rows.iter().enumerate() {
2841 let norm = self.norms[row];
2842 if norm <= 0.0 {
2843 crate::bail_invalid_estim!(
2844 "vacuous zero-norm constraint row {} entered the working set",
2845 row
2846 );
2847 }
2848 let inv = 1.0 / norm;
2849 gathered.a.row_mut(out_row).mapv_inplace(|v| v * inv);
2850 gathered.b[out_row] = self.bounds[row] * inv + self.scaled_margin;
2851 }
2852 Ok(gathered)
2853 }
2854
2855 fn compress_working(
2858 &self,
2859 active: &[usize],
2860 ) -> Result<CompressedActiveWorkingSet, EstimationError> {
2861 let gathered = self.gather_unit_rows(active)?;
2862 let groups: Vec<Vec<usize>> = (0..active.len()).map(|pos| vec![pos]).collect();
2863 let (a_out, b_out, groups_out, _) =
2866 rank_reduce_rows_pivoted_qr_with_dependence(gathered.a, gathered.b, groups);
2867 Ok(CompressedActiveWorkingSet {
2868 constraints: LinearInequalityConstraints::new(a_out, b_out)
2869 .expect("compressed operator working-set shape invariant"),
2870 groups: groups_out,
2871 original_active_count: active.len(),
2872 })
2873 }
2874}
2875
2876pub fn constraint_set_rows_tight_at_point(
2885 set: &ConstraintSet,
2886 beta: &Array1<f64>,
2887 candidate_rows: &[usize],
2888) -> Result<Vec<usize>, EstimationError> {
2889 if set.ncols() != beta.len() {
2890 crate::bail_invalid_estim!(
2891 "active-face point dimension mismatch: set has {} columns, beta has {}",
2892 set.ncols(),
2893 beta.len()
2894 );
2895 }
2896 let mut seen = HashSet::with_capacity(candidate_rows.len());
2897 let mut unique = Vec::with_capacity(candidate_rows.len());
2898 for &row in candidate_rows {
2899 if row < set.nrows() && seen.insert(row) {
2900 unique.push(row);
2901 }
2902 }
2903 if unique.is_empty() {
2904 return Ok(Vec::new());
2905 }
2906 let gathered = set.gather_rows(&unique).map_err(|error| {
2907 EstimationError::ParameterConstraintViolation(format!(
2908 "active-face candidate-row gather failed: {error}"
2909 ))
2910 })?;
2911 let mut tight = Vec::with_capacity(unique.len());
2912 for (position, &row) in unique.iter().enumerate() {
2913 let constraint_row = gathered.a.row(position);
2914 let norm = constraint_row.dot(&constraint_row).sqrt();
2915 if norm > 0.0 {
2916 let scaled_slack = (constraint_row.dot(beta) - gathered.b[position]) / norm;
2917 if scaled_slack <= ACTIVE_SET_WORKING_FACE_TOL {
2918 tight.push(row);
2919 }
2920 }
2921 }
2922 Ok(tight)
2923}
2924
2925pub fn project_stationarity_residual_on_constraint_set(
2933 residual: &Array1<f64>,
2934 beta: &Array1<f64>,
2935 set: &ConstraintSet,
2936 seed_active: &[usize],
2937) -> Option<(Array1<f64>, Vec<usize>)> {
2938 let p = residual.len();
2939 if beta.len() != p || set.ncols() != p {
2940 return None;
2941 }
2942 match set {
2943 ConstraintSet::KhatriRaoCone(cone) if cone.p_left() != 1 || cone.coupled_rows() != &[0] => {
2944 let p_cov = cone.factor().ncols();
2950 let n = cone.factor().nrows();
2951 let mut projected = residual.clone();
2952 let mut active = Vec::new();
2953 for (slot, &coefficient_row) in cone.coupled_rows().iter().enumerate() {
2954 let start = coefficient_row * p_cov;
2955 let end = start + p_cov;
2956 let local_residual = residual.slice(s![start..end]).to_owned();
2957 let local_beta = beta.slice(s![start..end]).to_owned();
2958 let local_set = ConstraintSet::KhatriRaoCone(cone.single_coupled_slot(slot).ok()?);
2959 let row_start = slot * n;
2960 let row_end = row_start + n;
2961 let local_seed: Vec<usize> = seed_active
2962 .iter()
2963 .copied()
2964 .filter(|&row| row >= row_start && row < row_end)
2965 .map(|row| row - row_start)
2966 .collect();
2967 let (local_projected, local_active) =
2968 project_stationarity_residual_on_constraint_set(
2969 &local_residual,
2970 &local_beta,
2971 &local_set,
2972 &local_seed,
2973 )?;
2974 projected.slice_mut(s![start..end]).assign(&local_projected);
2975 active.extend(local_active.into_iter().map(|row| row_start + row));
2976 }
2977 Some((projected, active))
2978 }
2979 ConstraintSet::BlockDiagonal { blocks, .. } => {
2980 let mut projected = residual.clone();
2984 let mut active = Vec::new();
2985 let mut row_offset = 0usize;
2986 for block in blocks {
2987 let width = block.set.ncols();
2988 let start = block.col_start;
2989 let end = start + width;
2990 let local_residual = residual.slice(s![start..end]).to_owned();
2991 let local_beta = beta.slice(s![start..end]).to_owned();
2992 let row_end = row_offset + block.set.nrows();
2993 let local_seed: Vec<usize> = seed_active
2994 .iter()
2995 .copied()
2996 .filter(|&row| row >= row_offset && row < row_end)
2997 .map(|row| row - row_offset)
2998 .collect();
2999 let (local_projected, local_active) =
3000 project_stationarity_residual_on_constraint_set(
3001 &local_residual,
3002 &local_beta,
3003 &block.set,
3004 &local_seed,
3005 )?;
3006 projected.slice_mut(s![start..end]).assign(&local_projected);
3007 active.extend(local_active.into_iter().map(|row| row_offset + row));
3008 row_offset = row_end;
3009 }
3010 Some((projected, active))
3011 }
3012 _ => project_stationarity_residual_on_constraint_set_undivided(
3013 residual,
3014 beta,
3015 set,
3016 seed_active,
3017 ),
3018 }
3019}
3020
3021fn project_stationarity_residual_on_constraint_set_undivided(
3022 residual: &Array1<f64>,
3023 beta: &Array1<f64>,
3024 set: &ConstraintSet,
3025 seed_active: &[usize],
3026) -> Option<(Array1<f64>, Vec<usize>)> {
3027 let p = residual.len();
3028 let ops = ConstraintSetOps::tangent_face(set, beta).ok()?;
3029 let mut active = Vec::with_capacity(seed_active.len().min(p));
3030 for &row in seed_active {
3031 if row < ops.nrows() && ops.norms[row] > 0.0 && !active.contains(&row) {
3032 active.push(row);
3033 }
3034 }
3035
3036 let identity = Array2::<f64>::eye(p);
3048 let origin = Array1::<f64>::zeros(p);
3049 let mut tangent_direction = Array1::<f64>::zeros(p);
3050 let max_iterations = (p + active.len() + 8) * 4;
3051 if let Err(error) = solve_newton_direction_with_constraint_set_impl(
3052 &identity,
3053 residual,
3054 &origin,
3055 &ops,
3056 &mut tangent_direction,
3057 Some(&mut active),
3058 max_iterations,
3059 false,
3060 ) {
3061 log::warn!(
3062 "factored tangent-cone projection QP failed \
3063 (p={p}, rows={}, seed_active_rows={}, residual_inf={:.6e}): {error}; \
3064 attempting the direct Lawson-Hanson Moreau fallback on the final working face",
3065 ops.nrows(),
3066 seed_active.len(),
3067 residual
3068 .iter()
3069 .fold(0.0_f64, |scale, value| scale.max(value.abs())),
3070 );
3071 return nnls_tangent_cone_projection_fallback(residual, beta, set, &active, seed_active);
3072 }
3073 if !array_is_finite(&tangent_direction) {
3074 return nnls_tangent_cone_projection_fallback(residual, beta, set, &active, seed_active);
3075 }
3076 Some((-tangent_direction, active))
3077}
3078
3079fn nnls_tangent_cone_projection_fallback(
3099 residual: &Array1<f64>,
3100 beta: &Array1<f64>,
3101 set: &ConstraintSet,
3102 face_rows: &[usize],
3103 seed_active: &[usize],
3104) -> Option<(Array1<f64>, Vec<usize>)> {
3105 let mut candidates: Vec<usize> = Vec::with_capacity(face_rows.len() + seed_active.len());
3106 for &row in face_rows.iter().chain(seed_active.iter()) {
3107 if row < set.nrows() && !candidates.contains(&row) {
3108 candidates.push(row);
3109 }
3110 }
3111 if candidates.is_empty() {
3112 return Some((residual.clone(), Vec::new()));
3116 }
3117 let candidate_rows = set.gather_rows(&candidates).ok()?;
3118 let mut tight = Vec::with_capacity(candidates.len());
3121 let mut tight_a = Vec::with_capacity(candidates.len());
3122 for (position, &row) in candidates.iter().enumerate() {
3123 let constraint_row = candidate_rows.a.row(position);
3124 let norm = constraint_row.dot(&constraint_row).sqrt();
3125 if norm > 0.0
3126 && (constraint_row.dot(beta) - candidate_rows.b[position]) / norm
3127 <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
3128 {
3129 tight.push(row);
3130 tight_a.push(position);
3131 }
3132 }
3133 if tight.is_empty() {
3134 return Some((residual.clone(), Vec::new()));
3135 }
3136 let mut generators = Array2::<f64>::zeros((tight.len(), residual.len()));
3137 for (out_row, &position) in tight_a.iter().enumerate() {
3138 generators
3139 .row_mut(out_row)
3140 .assign(&candidate_rows.a.row(position));
3141 }
3142 let (lambda, projected) = nonnegative_cone_multipliers(&generators, residual)?;
3143 let active: Vec<usize> = tight
3144 .iter()
3145 .zip(lambda.iter())
3146 .filter(|&(_, &multiplier)| multiplier > 0.0)
3147 .map(|(&row, _)| row)
3148 .collect();
3149 log::info!(
3150 "tangent-cone Moreau fallback certified the projection the primal QP refused: \
3151 face_rows={} tight_rows={} supported_rows={} projected_inf={:.6e}",
3152 face_rows.len(),
3153 tight.len(),
3154 active.len(),
3155 projected
3156 .iter()
3157 .fold(0.0_f64, |scale, value| scale.max(value.abs())),
3158 );
3159 Some((projected, active))
3160}
3161
3162fn fallback_projected_gradient_direction_with_constraint_set(
3176 beta: &Array1<f64>,
3177 x: &Array1<f64>,
3178 d_total: &Array1<f64>,
3179 gradient: &Array1<f64>,
3180 active: &[usize],
3181 ops: &ConstraintSetOps<'_>,
3182) -> Result<Option<(Array1<f64>, Vec<usize>)>, EstimationError> {
3183 let p = gradient.len();
3184 if x.len() != p || d_total.len() != p || beta.len() != p || ops.set.ncols() != p {
3185 crate::bail_invalid_estim!("operator projected-gradient fallback dimension mismatch");
3186 }
3187
3188 let values_x = ops.values(x)?;
3189 let Some((stationarity_residual, mut tangent_active)) =
3190 project_stationarity_residual_on_constraint_set(gradient, x, ops.set, active)
3191 else {
3192 return Ok(None);
3193 };
3194 let tangent_direction = -stationarity_residual;
3195 let step_inf = tangent_direction
3196 .iter()
3197 .fold(0.0_f64, |acc, &value| acc.max(value.abs()));
3198 if step_inf <= 1e-12 {
3199 let (worst, _) = ops.max_violation(&values_x);
3200 if worst > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
3201 let Some(projected) = project_point_strictly_into_feasible_constraint_set(x, ops.set)
3202 .ok()
3203 .filter(|candidate| {
3204 ops.values(candidate)
3205 .map(|candidate_values| {
3206 ops.max_violation(&candidate_values).0
3207 <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
3208 })
3209 .unwrap_or(false)
3210 })
3211 else {
3212 return Ok(None);
3213 };
3214 let repair = &projected - x;
3215 let new_direction = d_total + &repair;
3216 let candidate = beta + &new_direction;
3220 let candidate_values = ops.values(&candidate)?;
3221 if ops.max_violation(&candidate_values).0 > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
3222 return Ok(None);
3223 }
3224 return Ok(Some((new_direction, Vec::new())));
3225 }
3226 return Ok(Some((d_total.clone(), tangent_active)));
3227 }
3228
3229 let directional_derivative = gradient.dot(&tangent_direction);
3230 if !directional_derivative.is_finite() || directional_derivative >= 0.0 {
3231 return Ok(None);
3232 }
3233 let values_direction = ops.values(&tangent_direction)?;
3234 let mut alpha = 1.0_f64;
3235 let mut blocking_row = None;
3236 for row in 0..ops.nrows() {
3237 if ops.norms[row] <= 0.0 {
3238 continue;
3239 }
3240 let slack = ops.scaled_slack(&values_x, row);
3241 let rate = values_direction[row] / ops.norms[row];
3242 if let Some(candidate) = active_set_boundary_hit_step_fraction(slack, rate, alpha) {
3243 alpha = candidate;
3244 blocking_row = Some(row);
3245 }
3246 }
3247 if !alpha.is_finite() || alpha <= 0.0 {
3248 return Ok(None);
3249 }
3250 let fallback_step = tangent_direction * alpha;
3251 let new_direction = d_total + &fallback_step;
3252 let new_x = beta + &new_direction;
3255 let new_values = ops.values(&new_x)?;
3256 if ops.max_violation(&new_values).0 > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
3257 return Ok(None);
3258 }
3259 if let Some(row) = blocking_row
3260 && !tangent_active.contains(&row)
3261 {
3262 tangent_active.push(row);
3263 }
3264 tangent_active.retain(|&row| ops.scaled_slack(&new_values, row) <= 1e-10);
3265 Ok(Some((new_direction, tangent_active)))
3266}
3267
3268fn solve_newton_direction_with_constraint_set_impl(
3269 hessian: &Array2<f64>,
3270 gradient: &Array1<f64>,
3271 beta: &Array1<f64>,
3272 ops: &ConstraintSetOps<'_>,
3273 direction_out: &mut Array1<f64>,
3274 mut active_hint: Option<&mut Vec<usize>>,
3275 max_iterations: usize,
3276 allow_projected_gradient_fallback: bool,
3277) -> Result<(), EstimationError> {
3278 let p = gradient.len();
3279 if direction_out.len() != p {
3280 *direction_out = Array1::zeros(p);
3281 }
3282 let m = ops.nrows();
3283 if ops.set.ncols() != p || beta.len() != p {
3284 crate::bail_invalid_estim!(
3285 "constraint-set shape mismatch: set={}x{}, p={}",
3286 m,
3287 ops.set.ncols(),
3288 p
3289 );
3290 }
3291
3292 let tol_active = ACTIVE_SET_WORKING_FACE_TOL;
3293 let tol_step = 1e-12;
3294 let tol_dual = 1e-10;
3295 let mut x = beta.to_owned();
3296 let mut d_total = Array1::<f64>::zeros(p);
3297 let mut g_cur = gradient.to_owned();
3298 let mut values_x = ops.values(&x)?;
3299
3300 if let Some(hint) = active_hint.as_mut() {
3307 hint.retain(|&idx| {
3308 idx < m && ops.norms[idx] > 0.0 && ops.scaled_slack(&values_x, idx) <= tol_active
3309 });
3310 }
3311
3312 let has_active_hint = active_hint
3313 .as_ref()
3314 .map(|hint| !hint.is_empty())
3315 .unwrap_or(false);
3316 if !has_active_hint && solve_newton_direction_dense(hessian, gradient, direction_out).is_ok() {
3317 let candidate = beta + &*direction_out;
3318 let candidate_values = ops.values(&candidate)?;
3319 let feasible = (0..m).all(|row| ops.scaled_slack(&candidate_values, row) >= -tol_active);
3320 if feasible {
3321 return Ok(());
3328 }
3329 }
3330
3331 let mut active: Vec<usize> = Vec::new();
3332 let mut is_active = vec![false; m];
3333 if let Some(hint) = active_hint.as_ref() {
3334 for &idx in hint.iter() {
3335 if idx < m && !is_active[idx] && ops.norms[idx] > 0.0 {
3336 active.push(idx);
3337 is_active[idx] = true;
3338 log_active_set_transition("warm-add", 0, active.len(), Some(idx));
3339 }
3340 }
3341 }
3342 let mut visited_working_sets: HashSet<(Vec<usize>, Vec<u64>)> = HashSet::new();
3357 record_active_working_set(&mut visited_working_sets, &active, &x, 0);
3358
3359 let mut count_blocking_add = 0usize;
3364 let mut count_stationary_add = 0usize;
3365 let mut count_release = 0usize;
3366 let mut ws_repeat_break = false;
3367 let mut iterations_used = 0usize;
3368 let mut face_minimized = false;
3377
3378 for iteration in 0..max_iterations {
3379 iterations_used = iteration + 1;
3380 let adjudicate_face = face_minimized;
3381 face_minimized = false;
3382 let compressed_working = ops.compress_working(&active)?;
3383 let mut residualw = Array1::<f64>::zeros(compressed_working.constraints.a.nrows());
3384 for r in 0..compressed_working.constraints.a.nrows() {
3385 residualw[r] = compressed_working.constraints.b[r]
3386 - compressed_working.constraints.a.row(r).dot(&x);
3387 }
3388 let (d, lambdaw) = solve_kkt_direction(
3389 hessian,
3390 &g_cur,
3391 &compressed_working.constraints.a,
3392 Some(&residualw),
3393 )?;
3394 let step_norm = d.iter().map(|v| v * v).sum::<f64>().sqrt();
3395 if step_norm <= tol_step || adjudicate_face {
3396 let (worst, worst_row) = ops.max_violation(&values_x);
3397 if worst > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL && !is_active[worst_row] {
3398 active.push(worst_row);
3399 is_active[worst_row] = true;
3400 count_stationary_add += 1;
3401 log_active_set_transition(
3402 "stationary-infeasible-add",
3403 iteration,
3404 active.len(),
3405 Some(worst_row),
3406 );
3407 if !record_active_working_set(&mut visited_working_sets, &active, &x, iteration) {
3408 ws_repeat_break = true;
3409 break;
3410 }
3411 continue;
3412 }
3413 if worst > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
3414 let worst_pos = active.iter().position(|&idx| idx == worst_row);
3424 let enforced =
3425 worst_pos.is_some_and(|pos| compressed_working.position_enforced(pos));
3426 if !enforced {
3427 let violated_unit = ops.gather_unit_rows(&[worst_row])?;
3428 if let Some(mut group) = compressed_working
3429 .over_complete_release_group(violated_unit.a.row(0), &active)
3430 {
3431 group.sort_unstable_by(|a, b| b.cmp(a));
3432 let mut released = None;
3433 for active_pos in group {
3434 let idx = active.remove(active_pos);
3435 is_active[idx] = false;
3436 count_release += 1;
3437 released = Some(idx);
3438 }
3439 log_active_set_transition(
3440 "release-over-complete-face",
3441 iteration,
3442 active.len(),
3443 released,
3444 );
3445 if !record_active_working_set(
3446 &mut visited_working_sets,
3447 &active,
3448 &x,
3449 iteration,
3450 ) {
3451 ws_repeat_break = true;
3452 break;
3453 }
3454 continue;
3455 }
3456 }
3457 break;
3458 }
3459 if compressed_working.groups.is_empty() {
3460 direction_out.assign(&d_total);
3461 return Ok(());
3462 }
3463 let remove_group =
3464 compressed_working.negative_representative_group(&lambdaw, tol_dual, &active);
3465 if let Some(mut group) = remove_group {
3466 group.sort_unstable_by(|a, b| b.cmp(a));
3470 let mut released = None;
3471 for active_pos in group {
3472 let idx = active.remove(active_pos);
3473 is_active[idx] = false;
3474 count_release += 1;
3475 released = Some(idx);
3476 }
3477 log_active_set_transition(
3478 "release-negative-representative",
3479 iteration,
3480 active.len(),
3481 released,
3482 );
3483 if !record_active_working_set(&mut visited_working_sets, &active, &x, iteration) {
3484 ws_repeat_break = true;
3485 break;
3486 }
3487 continue;
3488 }
3489 if let Some(hint) = active_hint.as_mut() {
3490 hint.clear();
3491 let compressed = ops.compress_working(&active)?;
3492 for group in &compressed.groups {
3493 if let Some(&active_pos) = group.first() {
3494 hint.push(active[active_pos]);
3495 }
3496 }
3497 }
3498 direction_out.assign(&d_total);
3499 return Ok(());
3500 }
3501
3502 let values_d = ops.values(&d)?;
3503 let mut alpha = 1.0_f64;
3504 let mut blocking_row: Option<usize> = None;
3505 for row in 0..m {
3506 if is_active[row] || ops.norms[row] <= 0.0 {
3507 continue;
3508 }
3509 let slack = ops.scaled_slack(&values_x, row);
3510 let rate = values_d[row] / ops.norms[row];
3511 if let Some(cand) = active_set_boundary_hit_step_fraction(slack, rate, alpha) {
3512 alpha = cand;
3513 blocking_row = Some(row);
3514 }
3515 }
3516
3517 ndarray::Zip::from(&mut d_total)
3518 .and(&d)
3519 .for_each(|dt_i, &d_i| {
3520 *dt_i += alpha * d_i;
3521 });
3522 x = beta + &d_total;
3527 g_cur = gradient + &hessian.dot(&d_total);
3528 values_x = ops.values(&x)?;
3529
3530 let mut added_new_active = false;
3531 let mut working_set_repeated = false;
3532 if let Some(row) = blocking_row {
3533 active.push(row);
3534 is_active[row] = true;
3535 added_new_active = true;
3536 count_blocking_add += 1;
3537 log_active_set_transition("blocking-add", iteration, active.len(), Some(row));
3538 working_set_repeated =
3539 !record_active_working_set(&mut visited_working_sets, &active, &x, iteration);
3540 } else {
3541 face_minimized = true;
3545 }
3546 if working_set_repeated {
3547 ws_repeat_break = true;
3548 break;
3549 }
3550
3551 let primal_step_norm = alpha.abs() * step_norm;
3567 if allow_projected_gradient_fallback && added_new_active && primal_step_norm <= tol_step {
3568 if let Some((fallback_direction, fallback_active)) =
3569 fallback_projected_gradient_direction_with_constraint_set(
3570 beta, &x, &d_total, &g_cur, &active, ops,
3571 )?
3572 {
3573 if let Some(hint) = active_hint.as_mut() {
3574 hint.clear();
3575 hint.extend(fallback_active);
3576 }
3577 direction_out.assign(&fallback_direction);
3578 return Ok(());
3579 }
3580 }
3581
3582 if active.is_empty() && !added_new_active {
3583 if let Some(hint) = active_hint.as_mut() {
3584 hint.clear();
3585 }
3586 direction_out.assign(&d_total);
3587 return Ok(());
3588 }
3589 }
3590
3591 let compressed_working = ops.compress_working(&active)?;
3595 let mut residualw = Array1::<f64>::zeros(compressed_working.constraints.a.nrows());
3596 for r in 0..compressed_working.constraints.a.nrows() {
3597 residualw[r] =
3598 compressed_working.constraints.b[r] - compressed_working.constraints.a.row(r).dot(&x);
3599 }
3600 let (_, lambdaw) = solve_kkt_direction(
3601 hessian,
3602 &g_cur,
3603 &compressed_working.constraints.a,
3604 Some(&residualw),
3605 )?;
3606 let lambda_true = lambdaw.mapv(|lam_sys| -lam_sys);
3607 let (worst, row) = ops.max_violation(&values_x);
3608 let working_kkt = working_set_kkt_diagnostics_from_multipliers(
3609 &x,
3610 &g_cur,
3611 &compressed_working.constraints,
3612 &lambda_true,
3613 m,
3614 )?;
3615 let grad_inf = gradient_inf_norm(&g_cur);
3616 let stationarity_rel = working_kkt.stationarity / grad_inf.max(1.0);
3617 let step_inf = d_total.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
3618 let hd_total = hessian.dot(&d_total);
3619 let predicted_delta = gradient.dot(&d_total)
3620 + 0.5
3621 * d_total
3622 .iter()
3623 .zip(hd_total.iter())
3624 .map(|(a, b)| a * b)
3625 .sum::<f64>();
3626 let kkt_strong_ok = (working_kkt.stationarity <= ACTIVE_SET_KKT_STATIONARITY_TOL
3627 || stationarity_rel <= ACTIVE_SET_KKT_STATIONARITY_TOL)
3628 && working_kkt.complementarity <= ACTIVE_SET_KKT_COMPLEMENTARITY_TOL;
3629 let model_descent_ok =
3630 predicted_delta <= -ACTIVE_SET_MODEL_DESCENT_REL_TOL * (1.0 + grad_inf * step_inf);
3631 let degenerate_boundary_ok = compressed_working.is_degenerate_face()
3632 && worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
3633 && working_kkt.primal_feasibility <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
3634 && working_kkt.complementarity <= ACTIVE_SET_KKT_COMPLEMENTARITY_TOL
3635 && (working_kkt.stationarity <= ACTIVE_SET_KKT_DEGENERATE_STATIONARITY_TOL
3636 || stationarity_rel <= ACTIVE_SET_KKT_STATIONARITY_TOL);
3637 let strong_path_accepts =
3645 kkt_strong_ok && working_kkt.dual_feasibility <= ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL;
3646 let mut nnls_closure: Option<(f64, usize)> = None;
3647 let nnls_certified = worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL && !strong_path_accepts && {
3648 let tight: Vec<usize> = (0..m)
3649 .filter(|&i| {
3650 ops.norms[i] > 0.0 && (values_x[i] - ops.bounds[i]) / ops.norms[i] <= tol_active
3651 })
3652 .collect();
3653 let tight_len = tight.len();
3654 match ops.set.gather_rows(&tight) {
3655 Ok(gathered) => nonnegative_cone_multipliers(&gathered.a, &g_cur)
3656 .map(|(_, projected)| {
3657 let closure = projected.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
3658 nnls_closure = Some((closure, tight_len));
3659 closure <= ACTIVE_SET_KKT_STATIONARITY_TOL
3660 || closure / grad_inf.max(1.0) <= ACTIVE_SET_KKT_STATIONARITY_TOL
3661 })
3662 .unwrap_or(false),
3663 Err(_) => false,
3664 }
3665 };
3666 if worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
3667 && ((working_kkt.dual_feasibility <= ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL
3668 && (kkt_strong_ok || (allow_projected_gradient_fallback && model_descent_ok)))
3669 || degenerate_boundary_ok
3670 || nnls_certified)
3671 {
3672 if let Some(hint) = active_hint.as_mut() {
3673 hint.clear();
3674 for group in &compressed_working.groups {
3675 if let Some(&active_pos) = group.first() {
3676 hint.push(active[active_pos]);
3677 }
3678 }
3679 }
3680 direction_out.assign(&d_total);
3681 return Ok(());
3682 }
3683 let nnls_diag = match nnls_closure {
3684 Some((closure, tight_len)) => format!(
3685 "nnls_closure={closure:.3e} (tol={ACTIVE_SET_KKT_STATIONARITY_TOL:.1e}) over {tight_len} tight rows"
3686 ),
3687 None => "nnls_closure=not-evaluated".to_string(),
3688 };
3689 let churn_diag = format!(
3690 "iterations={iterations_used}/{max_iterations} transitions[blocking-add={count_blocking_add} stationary-add={count_stationary_add} release={count_release}] ws_repeat_break={ws_repeat_break}"
3691 );
3692 if !allow_projected_gradient_fallback {
3693 return Err(EstimationError::ParameterConstraintViolation(format!(
3694 "operator-constrained active-set did not certify the strict-convex projection QP; max scaled violation={worst:.3e} at row {row}; KKT[primal={:.3e}, dual={:.3e}, comp={:.3e}, stat={:.3e}, active={}/{}]; {nnls_diag}; {churn_diag}",
3695 working_kkt.primal_feasibility,
3696 working_kkt.dual_feasibility,
3697 working_kkt.complementarity,
3698 working_kkt.stationarity,
3699 working_kkt.n_active,
3700 working_kkt.n_constraints,
3701 )));
3702 }
3703 if let Some((fallback_direction, fallback_active)) =
3704 fallback_projected_gradient_direction_with_constraint_set(
3705 beta, &x, &d_total, &g_cur, &active, ops,
3706 )?
3707 {
3708 if let Some(hint) = active_hint.as_mut() {
3709 hint.clear();
3710 hint.extend(fallback_active);
3711 }
3712 direction_out.assign(&fallback_direction);
3713 return Ok(());
3714 }
3715 Err(EstimationError::ParameterConstraintViolation(format!(
3716 "operator-constrained Newton active-set failed to converge; max scaled violation={worst:.3e} at row {row}; KKT[primal={:.3e}, dual={:.3e}, comp={:.3e}, stat={:.3e}, active={}/{}]; {nnls_diag}; {churn_diag}; projected-gradient fallback declined",
3717 working_kkt.primal_feasibility,
3718 working_kkt.dual_feasibility,
3719 working_kkt.complementarity,
3720 working_kkt.stationarity,
3721 working_kkt.n_active,
3722 working_kkt.n_constraints,
3723 )))
3724}
3725
3726pub fn project_point_strictly_into_feasible_constraint_set(
3738 point: &Array1<f64>,
3739 set: &ConstraintSet,
3740) -> Result<Array1<f64>, EstimationError> {
3741 match set {
3742 ConstraintSet::Dense(dense) => {
3743 project_point_strictly_into_feasible_cone(point, dense).ok_or_else(|| {
3747 EstimationError::ParameterConstraintViolation(
3748 "dense strict-interior projection could not certify a feasible point"
3749 .to_string(),
3750 )
3751 })
3752 }
3753 _ => {
3754 let repair_guard = FeasibilityRepairGuard::enter().ok_or_else(|| {
3755 EstimationError::ParameterConstraintViolation(format!(
3756 "strict-interior projection exceeded feasibility-repair depth {MAX_FEASIBILITY_REPAIR_DEPTH}"
3757 ))
3758 })?;
3759 let p = point.len();
3760 if set.ncols() != p {
3761 return Err(EstimationError::ParameterConstraintViolation(format!(
3762 "strict-interior projection dimension mismatch: point length {p} != constraint columns {}",
3763 set.ncols()
3764 )));
3765 }
3766 let ops = ConstraintSetOps::new(set, ACTIVE_SET_INTERIOR_SEED_MARGIN)?;
3767 let identity = Array2::<f64>::eye(p);
3768 let mut direction = Array1::<f64>::zeros(p);
3771 let gradient = Array1::<f64>::zeros(p);
3772 let max_iterations = (p + set.nrows() + 8) * 4;
3773 solve_newton_direction_with_constraint_set_impl(
3774 &identity,
3775 &gradient,
3776 point,
3777 &ops,
3778 &mut direction,
3779 None,
3780 max_iterations,
3781 true,
3782 )?;
3783 let beta = point + &direction;
3784 if beta.iter().any(|v| !v.is_finite()) {
3785 return Err(EstimationError::ParameterConstraintViolation(
3786 "strict-interior projection produced a non-finite iterate".to_string(),
3787 ));
3788 }
3789 const SEED_FEASIBILITY_TOL: f64 = 1e-9;
3792 let unshifted = ConstraintSetOps::new(set, 0.0)?;
3793 let values = unshifted.values(&beta)?;
3794 let half_margin = 0.5 * ACTIVE_SET_INTERIOR_SEED_MARGIN - SEED_FEASIBILITY_TOL;
3795 for row in 0..unshifted.nrows() {
3796 if unshifted.norms[row] <= 0.0 {
3797 continue;
3798 }
3799 let slack = unshifted.scaled_slack(&values, row);
3800 if slack < half_margin {
3801 return Err(EstimationError::ParameterConstraintViolation(format!(
3802 "strict-interior projection could not clear the half-margin at row {row}: \
3803 scaled slack {slack:.3e} < {half_margin:.3e}"
3804 )));
3805 }
3806 }
3807 drop(repair_guard);
3808 Ok(beta)
3809 }
3810 }
3811}
3812
3813pub fn solve_quadratic_with_constraint_set(
3820 hessian: &Array2<f64>,
3821 rhs: &Array1<f64>,
3822 beta_start: &Array1<f64>,
3823 set: &ConstraintSet,
3824 warm_active_set: Option<&[usize]>,
3825) -> Result<(Array1<f64>, Vec<usize>), EstimationError> {
3826 match set {
3827 ConstraintSet::Dense(dense) => solve_quadratic_with_linear_constraints(
3828 hessian,
3829 rhs,
3830 beta_start,
3831 dense,
3832 warm_active_set,
3833 ),
3834 _ => {
3835 if hessian.ncols() != hessian.nrows()
3836 || rhs.len() != hessian.nrows()
3837 || beta_start.len() != hessian.nrows()
3838 || set.ncols() != hessian.nrows()
3839 {
3840 crate::bail_invalid_estim!(
3841 "operator-constrained quadratic solve: system dimension mismatch"
3842 );
3843 }
3844 let ops = ConstraintSetOps::new(set, 0.0)?;
3845 let gradient = hessian.dot(beta_start) - rhs;
3846 let mut delta = Array1::<f64>::zeros(beta_start.len());
3847 let mut active_hint = warm_active_set.map_or_else(Vec::new, |active| active.to_vec());
3848 let max_iterations = (beta_start.len() + set.nrows() + 8) * 4;
3849 solve_newton_direction_with_constraint_set_impl(
3850 hessian,
3851 &gradient,
3852 beta_start,
3853 &ops,
3854 &mut delta,
3855 Some(&mut active_hint),
3856 max_iterations,
3857 true,
3858 )?;
3859 let candidate = beta_start + δ
3860 let candidate_values = ops.values(&candidate)?;
3861 let (worst, _) = ops.max_violation(&candidate_values);
3862 if worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
3863 return Ok((candidate, active_hint));
3864 }
3865 let repaired = project_point_strictly_into_feasible_constraint_set(&candidate, set)
3866 .ok()
3867 .filter(|repaired_point| {
3868 ops.values(repaired_point)
3869 .map(|values| ops.max_violation(&values).0)
3870 .map(|violation| violation <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL)
3871 .unwrap_or(false)
3872 });
3873 match repaired {
3874 Some(feasible) => {
3875 let feasible_values = ops.values(&feasible)?;
3876 let active: Vec<usize> = (0..ops.nrows())
3877 .filter(|&row| {
3878 ops.norms[row] > 0.0
3879 && ops.scaled_slack(&feasible_values, row)
3880 <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
3881 })
3882 .collect();
3883 Ok((feasible, active))
3884 }
3885 None => Err(EstimationError::ParameterConstraintViolation(format!(
3886 "operator-constrained quadratic solve returned an infeasible iterate \
3887 (max scaled violation {worst:.3e}) and no feasible projection could be \
3888 certified onto the constraint cone",
3889 ))),
3890 }
3891 }
3892 }
3893}
3894
3895pub(crate) fn solve_newton_direction_with_linear_constraints(
3896 hessian: &Array2<f64>,
3897 gradient: &Array1<f64>,
3898 beta: &Array1<f64>,
3899 constraints: &LinearInequalityConstraints,
3900 direction_out: &mut Array1<f64>,
3901 active_hint: Option<&mut Vec<usize>>,
3902) -> Result<(), EstimationError> {
3903 let max_iterations = (gradient.len() + constraints.a.nrows() + 8) * 4;
3904 solve_newton_direction_with_linear_constraints_impl(
3905 hessian,
3906 gradient,
3907 beta,
3908 constraints,
3909 direction_out,
3910 active_hint,
3911 max_iterations,
3912 true,
3913 )
3914}
3915
3916pub fn solve_quadratic_with_linear_constraints(
3917 hessian: &Array2<f64>,
3918 rhs: &Array1<f64>,
3919 beta_start: &Array1<f64>,
3920 constraints: &LinearInequalityConstraints,
3921 warm_active_set: Option<&[usize]>,
3922) -> Result<(Array1<f64>, Vec<usize>), EstimationError> {
3923 if hessian.ncols() != hessian.nrows()
3924 || rhs.len() != hessian.nrows()
3925 || beta_start.len() != hessian.nrows()
3926 || constraints.a.ncols() != hessian.nrows()
3927 {
3928 crate::bail_invalid_estim!("constrained quadratic solve: system dimension mismatch");
3929 }
3930 let constraints = constraints.canonicalized().map_err(|e| {
3936 EstimationError::ParameterConstraintViolation(format!(
3937 "constrained quadratic solve: invalid constraint system: {e}"
3938 ))
3939 })?;
3940 let constraints = &constraints;
3941 let gradient = hessian.dot(beta_start) - rhs;
3942 let mut delta = Array1::<f64>::zeros(beta_start.len());
3943 let mut active_hint = warm_active_set.map_or_else(Vec::new, |active| active.to_vec());
3944 solve_newton_direction_with_linear_constraints(
3945 hessian,
3946 &gradient,
3947 beta_start,
3948 constraints,
3949 &mut delta,
3950 Some(&mut active_hint),
3951 )?;
3952 let candidate = beta_start + δ
3953 let (worst, _) = max_linear_constraint_violation(&candidate, constraints);
3970 if worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
3971 return Ok((candidate, active_hint));
3972 }
3973 let repaired = project_point_strictly_into_feasible_cone(&candidate, constraints).filter(|p| {
3974 max_linear_constraint_violation(p, constraints).0 <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
3975 });
3976 match repaired {
3977 Some(feasible) => {
3978 let active = canonicalize_active_constraint_ids(&feasible, constraints, &[])?;
3979 Ok((feasible, active))
3980 }
3981 None => Err(EstimationError::ParameterConstraintViolation(format!(
3982 "constrained quadratic solve returned an infeasible iterate \
3983 (max scaled violation {worst:.3e}) and no feasible projection could be \
3984 certified onto the constraint cone",
3985 ))),
3986 }
3987}
3988
3989#[cfg(test)]
3990mod tests {
3991 use super::{
3992 ACTIVE_SET_INTERIOR_SEED_MARGIN, ACTIVE_SET_PRIMAL_FEASIBILITY_TOL, ConstraintSet,
3993 ConstraintRowId, ConstraintSetOps, ConstraintSetReducedFace, LinearInequalityConstraints,
3994 active_set_boundary_hit_step_fraction, compute_constraint_kkt_diagnostics,
3995 constraint_set_rows_tight_at_point, fallback_projected_gradient_direction,
3996 khatri_rao_cone_reduced_face,
3997 fallback_projected_gradient_direction_with_constraint_set, moreau_projection_via_primal_qp,
3998 nnls_tangent_cone_projection_fallback, nonnegative_cone_multipliers,
3999 project_point_strictly_into_feasible_cone,
4000 project_point_strictly_into_feasible_constraint_set,
4001 project_stationarity_residual_on_constraint_cone,
4002 project_stationarity_residual_on_constraint_set,
4003 rank_reduce_rows_pivoted_qr_with_dependence, record_active_working_set,
4004 scaled_constraint_slack, solve_newton_direction_with_linear_constraints_impl,
4005 solve_quadratic_with_constraint_set, solve_quadratic_with_linear_constraints,
4006 };
4007 use approx::assert_relative_eq;
4008 use gam_problem::KhatriRaoConeConstraints;
4009 use ndarray::{Array1, Array2, array};
4010
4011 #[test]
4012 fn working_set_cycle_detection_requires_the_same_primal_point() {
4013 let mut visited = std::collections::HashSet::new();
4014 let x0 = array![0.0_f64, 1.0];
4015 let x1 = array![0.5_f64, 1.0];
4016
4017 assert!(record_active_working_set(&mut visited, &[3, 1], &x0, 0));
4018 assert!(record_active_working_set(&mut visited, &[1, 3], &x1, 1));
4019 assert!(!record_active_working_set(&mut visited, &[3, 1], &x1, 2));
4020 }
4021
4022 #[test]
4023 fn boundary_ratio_lands_on_the_exact_boundary_and_blocks_at_it() {
4024 let alpha = active_set_boundary_hit_step_fraction(0.1, -1.0, 1.0)
4028 .expect("a strictly feasible row moving toward its boundary must clip");
4029 assert_relative_eq!(alpha, 0.1, epsilon = 0.0);
4030 assert_relative_eq!(0.1 + alpha * -1.0, 0.0, epsilon = 0.0);
4031
4032 let blocked = active_set_boundary_hit_step_fraction(-2.5e-15, -1.0, 1.0)
4038 .expect("an at-boundary outward-moving row must block");
4039 assert_eq!(blocked, 0.0);
4040 }
4041
4042 #[test]
4043 fn warm_face_rows_are_point_local_for_dense_and_operator_constraints() {
4044 let hessian = array![[1.0_f64]];
4051 let rhs = array![2.0_f64];
4052 let interior = array![1.0_f64];
4053 let dense = LinearInequalityConstraints::new(array![[1.0]], array![0.0])
4054 .expect("one-dimensional half-line");
4055 let (dense_solution, dense_active) =
4056 solve_quadratic_with_linear_constraints(&hessian, &rhs, &interior, &dense, Some(&[0]))
4057 .expect("dense stale-face solve");
4058 assert_relative_eq!(dense_solution[0], 2.0, epsilon = 1e-12);
4059 assert!(dense_active.is_empty());
4060
4061 let factor = std::sync::Arc::new(array![[1.0_f64]]);
4062 let cone = KhatriRaoConeConstraints::new(factor, vec![0], 1)
4063 .expect("one-dimensional factored half-line");
4064 let operator = ConstraintSet::KhatriRaoCone(cone);
4065 let stale_terminal_face = constraint_set_rows_tight_at_point(&operator, &interior, &[0])
4066 .expect("terminal face classification");
4067 assert!(stale_terminal_face.is_empty());
4068 let (operator_solution, operator_active) =
4069 solve_quadratic_with_constraint_set(&hessian, &rhs, &interior, &operator, Some(&[0]))
4070 .expect("operator stale-face solve");
4071 assert_relative_eq!(operator_solution[0], 2.0, epsilon = 1e-12);
4072 assert!(operator_active.is_empty());
4073 }
4074
4075 #[test]
4084 fn strict_interior_projection_lifts_vertex_seed_off_every_constraint_row() {
4085 let p = 5usize;
4088 let rows = p - 2;
4089 let mut a = Array2::<f64>::zeros((rows, p));
4090 for i in 0..rows {
4091 a[[i, i]] = -1.0;
4092 a[[i, i + 1]] = 2.0;
4093 a[[i, i + 2]] = -1.0;
4094 }
4095 let constraints = LinearInequalityConstraints::new(a, Array1::zeros(rows))
4096 .expect("test constraint shape invariant");
4097
4098 let vertex = Array1::<f64>::zeros(p);
4099 for i in 0..rows {
4101 assert!(
4102 scaled_constraint_slack(&vertex, &constraints, i).abs() < 1e-12,
4103 "vertex seed should sit exactly on row {i}"
4104 );
4105 }
4106
4107 let interior = project_point_strictly_into_feasible_cone(&vertex, &constraints)
4108 .expect("strict-interior projection of the vertex must succeed");
4109 let min_slack = (0..rows)
4110 .map(|i| scaled_constraint_slack(&interior, &constraints, i))
4111 .fold(f64::INFINITY, f64::min);
4112 assert!(
4113 min_slack >= 0.5 * ACTIVE_SET_INTERIOR_SEED_MARGIN,
4114 "projected seed must be strictly interior on every row; min scaled slack = {min_slack:.3e}"
4115 );
4116 }
4117
4118 #[test]
4128 fn strict_interior_projection_keeps_equality_pairs_tight_with_shape_bounds() {
4129 let p = 5usize;
4130 let m = 3 + 2;
4133 let mut a = Array2::<f64>::zeros((m, p));
4134 a[[0, 2]] = 1.0;
4135 a[[1, 3]] = 1.0;
4136 a[[2, 4]] = 1.0;
4137 a[[3, 0]] = 1.0;
4138 a[[4, 0]] = -1.0;
4139 let constraints = LinearInequalityConstraints::new(a, Array1::zeros(m))
4140 .expect("test constraint shape invariant");
4141
4142 let point = Array1::from_vec(vec![0.7, -0.2, -0.5, -0.3, -0.1]);
4145 let seed = project_point_strictly_into_feasible_cone(&point, &constraints).expect(
4146 "strict-interior projection must succeed when an equality pair is present, \
4147 not collapse to the empty set and fall back to the vertex",
4148 );
4149
4150 for i in 0..3 {
4152 assert!(
4153 scaled_constraint_slack(&seed, &constraints, i)
4154 >= 0.4 * ACTIVE_SET_INTERIOR_SEED_MARGIN,
4155 "shape row {i} not strictly interior: scaled slack = {:.3e}",
4156 scaled_constraint_slack(&seed, &constraints, i)
4157 );
4158 }
4159 assert!(
4162 seed[0].abs() <= 1e-6,
4163 "boundary equality must be enforced, got β_0 = {:.3e}",
4164 seed[0]
4165 );
4166 }
4167
4168 #[test]
4172 fn strict_interior_projection_preserves_a_curvature_carrying_seed() {
4173 let p = 5usize;
4174 let rows = p - 2;
4175 let mut a = Array2::<f64>::zeros((rows, p));
4176 for i in 0..rows {
4177 a[[i, i]] = -1.0;
4178 a[[i, i + 1]] = 2.0;
4179 a[[i, i + 2]] = -1.0;
4180 }
4181 let constraints = LinearInequalityConstraints::new(a, Array1::zeros(rows))
4182 .expect("test constraint shape invariant");
4183 let seed = Array1::from_iter((0..p).map(|j| -((j as f64 - 2.0).powi(2))));
4187 let projected = project_point_strictly_into_feasible_cone(&seed, &constraints)
4188 .expect("already-interior seed must project");
4189 let max_move = seed
4190 .iter()
4191 .zip(projected.iter())
4192 .map(|(a, b)| (a - b).abs())
4193 .fold(0.0_f64, f64::max);
4194 assert!(
4195 max_move < 1e-3,
4196 "strictly-interior curvature-carrying seed should be preserved; max move = {max_move:.3e}"
4197 );
4198 }
4199
4200 #[test]
4201 fn maxiter_accepts_current_boundary_solution() {
4202 let hessian = array![[1.0]];
4203 let gradient = array![-1.0];
4204 let beta = array![0.0];
4205 let constraints = LinearInequalityConstraints {
4206 a: array![[-1.0]],
4207 b: array![-0.1],
4208 };
4209 let mut direction = Array1::zeros(1);
4210 let mut active_hint = Vec::new();
4211
4212 solve_newton_direction_with_linear_constraints_impl(
4213 &hessian,
4214 &gradient,
4215 &beta,
4216 &constraints,
4217 &mut direction,
4218 Some(&mut active_hint),
4219 1,
4220 true,
4221 )
4222 .expect("solver should accept the current boundary solution at the iteration limit");
4223
4224 assert_relative_eq!(direction[0], 0.1, epsilon = 1e-12);
4225 assert_eq!(active_hint, vec![0]);
4226 }
4227
4228 #[test]
4229 fn projected_gradient_releases_a_boundary_with_negative_multiplier() {
4230 let x = array![0.0_f64];
4236 let d_total = array![0.0_f64];
4237 let gradient = array![-1.0_f64];
4238 let constraints =
4239 LinearInequalityConstraints::new(array![[1.0]], array![0.0]).expect("one-sided bound");
4240
4241 let (direction, active) = fallback_projected_gradient_direction(
4242 &x,
4243 &x,
4244 &d_total,
4245 &gradient,
4246 &constraints,
4247 &constraints,
4248 )
4249 .expect("fallback evaluation")
4250 .expect("negative-multiplier face must have a feasible descent escape");
4251
4252 assert_relative_eq!(direction[0], 1.0, epsilon = 1e-12);
4253 assert!(gradient.dot(&direction) < 0.0);
4254 assert!(active.is_empty(), "descent moves strictly into the cone");
4255 }
4256
4257 #[test]
4258 fn rank_reduce_zero_rows_returns_empty_working_set() {
4259 let a = array![[0.0, 0.0], [0.0, 0.0],];
4260 let b = array![0.0, 0.0];
4261 let groups = vec![vec![0], vec![1]];
4262
4263 let (a_out, b_out, groups_out, _) =
4264 rank_reduce_rows_pivoted_qr_with_dependence(a, b, groups);
4265
4266 assert_eq!(a_out.nrows(), 0);
4267 assert_eq!(a_out.ncols(), 2);
4268 assert_eq!(b_out.len(), 0);
4269 assert!(groups_out.is_empty());
4270 }
4271
4272 #[test]
4273 fn cone_projection_solves_nonnegative_least_squares_not_one_way_pruning() {
4274 let active_a = array![
4275 [0.85258593, -0.77270261],
4276 [-1.22152485, 2.05129351],
4277 [0.22794844, 1.56987265],
4278 ];
4279 let residual = array![-0.50524761, -1.10104911];
4280
4281 let (projected, multipliers) =
4282 project_stationarity_residual_on_constraint_cone(&residual, &active_a)
4283 .expect("cone projection should solve");
4284
4285 let row0 = active_a.row(0);
4286 let expected_mu0 = row0.dot(&residual) / row0.dot(&row0);
4287 assert_relative_eq!(multipliers[0], expected_mu0, epsilon = 1e-8);
4288 assert_relative_eq!(multipliers[1], 0.0, epsilon = 1e-10);
4289 assert_relative_eq!(multipliers[2], 0.0, epsilon = 1e-10);
4290
4291 let raw_norm2 = residual.dot(&residual);
4292 let projected_norm2 = projected.dot(&projected);
4293 assert!(
4294 projected_norm2 < raw_norm2 - 0.1,
4295 "NNLS projection should keep the improving active row: raw={raw_norm2:.6e}, projected={projected_norm2:.6e}"
4296 );
4297 let dual = active_a.dot(&projected);
4298 for (idx, (&mu, &w)) in multipliers.iter().zip(dual.iter()).enumerate() {
4299 if mu <= 1e-10 {
4300 assert!(
4301 w <= 1e-8,
4302 "inactive cone generator {idx} has positive reduced gradient {w:.3e}"
4303 );
4304 }
4305 }
4306 }
4307
4308 #[test]
4312 fn nnls_moreau_projection_matches_primal_qp_route() {
4313 let cases: Vec<(Array2<f64>, Array1<f64>)> = vec![
4314 (
4315 array![
4316 [0.85258593, -0.77270261],
4317 [-1.22152485, 2.05129351],
4318 [0.22794844, 1.56987265],
4319 ],
4320 array![-0.50524761, -1.10104911],
4321 ),
4322 (array![[1.0, 0.0], [0.0, 1.0]], array![3.0, -2.0]),
4323 (
4324 array![[1.0, 1.0, 0.0], [1.0, -1.0, 0.0], [2.0, 2.0, 0.0]],
4325 array![1.5, 0.25, -0.75],
4326 ),
4327 ];
4328 for (rows, target) in cases {
4329 let qp = moreau_projection_via_primal_qp(&target, &rows)
4330 .expect("primal QP route must solve these well-posed instances");
4331 let (lambda, projected) = nonnegative_cone_multipliers(&rows, &target)
4332 .expect("LH route must solve the same instances");
4333 for (left, right) in qp.0.iter().zip(projected.iter()) {
4334 assert_relative_eq!(left, right, epsilon = 1e-8);
4335 }
4336 assert!(lambda.iter().all(|&v| v >= 0.0));
4338 let reconstructed = &target - &rows.t().dot(&lambda);
4339 for (left, right) in reconstructed.iter().zip(projected.iter()) {
4340 assert_relative_eq!(left, right, epsilon = 1e-12);
4341 }
4342 }
4343 }
4344
4345 #[test]
4346 fn nnls_projects_axis_cone_exactly() {
4347 let rows = array![[1.0, 0.0], [0.0, 1.0]];
4348 let target = array![3.0, -2.0];
4349 let (lambda, projected) =
4350 nonnegative_cone_multipliers(&rows, &target).expect("axis cone NNLS");
4351 assert_relative_eq!(lambda[0], 3.0, epsilon = 1e-10);
4352 assert_relative_eq!(lambda[1], 0.0, epsilon = 1e-10);
4353 assert_relative_eq!(projected[0], 0.0, epsilon = 1e-10);
4354 assert_relative_eq!(projected[1], -2.0, epsilon = 1e-10);
4355 }
4356
4357 #[test]
4363 fn nnls_closes_stationarity_on_weakly_aligned_dependent_face() {
4364 let eps = 1e-8_f64;
4365 let rows = array![[1.0, eps], [-1.0, eps], [0.0, 1.0]];
4366 let target = array![0.0, 1.0];
4367 let (lambda, projected) =
4368 nonnegative_cone_multipliers(&rows, &target).expect("dependent-face NNLS");
4369 let closure = projected.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
4370 assert!(
4371 closure <= 1e-10,
4372 "λ = e3 closes stationarity exactly; got closure {closure:.3e}"
4373 );
4374 assert!(lambda.iter().all(|&v| v >= 0.0));
4375 }
4376
4377 #[test]
4382 fn degenerate_face_with_weak_alignment_certifies_instead_of_cycling() {
4383 let eps = 1e-8_f64;
4384 let a = array![[1.0, eps], [-1.0, eps], [0.0, 1.0]];
4385 let b = array![0.0, 0.0, 0.0];
4386 let constraints = LinearInequalityConstraints::new(a.clone(), b).expect("constraints");
4387 let hessian = Array2::<f64>::eye(2);
4388 let gradient = array![0.0, 1.0];
4390 let beta = array![0.0, 0.0];
4391 let mut direction = Array1::<f64>::zeros(2);
4392 solve_newton_direction_with_linear_constraints_impl(
4393 &hessian,
4394 &gradient,
4395 &beta,
4396 &constraints,
4397 &mut direction,
4398 None,
4399 64,
4400 false,
4401 )
4402 .expect("the vertex is a certified KKT point; refusal is the #2298 defect");
4403 let step = direction.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
4404 assert!(
4405 step <= 1e-8,
4406 "optimum is the vertex itself; got |d|∞ = {step:.3e}"
4407 );
4408 }
4409
4410 #[test]
4417 fn nnls_fallback_certifies_pinned_degenerate_vertex_projection_979() {
4418 let a = array![
4422 [1.0_f64, 0.0, 0.0],
4423 [0.0, 1.0, 0.0],
4424 [0.0, 0.0, 1.0],
4425 [1.0, 1.0, 0.0],
4426 ];
4427 let b = array![0.0_f64, 0.0, 0.0, 0.0];
4428 let set = ConstraintSet::Dense(
4429 LinearInequalityConstraints::new(a, b).expect("degenerate vertex cone"),
4430 );
4431 let beta = array![0.0_f64, 0.0, 0.0];
4432 let residual = array![3.0_f64, 2.0, 0.0]; let (projected, active) =
4434 nnls_tangent_cone_projection_fallback(&residual, &beta, &set, &[0, 1, 2, 3], &[0, 1])
4435 .expect("fallback must solve the degenerate vertex");
4436 let closure = projected.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
4437 assert!(
4438 closure <= 1e-9,
4439 "residual is in the cone; projection must close to zero, got {closure:.3e}"
4440 );
4441 assert!(!active.is_empty(), "a supported face must be reported");
4442
4443 let outside = array![1.0_f64, 0.0, -1.0];
4445 let (projected_outside, _) =
4446 nnls_tangent_cone_projection_fallback(&outside, &beta, &set, &[0, 1, 2, 3], &[])
4447 .expect("fallback must solve the outside-component case");
4448 assert_relative_eq!(projected_outside[0], 0.0, epsilon = 1e-9);
4449 assert_relative_eq!(projected_outside[1], 0.0, epsilon = 1e-9);
4450 assert_relative_eq!(projected_outside[2], -1.0, epsilon = 1e-9);
4451 }
4452
4453 #[test]
4458 fn nnls_fallback_excludes_rows_not_tight_at_beta() {
4459 let a = array![[1.0_f64, 0.0], [0.0, 1.0]];
4460 let b = array![0.0_f64, -1.0]; let set = ConstraintSet::Dense(
4462 LinearInequalityConstraints::new(a, b).expect("half-tight system"),
4463 );
4464 let beta = array![0.0_f64, 0.0];
4465 let residual = array![0.0_f64, 1.0];
4466 let (projected, active) =
4467 nnls_tangent_cone_projection_fallback(&residual, &beta, &set, &[0, 1], &[])
4468 .expect("fallback must solve the half-tight system");
4469 assert_relative_eq!(projected[1], 1.0, epsilon = 1e-12);
4470 assert!(
4471 !active.contains(&1),
4472 "slack row 1 must not appear in the certified face"
4473 );
4474 }
4475
4476 #[test]
4477 fn cone_projection_preserves_original_multiplier_units_after_row_canonicalization() {
4478 let residual = array![2.0, -1.0];
4479 let unit_row = array![[1.0, 0.0]];
4480 let scaled_row = array![[4.0, 0.0]];
4481
4482 let (projected_unit, multiplier_unit) =
4483 project_stationarity_residual_on_constraint_cone(&residual, &unit_row)
4484 .expect("unit-row cone projection should solve");
4485 let (projected_scaled, multiplier_scaled) =
4486 project_stationarity_residual_on_constraint_cone(&residual, &scaled_row)
4487 .expect("scaled-row cone projection should solve");
4488
4489 assert_relative_eq!(projected_unit[0], 0.0, epsilon = 1e-12);
4490 assert_relative_eq!(projected_unit[1], -1.0, epsilon = 1e-12);
4491 assert_relative_eq!(projected_scaled[0], projected_unit[0], epsilon = 1e-12);
4492 assert_relative_eq!(projected_scaled[1], projected_unit[1], epsilon = 1e-12);
4493 assert_relative_eq!(multiplier_unit[0], 2.0, epsilon = 1e-12);
4494 assert_relative_eq!(multiplier_scaled[0], 0.5, epsilon = 1e-12);
4495
4496 let reconstructed_unit = &residual - &unit_row.t().dot(&multiplier_unit);
4497 let reconstructed_scaled = &residual - &scaled_row.t().dot(&multiplier_scaled);
4498 assert_relative_eq!(reconstructed_unit[0], projected_unit[0], epsilon = 1e-12);
4499 assert_relative_eq!(
4500 reconstructed_scaled[0],
4501 projected_scaled[0],
4502 epsilon = 1e-12
4503 );
4504 }
4505
4506 #[test]
4513 fn kkt_primal_is_per_row_scale_invariant() {
4514 let geometric_violation = 2.071e-8_f64;
4517 let gradient = Array1::<f64>::zeros(2);
4518
4519 let beta_unit = array![-geometric_violation, 0.0];
4521 let unit = LinearInequalityConstraints {
4522 a: array![[1.0, 0.0]],
4523 b: array![0.0],
4524 };
4525 let diag_unit = compute_constraint_kkt_diagnostics(&beta_unit, &gradient, &unit);
4526
4527 let beta_big = array![-geometric_violation, 0.0];
4530 let big = LinearInequalityConstraints {
4531 a: array![[1000.0, 0.0]],
4532 b: array![0.0],
4533 };
4534 let diag_big = compute_constraint_kkt_diagnostics(&beta_big, &gradient, &big);
4535
4536 assert_relative_eq!(
4537 diag_unit.primal_feasibility,
4538 geometric_violation,
4539 epsilon = 1e-14
4540 );
4541 assert_relative_eq!(
4542 diag_big.primal_feasibility,
4543 geometric_violation,
4544 epsilon = 1e-14
4545 );
4546 assert!(
4548 diag_big.primal_feasibility < 1e-7,
4549 "scaled primal {:.3e} should pass a 1e-7 gate; raw slack would be {:.3e}",
4550 diag_big.primal_feasibility,
4551 1000.0 * geometric_violation
4552 );
4553 }
4554
4555 #[test]
4563 fn opposing_inequality_pair_pins_equality_to_target() {
4564 let hessian = array![
4568 [1.0, 0.0, 0.0, 0.0],
4569 [0.0, 1.0, 0.0, 0.0],
4570 [0.0, 0.0, 1.0, 0.0],
4571 [0.0, 0.0, 0.0, 1.0],
4572 ];
4573 let rhs = array![5.0, 5.0, 0.0, 0.0];
4574 let beta_start = Array1::<f64>::zeros(4);
4575 let constraints = LinearInequalityConstraints {
4576 a: array![[1.0, 1.0, 0.0, 0.0], [-1.0, -1.0, 0.0, 0.0]],
4577 b: array![0.0, 0.0],
4578 };
4579
4580 let (beta, _active) = solve_quadratic_with_linear_constraints(
4581 &hessian,
4582 &rhs,
4583 &beta_start,
4584 &constraints,
4585 None,
4586 )
4587 .expect("opposing-inequality equality QP must solve");
4588
4589 let a_dot_beta = beta[0] + beta[1];
4590 assert!(
4591 a_dot_beta.abs() < 1e-8,
4592 "opposing inequalities must pin a·β to 0, got {a_dot_beta:.6e} (β = {beta:?})"
4593 );
4594 }
4595
4596 #[test]
4600 fn opposing_inequality_pair_pins_scaled_equality_to_nonzero_target() {
4601 let hessian = array![
4602 [1.0, 0.0, 0.0, 0.0],
4603 [0.0, 1.0, 0.0, 0.0],
4604 [0.0, 0.0, 1.0, 0.0],
4605 [0.0, 0.0, 0.0, 1.0],
4606 ];
4607 let rhs = array![5.0, 5.0, 0.0, 0.0];
4608 let beta_start = Array1::<f64>::zeros(4);
4609 let constraints = LinearInequalityConstraints {
4612 a: array![[1000.0, 1000.0, 0.0, 0.0], [-1000.0, -1000.0, 0.0, 0.0]],
4613 b: array![3000.0, -3000.0],
4614 };
4615
4616 let (beta, _active) = solve_quadratic_with_linear_constraints(
4617 &hessian,
4618 &rhs,
4619 &beta_start,
4620 &constraints,
4621 None,
4622 )
4623 .expect("scaled opposing-inequality equality QP must solve");
4624
4625 let a_dot_beta = 1000.0 * (beta[0] + beta[1]);
4626 assert!(
4627 (a_dot_beta - 3000.0).abs() < 1e-5,
4628 "opposing inequalities must pin a·β to 3000, got {a_dot_beta:.6e} (β = {beta:?})"
4629 );
4630 }
4631
4632 #[test]
4637 fn two_opposing_inequality_equalities_both_pinned() {
4638 let hessian = array![
4639 [1.0, 0.0, 0.0, 0.0],
4640 [0.0, 1.0, 0.0, 0.0],
4641 [0.0, 0.0, 1.0, 0.0],
4642 [0.0, 0.0, 0.0, 1.0],
4643 ];
4644 let rhs = array![5.0, 5.0, 5.0, 5.0];
4645 let beta_start = Array1::<f64>::zeros(4);
4646 let constraints = LinearInequalityConstraints {
4648 a: array![
4649 [1.0, 1.0, 0.0, 0.0],
4650 [-1.0, -1.0, 0.0, 0.0],
4651 [0.0, 0.0, 1.0, 1.0],
4652 [0.0, 0.0, -1.0, -1.0],
4653 ],
4654 b: array![0.0, 0.0, 0.0, 0.0],
4655 };
4656
4657 let (beta, _active) = solve_quadratic_with_linear_constraints(
4658 &hessian,
4659 &rhs,
4660 &beta_start,
4661 &constraints,
4662 None,
4663 )
4664 .expect("two-equality QP must solve");
4665
4666 assert!(
4667 (beta[0] + beta[1]).abs() < 1e-8,
4668 "equality A not pinned: β0+β1 = {:.6e}",
4669 beta[0] + beta[1]
4670 );
4671 assert!(
4672 (beta[2] + beta[3]).abs() < 1e-8,
4673 "equality B not pinned: β2+β3 = {:.6e}",
4674 beta[2] + beta[3]
4675 );
4676 }
4677
4678 #[test]
4685 fn opposing_inequality_equalities_pinned_under_ill_conditioned_penalty() {
4686 let lam = 1.0e8_f64;
4689 let hessian = array![
4690 [1.0, 0.0, 0.0, 0.0],
4691 [0.0, 1.0, 0.0, 0.0],
4692 [0.0, 0.0, lam, 0.0],
4693 [0.0, 0.0, 0.0, lam],
4694 ];
4695 let rhs = array![5.0, 5.0, 5.0, 5.0];
4696 let beta_start = Array1::<f64>::zeros(4);
4697 let constraints = LinearInequalityConstraints {
4701 a: array![
4702 [1.0, 0.0, 1.0, 0.0],
4703 [-1.0, 0.0, -1.0, 0.0],
4704 [0.0, 1.0, 0.0, 1.0],
4705 [0.0, -1.0, 0.0, -1.0],
4706 ],
4707 b: array![0.0, 0.0, 0.0, 0.0],
4708 };
4709
4710 let (beta, _active) = solve_quadratic_with_linear_constraints(
4711 &hessian,
4712 &rhs,
4713 &beta_start,
4714 &constraints,
4715 None,
4716 )
4717 .expect("ill-conditioned two-equality QP must solve");
4718
4719 assert!(
4720 (beta[0] + beta[2]).abs() < 1e-6,
4721 "equality A not pinned under ill-conditioning: β0+β2 = {:.6e}",
4722 beta[0] + beta[2]
4723 );
4724 assert!(
4725 (beta[1] + beta[3]).abs() < 1e-6,
4726 "equality B not pinned under ill-conditioning: β1+β3 = {:.6e}",
4727 beta[1] + beta[3]
4728 );
4729 }
4730
4731 fn small_cone() -> KhatriRaoConeConstraints {
4737 let psi = array![[1.0_f64, 0.2], [1.0, -0.4], [1.0, 1.3], [1.0, 0.8],];
4738 KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1, 2], 3).expect("small cone")
4739 }
4740
4741 #[test]
4744 fn cone_reduced_face_collapses_parallel_rows_to_lowest_index() {
4745 let psi = array![[1.0_f64, 0.0], [0.0, 1.0], [2.0, 0.0]];
4747 let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1], 2)
4748 .expect("parallel cone");
4749 let beta = Array1::<f64>::zeros(2 * 2);
4751 let face = khatri_rao_cone_reduced_face(&cone, beta.view(), 1e-8).expect("reduce");
4752 assert_eq!(face.tight_rows, rows(&[0, 1, 2]));
4753 assert_eq!(face.representatives, rows(&[0, 1]));
4755 assert_eq!(face.dependence.len(), 2);
4756 assert_eq!(face.dependence[0].len(), 1);
4758 assert_eq!(face.dependence[0][0].row.index(), 2);
4759 assert!((face.dependence[0][0].coeff - 2.0).abs() < 1e-12);
4760 assert!(face.dependence[1].is_empty());
4761 }
4762
4763 #[test]
4765 fn cone_reduced_face_full_rank_has_no_dependence() {
4766 let psi = array![[1.0_f64, 0.0], [0.0, 1.0]];
4767 let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1], 2)
4768 .expect("full-rank cone");
4769 let beta = Array1::<f64>::zeros(2 * 2);
4770 let face = khatri_rao_cone_reduced_face(&cone, beta.view(), 1e-8).expect("reduce");
4771 assert_eq!(face.representatives, rows(&[0, 1]));
4772 assert!(face.dependence.iter().all(|d| d.is_empty()));
4773 assert_eq!(face.tight_rows, rows(&[0, 1]));
4774 }
4775
4776 #[test]
4780 fn cone_reduced_face_general_combination_gets_no_dependence_entry() {
4781 let psi = array![[1.0_f64, 0.0], [0.0, 1.0], [1.0, 1.0]];
4783 let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1], 2)
4784 .expect("general-combo cone");
4785 let beta = Array1::<f64>::zeros(2 * 2);
4786 let face = khatri_rao_cone_reduced_face(&cone, beta.view(), 1e-8).expect("reduce");
4787 assert_eq!(face.representatives, rows(&[0, 1])); assert_eq!(face.tight_rows, rows(&[0, 1, 2])); assert!(
4790 face.dependence.iter().all(|d| d.is_empty()),
4791 "a general-position drop must carry no distributed multiplier"
4792 );
4793 }
4794
4795 #[test]
4799 fn cone_reduced_face_reduces_each_shape_block_independently() {
4800 let psi = array![[1.0_f64, 0.0], [0.0, 1.0]];
4801 let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1, 2], 3)
4802 .expect("two-block cone");
4803 let beta = Array1::<f64>::zeros(3 * 2);
4804 let face = khatri_rao_cone_reduced_face(&cone, beta.view(), 1e-8).expect("reduce");
4805 assert_eq!(face.representatives, rows(&[0, 1, 2, 3]));
4807 assert!(face.dependence.iter().all(|d| d.is_empty()));
4808 assert_eq!(face.tight_rows, rows(&[0, 1, 2, 3]));
4809 }
4810
4811 #[test]
4815 fn dense_reduced_face_via_dispatcher_collapses_parallel_rows() {
4816 let a = array![[1.0_f64, 0.0], [0.0, 1.0], [2.0, 0.0]];
4819 let set = ConstraintSet::Dense(
4820 LinearInequalityConstraints::new(a, Array1::<f64>::zeros(3)).expect("dense"),
4821 );
4822 let beta = Array1::<f64>::zeros(2);
4823 let face = set.reduced_face(beta.view(), 1e-8).expect("reduce");
4824 assert_eq!(face.tight_rows, rows(&[0, 1, 2]));
4825 assert_eq!(face.representatives, rows(&[0, 1]));
4826 assert_eq!(face.dependence[0].len(), 1);
4827 assert_eq!(face.dependence[0][0].row.index(), 2);
4828 assert!((face.dependence[0][0].coeff - 2.0).abs() < 1e-12);
4829 assert!(face.dependence[1].is_empty());
4830 }
4831
4832 fn rows(ids: &[usize]) -> Vec<ConstraintRowId> {
4834 ids.iter().copied().map(ConstraintRowId).collect()
4835 }
4836
4837 fn mixed_width_block_diagonal() -> ConstraintSet {
4845 let narrow = gam_problem::PlacedConstraintBlock {
4846 col_start: 0,
4847 set: ConstraintSet::Dense(
4848 LinearInequalityConstraints::new(
4849 array![[1.0_f64, 0.0, 0.0]],
4850 Array1::<f64>::zeros(1),
4851 )
4852 .expect("narrow block"),
4853 ),
4854 };
4855 let square = gam_problem::PlacedConstraintBlock {
4856 col_start: 3,
4857 set: ConstraintSet::Dense(
4858 LinearInequalityConstraints::new(
4859 array![[1.0_f64, 0.0], [2.0, 0.0]],
4860 Array1::<f64>::zeros(2),
4861 )
4862 .expect("square block"),
4863 ),
4864 };
4865 ConstraintSet::block_diagonal(vec![narrow, square], 5).expect("block-diagonal")
4866 }
4867
4868 #[test]
4875 fn block_diagonal_reduced_face_row_ids_address_the_joint_constraint_row_space() {
4876 let set = mixed_width_block_diagonal();
4877 let beta = Array1::<f64>::zeros(5);
4878 let values = set.values(beta.view()).expect("values");
4879 let face = set.reduced_face(beta.view(), 1e-8).expect("reduce");
4880
4881 assert_eq!(set.nrows(), 3);
4883 assert_eq!(face.tight_rows, rows(&[0, 1, 2]));
4884 assert_eq!(face.representatives, rows(&[0, 1]));
4886 assert_eq!(face.dependence[1][0].row.index(), 2);
4887
4888 for id in &face.tight_rows {
4889 let row = id.index();
4890 assert!(row < set.nrows(), "id {row} outside the joint row space");
4891 let norm = set.row_norm(row).expect("row norm resolves");
4892 let bound = set.bound(row).expect("bound resolves");
4893 assert!(
4894 (values[row] - bound) / norm <= 1e-8,
4895 "row {row} reported tight but has slack {}",
4896 (values[row] - bound) / norm
4897 );
4898 }
4899 }
4900
4901 #[test]
4910 fn block_diagonal_reduced_face_row_ids_are_not_beta_coordinates() {
4911 let set = mixed_width_block_diagonal();
4912 let beta = Array1::<f64>::zeros(5);
4913 let face = set.reduced_face(beta.view(), 1e-8).expect("reduce");
4914
4915 let block1_rep = face.representatives[1];
4916 assert_eq!(block1_rep.index(), 1);
4917 assert_eq!(
4918 set.row_column_support(block1_rep).expect("support"),
4919 vec![3],
4920 "block 1's row acts on the joint column 3 (col_start 3 + local 0)"
4921 );
4922 assert!(block1_rep.index() < 3, "id 1 falls inside block 0's columns");
4925
4926 assert_eq!(
4929 set.row_column_support(face.representatives[0])
4930 .expect("support"),
4931 vec![0]
4932 );
4933 }
4934
4935 #[test]
4939 fn block_diagonal_reduced_face_concatenates_member_row_ids() {
4940 let make = |c0: usize| gam_problem::PlacedConstraintBlock {
4943 col_start: c0,
4944 set: ConstraintSet::Dense(
4945 LinearInequalityConstraints::new(
4946 array![[1.0_f64, 0.0], [2.0, 0.0]],
4947 Array1::<f64>::zeros(2),
4948 )
4949 .expect("dense block"),
4950 ),
4951 };
4952 let set = ConstraintSet::block_diagonal(vec![make(0), make(2)], 4).expect("block-diagonal");
4953 let beta = Array1::<f64>::zeros(4);
4954 let face = set.reduced_face(beta.view(), 1e-8).expect("reduce");
4955 assert_eq!(face.tight_rows, rows(&[0, 1, 2, 3]));
4956 assert_eq!(face.representatives, rows(&[0, 2]));
4957 assert_eq!(face.dependence[0][0].row.index(), 1);
4958 assert_eq!(face.dependence[1][0].row.index(), 3);
4959 }
4960
4961 fn coupled_pd_hessian(p: usize) -> Array2<f64> {
4964 let mut h = Array2::<f64>::eye(p) * 2.0;
4965 for i in 0..p {
4966 for j in 0..p {
4967 if i != j {
4968 h[[i, j]] = 0.3 / (1.0 + (i as f64 - j as f64).abs());
4969 }
4970 }
4971 }
4972 h
4973 }
4974
4975 #[test]
4976 fn operator_cone_qp_matches_dense_oracle_when_constraints_bind() {
4977 let cone = small_cone();
4978 let set = ConstraintSet::KhatriRaoCone(cone.clone());
4979 let dense = cone.to_dense().expect("dense oracle");
4980 let p = set.ncols();
4981 let hessian = coupled_pd_hessian(p);
4982 let rhs = array![0.5_f64, -0.3, -2.0, 1.0, -1.5, -0.7];
4985 let beta_start = array![0.0_f64, 0.0, 1.0, 0.1, 1.0, 0.1];
4988
4989 let (beta_op, mut active_op) =
4990 solve_quadratic_with_constraint_set(&hessian, &rhs, &beta_start, &set, None)
4991 .expect("operator solve");
4992 let (beta_dense, mut active_dense) =
4993 solve_quadratic_with_linear_constraints(&hessian, &rhs, &beta_start, &dense, None)
4994 .expect("dense solve");
4995
4996 for j in 0..p {
4997 assert!(
4998 (beta_op[j] - beta_dense[j]).abs() < 1e-7,
4999 "operator/dense coefficient {j} mismatch: {} vs {}",
5000 beta_op[j],
5001 beta_dense[j]
5002 );
5003 }
5004 active_op.sort_unstable();
5011 active_dense.sort_unstable();
5012 let values_at_solution = set.values(beta_op.view()).expect("values at solution");
5013 let tight_at_solution: Vec<usize> = (0..set.nrows())
5014 .filter(|&row| {
5015 let norm = set.row_norm(row).expect("norm");
5016 norm > 0.0 && values_at_solution[row] / norm <= 1e-7
5017 })
5018 .collect();
5019 for &row in active_op.iter().chain(active_dense.iter()) {
5020 assert!(
5021 tight_at_solution.contains(&row),
5022 "reported active row {row} is not tight at the common solution \
5023 (op face {active_op:?}, dense face {active_dense:?}, tight {tight_at_solution:?})"
5024 );
5025 }
5026 assert_eq!(
5027 active_op.len(),
5028 active_dense.len(),
5029 "carriers disagree on the face dimension: op {active_op:?} vs dense {active_dense:?}"
5030 );
5031 assert!(
5032 !active_op.is_empty(),
5033 "fixture must actually bind at least one cone row"
5034 );
5035 let values = set.values(beta_op.view()).expect("values");
5037 let (worst, _) = set.max_scaled_violation(beta_op.view()).expect("violation");
5038 assert!(worst <= 1e-8, "operator answer infeasible: {worst:.3e}");
5039 assert_eq!(values.len(), 8);
5040 }
5041
5042 #[test]
5043 fn separable_khatri_rao_tangent_projection_matches_dense_oracle() {
5044 let cone = small_cone();
5045 let set = ConstraintSet::KhatriRaoCone(cone.clone());
5046 let dense = cone.to_dense().expect("dense projection oracle");
5047 let beta = Array1::<f64>::zeros(set.ncols());
5048 let residual = array![0.4_f64, -0.2, 1.1, -0.7, -0.9, 0.8];
5049
5050 let (operator_projected, _) =
5051 project_stationarity_residual_on_constraint_set(&residual, &beta, &set, &[])
5052 .expect("separable operator projection");
5053 let (dense_projected, _) =
5054 project_stationarity_residual_on_constraint_cone(&residual, &dense.a)
5055 .expect("dense cone projection");
5056
5057 for index in 0..residual.len() {
5058 assert_relative_eq!(
5059 operator_projected[index],
5060 dense_projected[index],
5061 epsilon = 1e-8
5062 );
5063 }
5064 }
5065
5066 #[test]
5067 fn operator_cone_qp_takes_unconstrained_path_when_interior() {
5068 let cone = small_cone();
5069 let set = ConstraintSet::KhatriRaoCone(cone);
5070 let p = set.ncols();
5071 let hessian = coupled_pd_hessian(p);
5072 let rhs = array![0.2_f64, 0.1, 3.0, 0.2, 2.5, 0.1];
5075 let beta_start = array![0.0_f64, 0.0, 1.0, 0.0, 1.0, 0.0];
5076 let (beta_op, active_op) =
5077 solve_quadratic_with_constraint_set(&hessian, &rhs, &beta_start, &set, None)
5078 .expect("operator solve");
5079 let mut beta_unconstrained = Array1::<f64>::zeros(p);
5081 super::solve_newton_direction_dense(
5082 &hessian,
5083 &(hessian.dot(&beta_start) - &rhs),
5084 &mut beta_unconstrained,
5085 )
5086 .expect("unconstrained newton");
5087 let beta_unconstrained = &beta_start + &beta_unconstrained;
5088 for j in 0..p {
5089 assert!(
5090 (beta_op[j] - beta_unconstrained[j]).abs() < 1e-8,
5091 "interior operator solve must match unconstrained optimum at {j}"
5092 );
5093 }
5094 assert!(
5095 active_op.is_empty(),
5096 "interior optimum must have empty face"
5097 );
5098 }
5099
5100 #[test]
5101 fn operator_projection_returns_strictly_interior_point() {
5102 let cone = small_cone();
5103 let set = ConstraintSet::KhatriRaoCone(cone);
5104 let point = array![0.4_f64, -0.2, -1.0, -0.5, 0.3, 0.05];
5106 let projected = project_point_strictly_into_feasible_constraint_set(&point, &set)
5107 .expect("projection must succeed on a one-sided homogeneous cone");
5108 let values = set.values(projected.view()).expect("values");
5109 for row in 0..set.nrows() {
5110 let norm = set.row_norm(row).expect("norm");
5111 if norm <= 0.0 {
5112 continue;
5113 }
5114 let slack = values[row] / norm;
5115 assert!(
5116 slack >= 0.5 * ACTIVE_SET_INTERIOR_SEED_MARGIN - 1e-9,
5117 "projected point not strictly interior on row {row}: slack {slack:.3e}"
5118 );
5119 }
5120 assert!((projected[0] - point[0]).abs() < 1e-8);
5126 assert!((projected[1] - point[1]).abs() < 1e-8);
5127 }
5128
5129 #[test]
5139 fn operator_projection_adjudicates_the_over_complete_face_2378() {
5140 let cone = small_cone();
5141 let set = ConstraintSet::KhatriRaoCone(cone.clone());
5142 let point = array![0.4_f64, -0.2, -1.0, -0.5, 0.3, 0.05];
5145 let projected = project_point_strictly_into_feasible_constraint_set(&point, &set)
5146 .expect("operator projection must certify the over-complete-face vertex");
5147
5148 let dense = ConstraintSet::Dense(cone.to_dense().expect("dense oracle"));
5151 let dense_proj = project_point_strictly_into_feasible_constraint_set(&point, &dense)
5152 .expect("dense projection oracle");
5153 for j in 0..point.len() {
5154 assert!(
5155 (projected[j] - dense_proj[j]).abs() < 1e-7,
5156 "operator projection diverged from the dense oracle at {j}: \
5157 op={:.9e} dense={:.9e}",
5158 projected[j],
5159 dense_proj[j]
5160 );
5161 }
5162
5163 let values = set.values(projected.view()).expect("values");
5167 let scaled = |row: usize| values[row] / set.row_norm(row).expect("norm");
5168 for row in [1usize, 2] {
5170 assert!(
5171 scaled(row) < ACTIVE_SET_INTERIOR_SEED_MARGIN + 1e-7,
5172 "block-1 row {row} should bind, scaled slack {:.3e}",
5173 scaled(row)
5174 );
5175 }
5176 for row in [0usize, 3] {
5178 assert!(
5179 scaled(row) > scaled(2) + 1e-9,
5180 "non-binding row {row} (slack {:.3e}) must exceed the binding \
5181 row 2 (slack {:.3e})",
5182 scaled(row),
5183 scaled(2)
5184 );
5185 }
5186 }
5187
5188 #[test]
5193 fn operator_cone_qp_over_complete_face_matches_dense_oracle_2378() {
5194 let cone = small_cone();
5195 let set = ConstraintSet::KhatriRaoCone(cone.clone());
5196 let dense = cone.to_dense().expect("dense oracle");
5197 let p = set.ncols();
5198 let hessian = coupled_pd_hessian(p);
5199 let rhs = array![0.3_f64, -0.1, -2.5, -1.2, -0.4, 0.2];
5203 let beta_start = array![0.0_f64, 0.0, 1.0, 0.1, 1.0, 0.1];
5204
5205 let (beta_op, _active_op) =
5206 solve_quadratic_with_constraint_set(&hessian, &rhs, &beta_start, &set, None)
5207 .expect("operator QP solve over an over-complete face");
5208 let (beta_dense, _active_dense) =
5209 solve_quadratic_with_linear_constraints(&hessian, &rhs, &beta_start, &dense, None)
5210 .expect("dense QP oracle");
5211
5212 for j in 0..p {
5213 assert!(
5214 (beta_op[j] - beta_dense[j]).abs() < 1e-7,
5215 "operator/dense coefficient {j} mismatch: {} vs {}",
5216 beta_op[j],
5217 beta_dense[j]
5218 );
5219 }
5220 let values = set.values(beta_op.view()).expect("values");
5222 for row in 0..set.nrows() {
5223 let norm = set.row_norm(row).expect("norm");
5224 if norm > 0.0 {
5225 assert!(
5226 values[row] / norm >= -ACTIVE_SET_PRIMAL_FEASIBILITY_TOL,
5227 "row {row} violated at the operator optimum: {:.3e}",
5228 values[row] / norm
5229 );
5230 }
5231 }
5232 }
5233
5234 #[test]
5235 fn operator_cone_does_not_materialize_a_whole_tight_face() {
5236 let mut psi = Array2::<f64>::zeros((4096, 2));
5243 psi.column_mut(0).fill(1.0);
5244 let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1], 2)
5245 .expect("repeated-row cone");
5246 let set = ConstraintSet::KhatriRaoCone(cone);
5247 let hessian = Array2::<f64>::eye(4);
5248 let rhs = array![0.3_f64, -0.2, -1.0, 0.0];
5249 let beta_start = Array1::<f64>::zeros(4);
5250
5251 let (beta, active) =
5252 solve_quadratic_with_constraint_set(&hessian, &rhs, &beta_start, &set, Some(&[0]))
5253 .expect("vertex solve");
5254
5255 assert_eq!(
5256 active,
5257 vec![0],
5258 "redundant tight rows entered the working set"
5259 );
5260 assert!(beta[2].abs() <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL);
5261 assert!((beta[0] - 0.3).abs() < 1e-10);
5262 assert!((beta[1] + 0.2).abs() < 1e-10);
5263 }
5264
5265 #[test]
5266 fn operator_cycle_escape_is_descending_feasible_and_sparse() {
5267 let psi = array![[1.0_f64, 0.0], [1.0, 1.0], [1.0, 2.0]];
5274 let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1], 2)
5275 .expect("cycle-escape cone");
5276 let set = ConstraintSet::KhatriRaoCone(cone);
5277 let ops = ConstraintSetOps::new(&set, 0.0).expect("operator geometry");
5278 let x = Array1::<f64>::zeros(4);
5279 let d_total = Array1::<f64>::zeros(4);
5280 let gradient = array![0.0_f64, 0.0, 0.0, -1.0];
5284 let (direction, active) = fallback_projected_gradient_direction_with_constraint_set(
5285 &x,
5286 &x,
5287 &d_total,
5288 &gradient,
5289 &[0],
5290 &ops,
5291 )
5292 .expect("operator fallback evaluation")
5293 .expect("a certified tangent descent direction must exist");
5294
5295 assert!(
5296 gradient.dot(&direction) < 0.0,
5297 "escape must be a strict descent direction"
5298 );
5299 let candidate = &x + &direction;
5300 let (worst, _) = set
5301 .max_scaled_violation(candidate.view())
5302 .expect("full-set feasibility");
5303 assert!(
5304 worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL,
5305 "escape must remain feasible on every operator row: {worst:.3e}"
5306 );
5307 assert_eq!(
5308 active,
5309 vec![0],
5310 "operator escape expanded one sparse face row into all tight rows"
5311 );
5312 }
5313
5314 #[test]
5315 fn operator_tangent_projection_does_not_constrain_interior_rows() {
5316 let psi = array![[1.0_f64, 0.0], [1.0, 1.0], [1.0, -1.0]];
5317 let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1], 2)
5318 .expect("interior tangent cone");
5319 let set = ConstraintSet::KhatriRaoCone(cone);
5320 let beta = array![0.0_f64, 0.0, 1.0, 0.0];
5325 let residual = array![0.0_f64, 0.0, 1.0, 0.0];
5326 let (projected, active) =
5327 project_stationarity_residual_on_constraint_set(&residual, &beta, &set, &[])
5328 .expect("interior tangent projection");
5329
5330 for index in 0..residual.len() {
5331 assert_relative_eq!(projected[index], residual[index], epsilon = 1e-12);
5332 }
5333 assert!(active.is_empty(), "interior rows entered the tangent face");
5334 }
5335
5336 #[test]
5337 fn operator_tangent_projection_homogenizes_an_affine_boundary() {
5338 let set = ConstraintSet::Dense(
5339 LinearInequalityConstraints::new(array![[1.0_f64, 0.0]], array![2.0])
5340 .expect("affine half-space"),
5341 );
5342 let beta = array![2.0_f64, 0.0];
5343 let residual = array![1.0_f64, -1.0];
5344 let (projected, active) =
5345 project_stationarity_residual_on_constraint_set(&residual, &beta, &set, &[0])
5346 .expect("affine-boundary tangent projection");
5347
5348 assert_relative_eq!(projected[0], 0.0, epsilon = 1e-12);
5349 assert_relative_eq!(projected[1], -1.0, epsilon = 1e-12);
5350 assert_eq!(active, vec![0]);
5351 }
5352
5353 #[test]
5354 fn operator_cycle_escape_discovers_a_zero_step_tangent_separator() {
5355 let psi = array![[1.0_f64, 0.0], [1.0, 1.0], [1.0, -1.0]];
5360 let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1], 2)
5361 .expect("separator cone");
5362 let set = ConstraintSet::KhatriRaoCone(cone);
5363 let ops = ConstraintSetOps::new(&set, 0.0).expect("operator geometry");
5364 let x = Array1::<f64>::zeros(4);
5365 let d_total = Array1::<f64>::zeros(4);
5366 let gradient = array![0.0_f64, 0.0, 0.0, -1.0];
5367 let (direction, active) = fallback_projected_gradient_direction_with_constraint_set(
5368 &x,
5369 &x,
5370 &d_total,
5371 &gradient,
5372 &[0],
5373 &ops,
5374 )
5375 .expect("operator separator evaluation")
5376 .expect("one omitted tight separator must not defeat the escape");
5377
5378 assert!(gradient.dot(&direction) < 0.0);
5379 let candidate = &x + &direction;
5380 let (worst, _) = set
5381 .max_scaled_violation(candidate.view())
5382 .expect("full-set feasibility");
5383 assert!(worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL);
5384 assert!(
5385 active.len() <= 2,
5386 "separator discovery expanded a three-row vertex: {active:?}"
5387 );
5388 }
5389}