1use crate::estimate::EstimationError;
2use gam_linalg::faer_ndarray::{FaerArrayView, FaerLinalgError, FaerSvd, array1_to_col_matmut};
3use gam_linalg::utils::{StableSolver, array_is_finite, boundary_hit_step_fraction};
4use faer::linalg::solvers::{Lblt as FaerLblt, Solve as FaerSolve};
5use faer::{Side, Unbind};
6use gam_problem::LinearInequalityConstraints;
7use ndarray::{Array1, Array2, s};
8use serde::{Deserialize, Serialize};
9use std::collections::HashSet;
10
11pub const ACTIVE_SET_PRIMAL_FEASIBILITY_TOL: f64 = 1e-8;
24
25const ACTIVE_SET_KKT_STATIONARITY_TOL: f64 = 2e-6;
31
32const ACTIVE_SET_KKT_COMPLEMENTARITY_TOL: f64 = 1e-6;
36
37const ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL: f64 = 1e-8;
41
42pub(crate) const ACTIVE_SET_KKT_DEGENERATE_STATIONARITY_TOL: f64 = 1e-3;
66
67const ACTIVE_SET_MODEL_DESCENT_REL_TOL: f64 = 1e-10;
72
73#[derive(Clone, Debug, Serialize, Deserialize)]
84pub struct ConstraintKktDiagnostics {
85 pub n_constraints: usize,
87 pub n_active: usize,
89 pub primal_feasibility: f64,
91 pub dual_feasibility: f64,
93 pub complementarity: f64,
95 pub stationarity: f64,
97 pub active_tolerance: f64,
99 #[serde(default)]
114 pub working_set_rank_deficient: bool,
115 #[serde(default)]
132 pub gradient_scale: f64,
133}
134
135fn gradient_inf_norm(gradient: &Array1<f64>) -> f64 {
139 gradient.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()))
140}
141
142fn solve_newton_direction_dense(
143 hessian: &Array2<f64>,
144 gradient: &Array1<f64>,
145 direction_out: &mut Array1<f64>,
146) -> Result<(), EstimationError> {
147 if direction_out.len() != gradient.len() {
148 *direction_out = Array1::zeros(gradient.len());
149 }
150
151 let factor = StableSolver::new("active-set newton direction")
152 .factorize(hessian)
153 .map_err(EstimationError::LinearSystemSolveFailed)?;
154 direction_out.assign(gradient);
155 let mut rhsview = array1_to_col_matmut(direction_out);
156 factor.solve_in_place(rhsview.as_mut());
157 direction_out.mapv_inplace(|v| -v);
158 if array_is_finite(direction_out) {
159 return Ok(());
160 }
161 Err(EstimationError::LinearSystemSolveFailed(
162 FaerLinalgError::FactorizationFailed {
163 context: "active-set newton direction non-finite solve",
164 },
165 ))
166}
167
168fn solve_dense_system_via_pseudoinverse(
169 matrix: &Array2<f64>,
170 rhs: &Array1<f64>,
171 out: &mut Array1<f64>,
172) -> Result<(), EstimationError> {
173 if matrix.nrows() != matrix.ncols() || rhs.len() != matrix.nrows() {
174 crate::bail_invalid_estim!("dense pseudoinverse solve dimension mismatch");
175 }
176
177 let (u_opt, singular, vt_opt) = matrix.svd(true, true).map_err(|_| {
178 EstimationError::InvalidInput("dense pseudoinverse solve SVD failed".to_string())
179 })?;
180 let (Some(u), Some(vt)) = (u_opt, vt_opt) else {
181 crate::bail_invalid_estim!("dense pseudoinverse solve missing singular vectors");
182 };
183
184 let max_singular = singular.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
185 let tol = 100.0
186 * f64::EPSILON
187 * (matrix.nrows().max(matrix.ncols()).max(1) as f64)
188 * max_singular.max(1.0);
189 let mut coeff = u.t().dot(rhs);
190 for (idx, value) in coeff.iter_mut().enumerate() {
191 let sigma = singular[idx];
192 if sigma.abs() > tol {
193 *value /= sigma;
194 } else {
195 *value = 0.0;
196 }
197 }
198 let solution = vt.t().dot(&coeff);
199 if !array_is_finite(&solution) {
200 crate::bail_invalid_estim!("dense pseudoinverse solve produced non-finite values");
201 }
202 if out.len() != solution.len() {
203 *out = Array1::zeros(solution.len());
204 }
205 out.assign(&solution);
206 Ok(())
207}
208
209pub(crate) fn compute_constraint_kkt_diagnostics(
210 beta: &Array1<f64>,
211 gradient: &Array1<f64>,
212 constraints: &LinearInequalityConstraints,
213) -> ConstraintKktDiagnostics {
214 let m = constraints.a.nrows();
215 let active_tolerance = ACTIVE_SET_PRIMAL_FEASIBILITY_TOL;
216
217 let p = constraints.a.ncols();
232 let mut a_scaled = constraints.a.clone();
233 let mut b_scaled = constraints.b.clone();
234 for i in 0..m {
235 let n_i = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
236 if n_i > 0.0 {
237 let inv = 1.0 / n_i;
238 a_scaled.row_mut(i).mapv_inplace(|v| v * inv);
239 b_scaled[i] *= inv;
240 }
241 }
242
243 let mut slack = Array1::<f64>::zeros(m);
244 let mut primal_feasibility: f64 = 0.0;
245 for i in 0..m {
246 let s_i = a_scaled.row(i).dot(beta) - b_scaled[i];
247 slack[i] = s_i;
248 primal_feasibility = primal_feasibility.max((-s_i).max(0.0));
249 }
250
251 let active_idx: Vec<usize> = (0..m).filter(|&i| slack[i] <= active_tolerance).collect();
252 let mut lambda = Array1::<f64>::zeros(m);
253 let mut working_set_rank_deficient = false;
254 if !active_idx.is_empty() {
255 let n_active = active_idx.len();
256 let mut a_active = Array2::<f64>::zeros((n_active, p));
257 for (r, &idx) in active_idx.iter().enumerate() {
258 a_active.row_mut(r).assign(&a_scaled.row(idx));
259 }
260 if let Some((_, lambda_active)) =
261 project_stationarity_residual_on_constraint_cone(gradient, &a_active)
262 {
263 for (r, &idx) in active_idx.iter().enumerate() {
264 lambda[idx] = lambda_active[r];
265 }
266 }
267 working_set_rank_deficient = if n_active > p {
279 true
280 } else if n_active > 1 {
281 let groups: Vec<Vec<usize>> = (0..n_active).map(|i| vec![i]).collect();
282 let b_dummy = Array1::<f64>::zeros(n_active);
283 let (reduced_a, _, _, _) =
284 rank_reduce_rows_pivoted_qr_with_dependence(a_active, b_dummy, groups);
285 reduced_a.nrows() < n_active
286 } else {
287 false
288 };
289 }
290
291 let mut dual_feasibility: f64 = 0.0;
292 let mut complementarity: f64 = 0.0;
293 for i in 0..m {
294 dual_feasibility = dual_feasibility.max((-lambda[i]).max(0.0));
295 complementarity = complementarity.max((lambda[i] * slack[i]).abs());
296 }
297 let stationarity = {
298 let mut resid = gradient.to_owned();
299 resid -= &a_scaled.t().dot(&lambda);
300 resid.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()))
301 };
302
303 ConstraintKktDiagnostics {
304 n_constraints: m,
305 n_active: active_idx.len(),
306 primal_feasibility,
307 dual_feasibility,
308 complementarity,
309 stationarity,
310 active_tolerance,
311 working_set_rank_deficient,
312 gradient_scale: gradient_inf_norm(gradient),
313 }
314}
315
316pub fn project_stationarity_residual_on_constraint_cone(
317 residual: &Array1<f64>,
318 active_a: &Array2<f64>,
319) -> Option<(Array1<f64>, Array1<f64>)> {
320 let p = residual.len();
321 if active_a.ncols() != p {
322 return None;
323 }
324 if active_a.nrows() == 0 {
325 return Some((residual.clone(), Array1::zeros(0)));
326 }
327
328 let m = active_a.nrows();
329 let residual_scale = residual
330 .iter()
331 .fold(0.0_f64, |acc, &v| acc.max(v.abs()))
332 .max(1.0);
333 let row_scale = active_a
334 .iter()
335 .fold(0.0_f64, |acc, &v| acc.max(v.abs()))
336 .max(1.0);
337 let tol = 100.0 * f64::EPSILON * (p.max(m).max(1) as f64) * residual_scale * row_scale;
338
339 let mut lambda = Array1::<f64>::zeros(m);
340 let mut passive = vec![false; m];
341 let mut projected = residual.clone();
342 let max_iter = (3 * m * m).max(10);
343
344 for _ in 0..max_iter {
345 let dual = active_a.dot(&projected);
346 let entering = (0..m)
347 .filter(|&idx| !passive[idx] && dual[idx] > tol)
348 .max_by(|&left, &right| {
349 dual[left]
350 .partial_cmp(&dual[right])
351 .unwrap_or(std::cmp::Ordering::Equal)
352 });
353 let Some(entering) = entering else {
354 lambda.mapv_inplace(|v| if v > tol { v } else { 0.0 });
355 projected = residual - &active_a.t().dot(&lambda);
356 if !array_is_finite(&projected) || !array_is_finite(&lambda) {
357 return None;
358 }
359 return Some((projected, lambda));
360 };
361 passive[entering] = true;
362
363 loop {
364 let passive_rows: Vec<usize> = (0..m).filter(|&idx| passive[idx]).collect();
365 if passive_rows.is_empty() {
366 lambda.fill(0.0);
367 projected.assign(residual);
368 break;
369 }
370
371 let mut a_passive = Array2::<f64>::zeros((passive_rows.len(), p));
372 for (pos, &row) in passive_rows.iter().enumerate() {
373 a_passive.row_mut(pos).assign(&active_a.row(row));
374 }
375 let gram = a_passive.dot(&a_passive.t());
376 let rhs = a_passive.dot(residual);
377 let mut lambda_passive = Array1::<f64>::zeros(passive_rows.len());
378 solve_dense_system_via_pseudoinverse(&gram, &rhs, &mut lambda_passive).ok()?;
379 if !array_is_finite(&lambda_passive) {
380 return None;
381 }
382
383 let all_positive = lambda_passive.iter().all(|&v| v > tol);
384 if all_positive {
385 lambda.fill(0.0);
386 for (pos, &row) in passive_rows.iter().enumerate() {
387 lambda[row] = lambda_passive[pos];
388 }
389 projected = residual - &active_a.t().dot(&lambda);
390 break;
391 }
392
393 let mut alpha = f64::INFINITY;
394 for (pos, &row) in passive_rows.iter().enumerate() {
395 let candidate = lambda_passive[pos];
396 if candidate <= tol {
397 let current = lambda[row];
398 let denom = current - candidate;
399 if denom > 0.0 {
400 alpha = alpha.min(current / denom);
401 }
402 }
403 }
404 if !alpha.is_finite() {
405 alpha = 0.0;
406 }
407 alpha = alpha.clamp(0.0, 1.0);
408
409 for (pos, &row) in passive_rows.iter().enumerate() {
410 lambda[row] += alpha * (lambda_passive[pos] - lambda[row]);
411 if lambda[row] <= tol {
412 lambda[row] = 0.0;
413 passive[row] = false;
414 }
415 }
416 if passive.iter().all(|&is_passive| !is_passive) {
417 projected.assign(residual);
418 break;
419 }
420 }
421 }
422
423 None
426}
427
428pub(crate) fn feasible_point_for_linear_constraints(
429 constraints: &LinearInequalityConstraints,
430 p: usize,
431) -> Option<Array1<f64>> {
432 if constraints.a.ncols() != p
433 || constraints.a.nrows() == 0
434 || constraints.b.len() != constraints.a.nrows()
435 {
436 return None;
437 }
438 if constraints.b.iter().all(|v| v.abs() <= 1e-14) {
439 return Some(Array1::zeros(p));
440 }
441
442 let gram = constraints.a.dot(&constraints.a.t());
443 let (u_opt, singular, vt_opt) = gram.svd(true, true).ok()?;
444 let (Some(u), Some(vt)) = (u_opt, vt_opt) else {
445 return None;
446 };
447 let max_singular = singular.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
448 let tol = 100.0 * f64::EPSILON * constraints.a.nrows().max(1) as f64 * max_singular.max(1.0);
449 let mut coeff = u.t().dot(&constraints.b);
450 for (idx, value) in coeff.iter_mut().enumerate() {
451 let sigma = singular[idx];
452 if sigma.abs() > tol {
453 *value /= sigma;
454 } else {
455 *value = 0.0;
456 }
457 }
458 let dual = vt.t().dot(&coeff);
459 let beta = constraints.a.t().dot(&dual);
460 if beta.len() != p || beta.iter().any(|v| !v.is_finite()) {
461 return None;
462 }
463 let slack = constraints.a.dot(&beta) - &constraints.b;
464 if slack.iter().all(|v| *v >= -1e-8) {
465 Some(beta)
466 } else {
467 None
468 }
469}
470
471const ACTIVE_SET_INTERIOR_SEED_MARGIN: f64 = 1e-6;
481
482#[inline]
488pub(crate) fn interior_seed_margin() -> f64 {
489 ACTIVE_SET_INTERIOR_SEED_MARGIN
490}
491
492pub fn project_point_strictly_into_feasible_cone(
521 point: &Array1<f64>,
522 constraints: &LinearInequalityConstraints,
523) -> Option<Array1<f64>> {
524 let p = point.len();
525 let m = constraints.a.nrows();
526 if constraints.a.ncols() != p || m == 0 || constraints.b.len() != m {
527 return None;
528 }
529 let norms: Vec<f64> = (0..m)
530 .map(|i| constraints.a.row(i).dot(&constraints.a.row(i)).sqrt())
531 .collect();
532
533 const ANTIPARALLEL_COS_TOL: f64 = -1.0 + 1e-9;
547 const EQUALITY_WIDTH_TOL: f64 = 1e-9;
548 let mut is_equality_member = vec![false; m];
549 let mut equality_rows: Vec<usize> = Vec::new();
550 let mut margin = vec![ACTIVE_SET_INTERIOR_SEED_MARGIN; m];
551 for i in 0..m {
552 if norms[i] == 0.0 {
553 margin[i] = 0.0;
554 continue;
555 }
556 for j in (i + 1)..m {
557 if norms[j] == 0.0 {
558 continue;
559 }
560 let cos = constraints.a.row(i).dot(&constraints.a.row(j)) / (norms[i] * norms[j]);
561 if cos > ANTIPARALLEL_COS_TOL {
562 continue;
563 }
564 let width = -constraints.b[j] / norms[j] - constraints.b[i] / norms[i];
567 if width.abs() <= EQUALITY_WIDTH_TOL {
568 if !is_equality_member[i] && !is_equality_member[j] {
571 equality_rows.push(i);
572 }
573 is_equality_member[i] = true;
574 is_equality_member[j] = true;
575 } else {
576 let cap = (width / 3.0).max(0.0);
579 margin[i] = margin[i].min(cap);
580 margin[j] = margin[j].min(cap);
581 }
582 }
583 }
584
585 let ineq_rows: Vec<usize> = (0..m).filter(|&i| !is_equality_member[i]).collect();
588 let mut a_ineq = Array2::<f64>::zeros((ineq_rows.len(), p));
589 let mut b_ineq = Array1::<f64>::zeros(ineq_rows.len());
590 for (r, &i) in ineq_rows.iter().enumerate() {
591 a_ineq.row_mut(r).assign(&constraints.a.row(i));
592 b_ineq[r] = constraints.b[i] + margin[i] * norms[i];
593 }
594
595 let beta = if equality_rows.is_empty() {
596 let interior = LinearInequalityConstraints::new(a_ineq, b_ineq)
599 .expect("shifted interior constraint shape invariant");
600 let identity = Array2::<f64>::eye(p);
601 solve_quadratic_with_linear_constraints(&identity, point, point, &interior, None)
602 .ok()?
603 .0
604 } else {
605 let k = equality_rows.len();
615 let mut e_mat = Array2::<f64>::zeros((k, p));
616 let mut e_rhs = Array1::<f64>::zeros(k);
617 for (r, &i) in equality_rows.iter().enumerate() {
618 e_mat.row_mut(r).assign(&constraints.a.row(i));
619 e_rhs[r] = constraints.b[i];
620 }
621 let (u_opt, sing, vt_opt) = e_mat.svd(true, true).ok()?;
622 let (u_mat, vt) = (u_opt?, vt_opt?);
623 let smax = sing.iter().fold(0.0_f64, |acc, &v| acc.max(v));
624 let rank_tol = smax.max(1.0) * (k.max(p) as f64) * f64::EPSILON * 100.0;
625 let rank = sing.iter().filter(|&&s| s > rank_tol).count();
626 if rank == 0 || rank >= p {
627 return None;
628 }
629 let mut beta_p = Array1::<f64>::zeros(p);
630 for idx in 0..rank {
631 let coeff = u_mat.column(idx).dot(&e_rhs) / sing[idx];
632 beta_p.scaled_add(coeff, &vt.row(idx));
633 }
634 let mut basis: Vec<Array1<f64>> = (0..rank).map(|i| vt.row(i).to_owned()).collect();
637 let mut z = Array2::<f64>::zeros((p, p - rank));
638 let mut collected = 0usize;
639 for axis in 0..p {
640 if collected == p - rank {
641 break;
642 }
643 let mut v = Array1::<f64>::zeros(p);
644 v[axis] = 1.0;
645 for q in basis.iter() {
646 let c = q.dot(&v);
647 v.scaled_add(-c, q);
648 }
649 let nrm = v.dot(&v).sqrt();
650 if nrm > 1e-8 {
651 v /= nrm;
652 z.column_mut(collected).assign(&v);
653 basis.push(v);
654 collected += 1;
655 }
656 }
657 if collected != p - rank {
658 return None;
659 }
660 let a_red = a_ineq.dot(&z);
661 let b_red = &b_ineq - &a_ineq.dot(&beta_p);
662 let u0 = z.t().dot(&(point - &beta_p));
663 let reduced = LinearInequalityConstraints::new(a_red, b_red)
664 .expect("reduced constraint shape invariant");
665 let identity = Array2::<f64>::eye(z.ncols());
666 let (u_sol, _active) =
667 solve_quadratic_with_linear_constraints(&identity, &u0, &u0, &reduced, None).ok()?;
668 &beta_p + &z.dot(&u_sol)
669 };
670
671 if beta.len() != p || beta.iter().any(|v| !v.is_finite()) {
672 return None;
673 }
674 const SEED_FEASIBILITY_TOL: f64 = 1e-9;
679 for i in 0..m {
680 let s = scaled_constraint_slack(&beta, constraints, i);
681 let lower = if is_equality_member[i] {
682 -SEED_FEASIBILITY_TOL
683 } else {
684 0.5 * margin[i] - SEED_FEASIBILITY_TOL
685 };
686 if s < lower {
687 return None;
688 }
689 }
690 Some(beta)
691}
692
693fn max_linear_constraint_violation(
705 beta: &Array1<f64>,
706 constraints: &LinearInequalityConstraints,
707) -> (f64, usize) {
708 let mut worst = 0.0_f64;
709 let mut worst_row = 0usize;
710 for i in 0..constraints.a.nrows() {
711 let norm = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
712 let inv = if norm > 0.0 { 1.0 / norm } else { 0.0 };
713 let slack = (constraints.a.row(i).dot(beta) - constraints.b[i]) * inv;
714 let viol = (-slack).max(0.0);
715 if viol > worst {
716 worst = viol;
717 worst_row = i;
718 }
719 }
720 (worst, worst_row)
721}
722
723#[inline]
728fn scaled_constraint_slack(
729 beta: &Array1<f64>,
730 constraints: &LinearInequalityConstraints,
731 i: usize,
732) -> f64 {
733 let norm = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
734 let inv = if norm > 0.0 { 1.0 / norm } else { 0.0 };
735 (constraints.a.row(i).dot(beta) - constraints.b[i]) * inv
736}
737
738pub(crate) fn solve_kkt_direction(
739 hessian: &Array2<f64>,
740 gradient: &Array1<f64>,
741 active_a: &Array2<f64>,
742 active_residual: Option<&Array1<f64>>,
743) -> Result<(Array1<f64>, Array1<f64>), EstimationError> {
744 let p = hessian.nrows();
745 let m = active_a.nrows();
746 if hessian.ncols() != p || gradient.len() != p || active_a.ncols() != p {
747 crate::bail_invalid_estim!("KKT solve dimension mismatch");
748 }
749 if let Some(residual) = active_residual
750 && residual.len() != m
751 {
752 crate::bail_invalid_estim!(
753 "KKT active residual length mismatch: got {}, expected {}",
754 residual.len(),
755 m
756 );
757 }
758 if m == 0 {
759 let mut d = Array1::<f64>::zeros(p);
760 solve_newton_direction_dense(hessian, gradient, &mut d)?;
761 return Ok((d, Array1::zeros(0)));
762 }
763 let mut kkt = Array2::<f64>::zeros((p + m, p + m));
764 kkt.slice_mut(s![0..p, 0..p]).assign(hessian);
765 kkt.slice_mut(s![0..p, p..(p + m)]).assign(&active_a.t());
766 kkt.slice_mut(s![p..(p + m), 0..p]).assign(active_a);
767
768 let mut rhs = Array1::<f64>::zeros(p + m);
769 for i in 0..p {
770 rhs[i] = -gradient[i];
771 }
772 if let Some(residual) = active_residual {
773 for i in 0..m {
774 rhs[p + i] = residual[i];
775 }
776 }
777 let rhs_target = rhs.clone();
778
779 let kkt_view = FaerArrayView::new(&kkt);
780 let factor = FaerLblt::new(kkt_view.as_ref(), Side::Lower);
781 let mut rhs_col = array1_to_col_matmut(&mut rhs);
782 factor.solve_in_place(rhs_col.as_mut());
783 if !rhs.iter().all(|v| v.is_finite()) {
784 solve_dense_system_via_pseudoinverse(&kkt, &rhs_target, &mut rhs)?;
785 }
786 let d = rhs.slice(s![0..p]).to_owned();
787 let lambda = rhs.slice(s![p..(p + m)]).to_owned();
788 Ok((d, lambda))
789}
790
791#[derive(Clone, Debug)]
792pub(crate) struct CompressedActiveWorkingSet {
793 pub(crate) constraints: LinearInequalityConstraints,
794 pub(crate) groups: Vec<Vec<usize>>,
795 multiplier_dependence: Vec<Vec<ActiveRowDependence>>,
796 pub(crate) original_active_count: usize,
797}
798
799#[derive(Clone, Copy, Debug)]
804pub struct ActiveRowDependence {
805 active_pos: usize,
806 coeff: f64,
807}
808
809impl CompressedActiveWorkingSet {
810 fn is_degenerate_face(&self) -> bool {
811 self.constraints.a.nrows() < self.original_active_count
812 || self.groups.iter().any(|group| group.len() > 1)
813 }
814
815 fn reconstructed_active_multipliers(&self, lambda_system: &Array1<f64>) -> Vec<(usize, f64)> {
816 let mut multipliers = Vec::new();
817 for (group_pos, &lambda_system_value) in lambda_system.iter().enumerate() {
818 let lambda_true = -lambda_system_value;
819 if let Some(dependencies) = self.multiplier_dependence.get(group_pos) {
820 for dependency in dependencies {
821 if dependency.coeff.abs() > f64::EPSILON {
822 multipliers.push((dependency.active_pos, lambda_true / dependency.coeff));
823 }
824 }
825 }
826 }
827 multipliers
828 }
829}
830
831pub(crate) fn compress_active_working_set(
832 x: &Array1<f64>,
833 constraints: &LinearInequalityConstraints,
834 active: &[usize],
835) -> Result<CompressedActiveWorkingSet, EstimationError> {
836 let p = constraints.a.ncols();
837 if x.len() != p {
838 crate::bail_invalid_estim!("active working-set compression dimension mismatch");
839 }
840
841 let mut a_out = Array2::<f64>::zeros((active.len(), p));
842 let mut b_out = Array1::<f64>::zeros(active.len());
843 let mut groups_out: Vec<Vec<usize>> = Vec::with_capacity(active.len());
844 for (pos, &idx) in active.iter().enumerate() {
845 if idx >= constraints.a.nrows() {
846 crate::bail_invalid_estim!(
847 "active working-set index {} out of bounds for {} constraints",
848 idx,
849 constraints.a.nrows()
850 );
851 }
852 a_out.row_mut(pos).assign(&constraints.a.row(idx));
853 b_out[pos] = constraints.b[idx];
854 groups_out.push(vec![pos]);
855 }
856
857 let (a_out, b_out, groups_out, multiplier_dependence) =
858 rank_reduce_rows_pivoted_qr_with_dependence(a_out, b_out, groups_out);
859
860 Ok(CompressedActiveWorkingSet {
861 constraints: LinearInequalityConstraints::new(a_out, b_out)
862 .expect("compressed active constraint shape invariant"),
863 groups: groups_out,
864 multiplier_dependence,
865 original_active_count: active.len(),
866 })
867}
868
869fn identity_multiplier_dependence(groups: &[Vec<usize>]) -> Vec<Vec<ActiveRowDependence>> {
870 groups
871 .iter()
872 .map(|group| {
873 group
874 .iter()
875 .copied()
876 .map(|active_pos| ActiveRowDependence {
877 active_pos,
878 coeff: 1.0,
879 })
880 .collect()
881 })
882 .collect()
883}
884
885pub fn rank_reduce_rows_pivoted_qr_with_dependence(
886 a: Array2<f64>,
887 b: Array1<f64>,
888 groups: Vec<Vec<usize>>,
889) -> (
890 Array2<f64>,
891 Array1<f64>,
892 Vec<Vec<usize>>,
893 Vec<Vec<ActiveRowDependence>>,
894) {
895 let k = a.nrows();
896 let p = a.ncols();
897 if k <= 1 {
898 let multiplier_dependence = identity_multiplier_dependence(&groups);
899 return (a, b, groups, multiplier_dependence);
900 }
901
902 let mut at_faer = faer::Mat::<f64>::zeros(p, k);
903 for i in 0..k {
904 for j in 0..p {
905 at_faer[(j, i)] = a[[i, j]];
906 }
907 }
908
909 let qr = at_faer.as_ref().col_piv_qr();
910 let r_mat = qr.thin_R();
911 let diag_len = r_mat.nrows().min(r_mat.ncols());
912 let leading_diag = if diag_len > 0 {
913 r_mat[(0, 0)].abs()
914 } else {
915 0.0
916 };
917
918 const RANK_ALPHA: f64 = 100.0;
919 let tol = RANK_ALPHA * f64::EPSILON * (k.max(p).max(1) as f64) * leading_diag.max(1.0);
920
921 let rank = (0..diag_len).filter(|&i| r_mat[(i, i)].abs() > tol).count();
922 if rank >= k {
923 let multiplier_dependence = identity_multiplier_dependence(&groups);
924 return (a, b, groups, multiplier_dependence);
925 }
926 if rank == 0 {
927 log::debug!(
928 "rank-reduced active constraints from {} to 0 rows (all active rows numerically zero)",
929 k
930 );
931 return (
932 Array2::<f64>::zeros((0, p)),
933 Array1::<f64>::zeros(0),
934 Vec::new(),
935 Vec::new(),
936 );
937 }
938
939 let (perm_fwd, _) = qr.P().arrays();
940 let kept_orig: Vec<usize> = (0..rank).map(|j| perm_fwd[j].unbound()).collect();
941 let dropped_orig: Vec<usize> = (rank..k).map(|j| perm_fwd[j].unbound()).collect();
942
943 let mut orig_to_out = std::collections::HashMap::with_capacity(rank);
944 let mut a_out = Array2::<f64>::zeros((rank, p));
945 let mut b_out = Array1::<f64>::zeros(rank);
946 let mut groups_out: Vec<Vec<usize>> = Vec::with_capacity(rank);
947 let mut multiplier_dependence: Vec<Vec<ActiveRowDependence>> = Vec::with_capacity(rank);
948 for (out_idx, &orig_idx) in kept_orig.iter().enumerate() {
949 a_out.row_mut(out_idx).assign(&a.row(orig_idx));
950 b_out[out_idx] = b[orig_idx];
951 groups_out.push(groups[orig_idx].clone());
952 multiplier_dependence.push(
953 groups[orig_idx]
954 .iter()
955 .copied()
956 .map(|active_pos| ActiveRowDependence {
957 active_pos,
958 coeff: 1.0,
959 })
960 .collect(),
961 );
962 orig_to_out.insert(orig_idx, out_idx);
963 }
964
965 for &dropped_idx in &dropped_orig {
966 let dropped_row = a.row(dropped_idx);
967 let dropped_norm = dropped_row.dot(&dropped_row).sqrt();
968 let mut best_positive_align = -1.0_f64;
969 let mut best_positive_target: Option<(usize, f64)> = None;
970 let mut best_abs_align = -1.0_f64;
971 let mut best_signed_target = (kept_orig[0], 1.0_f64);
972 for &kept_idx in &kept_orig {
973 let kept_row = a.row(kept_idx);
974 let kept_norm = kept_row.dot(&kept_row).sqrt();
975 let dot = kept_row.dot(&dropped_row);
976 let align = if kept_norm > 0.0 && dropped_norm > 0.0 {
977 dot / (kept_norm * dropped_norm)
978 } else {
979 dot
980 };
981 let coeff = if kept_norm > 0.0 {
982 dot / (kept_norm * kept_norm)
983 } else {
984 0.0
985 };
986 if align.abs() > best_abs_align {
987 best_abs_align = align.abs();
988 best_signed_target = (kept_idx, coeff);
989 }
990 if coeff > 0.0 && align > best_positive_align {
991 best_positive_align = align;
992 best_positive_target = Some((kept_idx, coeff));
993 }
994 }
995 let (best_target, coeff) = best_positive_target.unwrap_or(best_signed_target);
996 let &out_idx = orig_to_out
997 .get(&best_target)
998 .expect("merge target must be a kept row");
999 for &active_pos in &groups[dropped_idx] {
1000 multiplier_dependence[out_idx].push(ActiveRowDependence { active_pos, coeff });
1001 }
1002 if coeff > 0.0 {
1003 groups_out[out_idx].extend_from_slice(&groups[dropped_idx]);
1004 }
1005 }
1006
1007 for group in &mut groups_out {
1008 group.sort_unstable();
1009 group.dedup();
1010 }
1011 for dependencies in &mut multiplier_dependence {
1012 dependencies.sort_unstable_by_key(|dependency| dependency.active_pos);
1013 dependencies.dedup_by_key(|dependency| dependency.active_pos);
1014 }
1015
1016 let mut row_order: Vec<usize> = (0..groups_out.len()).collect();
1017 row_order.sort_by_key(|&idx| groups_out[idx].first().copied().unwrap_or(usize::MAX));
1018 if row_order.iter().enumerate().any(|(idx, &orig)| idx != orig) {
1019 let mut a_sorted = Array2::<f64>::zeros((rank, p));
1020 let mut b_sorted = Array1::<f64>::zeros(rank);
1021 let mut groups_sorted = Vec::with_capacity(rank);
1022 let mut dependence_sorted = Vec::with_capacity(rank);
1023 for (out_idx, orig_idx) in row_order.into_iter().enumerate() {
1024 a_sorted.row_mut(out_idx).assign(&a_out.row(orig_idx));
1025 b_sorted[out_idx] = b_out[orig_idx];
1026 groups_sorted.push(groups_out[orig_idx].clone());
1027 dependence_sorted.push(multiplier_dependence[orig_idx].clone());
1028 }
1029 a_out = a_sorted;
1030 b_out = b_sorted;
1031 groups_out = groups_sorted;
1032 multiplier_dependence = dependence_sorted;
1033 }
1034
1035 if rank < k {
1036 log::debug!(
1037 "rank-reduced active constraints from {} to {} rows (rank deficiency {})",
1038 k,
1039 rank,
1040 k - rank
1041 );
1042 }
1043
1044 (a_out, b_out, groups_out, multiplier_dependence)
1045}
1046
1047pub(crate) fn working_set_kkt_diagnostics_from_multipliers(
1048 x: &Array1<f64>,
1049 gradient: &Array1<f64>,
1050 working_constraints: &LinearInequalityConstraints,
1051 lambda_active_true: &Array1<f64>,
1052 n_total_constraints: usize,
1053) -> Result<ConstraintKktDiagnostics, EstimationError> {
1054 let p = working_constraints.a.ncols();
1055 if x.len() != p || gradient.len() != p {
1056 crate::bail_invalid_estim!("working-set KKT diagnostic dimension mismatch");
1057 }
1058 if lambda_active_true.len() != working_constraints.a.nrows() {
1059 crate::bail_invalid_estim!(
1060 "working-set KKT multiplier length mismatch: got {}, expected {}",
1061 lambda_active_true.len(),
1062 working_constraints.a.nrows()
1063 );
1064 }
1065 let m = working_constraints.a.nrows();
1076 let mut slack = Array1::<f64>::zeros(m);
1077 let mut primal_feasibility: f64 = 0.0;
1078 for i in 0..m {
1079 let s_i = scaled_constraint_slack(x, working_constraints, i);
1080 slack[i] = s_i;
1081 primal_feasibility = primal_feasibility.max((-s_i).max(0.0));
1082 }
1083
1084 let lambda = lambda_active_true.to_owned();
1085
1086 let mut dual_feasibility: f64 = 0.0;
1087 let mut complementarity: f64 = 0.0;
1088 for i in 0..m {
1089 dual_feasibility = dual_feasibility.max((-lambda[i]).max(0.0));
1090 let norm_i = working_constraints
1098 .a
1099 .row(i)
1100 .dot(&working_constraints.a.row(i))
1101 .sqrt();
1102 complementarity = complementarity.max((norm_i * lambda[i] * slack[i]).abs());
1103 }
1104 let stationarity = {
1105 let mut resid = gradient.to_owned();
1106 resid -= &working_constraints.a.t().dot(&lambda);
1107 resid.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()))
1108 };
1109
1110 Ok(ConstraintKktDiagnostics {
1111 n_constraints: n_total_constraints,
1112 n_active: m,
1113 primal_feasibility,
1114 dual_feasibility,
1115 complementarity,
1116 stationarity,
1117 active_tolerance: ACTIVE_SET_PRIMAL_FEASIBILITY_TOL,
1118 working_set_rank_deficient: false,
1125 gradient_scale: gradient_inf_norm(gradient),
1126 })
1127}
1128
1129fn canonicalize_active_constraint_ids(
1130 x: &Array1<f64>,
1131 constraints: &LinearInequalityConstraints,
1132 active: &[usize],
1133) -> Result<Vec<usize>, EstimationError> {
1134 if active.is_empty() {
1135 return Ok(Vec::new());
1136 }
1137 let compressed_working = compress_active_working_set(x, constraints, active)?;
1138 let mut canonical = Vec::with_capacity(compressed_working.groups.len());
1139 for group in &compressed_working.groups {
1140 if let Some(&active_pos) = group.first() {
1141 canonical.push(active[active_pos]);
1142 }
1143 }
1144 Ok(canonical)
1145}
1146
1147fn fallback_projected_gradient_direction(
1148 x: &Array1<f64>,
1149 d_total: &Array1<f64>,
1150 gradient: &Array1<f64>,
1151 working_constraints: &LinearInequalityConstraints,
1152 constraints: &LinearInequalityConstraints,
1153) -> Result<Option<(Array1<f64>, Vec<usize>)>, EstimationError> {
1154 let p = gradient.len();
1155 if x.len() != p || d_total.len() != p || constraints.a.ncols() != p {
1156 crate::bail_invalid_estim!("projected-gradient fallback dimension mismatch");
1157 }
1158
1159 let tangent_direction = if working_constraints.a.nrows() == 0 {
1160 -gradient
1161 } else {
1162 let identity = Array2::<f64>::eye(p);
1163 let residual = &working_constraints.b - &working_constraints.a.dot(x);
1164 let (direction, _) =
1165 solve_kkt_direction(&identity, gradient, &working_constraints.a, Some(&residual))?;
1166 direction
1167 };
1168
1169 if !array_is_finite(&tangent_direction) {
1170 return Ok(None);
1171 }
1172
1173 let step_inf = tangent_direction
1174 .iter()
1175 .fold(0.0_f64, |acc, &value| acc.max(value.abs()));
1176 if step_inf <= 1e-12 {
1177 let (worst, _) = max_linear_constraint_violation(x, constraints);
1193 if worst > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
1194 let projected = project_point_strictly_into_feasible_cone(x, constraints)
1195 .or_else(|| {
1196 let identity = Array2::<f64>::eye(p);
1197 solve_quadratic_with_linear_constraints(&identity, x, x, constraints, None)
1198 .ok()
1199 .map(|(beta, _active)| beta)
1200 })
1201 .filter(|p_candidate| {
1202 max_linear_constraint_violation(p_candidate, constraints).0
1203 <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
1204 });
1205 let Some(projected) = projected else {
1206 return Ok(None);
1209 };
1210 let repair = &projected - x;
1211 let active = canonicalize_active_constraint_ids(&projected, constraints, &[])?;
1212 return Ok(Some((d_total + &repair, active)));
1213 }
1214 let active = canonicalize_active_constraint_ids(x, constraints, &[])?;
1215 return Ok(Some((d_total.clone(), active)));
1216 }
1217
1218 let directional_derivative = gradient.dot(&tangent_direction);
1219 if !directional_derivative.is_finite() || directional_derivative >= 0.0 {
1220 return Ok(None);
1221 }
1222
1223 let mut alpha = 1.0_f64;
1224 for i in 0..constraints.a.nrows() {
1225 let norm = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
1229 let inv = if norm > 0.0 { 1.0 / norm } else { 0.0 };
1230 let slack = (constraints.a.row(i).dot(x) - constraints.b[i]) * inv;
1231 let ai_d = constraints.a.row(i).dot(&tangent_direction) * inv;
1232 if let Some(candidate) = boundary_hit_step_fraction(slack, ai_d, alpha) {
1233 alpha = candidate;
1234 }
1235 }
1236 if !alpha.is_finite() || alpha <= 0.0 {
1237 return Ok(None);
1238 }
1239
1240 let fallback_step = tangent_direction * alpha;
1241 let new_x = x + &fallback_step;
1242 let (worst, _) = max_linear_constraint_violation(&new_x, constraints);
1244 if worst > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
1245 return Ok(None);
1246 }
1247 let active = (0..constraints.a.nrows())
1248 .filter(|&i| scaled_constraint_slack(&new_x, constraints, i) <= 1e-10)
1249 .collect::<Vec<_>>();
1250 let active = canonicalize_active_constraint_ids(&new_x, constraints, &active)?;
1251 Ok(Some((d_total + &fallback_step, active)))
1252}
1253
1254fn log_active_set_transition(
1255 event: &str,
1256 iteration: usize,
1257 active_len: usize,
1258 constraint: Option<usize>,
1259) {
1260 log::debug!(
1261 "[active-set/QP] iter={} event={} active={} constraint={}",
1262 iteration,
1263 event,
1264 active_len,
1265 constraint
1266 .map(|idx| idx.to_string())
1267 .unwrap_or_else(|| "NA".to_string()),
1268 );
1269}
1270
1271fn record_active_working_set(
1281 visited: &mut HashSet<Vec<usize>>,
1282 active: &[usize],
1283 iteration: usize,
1284) -> bool {
1285 let mut key = active.to_vec();
1286 key.sort_unstable();
1287 if visited.insert(key.clone()) {
1288 return true;
1289 }
1290 log::debug!(
1291 "[active-set/QP] iter={iteration} repeated working set ({} rows); \
1292 deferring to the post-loop KKT exit gate",
1293 key.len()
1294 );
1295 false
1296}
1297
1298fn solve_newton_direction_with_linear_constraints_impl(
1299 hessian: &Array2<f64>,
1300 gradient: &Array1<f64>,
1301 beta: &Array1<f64>,
1302 constraints: &LinearInequalityConstraints,
1303 direction_out: &mut Array1<f64>,
1304 active_hint: Option<&mut Vec<usize>>,
1305 max_iterations: usize,
1306) -> Result<(), EstimationError> {
1307 let p = gradient.len();
1308 if direction_out.len() != p {
1309 *direction_out = Array1::zeros(p);
1310 }
1311 let m = constraints.a.nrows();
1312 if constraints.a.ncols() != p || constraints.b.len() != m || beta.len() != p {
1313 crate::bail_invalid_estim!(
1314 "linear constraint shape mismatch: A={}x{}, b={}, p={}",
1315 constraints.a.nrows(),
1316 constraints.a.ncols(),
1317 constraints.b.len(),
1318 p
1319 );
1320 }
1321
1322 let tol_active = 1e-10;
1323 let tol_step = 1e-12;
1324 let tol_dual = 1e-10;
1325 let mut x = beta.to_owned();
1326 let mut d_total = Array1::<f64>::zeros(p);
1327 let mut g_cur = gradient.to_owned();
1328
1329 let has_active_hint = active_hint
1330 .as_ref()
1331 .map(|hint| !hint.is_empty())
1332 .unwrap_or(false);
1333 if !has_active_hint && solve_newton_direction_dense(hessian, gradient, direction_out).is_ok() {
1334 let candidate = beta + &*direction_out;
1335 let mut feasible = true;
1336 for i in 0..m {
1337 let slack = scaled_constraint_slack(&candidate, constraints, i);
1340 if slack < -tol_active {
1341 feasible = false;
1342 break;
1343 }
1344 }
1345 if feasible {
1346 return Ok(());
1347 }
1348 }
1349
1350 let mut active: Vec<usize> = Vec::new();
1351 let mut is_active = vec![false; m];
1352 if let Some(hint) = active_hint.as_ref() {
1353 for &idx in hint.iter() {
1354 if idx < m && !is_active[idx] {
1355 active.push(idx);
1356 is_active[idx] = true;
1357 log_active_set_transition("warm-add", 0, active.len(), Some(idx));
1358 }
1359 }
1360 }
1361 for i in 0..m {
1362 let slack = scaled_constraint_slack(&x, constraints, i);
1368 if slack <= tol_active && !is_active[i] {
1369 active.push(i);
1370 is_active[i] = true;
1371 log_active_set_transition("initial-boundary-add", 0, active.len(), Some(i));
1372 }
1373 }
1374 let mut visited_working_sets: HashSet<Vec<usize>> = HashSet::new();
1375 record_active_working_set(&mut visited_working_sets, &active, 0);
1376
1377 for iteration in 0..max_iterations {
1378 let compressed_working = compress_active_working_set(&x, constraints, &active)?;
1379 let mut residualw = Array1::<f64>::zeros(compressed_working.constraints.a.nrows());
1380 for r in 0..compressed_working.constraints.a.nrows() {
1381 residualw[r] = compressed_working.constraints.b[r]
1382 - compressed_working.constraints.a.row(r).dot(&x);
1383 }
1384 let (d, lambdaw) = solve_kkt_direction(
1385 hessian,
1386 &g_cur,
1387 &compressed_working.constraints.a,
1388 Some(&residualw),
1389 )?;
1390 let step_norm = d.iter().map(|v| v * v).sum::<f64>().sqrt();
1391 if step_norm <= tol_step {
1392 let (worst, worst_row) = max_linear_constraint_violation(&x, constraints);
1403 if worst > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL && !is_active[worst_row] {
1404 active.push(worst_row);
1405 is_active[worst_row] = true;
1406 log_active_set_transition(
1407 "stationary-infeasible-add",
1408 iteration,
1409 active.len(),
1410 Some(worst_row),
1411 );
1412 if !record_active_working_set(&mut visited_working_sets, &active, iteration) {
1413 break;
1414 }
1415 continue;
1416 }
1417 if worst > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
1418 break;
1427 }
1428 if compressed_working.groups.is_empty() {
1429 direction_out.assign(&d_total);
1430 return Ok(());
1431 }
1432 let remove_pos = compressed_working
1433 .reconstructed_active_multipliers(&lambdaw)
1434 .into_iter()
1435 .filter(|&(_, lambda_true)| lambda_true < -tol_dual)
1436 .min_by_key(|(active_pos, _)| (active[*active_pos], *active_pos))
1437 .map(|(active_pos, _)| active_pos);
1438 if let Some(active_pos) = remove_pos {
1439 let idx = active.remove(active_pos);
1440 is_active[idx] = false;
1441 log_active_set_transition(
1442 "remove-negative-dual",
1443 iteration,
1444 active.len(),
1445 Some(idx),
1446 );
1447 if !record_active_working_set(&mut visited_working_sets, &active, iteration) {
1448 break;
1449 }
1450 continue;
1451 }
1452 if let Some(hint) = active_hint {
1453 hint.clear();
1454 hint.extend(canonicalize_active_constraint_ids(
1455 &x,
1456 constraints,
1457 &active,
1458 )?);
1459 }
1460 direction_out.assign(&d_total);
1461 return Ok(());
1462 }
1463
1464 let mut alpha = 1.0_f64;
1465 for i in 0..m {
1466 if is_active[i] {
1467 continue;
1468 }
1469 let norm = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
1476 let inv = if norm > 0.0 { 1.0 / norm } else { 0.0 };
1477 let slack = (constraints.a.row(i).dot(&x) - constraints.b[i]) * inv;
1478 let ai_d = constraints.a.row(i).dot(&d) * inv;
1479 if let Some(cand) = boundary_hit_step_fraction(slack, ai_d, alpha) {
1480 alpha = cand;
1481 }
1482 }
1483
1484 ndarray::Zip::from(&mut x)
1485 .and(&mut d_total)
1486 .and(&d)
1487 .for_each(|x_i, dt_i, &d_i| {
1488 let alpha_d = alpha * d_i;
1489 *x_i += alpha_d;
1490 *dt_i += alpha_d;
1491 });
1492 g_cur = gradient + &hessian.dot(&d_total);
1493
1494 let mut added_new_active = false;
1495 let mut working_set_repeated = false;
1496 for i in 0..m {
1497 if is_active[i] {
1498 continue;
1499 }
1500 let slack = scaled_constraint_slack(&x, constraints, i);
1501 if slack <= tol_active {
1502 active.push(i);
1503 is_active[i] = true;
1504 added_new_active = true;
1505 log_active_set_transition("blocking-add", iteration, active.len(), Some(i));
1506 working_set_repeated =
1507 !record_active_working_set(&mut visited_working_sets, &active, iteration);
1508 break;
1509 }
1510 }
1511 if working_set_repeated {
1512 break;
1513 }
1514
1515 if active.is_empty() && !added_new_active {
1516 if let Some(hint) = active_hint {
1517 hint.clear();
1518 }
1519 direction_out.assign(&d_total);
1520 return Ok(());
1521 }
1522 }
1523
1524 let compressed_working = compress_active_working_set(&x, constraints, &active)?;
1525 let mut residualw = Array1::<f64>::zeros(compressed_working.constraints.a.nrows());
1526 for r in 0..compressed_working.constraints.a.nrows() {
1527 residualw[r] =
1528 compressed_working.constraints.b[r] - compressed_working.constraints.a.row(r).dot(&x);
1529 }
1530 let (_, lambdaw) = solve_kkt_direction(
1531 hessian,
1532 &g_cur,
1533 &compressed_working.constraints.a,
1534 Some(&residualw),
1535 )?;
1536 let lambda_true = lambdaw.mapv(|lam_sys| -lam_sys);
1537 let (worst, row) = max_linear_constraint_violation(&x, constraints);
1538 let working_kkt = working_set_kkt_diagnostics_from_multipliers(
1539 &x,
1540 &g_cur,
1541 &compressed_working.constraints,
1542 &lambda_true,
1543 m,
1544 )?;
1545 let kkt = compute_constraint_kkt_diagnostics(&x, &g_cur, constraints);
1546 let grad_inf = gradient_inf_norm(&g_cur);
1547 let stationarity_rel = working_kkt.stationarity / grad_inf.max(1.0);
1548 let step_inf = d_total.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
1549 let hd_total = hessian.dot(&d_total);
1550 let predicted_delta = gradient.dot(&d_total)
1551 + 0.5
1552 * d_total
1553 .iter()
1554 .zip(hd_total.iter())
1555 .map(|(a, b)| a * b)
1556 .sum::<f64>();
1557 let kkt_strong_ok = (working_kkt.stationarity <= ACTIVE_SET_KKT_STATIONARITY_TOL
1558 || stationarity_rel <= ACTIVE_SET_KKT_STATIONARITY_TOL)
1559 && working_kkt.complementarity <= ACTIVE_SET_KKT_COMPLEMENTARITY_TOL;
1560 let model_descent_ok =
1561 predicted_delta <= -ACTIVE_SET_MODEL_DESCENT_REL_TOL * (1.0 + grad_inf * step_inf);
1562 let degenerate_boundary_ok = compressed_working.is_degenerate_face()
1563 && worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
1564 && working_kkt.primal_feasibility <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
1565 && working_kkt.complementarity <= ACTIVE_SET_KKT_COMPLEMENTARITY_TOL
1566 && (working_kkt.stationarity <= ACTIVE_SET_KKT_DEGENERATE_STATIONARITY_TOL
1567 || stationarity_rel <= ACTIVE_SET_KKT_STATIONARITY_TOL);
1568 if worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
1569 && ((working_kkt.dual_feasibility <= ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL
1570 && (kkt_strong_ok || model_descent_ok))
1571 || degenerate_boundary_ok)
1572 {
1573 if let Some(hint) = active_hint {
1574 hint.clear();
1575 hint.extend(canonicalize_active_constraint_ids(
1576 &x,
1577 constraints,
1578 &active,
1579 )?);
1580 }
1581 direction_out.assign(&d_total);
1582 return Ok(());
1583 }
1584 if let Some((fallback_direction, fallback_active)) = fallback_projected_gradient_direction(
1585 &x,
1586 &d_total,
1587 &g_cur,
1588 &compressed_working.constraints,
1589 constraints,
1590 )? {
1591 if let Some(hint) = active_hint {
1592 hint.clear();
1593 hint.extend(fallback_active);
1594 }
1595 direction_out.assign(&fallback_direction);
1596 return Ok(());
1597 }
1598 Err(EstimationError::ParameterConstraintViolation(format!(
1599 "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}]",
1600 working_kkt.primal_feasibility,
1601 working_kkt.dual_feasibility,
1602 working_kkt.complementarity,
1603 working_kkt.stationarity,
1604 working_kkt.n_active,
1605 working_kkt.n_constraints,
1606 kkt.dual_feasibility,
1607 kkt.stationarity
1608 )))
1609}
1610
1611pub(crate) fn solve_newton_direction_with_linear_constraints(
1612 hessian: &Array2<f64>,
1613 gradient: &Array1<f64>,
1614 beta: &Array1<f64>,
1615 constraints: &LinearInequalityConstraints,
1616 direction_out: &mut Array1<f64>,
1617 active_hint: Option<&mut Vec<usize>>,
1618) -> Result<(), EstimationError> {
1619 let max_iterations = (gradient.len() + constraints.a.nrows() + 8) * 4;
1620 solve_newton_direction_with_linear_constraints_impl(
1621 hessian,
1622 gradient,
1623 beta,
1624 constraints,
1625 direction_out,
1626 active_hint,
1627 max_iterations,
1628 )
1629}
1630
1631pub fn solve_quadratic_with_linear_constraints(
1632 hessian: &Array2<f64>,
1633 rhs: &Array1<f64>,
1634 beta_start: &Array1<f64>,
1635 constraints: &LinearInequalityConstraints,
1636 warm_active_set: Option<&[usize]>,
1637) -> Result<(Array1<f64>, Vec<usize>), EstimationError> {
1638 if hessian.ncols() != hessian.nrows()
1639 || rhs.len() != hessian.nrows()
1640 || beta_start.len() != hessian.nrows()
1641 || constraints.a.ncols() != hessian.nrows()
1642 {
1643 crate::bail_invalid_estim!("constrained quadratic solve: system dimension mismatch");
1644 }
1645 let gradient = hessian.dot(beta_start) - rhs;
1646 let mut delta = Array1::<f64>::zeros(beta_start.len());
1647 let mut active_hint = warm_active_set.map_or_else(Vec::new, |active| active.to_vec());
1648 solve_newton_direction_with_linear_constraints(
1649 hessian,
1650 &gradient,
1651 beta_start,
1652 constraints,
1653 &mut delta,
1654 Some(&mut active_hint),
1655 )?;
1656 let candidate = beta_start + δ
1657 let (worst, _) = max_linear_constraint_violation(&candidate, constraints);
1674 if worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
1675 return Ok((candidate, active_hint));
1676 }
1677 let repaired = project_point_strictly_into_feasible_cone(&candidate, constraints).filter(|p| {
1678 max_linear_constraint_violation(p, constraints).0 <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
1679 });
1680 match repaired {
1681 Some(feasible) => {
1682 let active = canonicalize_active_constraint_ids(&feasible, constraints, &[])?;
1683 Ok((feasible, active))
1684 }
1685 None => Err(EstimationError::ParameterConstraintViolation(format!(
1686 "constrained quadratic solve returned an infeasible iterate \
1687 (max scaled violation {worst:.3e}) and no feasible projection could be \
1688 certified onto the constraint cone",
1689 ))),
1690 }
1691}
1692
1693#[cfg(test)]
1694mod tests {
1695 use super::{
1696 ACTIVE_SET_INTERIOR_SEED_MARGIN, LinearInequalityConstraints,
1697 compute_constraint_kkt_diagnostics, project_point_strictly_into_feasible_cone,
1698 project_stationarity_residual_on_constraint_cone,
1699 rank_reduce_rows_pivoted_qr_with_dependence, scaled_constraint_slack,
1700 solve_newton_direction_with_linear_constraints_impl,
1701 solve_quadratic_with_linear_constraints,
1702 };
1703 use approx::assert_relative_eq;
1704 use ndarray::{Array1, Array2, array};
1705
1706 #[test]
1715 fn strict_interior_projection_lifts_vertex_seed_off_every_constraint_row() {
1716 let p = 5usize;
1719 let rows = p - 2;
1720 let mut a = Array2::<f64>::zeros((rows, p));
1721 for i in 0..rows {
1722 a[[i, i]] = -1.0;
1723 a[[i, i + 1]] = 2.0;
1724 a[[i, i + 2]] = -1.0;
1725 }
1726 let constraints = LinearInequalityConstraints::new(a, Array1::zeros(rows))
1727 .expect("test constraint shape invariant");
1728
1729 let vertex = Array1::<f64>::zeros(p);
1730 for i in 0..rows {
1732 assert!(
1733 scaled_constraint_slack(&vertex, &constraints, i).abs() < 1e-12,
1734 "vertex seed should sit exactly on row {i}"
1735 );
1736 }
1737
1738 let interior = project_point_strictly_into_feasible_cone(&vertex, &constraints)
1739 .expect("strict-interior projection of the vertex must succeed");
1740 let min_slack = (0..rows)
1741 .map(|i| scaled_constraint_slack(&interior, &constraints, i))
1742 .fold(f64::INFINITY, f64::min);
1743 assert!(
1744 min_slack >= 0.5 * ACTIVE_SET_INTERIOR_SEED_MARGIN,
1745 "projected seed must be strictly interior on every row; min scaled slack = {min_slack:.3e}"
1746 );
1747 }
1748
1749 #[test]
1759 fn strict_interior_projection_keeps_equality_pairs_tight_with_shape_bounds() {
1760 let p = 5usize;
1761 let m = 3 + 2;
1764 let mut a = Array2::<f64>::zeros((m, p));
1765 a[[0, 2]] = 1.0;
1766 a[[1, 3]] = 1.0;
1767 a[[2, 4]] = 1.0;
1768 a[[3, 0]] = 1.0;
1769 a[[4, 0]] = -1.0;
1770 let constraints = LinearInequalityConstraints::new(a, Array1::zeros(m))
1771 .expect("test constraint shape invariant");
1772
1773 let point = Array1::from_vec(vec![0.7, -0.2, -0.5, -0.3, -0.1]);
1776 let seed = project_point_strictly_into_feasible_cone(&point, &constraints).expect(
1777 "strict-interior projection must succeed when an equality pair is present, \
1778 not collapse to the empty set and fall back to the vertex",
1779 );
1780
1781 for i in 0..3 {
1783 assert!(
1784 scaled_constraint_slack(&seed, &constraints, i)
1785 >= 0.4 * ACTIVE_SET_INTERIOR_SEED_MARGIN,
1786 "shape row {i} not strictly interior: scaled slack = {:.3e}",
1787 scaled_constraint_slack(&seed, &constraints, i)
1788 );
1789 }
1790 assert!(
1793 seed[0].abs() <= 1e-6,
1794 "boundary equality must be enforced, got β_0 = {:.3e}",
1795 seed[0]
1796 );
1797 }
1798
1799 #[test]
1803 fn strict_interior_projection_preserves_a_curvature_carrying_seed() {
1804 let p = 5usize;
1805 let rows = p - 2;
1806 let mut a = Array2::<f64>::zeros((rows, p));
1807 for i in 0..rows {
1808 a[[i, i]] = -1.0;
1809 a[[i, i + 1]] = 2.0;
1810 a[[i, i + 2]] = -1.0;
1811 }
1812 let constraints = LinearInequalityConstraints::new(a, Array1::zeros(rows))
1813 .expect("test constraint shape invariant");
1814 let seed = Array1::from_iter((0..p).map(|j| -((j as f64 - 2.0).powi(2))));
1818 let projected = project_point_strictly_into_feasible_cone(&seed, &constraints)
1819 .expect("already-interior seed must project");
1820 let max_move = seed
1821 .iter()
1822 .zip(projected.iter())
1823 .map(|(a, b)| (a - b).abs())
1824 .fold(0.0_f64, f64::max);
1825 assert!(
1826 max_move < 1e-3,
1827 "strictly-interior curvature-carrying seed should be preserved; max move = {max_move:.3e}"
1828 );
1829 }
1830
1831 #[test]
1832 fn maxiter_accepts_current_boundary_solution() {
1833 let hessian = array![[1.0]];
1834 let gradient = array![-1.0];
1835 let beta = array![0.0];
1836 let constraints = LinearInequalityConstraints {
1837 a: array![[-1.0]],
1838 b: array![-0.1],
1839 };
1840 let mut direction = Array1::zeros(1);
1841 let mut active_hint = Vec::new();
1842
1843 solve_newton_direction_with_linear_constraints_impl(
1844 &hessian,
1845 &gradient,
1846 &beta,
1847 &constraints,
1848 &mut direction,
1849 Some(&mut active_hint),
1850 1,
1851 )
1852 .expect("solver should accept the current boundary solution at the iteration limit");
1853
1854 assert_relative_eq!(direction[0], 0.1, epsilon = 1e-12);
1855 assert_eq!(active_hint, vec![0]);
1856 }
1857
1858 #[test]
1859 fn rank_reduce_zero_rows_returns_empty_working_set() {
1860 let a = array![[0.0, 0.0], [0.0, 0.0],];
1861 let b = array![0.0, 0.0];
1862 let groups = vec![vec![0], vec![1]];
1863
1864 let (a_out, b_out, groups_out, _) =
1865 rank_reduce_rows_pivoted_qr_with_dependence(a, b, groups);
1866
1867 assert_eq!(a_out.nrows(), 0);
1868 assert_eq!(a_out.ncols(), 2);
1869 assert_eq!(b_out.len(), 0);
1870 assert!(groups_out.is_empty());
1871 }
1872
1873 #[test]
1874 fn cone_projection_solves_nonnegative_least_squares_not_one_way_pruning() {
1875 let active_a = array![
1876 [0.85258593, -0.77270261],
1877 [-1.22152485, 2.05129351],
1878 [0.22794844, 1.56987265],
1879 ];
1880 let residual = array![-0.50524761, -1.10104911];
1881
1882 let (projected, multipliers) =
1883 project_stationarity_residual_on_constraint_cone(&residual, &active_a)
1884 .expect("cone projection should solve");
1885
1886 let row0 = active_a.row(0);
1887 let expected_mu0 = row0.dot(&residual) / row0.dot(&row0);
1888 assert_relative_eq!(multipliers[0], expected_mu0, epsilon = 1e-8);
1889 assert_relative_eq!(multipliers[1], 0.0, epsilon = 1e-10);
1890 assert_relative_eq!(multipliers[2], 0.0, epsilon = 1e-10);
1891
1892 let raw_norm2 = residual.dot(&residual);
1893 let projected_norm2 = projected.dot(&projected);
1894 assert!(
1895 projected_norm2 < raw_norm2 - 0.1,
1896 "NNLS projection should keep the improving active row: raw={raw_norm2:.6e}, projected={projected_norm2:.6e}"
1897 );
1898 let dual = active_a.dot(&projected);
1899 for (idx, (&mu, &w)) in multipliers.iter().zip(dual.iter()).enumerate() {
1900 if mu <= 1e-10 {
1901 assert!(
1902 w <= 1e-8,
1903 "inactive cone generator {idx} has positive reduced gradient {w:.3e}"
1904 );
1905 }
1906 }
1907 }
1908
1909 #[test]
1916 fn kkt_primal_is_per_row_scale_invariant() {
1917 let geometric_violation = 2.071e-8_f64;
1920 let gradient = Array1::<f64>::zeros(2);
1921
1922 let beta_unit = array![-geometric_violation, 0.0];
1924 let unit = LinearInequalityConstraints {
1925 a: array![[1.0, 0.0]],
1926 b: array![0.0],
1927 };
1928 let diag_unit = compute_constraint_kkt_diagnostics(&beta_unit, &gradient, &unit);
1929
1930 let beta_big = array![-geometric_violation, 0.0];
1933 let big = LinearInequalityConstraints {
1934 a: array![[1000.0, 0.0]],
1935 b: array![0.0],
1936 };
1937 let diag_big = compute_constraint_kkt_diagnostics(&beta_big, &gradient, &big);
1938
1939 assert_relative_eq!(
1940 diag_unit.primal_feasibility,
1941 geometric_violation,
1942 epsilon = 1e-14
1943 );
1944 assert_relative_eq!(
1945 diag_big.primal_feasibility,
1946 geometric_violation,
1947 epsilon = 1e-14
1948 );
1949 assert!(
1951 diag_big.primal_feasibility < 1e-7,
1952 "scaled primal {:.3e} should pass a 1e-7 gate; raw slack would be {:.3e}",
1953 diag_big.primal_feasibility,
1954 1000.0 * geometric_violation
1955 );
1956 }
1957
1958 #[test]
1966 fn opposing_inequality_pair_pins_equality_to_target() {
1967 let hessian = array![
1971 [1.0, 0.0, 0.0, 0.0],
1972 [0.0, 1.0, 0.0, 0.0],
1973 [0.0, 0.0, 1.0, 0.0],
1974 [0.0, 0.0, 0.0, 1.0],
1975 ];
1976 let rhs = array![5.0, 5.0, 0.0, 0.0];
1977 let beta_start = Array1::<f64>::zeros(4);
1978 let constraints = LinearInequalityConstraints {
1979 a: array![[1.0, 1.0, 0.0, 0.0], [-1.0, -1.0, 0.0, 0.0]],
1980 b: array![0.0, 0.0],
1981 };
1982
1983 let (beta, _active) = solve_quadratic_with_linear_constraints(
1984 &hessian,
1985 &rhs,
1986 &beta_start,
1987 &constraints,
1988 None,
1989 )
1990 .expect("opposing-inequality equality QP must solve");
1991
1992 let a_dot_beta = beta[0] + beta[1];
1993 assert!(
1994 a_dot_beta.abs() < 1e-8,
1995 "opposing inequalities must pin a·β to 0, got {a_dot_beta:.6e} (β = {beta:?})"
1996 );
1997 }
1998
1999 #[test]
2003 fn opposing_inequality_pair_pins_scaled_equality_to_nonzero_target() {
2004 let hessian = array![
2005 [1.0, 0.0, 0.0, 0.0],
2006 [0.0, 1.0, 0.0, 0.0],
2007 [0.0, 0.0, 1.0, 0.0],
2008 [0.0, 0.0, 0.0, 1.0],
2009 ];
2010 let rhs = array![5.0, 5.0, 0.0, 0.0];
2011 let beta_start = Array1::<f64>::zeros(4);
2012 let constraints = LinearInequalityConstraints {
2015 a: array![[1000.0, 1000.0, 0.0, 0.0], [-1000.0, -1000.0, 0.0, 0.0]],
2016 b: array![3000.0, -3000.0],
2017 };
2018
2019 let (beta, _active) = solve_quadratic_with_linear_constraints(
2020 &hessian,
2021 &rhs,
2022 &beta_start,
2023 &constraints,
2024 None,
2025 )
2026 .expect("scaled opposing-inequality equality QP must solve");
2027
2028 let a_dot_beta = 1000.0 * (beta[0] + beta[1]);
2029 assert!(
2030 (a_dot_beta - 3000.0).abs() < 1e-5,
2031 "opposing inequalities must pin a·β to 3000, got {a_dot_beta:.6e} (β = {beta:?})"
2032 );
2033 }
2034
2035 #[test]
2040 fn two_opposing_inequality_equalities_both_pinned() {
2041 let hessian = array![
2042 [1.0, 0.0, 0.0, 0.0],
2043 [0.0, 1.0, 0.0, 0.0],
2044 [0.0, 0.0, 1.0, 0.0],
2045 [0.0, 0.0, 0.0, 1.0],
2046 ];
2047 let rhs = array![5.0, 5.0, 5.0, 5.0];
2048 let beta_start = Array1::<f64>::zeros(4);
2049 let constraints = LinearInequalityConstraints {
2051 a: array![
2052 [1.0, 1.0, 0.0, 0.0],
2053 [-1.0, -1.0, 0.0, 0.0],
2054 [0.0, 0.0, 1.0, 1.0],
2055 [0.0, 0.0, -1.0, -1.0],
2056 ],
2057 b: array![0.0, 0.0, 0.0, 0.0],
2058 };
2059
2060 let (beta, _active) = solve_quadratic_with_linear_constraints(
2061 &hessian,
2062 &rhs,
2063 &beta_start,
2064 &constraints,
2065 None,
2066 )
2067 .expect("two-equality QP must solve");
2068
2069 assert!(
2070 (beta[0] + beta[1]).abs() < 1e-8,
2071 "equality A not pinned: β0+β1 = {:.6e}",
2072 beta[0] + beta[1]
2073 );
2074 assert!(
2075 (beta[2] + beta[3]).abs() < 1e-8,
2076 "equality B not pinned: β2+β3 = {:.6e}",
2077 beta[2] + beta[3]
2078 );
2079 }
2080
2081 #[test]
2088 fn opposing_inequality_equalities_pinned_under_ill_conditioned_penalty() {
2089 let lam = 1.0e8_f64;
2092 let hessian = array![
2093 [1.0, 0.0, 0.0, 0.0],
2094 [0.0, 1.0, 0.0, 0.0],
2095 [0.0, 0.0, lam, 0.0],
2096 [0.0, 0.0, 0.0, lam],
2097 ];
2098 let rhs = array![5.0, 5.0, 5.0, 5.0];
2099 let beta_start = Array1::<f64>::zeros(4);
2100 let constraints = LinearInequalityConstraints {
2104 a: array![
2105 [1.0, 0.0, 1.0, 0.0],
2106 [-1.0, 0.0, -1.0, 0.0],
2107 [0.0, 1.0, 0.0, 1.0],
2108 [0.0, -1.0, 0.0, -1.0],
2109 ],
2110 b: array![0.0, 0.0, 0.0, 0.0],
2111 };
2112
2113 let (beta, _active) = solve_quadratic_with_linear_constraints(
2114 &hessian,
2115 &rhs,
2116 &beta_start,
2117 &constraints,
2118 None,
2119 )
2120 .expect("ill-conditioned two-equality QP must solve");
2121
2122 assert!(
2123 (beta[0] + beta[2]).abs() < 1e-6,
2124 "equality A not pinned under ill-conditioning: β0+β2 = {:.6e}",
2125 beta[0] + beta[2]
2126 );
2127 assert!(
2128 (beta[1] + beta[3]).abs() < 1e-6,
2129 "equality B not pinned under ill-conditioning: β1+β3 = {:.6e}",
2130 beta[1] + beta[3]
2131 );
2132 }
2133}