1use crate::estimate::EstimationError;
2use faer::linalg::solvers::SolveLstsq;
3use faer::Side;
4use gam_linalg::faer_ndarray::{
5 FaerArrayView, FaerCholesky, FaerLinalgError, FaerSvd, array1_to_col_matmut,
6 default_rrqr_rank_alpha, rrqr_nullspace_basis,
7};
8use gam_linalg::utils::{KahanSum, StableSolver, array_is_finite, boundary_hit_step_fraction};
9use gam_problem::{
10 ConstraintRowId, ConstraintSet, KhatriRaoConeConstraints, LinearInequalityConstraints,
11};
12use ndarray::{Array1, Array2, ArrayView1, s};
13use serde::{Deserialize, Serialize};
14use std::cell::Cell;
15use std::collections::HashSet;
16
17pub const ACTIVE_SET_PRIMAL_FEASIBILITY_TOL: f64 = 1e-8;
30
31pub const ACTIVE_SET_WORKING_FACE_TOL: f64 = 1e-10;
39
40#[inline]
57fn active_set_boundary_hit_step_fraction(
58 scaled_slack: f64,
59 scaled_directional_change: f64,
60 current_step_limit: f64,
61) -> Option<f64> {
62 boundary_hit_step_fraction(
63 scaled_slack.max(0.0),
64 scaled_directional_change,
65 current_step_limit,
66 )
67}
68
69const ACTIVE_SET_KKT_STATIONARITY_TOL: f64 = 2e-6;
75
76const ACTIVE_SET_KKT_COMPLEMENTARITY_TOL: f64 = 1e-6;
80
81const ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL: f64 = 1e-8;
85
86pub(crate) const ACTIVE_SET_KKT_DEGENERATE_STATIONARITY_TOL: f64 = 1e-3;
110
111const ACTIVE_SET_MODEL_DESCENT_REL_TOL: f64 = 1e-10;
116
117#[derive(Clone, Debug, Serialize, Deserialize)]
128pub struct ConstraintKktDiagnostics {
129 pub n_constraints: usize,
131 pub n_active: usize,
133 pub primal_feasibility: f64,
135 pub dual_feasibility: f64,
137 pub complementarity: f64,
139 pub stationarity: f64,
141 pub active_tolerance: f64,
143 #[serde(default)]
158 pub working_set_rank_deficient: bool,
159 #[serde(default)]
176 pub gradient_scale: f64,
177}
178
179fn gradient_inf_norm(gradient: &Array1<f64>) -> f64 {
183 gradient.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()))
184}
185
186fn solve_newton_direction_dense(
187 hessian: &Array2<f64>,
188 gradient: &Array1<f64>,
189 direction_out: &mut Array1<f64>,
190) -> Result<(), EstimationError> {
191 if direction_out.len() != gradient.len() {
192 *direction_out = Array1::zeros(gradient.len());
193 }
194
195 let factor = StableSolver::new()
196 .factorize(hessian)
197 .map_err(EstimationError::LinearSystemSolveFailed)?;
198 direction_out.assign(gradient);
199 let mut rhsview = array1_to_col_matmut(direction_out);
200 factor.solve_in_place(rhsview.as_mut());
201 direction_out.mapv_inplace(|v| -v);
202 if array_is_finite(direction_out) {
203 return Ok(());
204 }
205 Err(EstimationError::LinearSystemSolveFailed(
206 FaerLinalgError::FactorizationFailed {
207 context: "active-set newton direction non-finite solve",
208 },
209 ))
210}
211
212fn solve_dense_system_via_pseudoinverse(
213 matrix: &Array2<f64>,
214 rhs: &Array1<f64>,
215 out: &mut Array1<f64>,
216) -> Result<(), EstimationError> {
217 if matrix.nrows() != matrix.ncols() || rhs.len() != matrix.nrows() {
218 crate::bail_invalid_estim!("dense pseudoinverse solve dimension mismatch");
219 }
220
221 let (u_opt, singular, vt_opt) = matrix.svd(true, true).map_err(|_| {
222 EstimationError::InvalidInput("dense pseudoinverse solve SVD failed".to_string())
223 })?;
224 let (Some(u), Some(vt)) = (u_opt, vt_opt) else {
225 crate::bail_invalid_estim!("dense pseudoinverse solve missing singular vectors");
226 };
227
228 let max_singular = singular.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
229 let tol = 100.0
230 * f64::EPSILON
231 * (matrix.nrows().max(matrix.ncols()).max(1) as f64)
232 * max_singular.max(1.0);
233 let mut coeff = u.t().dot(rhs);
234 for (idx, value) in coeff.iter_mut().enumerate() {
235 let sigma = singular[idx];
236 if sigma.abs() > tol {
237 *value /= sigma;
238 } else {
239 *value = 0.0;
240 }
241 }
242 let solution = vt.t().dot(&coeff);
243 if !array_is_finite(&solution) {
244 crate::bail_invalid_estim!("dense pseudoinverse solve produced non-finite values");
245 }
246 if out.len() != solution.len() {
247 *out = Array1::zeros(solution.len());
248 }
249 out.assign(&solution);
250 Ok(())
251}
252
253fn least_squares_min_norm_any_shape(a: &Array2<f64>, b: &Array1<f64>) -> Option<Array1<f64>> {
271 let p = a.nrows();
272 let k = a.ncols();
273 if b.len() != p {
274 return None;
275 }
276 if k == 0 {
277 return Some(Array1::zeros(0));
278 }
279 if k <= p {
280 let mut rhs = Array2::<f64>::zeros((p, 1));
281 rhs.column_mut(0).assign(b);
282 let a_view = FaerArrayView::new(a);
283 let rhs_view = FaerArrayView::new(&rhs);
284 let solved = a_view.as_ref().col_piv_qr().solve_lstsq(rhs_view.as_ref());
285 let mut z = Array1::<f64>::zeros(k);
286 for c in 0..k {
287 let value = solved[(c, 0)];
288 if !value.is_finite() {
289 return None;
290 }
291 z[c] = value;
292 }
293 Some(z)
294 } else {
295 let gram = a.dot(&a.t());
299 let mut y = Array1::<f64>::zeros(p);
300 solve_dense_system_via_pseudoinverse(&gram, b, &mut y).ok()?;
301 let z = a.t().dot(&y);
302 if z.iter().any(|value| !value.is_finite()) {
303 return None;
304 }
305 Some(z)
306 }
307}
308
309pub(crate) fn compute_constraint_kkt_diagnostics(
310 beta: &Array1<f64>,
311 gradient: &Array1<f64>,
312 constraints: &LinearInequalityConstraints,
313) -> ConstraintKktDiagnostics {
314 let m = constraints.a.nrows();
315 let active_tolerance = ACTIVE_SET_PRIMAL_FEASIBILITY_TOL;
316
317 let p = constraints.a.ncols();
332 let mut a_scaled = constraints.a.clone();
333 let mut b_scaled = constraints.b.clone();
334 for i in 0..m {
335 let n_i = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
336 if n_i > 0.0 {
337 let inv = 1.0 / n_i;
338 a_scaled.row_mut(i).mapv_inplace(|v| v * inv);
339 b_scaled[i] *= inv;
340 }
341 }
342
343 let mut slack = Array1::<f64>::zeros(m);
344 let mut primal_feasibility: f64 = 0.0;
345 for i in 0..m {
346 let s_i = a_scaled.row(i).dot(beta) - b_scaled[i];
347 slack[i] = s_i;
348 primal_feasibility = primal_feasibility.max((-s_i).max(0.0));
349 }
350
351 let active_idx: Vec<usize> = (0..m).filter(|&i| slack[i] <= active_tolerance).collect();
352 let mut lambda = Array1::<f64>::zeros(m);
353 let mut working_set_rank_deficient = false;
354 if !active_idx.is_empty() {
355 let n_active = active_idx.len();
356 let mut a_active = Array2::<f64>::zeros((n_active, p));
357 for (r, &idx) in active_idx.iter().enumerate() {
358 a_active.row_mut(r).assign(&a_scaled.row(idx));
359 }
360 if let Some((_, lambda_active)) =
361 project_stationarity_residual_on_constraint_cone(gradient, &a_active)
362 {
363 for (r, &idx) in active_idx.iter().enumerate() {
364 lambda[idx] = lambda_active[r];
365 }
366 }
367 working_set_rank_deficient = if n_active > p {
379 true
380 } else if n_active > 1 {
381 let groups: Vec<Vec<usize>> = (0..n_active).map(|i| vec![i]).collect();
382 let b_dummy = Array1::<f64>::zeros(n_active);
383 let (reduced_a, _, _, _) =
384 rank_reduce_rows_pivoted_qr_with_dependence(a_active, b_dummy, groups);
385 reduced_a.nrows() < n_active
386 } else {
387 false
388 };
389 }
390
391 let mut dual_feasibility: f64 = 0.0;
392 let mut complementarity: f64 = 0.0;
393 for i in 0..m {
394 dual_feasibility = dual_feasibility.max((-lambda[i]).max(0.0));
395 complementarity = complementarity.max((lambda[i] * slack[i]).abs());
396 }
397 let stationarity = {
398 let mut resid = gradient.to_owned();
399 resid -= &a_scaled.t().dot(&lambda);
400 resid.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()))
401 };
402
403 ConstraintKktDiagnostics {
404 n_constraints: m,
405 n_active: active_idx.len(),
406 primal_feasibility,
407 dual_feasibility,
408 complementarity,
409 stationarity,
410 active_tolerance,
411 working_set_rank_deficient,
412 gradient_scale: gradient_inf_norm(gradient),
413 }
414}
415
416fn nonnegative_cone_projection_by_rows<RowValues, GatherRows>(
425 row_norms: &[f64],
426 target: &Array1<f64>,
427 row_values: RowValues,
428 gather_rows: GatherRows,
429) -> Option<(Vec<(usize, f64)>, Array1<f64>)>
430where
431 RowValues: Fn(&Array1<f64>) -> Option<Array1<f64>>,
432 GatherRows: Fn(&[usize]) -> Option<Array2<f64>>,
433{
434 let p = target.len();
435 let m = row_norms.len();
436 if m == 0 {
437 return Some((Vec::new(), target.clone()));
438 }
439 if target.iter().any(|v| !v.is_finite())
440 || row_norms
441 .iter()
442 .any(|norm| !norm.is_finite() || *norm < 0.0)
443 {
444 return None;
445 }
446 let target_inf = target.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
447 if target_inf == 0.0 {
448 return Some((Vec::new(), target.clone()));
449 }
450 let tol_w = 1e-10 * target_inf;
453 let lambda_floor = 1e-14 * target_inf;
454
455 let mut lambda_unit = Array1::<f64>::zeros(m);
456 let mut passive: Vec<usize> = Vec::new();
457 let mut in_passive = vec![false; m];
458 let mut residual = target.clone();
459 let mut banned = vec![false; m];
463
464 let solve_passive = |passive: &[usize]| -> Option<Array1<f64>> {
465 let k = passive.len();
466 let mut design = Array2::<f64>::zeros((p, k));
471 let rows = gather_rows(passive)?;
472 if rows.nrows() != k || rows.ncols() != p || rows.iter().any(|value| !value.is_finite()) {
473 return None;
474 }
475 for (col, &row) in passive.iter().enumerate() {
476 let norm = row_norms[row];
477 if !(norm > 0.0) {
478 return None;
479 }
480 design
481 .column_mut(col)
482 .assign(&(&rows.row(col) / norm));
483 }
484 least_squares_min_norm_any_shape(&design, target)
485 };
486
487 let max_outer = m.saturating_mul(3).saturating_add(30);
488 for _ in 0..max_outer {
489 let values = row_values(&residual)?;
491 if values.len() != m || values.iter().any(|value| !value.is_finite()) {
492 return None;
493 }
494 let mut best: Option<(usize, f64)> = None;
495 for i in 0..m {
496 if in_passive[i] || banned[i] || row_norms[i] <= 0.0 {
497 continue;
498 }
499 let w = values[i] / row_norms[i];
500 if w > tol_w && best.map(|(_, best_w)| w > best_w).unwrap_or(true) {
501 best = Some((i, w));
502 }
503 }
504 let Some((entering, _)) = best else {
505 break;
506 };
507 passive.push(entering);
508 in_passive[entering] = true;
509
510 let mut inner_ok = false;
511 for _ in 0..(m + 2) {
512 let Some(z) = solve_passive(&passive) else {
513 return None;
514 };
515 let min_z = z.iter().copied().fold(f64::INFINITY, f64::min);
516 if min_z > lambda_floor {
517 for (pos, &row) in passive.iter().enumerate() {
518 lambda_unit[row] = z[pos];
519 }
520 inner_ok = true;
521 break;
522 }
523 let mut alpha = 1.0_f64;
526 for (pos, &row) in passive.iter().enumerate() {
527 if z[pos] <= lambda_floor {
528 let current = lambda_unit[row];
529 let denom = current - z[pos];
530 if denom > 0.0 {
531 alpha = alpha.min((current / denom).clamp(0.0, 1.0));
532 } else {
533 alpha = 0.0;
534 }
535 }
536 }
537 for (pos, &row) in passive.iter().enumerate() {
538 lambda_unit[row] += alpha * (z[pos] - lambda_unit[row]);
539 }
540 let mut retained = Vec::with_capacity(passive.len());
541 for &row in &passive {
542 if lambda_unit[row] > lambda_floor {
543 retained.push(row);
544 } else {
545 lambda_unit[row] = 0.0;
546 in_passive[row] = false;
547 banned[row] = true;
551 }
552 }
553 if retained.len() == passive.len() {
554 inner_ok = true;
557 for (pos, &row) in passive.iter().enumerate() {
558 lambda_unit[row] = z[pos].max(0.0);
559 }
560 break;
561 }
562 passive = retained;
563 if passive.is_empty() {
564 break;
565 }
566 }
567 let mut fitted = Array1::<f64>::zeros(p);
569 let passive_rows = gather_rows(&passive)?;
570 if passive_rows.nrows() != passive.len()
571 || passive_rows.ncols() != p
572 || passive_rows.iter().any(|value| !value.is_finite())
573 {
574 return None;
575 }
576 for (position, &row) in passive.iter().enumerate() {
577 fitted.scaled_add(
578 lambda_unit[row] / row_norms[row],
579 &passive_rows.row(position),
580 );
581 }
582 let new_residual = target - &fitted;
583 let moved = new_residual
584 .iter()
585 .zip(residual.iter())
586 .any(|(a, b)| (a - b).abs() > 1e-15 * target_inf);
587 residual = new_residual;
588 if moved {
589 banned.iter_mut().for_each(|b| *b = false);
590 } else if !inner_ok {
591 break;
592 }
593 }
594
595 let final_values = row_values(&residual)?;
602 if final_values.len() != m
603 || final_values.iter().any(|value| !value.is_finite())
604 || (0..m).any(|row| {
605 row_norms[row] > 0.0 && final_values[row] / row_norms[row] > tol_w
606 })
607 {
608 return None;
609 }
610
611 let multipliers: Vec<(usize, f64)> = passive
612 .into_iter()
613 .filter_map(|row| {
614 let lambda = lambda_unit[row] / row_norms[row];
615 (lambda > 0.0).then_some((row, lambda))
616 })
617 .collect();
618 if multipliers.iter().any(|(_, value)| !value.is_finite())
619 || !array_is_finite(&residual)
620 {
621 return None;
622 }
623 Some((multipliers, residual))
624}
625
626pub(crate) fn nonnegative_cone_multipliers(
650 rows: &Array2<f64>,
651 target: &Array1<f64>,
652) -> Option<(Array1<f64>, Array1<f64>)> {
653 let p = target.len();
654 let m = rows.nrows();
655 if rows.ncols() != p {
656 return None;
657 }
658 let norms: Vec<f64> = (0..m)
659 .map(|row| rows.row(row).dot(&rows.row(row)).sqrt())
660 .collect();
661 let (sparse, projected) = nonnegative_cone_projection_by_rows(
662 &norms,
663 target,
664 |residual| Some(rows.dot(residual)),
665 |ids| {
666 let mut gathered = Array2::<f64>::zeros((ids.len(), p));
667 for (position, &row) in ids.iter().enumerate() {
668 gathered.row_mut(position).assign(&rows.row(row));
669 }
670 Some(gathered)
671 },
672 )?;
673 let mut lambda = Array1::<f64>::zeros(m);
674 for (row, value) in sparse {
675 lambda[row] = value;
676 }
677 Some((lambda, projected))
678}
679
680pub fn project_stationarity_residual_on_constraint_cone(
681 residual: &Array1<f64>,
682 active_a: &Array2<f64>,
683) -> Option<(Array1<f64>, Array1<f64>)> {
684 let p = residual.len();
685 if active_a.ncols() != p {
686 return None;
687 }
688 if active_a.nrows() == 0 {
689 return Some((residual.clone(), Array1::zeros(0)));
690 }
691 nonnegative_cone_multipliers(active_a, residual).map(|(lambda, projected)| (projected, lambda))
699}
700
701pub(crate) fn feasible_point_for_linear_constraints(
702 constraints: &LinearInequalityConstraints,
703 p: usize,
704) -> Option<Array1<f64>> {
705 if constraints.a.ncols() != p
706 || constraints.a.nrows() == 0
707 || constraints.b.len() != constraints.a.nrows()
708 {
709 return None;
710 }
711 let mut all_scaled_b_tiny = true;
716 for i in 0..constraints.a.nrows() {
717 let norm = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
718 if norm > 0.0 {
719 if constraints.b[i].abs() > 1e-14 * norm {
720 all_scaled_b_tiny = false;
721 }
722 } else if constraints.b[i] > 0.0 {
723 return None;
724 }
725 }
726 if all_scaled_b_tiny {
727 return Some(Array1::zeros(p));
728 }
729
730 let gram = constraints.a.dot(&constraints.a.t());
731 let (u_opt, singular, vt_opt) = gram.svd(true, true).ok()?;
732 let (Some(u), Some(vt)) = (u_opt, vt_opt) else {
733 return None;
734 };
735 let max_singular = singular.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
736 let tol = 100.0 * f64::EPSILON * constraints.a.nrows().max(1) as f64 * max_singular;
740 let mut coeff = u.t().dot(&constraints.b);
741 for (idx, value) in coeff.iter_mut().enumerate() {
742 let sigma = singular[idx];
743 if sigma.abs() > tol {
744 *value /= sigma;
745 } else {
746 *value = 0.0;
747 }
748 }
749 let dual = vt.t().dot(&coeff);
750 let beta = constraints.a.t().dot(&dual);
751 if beta.len() != p || beta.iter().any(|v| !v.is_finite()) {
752 return None;
753 }
754 let feasible = (0..constraints.a.nrows()).all(|i| {
757 let norm = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
758 if norm > 0.0 {
759 (constraints.a.row(i).dot(&beta) - constraints.b[i]) / norm >= -1e-8
760 } else {
761 constraints.b[i] <= 0.0
762 }
763 });
764 if feasible { Some(beta) } else { None }
765}
766
767const ACTIVE_SET_INTERIOR_SEED_MARGIN: f64 = 1e-6;
777
778#[inline]
784pub(crate) fn interior_seed_margin() -> f64 {
785 ACTIVE_SET_INTERIOR_SEED_MARGIN
786}
787
788const MAX_FEASIBILITY_REPAIR_DEPTH: u32 = 16;
803
804thread_local! {
805 static FEASIBILITY_REPAIR_DEPTH: Cell<u32> = const { Cell::new(0) };
812}
813
814struct FeasibilityRepairGuard;
823
824impl FeasibilityRepairGuard {
825 fn enter() -> Option<Self> {
826 FEASIBILITY_REPAIR_DEPTH.with(|depth| {
827 let current = depth.get();
828 if current >= MAX_FEASIBILITY_REPAIR_DEPTH {
829 None
830 } else {
831 depth.set(current + 1);
832 Some(Self)
833 }
834 })
835 }
836}
837
838impl Drop for FeasibilityRepairGuard {
839 fn drop(&mut self) {
840 FEASIBILITY_REPAIR_DEPTH.with(|depth| depth.set(depth.get().saturating_sub(1)));
841 }
842}
843
844pub fn project_point_strictly_into_feasible_cone(
873 point: &Array1<f64>,
874 constraints: &LinearInequalityConstraints,
875) -> Option<Array1<f64>> {
876 let repair_guard = FeasibilityRepairGuard::enter()?;
882 let p = point.len();
883 let m = constraints.a.nrows();
884 if constraints.a.ncols() != p || m == 0 || constraints.b.len() != m {
885 return None;
886 }
887 let norms: Vec<f64> = (0..m)
888 .map(|i| constraints.a.row(i).dot(&constraints.a.row(i)).sqrt())
889 .collect();
890
891 const ANTIPARALLEL_COS_TOL: f64 = -1.0 + 1e-9;
905 const EQUALITY_WIDTH_TOL: f64 = 1e-9;
906 let mut is_equality_member = vec![false; m];
907 let mut equality_rows: Vec<usize> = Vec::new();
908 let mut margin = vec![ACTIVE_SET_INTERIOR_SEED_MARGIN; m];
909 for i in 0..m {
910 if norms[i] == 0.0 {
911 margin[i] = 0.0;
912 continue;
913 }
914 for j in (i + 1)..m {
915 if norms[j] == 0.0 {
916 continue;
917 }
918 let cos = constraints.a.row(i).dot(&constraints.a.row(j)) / (norms[i] * norms[j]);
919 if cos > ANTIPARALLEL_COS_TOL {
920 continue;
921 }
922 let width = -constraints.b[j] / norms[j] - constraints.b[i] / norms[i];
925 if width.abs() <= EQUALITY_WIDTH_TOL {
926 if !is_equality_member[i] && !is_equality_member[j] {
929 equality_rows.push(i);
930 }
931 is_equality_member[i] = true;
932 is_equality_member[j] = true;
933 } else {
934 let cap = (width / 3.0).max(0.0);
937 margin[i] = margin[i].min(cap);
938 margin[j] = margin[j].min(cap);
939 }
940 }
941 }
942
943 let ineq_rows: Vec<usize> = (0..m).filter(|&i| !is_equality_member[i]).collect();
946 let mut a_ineq = Array2::<f64>::zeros((ineq_rows.len(), p));
947 let mut b_ineq = Array1::<f64>::zeros(ineq_rows.len());
948 for (r, &i) in ineq_rows.iter().enumerate() {
949 a_ineq.row_mut(r).assign(&constraints.a.row(i));
950 b_ineq[r] = constraints.b[i] + margin[i] * norms[i];
951 }
952
953 let beta = if equality_rows.is_empty() {
954 let interior = LinearInequalityConstraints::new(a_ineq, b_ineq)
957 .expect("shifted interior constraint shape invariant");
958 let identity = Array2::<f64>::eye(p);
959 solve_quadratic_with_linear_constraints(&identity, point, point, &interior, None)
960 .ok()?
961 .0
962 } else {
963 let k = equality_rows.len();
973 let mut e_mat = Array2::<f64>::zeros((k, p));
974 let mut e_rhs = Array1::<f64>::zeros(k);
975 for (r, &i) in equality_rows.iter().enumerate() {
976 e_mat.row_mut(r).assign(&constraints.a.row(i));
977 e_rhs[r] = constraints.b[i];
978 }
979 let (u_opt, sing, vt_opt) = e_mat.svd(true, true).ok()?;
980 let (u_mat, vt) = (u_opt?, vt_opt?);
981 let smax = sing.iter().fold(0.0_f64, |acc, &v| acc.max(v));
982 let rank_tol = smax.max(1.0) * (k.max(p) as f64) * f64::EPSILON * 100.0;
983 let rank = sing.iter().filter(|&&s| s > rank_tol).count();
984 if rank == 0 || rank >= p {
985 return None;
986 }
987 let mut beta_p = Array1::<f64>::zeros(p);
988 for idx in 0..rank {
989 let coeff = u_mat.column(idx).dot(&e_rhs) / sing[idx];
990 beta_p.scaled_add(coeff, &vt.row(idx));
991 }
992 let mut basis: Vec<Array1<f64>> = (0..rank).map(|i| vt.row(i).to_owned()).collect();
995 let mut z = Array2::<f64>::zeros((p, p - rank));
996 let mut collected = 0usize;
997 for axis in 0..p {
998 if collected == p - rank {
999 break;
1000 }
1001 let mut v = Array1::<f64>::zeros(p);
1002 v[axis] = 1.0;
1003 for q in basis.iter() {
1004 let c = q.dot(&v);
1005 v.scaled_add(-c, q);
1006 }
1007 let nrm = v.dot(&v).sqrt();
1008 if nrm > 1e-8 {
1009 v /= nrm;
1010 z.column_mut(collected).assign(&v);
1011 basis.push(v);
1012 collected += 1;
1013 }
1014 }
1015 if collected != p - rank {
1016 return None;
1017 }
1018 let a_red = a_ineq.dot(&z);
1019 let b_red = &b_ineq - &a_ineq.dot(&beta_p);
1020 let u0 = z.t().dot(&(point - &beta_p));
1021 let reduced = LinearInequalityConstraints::new(a_red, b_red)
1022 .expect("reduced constraint shape invariant");
1023 let identity = Array2::<f64>::eye(z.ncols());
1024 let (u_sol, _active) =
1025 solve_quadratic_with_linear_constraints(&identity, &u0, &u0, &reduced, None).ok()?;
1026 &beta_p + &z.dot(&u_sol)
1027 };
1028
1029 if beta.len() != p || beta.iter().any(|v| !v.is_finite()) {
1030 return None;
1031 }
1032 const SEED_FEASIBILITY_TOL: f64 = 1e-9;
1037 for i in 0..m {
1038 let s = scaled_constraint_slack(&beta, constraints, i);
1039 let lower = if is_equality_member[i] {
1040 -SEED_FEASIBILITY_TOL
1041 } else {
1042 0.5 * margin[i] - SEED_FEASIBILITY_TOL
1043 };
1044 if s < lower {
1045 return None;
1046 }
1047 }
1048 drop(repair_guard);
1053 Some(beta)
1054}
1055
1056#[inline]
1062fn scaled_constraint_slack(
1063 beta: &Array1<f64>,
1064 constraints: &LinearInequalityConstraints,
1065 i: usize,
1066) -> f64 {
1067 let norm = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
1068 if norm > 0.0 {
1069 (constraints.a.row(i).dot(beta) - constraints.b[i]) / norm
1070 } else if constraints.b[i] > 0.0 {
1071 f64::NEG_INFINITY
1072 } else {
1073 f64::INFINITY
1074 }
1075}
1076
1077struct ActiveEqualityResidualCertificate {
1078 worst_row: usize,
1079 residual: f64,
1080 allowed: f64,
1081}
1082
1083impl ActiveEqualityResidualCertificate {
1084 fn is_certified(&self) -> bool {
1085 self.residual.is_finite() && self.allowed.is_finite() && self.residual <= self.allowed
1086 }
1087}
1088
1089fn certify_active_equalities(
1115 active_a: &Array2<f64>,
1116 rhs: &Array1<f64>,
1117 direction: &Array1<f64>,
1118) -> ActiveEqualityResidualCertificate {
1119 let p = active_a.ncols();
1120 let m = active_a.nrows();
1121 let operations = p.saturating_add(1).max(1);
1122 let roundoff = operations as f64 * f64::EPSILON;
1123 let gamma = roundoff / (1.0 - roundoff);
1124 let direction_scale = direction
1125 .iter()
1126 .fold(0.0_f64, |acc, value| acc.max(value.abs()));
1127 let mut worst = ActiveEqualityResidualCertificate {
1128 worst_row: 0,
1129 residual: 0.0,
1130 allowed: f64::MIN_POSITIVE,
1131 };
1132 let mut worst_ratio = 0.0_f64;
1133 for active_row in 0..m {
1134 let mut dot = KahanSum::default();
1135 let mut magnitude = KahanSum::default();
1136 let mut row_magnitude = KahanSum::default();
1137 for column in 0..p {
1138 let entry = active_a[[active_row, column]];
1139 let product = entry * direction[column];
1140 dot.add(product);
1141 magnitude.add(product.abs());
1142 row_magnitude.add(entry.abs());
1143 }
1144 let residual = (rhs[active_row] - dot.sum()).abs();
1145 let solve_scale = row_magnitude.sum() * direction_scale;
1146 let allowed = (gamma * (magnitude.sum() + rhs[active_row].abs() + solve_scale))
1147 .max(f64::MIN_POSITIVE);
1148 if !residual.is_finite() || !allowed.is_finite() {
1149 return ActiveEqualityResidualCertificate {
1150 worst_row: active_row,
1151 residual,
1152 allowed,
1153 };
1154 }
1155 let ratio = residual / allowed;
1156 if ratio > worst_ratio {
1157 worst_ratio = ratio;
1158 worst = ActiveEqualityResidualCertificate {
1159 worst_row: active_row,
1160 residual,
1161 allowed,
1162 };
1163 }
1164 }
1165 worst
1166}
1167
1168fn compensated_active_residual(
1170 active_a: &Array2<f64>,
1171 rhs: &Array1<f64>,
1172 direction: &Array1<f64>,
1173) -> Array1<f64> {
1174 Array1::from_shape_fn(active_a.nrows(), |row| {
1175 let mut dot = KahanSum::default();
1176 for column in 0..active_a.ncols() {
1177 dot.add(active_a[[row, column]] * direction[column]);
1178 }
1179 rhs[row] - dot.sum()
1180 })
1181}
1182
1183fn minimum_norm_from_svd(
1184 u: &Array2<f64>,
1185 singular: &Array1<f64>,
1186 vt: &Array2<f64>,
1187 rank: usize,
1188 rhs: &Array1<f64>,
1189) -> Array1<f64> {
1190 let mut solution = Array1::<f64>::zeros(vt.ncols());
1191 for index in 0..rank {
1192 let coefficient = u.column(index).dot(rhs) / singular[index];
1193 solution.scaled_add(coefficient, &vt.row(index));
1194 }
1195 solution
1196}
1197
1198fn transposed_minimum_norm_from_svd(
1199 u: &Array2<f64>,
1200 singular: &Array1<f64>,
1201 vt: &Array2<f64>,
1202 rank: usize,
1203 rhs: &Array1<f64>,
1204) -> Array1<f64> {
1205 let mut solution = Array1::<f64>::zeros(u.nrows());
1206 for index in 0..rank {
1207 let coefficient = vt.row(index).dot(rhs) / singular[index];
1208 solution.scaled_add(coefficient, &u.column(index));
1209 }
1210 solution
1211}
1212
1213pub(crate) fn solve_kkt_direction(
1238 hessian: &Array2<f64>,
1239 gradient: &Array1<f64>,
1240 active_a: &Array2<f64>,
1241 active_residual: Option<&Array1<f64>>,
1242) -> Result<(Array1<f64>, Array1<f64>), EstimationError> {
1243 let p = hessian.nrows();
1244 let m = active_a.nrows();
1245 if hessian.ncols() != p || gradient.len() != p || active_a.ncols() != p {
1246 crate::bail_invalid_estim!("null-space constrained solve dimension mismatch");
1247 }
1248 if let Some(residual) = active_residual
1249 && residual.len() != m
1250 {
1251 crate::bail_invalid_estim!(
1252 "active-equality residual length mismatch: got {}, expected {}",
1253 residual.len(),
1254 m
1255 );
1256 }
1257 if m == 0 {
1258 let mut d = Array1::<f64>::zeros(p);
1259 solve_newton_direction_dense(hessian, gradient, &mut d)?;
1260 return Ok((d, Array1::zeros(0)));
1261 }
1262
1263 let mut scaled_a = active_a.clone();
1264 let mut scaled_rhs = active_residual
1265 .cloned()
1266 .unwrap_or_else(|| Array1::<f64>::zeros(m));
1267 let mut row_norms = Array1::<f64>::zeros(m);
1268 for row in 0..m {
1269 let norm = active_a.row(row).dot(&active_a.row(row)).sqrt();
1270 if !(norm.is_finite() && norm > 0.0) {
1271 crate::bail_invalid_estim!(
1272 "active equality row {row} has invalid norm {norm}"
1273 );
1274 }
1275 row_norms[row] = norm;
1276 let inverse = 1.0 / norm;
1277 scaled_a.row_mut(row).mapv_inplace(|value| value * inverse);
1278 scaled_rhs[row] *= inverse;
1279 }
1280
1281 let (u_opt, singular, vt_opt) = scaled_a.svd(true, true).map_err(|_| {
1282 EstimationError::InvalidInput(
1283 "null-space constrained quadratic active-equation SVD failed".to_string(),
1284 )
1285 })?;
1286 let (Some(u), Some(vt)) = (u_opt, vt_opt) else {
1287 crate::bail_invalid_estim!(
1288 "null-space constrained quadratic SVD omitted singular vectors"
1289 );
1290 };
1291 let (mut null_basis, rank) =
1292 rrqr_nullspace_basis(&scaled_a.t(), default_rrqr_rank_alpha()).map_err(|_| {
1293 EstimationError::InvalidInput(
1294 "null-space constrained quadratic active-equation RRQR failed".to_string(),
1295 )
1296 })?;
1297 if rank == 0 {
1298 crate::bail_invalid_estim!(
1299 "null-space constrained quadratic active equations have numerical rank zero"
1300 );
1301 }
1302 if rank > singular.len()
1303 || !singular[rank - 1].is_finite()
1304 || singular[rank - 1] <= 0.0
1305 {
1306 crate::bail_invalid_estim!(
1307 "null-space constrained quadratic RRQR rank {rank} has no positive SVD pivot"
1308 );
1309 }
1310 let nullity = p.saturating_sub(rank);
1311 if null_basis.dim() != (p, nullity) {
1312 crate::bail_invalid_estim!(
1313 "null-space constrained quadratic RRQR basis has shape {}x{}, expected {}x{}",
1314 null_basis.nrows(),
1315 null_basis.ncols(),
1316 p,
1317 nullity,
1318 );
1319 }
1320 let zero_active_rhs = Array1::<f64>::zeros(m);
1321 for column in 0..nullity {
1322 let basis_column = null_basis.column(column).to_owned();
1323 let residual =
1324 compensated_active_residual(&scaled_a, &zero_active_rhs, &basis_column);
1325 let correction =
1326 minimum_norm_from_svd(&u, &singular, &vt, rank, &residual);
1327 null_basis.column_mut(column).scaled_add(1.0, &correction);
1328 }
1329 if !array_is_finite(&null_basis) {
1330 crate::bail_invalid_estim!(
1331 "null-space constrained quadratic refined RRQR basis is non-finite"
1332 );
1333 }
1334
1335 let mut particular = minimum_norm_from_svd(&u, &singular, &vt, rank, &scaled_rhs);
1336 if !array_is_finite(&particular) {
1337 crate::bail_invalid_estim!(
1338 "null-space constrained quadratic affine solution is non-finite"
1339 );
1340 }
1341
1342 let initial_affine_residual =
1343 compensated_active_residual(&scaled_a, &scaled_rhs, &particular);
1344 let affine_correction =
1345 minimum_norm_from_svd(&u, &singular, &vt, rank, &initial_affine_residual);
1346 particular += &affine_correction;
1347
1348 let mut direction = particular.clone();
1349 if nullity > 0 {
1350 let mut reduced_hessian = null_basis.t().dot(hessian).dot(&null_basis);
1351 for row in 0..nullity {
1352 for column in (row + 1)..nullity {
1353 let average =
1354 0.5 * (reduced_hessian[[row, column]] + reduced_hessian[[column, row]]);
1355 reduced_hessian[[row, column]] = average;
1356 reduced_hessian[[column, row]] = average;
1357 }
1358 }
1359 let affine_gradient = gradient + &hessian.dot(&particular);
1360 let reduced_rhs = -null_basis.t().dot(&affine_gradient);
1361 let factor = reduced_hessian
1362 .cholesky(Side::Lower)
1363 .map_err(EstimationError::LinearSystemSolveFailed)?;
1364 let reduced_solution = factor.solvevec(&reduced_rhs);
1365 if !array_is_finite(&reduced_solution) {
1366 crate::bail_invalid_estim!(
1367 "null-space constrained quadratic reduced solve is non-finite"
1368 );
1369 }
1370 direction += &null_basis.dot(&reduced_solution);
1371 }
1372
1373 let initial_certificate =
1374 certify_active_equalities(&scaled_a, &scaled_rhs, &direction);
1375 if !initial_certificate.is_certified() {
1376 let affine_residual =
1377 compensated_active_residual(&scaled_a, &scaled_rhs, &direction);
1378 let correction =
1379 minimum_norm_from_svd(&u, &singular, &vt, rank, &affine_residual);
1380 if !correction.iter().all(|value| value.is_finite()) {
1381 return Err(EstimationError::ParameterConstraintViolation(format!(
1382 "null-space active-equality correction produced a non-finite value \
1383 (active_row={}, residual={:.3e}, roundoff_bound={:.3e})",
1384 initial_certificate.worst_row,
1385 initial_certificate.residual,
1386 initial_certificate.allowed,
1387 )));
1388 }
1389 direction += &correction;
1390 let refined_certificate =
1391 certify_active_equalities(&scaled_a, &scaled_rhs, &direction);
1392 if !refined_certificate.is_certified() {
1393 return Err(EstimationError::ParameterConstraintViolation(format!(
1394 "null-space active equality is unresolved after affine correction \
1395 (active_row={}, residual={:.3e}, roundoff_bound={:.3e}; \
1396 initial_active_row={}, initial_residual={:.3e}, \
1397 initial_roundoff_bound={:.3e})",
1398 refined_certificate.worst_row,
1399 refined_certificate.residual,
1400 refined_certificate.allowed,
1401 initial_certificate.worst_row,
1402 initial_certificate.residual,
1403 initial_certificate.allowed,
1404 )));
1405 }
1406 }
1407
1408 let stationarity_rhs = -(gradient + &hessian.dot(&direction));
1409 let scaled_multiplier =
1410 transposed_minimum_norm_from_svd(&u, &singular, &vt, rank, &stationarity_rhs);
1411 let multiplier = &scaled_multiplier / &row_norms;
1412 if !array_is_finite(&multiplier) {
1413 crate::bail_invalid_estim!(
1414 "null-space constrained quadratic multiplier recovery is non-finite"
1415 );
1416 }
1417 Ok((direction, multiplier))
1418}
1419
1420#[derive(Clone, Debug)]
1421pub(crate) struct CompressedActiveWorkingSet {
1422 pub(crate) constraints: LinearInequalityConstraints,
1423 pub(crate) groups: Vec<Vec<usize>>,
1428 pub(crate) original_active_count: usize,
1429}
1430
1431#[derive(Clone, Copy, Debug)]
1447pub struct ActiveRowDependence {
1448 pub active_pos: usize,
1449 pub coeff: f64,
1450}
1451
1452#[derive(Clone, Copy, Debug)]
1461pub struct ConstraintRowDependence {
1462 pub row: ConstraintRowId,
1463 pub coeff: f64,
1464}
1465
1466#[derive(Clone, Debug)]
1477pub struct ReducedFace {
1478 pub representatives: Vec<ConstraintRowId>,
1482 pub dependence: Vec<Vec<ConstraintRowDependence>>,
1487 pub tight_rows: Vec<ConstraintRowId>,
1489}
1490
1491pub fn khatri_rao_cone_reduced_face(
1514 cone: &KhatriRaoConeConstraints,
1515 beta: ndarray::ArrayView1<'_, f64>,
1516 membership_tol: f64,
1517) -> Result<ReducedFace, EstimationError> {
1518 let psi = cone.factor();
1519 let n = psi.nrows();
1520 let p_cov = psi.ncols();
1521 let coupled = cone.coupled_rows();
1522 let values = cone.values(beta).map_err(|error| {
1523 EstimationError::ParameterConstraintViolation(format!(
1524 "Khatri-Rao cone reduced-face values: {error}"
1525 ))
1526 })?;
1527
1528 let row_norms: Vec<f64> = (0..n)
1530 .map(|i| {
1531 let row = psi.row(i);
1532 row.dot(&row).sqrt()
1533 })
1534 .collect();
1535
1536 const RANK_ALPHA: f64 = 100.0;
1537 const PARALLEL_COS_TOL: f64 = 1.0 - 1e-9;
1539
1540 let mut representatives: Vec<ConstraintRowId> = Vec::new();
1541 let mut dependence: Vec<Vec<ConstraintRowDependence>> = Vec::new();
1542 let mut tight_rows: Vec<ConstraintRowId> = Vec::new();
1543
1544 for slot in 0..coupled.len() {
1545 let mut tight_obs: Vec<usize> = Vec::new();
1548 for i in 0..n {
1549 let norm_i = row_norms[i];
1550 if norm_i <= 0.0 {
1551 continue;
1552 }
1553 let scaled_slack = values[slot * n + i] / norm_i;
1554 if scaled_slack <= membership_tol {
1555 tight_rows.push(ConstraintRowId(slot * n + i));
1556 tight_obs.push(i);
1557 }
1558 }
1559 if tight_obs.is_empty() {
1560 continue;
1561 }
1562
1563 let max_norm = tight_obs
1564 .iter()
1565 .map(|&i| row_norms[i])
1566 .fold(0.0_f64, f64::max);
1567 let rank_tol =
1568 RANK_ALPHA * f64::EPSILON * (tight_obs.len().max(p_cov).max(1) as f64) * max_norm;
1569
1570 let mut ortho_basis: Vec<Array1<f64>> = Vec::new();
1571 let mut kept: Vec<(usize, Array1<f64>, usize)> = Vec::new();
1573 for &i in &tight_obs {
1574 let psi_i = psi.row(i).to_owned();
1575 let mut resid = psi_i.clone();
1576 for q in &ortho_basis {
1577 let proj = resid.dot(q);
1578 resid.scaled_add(-proj, q);
1579 }
1580 let resid_norm = resid.dot(&resid).sqrt();
1581 let flat = ConstraintRowId(slot * n + i);
1582 if resid_norm > rank_tol {
1583 ortho_basis.push(&resid / resid_norm);
1584 let out_idx = representatives.len();
1585 representatives.push(flat);
1586 dependence.push(Vec::new());
1587 kept.push((i, psi_i, out_idx));
1588 } else {
1589 let mut best_abs_cos = 0.0_f64;
1592 let mut best: Option<(usize, f64)> = None;
1593 for (rep_obs, rep_psi, rep_out_idx) in &kept {
1594 let rep_norm = row_norms[*rep_obs];
1595 let dot = psi_i.dot(rep_psi);
1596 let cos = if rep_norm > 0.0 {
1597 dot / (row_norms[i] * rep_norm)
1598 } else {
1599 0.0
1600 };
1601 if cos.abs() > best_abs_cos {
1602 best_abs_cos = cos.abs();
1603 best = Some((*rep_out_idx, dot / (rep_norm * rep_norm)));
1604 }
1605 }
1606 if best_abs_cos >= PARALLEL_COS_TOL {
1607 if let Some((out_idx, coeff)) = best {
1608 dependence[out_idx].push(ConstraintRowDependence {
1609 row: flat,
1610 coeff,
1611 });
1612 }
1613 }
1614 }
1615 }
1616 }
1617
1618 Ok(ReducedFace {
1619 representatives,
1620 dependence,
1621 tight_rows,
1622 })
1623}
1624
1625pub fn dense_reduced_face(
1634 lin: &LinearInequalityConstraints,
1635 beta: ndarray::ArrayView1<'_, f64>,
1636 membership_tol: f64,
1637) -> Result<ReducedFace, EstimationError> {
1638 let a = &lin.a;
1639 let b = &lin.b;
1640 let n = a.nrows();
1641 let p = a.ncols();
1642
1643 let row_norms: Vec<f64> = (0..n)
1644 .map(|i| {
1645 let row = a.row(i);
1646 row.dot(&row).sqrt()
1647 })
1648 .collect();
1649
1650 const RANK_ALPHA: f64 = 100.0;
1651 const PARALLEL_COS_TOL: f64 = 1.0 - 1e-9;
1652
1653 let mut tight: Vec<usize> = Vec::new();
1656 for i in 0..n {
1657 let norm_i = row_norms[i];
1658 if norm_i <= 0.0 {
1659 continue;
1660 }
1661 let scaled_slack = (a.row(i).dot(&beta) - b[i]) / norm_i;
1662 if scaled_slack <= membership_tol {
1663 tight.push(i);
1664 }
1665 }
1666
1667 let mut representatives: Vec<ConstraintRowId> = Vec::new();
1668 let mut dependence: Vec<Vec<ConstraintRowDependence>> = Vec::new();
1669 if tight.is_empty() {
1670 return Ok(ReducedFace {
1671 representatives,
1672 dependence,
1673 tight_rows: Vec::new(),
1674 });
1675 }
1676
1677 let max_norm = tight
1678 .iter()
1679 .map(|&i| row_norms[i])
1680 .fold(0.0_f64, f64::max);
1681 let rank_tol = RANK_ALPHA * f64::EPSILON * (tight.len().max(p).max(1) as f64) * max_norm;
1682
1683 let mut ortho_basis: Vec<Array1<f64>> = Vec::new();
1684 let mut kept: Vec<(usize, Array1<f64>, usize)> = Vec::new();
1686 for &i in &tight {
1687 let a_i = a.row(i).to_owned();
1688 let mut resid = a_i.clone();
1689 for q in &ortho_basis {
1690 let proj = resid.dot(q);
1691 resid.scaled_add(-proj, q);
1692 }
1693 let resid_norm = resid.dot(&resid).sqrt();
1694 if resid_norm > rank_tol {
1695 ortho_basis.push(&resid / resid_norm);
1696 let out_idx = representatives.len();
1697 representatives.push(ConstraintRowId(i));
1698 dependence.push(Vec::new());
1699 kept.push((i, a_i, out_idx));
1700 } else {
1701 let mut best_abs_cos = 0.0_f64;
1705 let mut best: Option<(usize, f64)> = None;
1706 for (rep_row, rep_a, rep_out_idx) in &kept {
1707 let rep_norm = row_norms[*rep_row];
1708 let dot = a_i.dot(rep_a);
1709 let cos = if rep_norm > 0.0 {
1710 dot / (row_norms[i] * rep_norm)
1711 } else {
1712 0.0
1713 };
1714 if cos.abs() > best_abs_cos {
1715 best_abs_cos = cos.abs();
1716 best = Some((*rep_out_idx, dot / (rep_norm * rep_norm)));
1717 }
1718 }
1719 if best_abs_cos >= PARALLEL_COS_TOL {
1720 if let Some((out_idx, coeff)) = best {
1721 dependence[out_idx].push(ConstraintRowDependence {
1722 row: ConstraintRowId(i),
1723 coeff,
1724 });
1725 }
1726 }
1727 }
1728 }
1729
1730 Ok(ReducedFace {
1731 representatives,
1732 dependence,
1733 tight_rows: tight.into_iter().map(ConstraintRowId).collect(),
1734 })
1735}
1736
1737#[inline]
1753fn lift_member_row(local: ConstraintRowId, row_offset: usize) -> ConstraintRowId {
1754 ConstraintRowId(local.index() + row_offset)
1755}
1756
1757pub trait ConstraintSetReducedFace {
1762 fn reduced_face(
1763 &self,
1764 beta: ndarray::ArrayView1<'_, f64>,
1765 membership_tol: f64,
1766 ) -> Result<ReducedFace, EstimationError>;
1767}
1768
1769impl ConstraintSetReducedFace for ConstraintSet {
1770 fn reduced_face(
1771 &self,
1772 beta: ndarray::ArrayView1<'_, f64>,
1773 membership_tol: f64,
1774 ) -> Result<ReducedFace, EstimationError> {
1775 match self {
1776 ConstraintSet::Dense(lin) => dense_reduced_face(lin, beta, membership_tol),
1777 ConstraintSet::KhatriRaoCone(cone) => {
1778 khatri_rao_cone_reduced_face(cone, beta, membership_tol)
1779 }
1780 ConstraintSet::BlockDiagonal { blocks, .. } => {
1781 let mut representatives: Vec<ConstraintRowId> = Vec::new();
1792 let mut dependence: Vec<Vec<ConstraintRowDependence>> = Vec::new();
1793 let mut tight_rows: Vec<ConstraintRowId> = Vec::new();
1794 let mut row_offset = 0usize;
1795 for block in blocks {
1796 let start = block.col_start;
1797 let end = start + block.set.ncols();
1798 let beta_block = beta.slice(ndarray::s![start..end]);
1799 let sub = block.set.reduced_face(beta_block, membership_tol)?;
1800 for r in sub.representatives {
1801 representatives.push(lift_member_row(r, row_offset));
1802 }
1803 for deps in sub.dependence {
1804 dependence.push(
1805 deps.into_iter()
1806 .map(|d| ConstraintRowDependence {
1807 row: lift_member_row(d.row, row_offset),
1808 coeff: d.coeff,
1809 })
1810 .collect(),
1811 );
1812 }
1813 for t in sub.tight_rows {
1814 tight_rows.push(lift_member_row(t, row_offset));
1815 }
1816 row_offset += block.set.nrows();
1817 }
1818 Ok(ReducedFace {
1819 representatives,
1820 dependence,
1821 tight_rows,
1822 })
1823 }
1824 }
1825 }
1826}
1827
1828impl CompressedActiveWorkingSet {
1829 fn is_degenerate_face(&self) -> bool {
1830 self.constraints.a.nrows() < self.original_active_count
1831 || self.groups.iter().any(|group| group.len() > 1)
1832 }
1833
1834 fn negative_representative_group(
1854 &self,
1855 lambda_system: &Array1<f64>,
1856 tol_dual: f64,
1857 active: &[usize],
1858 ) -> Option<Vec<usize>> {
1859 self.groups
1860 .iter()
1861 .enumerate()
1862 .filter(|&(group_pos, _)| {
1863 lambda_system
1865 .get(group_pos)
1866 .is_some_and(|&value| -value < -tol_dual)
1867 })
1868 .min_by_key(|&(_, group)| {
1869 let first = group.first().copied().unwrap_or(usize::MAX);
1870 (active.get(first).copied().unwrap_or(usize::MAX), first)
1871 })
1872 .map(|(_, group)| group.clone())
1873 }
1874
1875 fn position_enforced(&self, pos: usize) -> bool {
1882 self.groups.iter().any(|group| group.contains(&pos))
1883 }
1884
1885 fn over_complete_release_group(
1900 &self,
1901 violated: ndarray::ArrayView1<'_, f64>,
1902 active: &[usize],
1903 ) -> Option<Vec<usize>> {
1904 let v_norm = violated.dot(&violated).sqrt();
1905 if !(v_norm > 0.0) {
1906 return None;
1907 }
1908 const COS_TIE_TOL: f64 = 1e-12;
1909 let mut best: Option<(f64, (usize, usize), usize)> = None;
1910 for (group_pos, group) in self.groups.iter().enumerate() {
1911 let rep = self.constraints.a.row(group_pos);
1912 let rep_norm = rep.dot(&rep).sqrt();
1913 if !(rep_norm > 0.0) {
1914 continue;
1915 }
1916 let cos = rep.dot(&violated) / (rep_norm * v_norm);
1917 if cos <= 0.0 {
1918 continue;
1919 }
1920 let first = group.first().copied().unwrap_or(usize::MAX);
1921 let key = (active.get(first).copied().unwrap_or(usize::MAX), first);
1922 let take = match &best {
1923 None => true,
1924 Some((best_cos, best_key, _)) => {
1925 cos > best_cos + COS_TIE_TOL
1926 || ((cos - best_cos).abs() <= COS_TIE_TOL && key < *best_key)
1927 }
1928 };
1929 if take {
1930 best = Some((cos, key, group_pos));
1931 }
1932 }
1933 best.map(|(_, _, group_pos)| self.groups[group_pos].clone())
1934 }
1935}
1936
1937fn identity_multiplier_dependence(groups: &[Vec<usize>]) -> Vec<Vec<ActiveRowDependence>> {
1938 groups
1939 .iter()
1940 .map(|group| {
1941 group
1942 .iter()
1943 .copied()
1944 .map(|active_pos| ActiveRowDependence {
1945 active_pos,
1946 coeff: 1.0,
1947 })
1948 .collect()
1949 })
1950 .collect()
1951}
1952
1953pub fn rank_reduce_rows_pivoted_qr_with_dependence(
1954 a: Array2<f64>,
1955 b: Array1<f64>,
1956 groups: Vec<Vec<usize>>,
1957) -> (
1958 Array2<f64>,
1959 Array1<f64>,
1960 Vec<Vec<usize>>,
1961 Vec<Vec<ActiveRowDependence>>,
1962) {
1963 let k = a.nrows();
1964 let p = a.ncols();
1965 if k <= 1 {
1966 let multiplier_dependence = identity_multiplier_dependence(&groups);
1967 return (a, b, groups, multiplier_dependence);
1968 }
1969
1970 const RANK_ALPHA: f64 = 100.0;
1990 let max_row_norm = (0..k)
1991 .map(|r| {
1992 let row = a.row(r);
1993 row.dot(&row).sqrt()
1994 })
1995 .fold(0.0_f64, f64::max);
1996 let tol = RANK_ALPHA * f64::EPSILON * (k.max(p).max(1) as f64) * max_row_norm;
1997
1998 let mut ortho_basis: Vec<Array1<f64>> = Vec::new();
1999 let mut kept_orig: Vec<usize> = Vec::new();
2000 let mut dropped_orig: Vec<usize> = Vec::new();
2001 for r in 0..k {
2002 let mut resid = a.row(r).to_owned();
2003 for q in &ortho_basis {
2004 let proj = resid.dot(q);
2005 resid.scaled_add(-proj, q);
2006 }
2007 let resid_norm = resid.dot(&resid).sqrt();
2008 if resid_norm > tol {
2009 kept_orig.push(r);
2010 ortho_basis.push(&resid / resid_norm);
2011 } else {
2012 dropped_orig.push(r);
2013 }
2014 }
2015 let rank = kept_orig.len();
2016 if rank >= k {
2017 let multiplier_dependence = identity_multiplier_dependence(&groups);
2018 return (a, b, groups, multiplier_dependence);
2019 }
2020 if rank == 0 {
2021 log::debug!(
2022 "rank-reduced active constraints from {} to 0 rows (all active rows numerically zero)",
2023 k
2024 );
2025 return (
2026 Array2::<f64>::zeros((0, p)),
2027 Array1::<f64>::zeros(0),
2028 Vec::new(),
2029 Vec::new(),
2030 );
2031 }
2032
2033 let mut orig_to_out = std::collections::HashMap::with_capacity(rank);
2034 let mut a_out = Array2::<f64>::zeros((rank, p));
2035 let mut b_out = Array1::<f64>::zeros(rank);
2036 let mut groups_out: Vec<Vec<usize>> = Vec::with_capacity(rank);
2037 let mut multiplier_dependence: Vec<Vec<ActiveRowDependence>> = Vec::with_capacity(rank);
2038 for (out_idx, &orig_idx) in kept_orig.iter().enumerate() {
2039 a_out.row_mut(out_idx).assign(&a.row(orig_idx));
2040 b_out[out_idx] = b[orig_idx];
2041 groups_out.push(groups[orig_idx].clone());
2042 multiplier_dependence.push(
2043 groups[orig_idx]
2044 .iter()
2045 .copied()
2046 .map(|active_pos| ActiveRowDependence {
2047 active_pos,
2048 coeff: 1.0,
2049 })
2050 .collect(),
2051 );
2052 orig_to_out.insert(orig_idx, out_idx);
2053 }
2054
2055 const PARALLEL_COS_TOL: f64 = 1.0 - 1e-9;
2068 for &dropped_idx in &dropped_orig {
2069 let dropped_row = a.row(dropped_idx);
2070 let dropped_norm = dropped_row.dot(&dropped_row).sqrt();
2071 let mut best_abs_cos = 0.0_f64;
2072 let mut best_target: Option<(usize, f64)> = None;
2073 for &kept_idx in &kept_orig {
2074 let kept_row = a.row(kept_idx);
2075 let kept_norm = kept_row.dot(&kept_row).sqrt();
2076 let dot = kept_row.dot(&dropped_row);
2077 let cos = if kept_norm > 0.0 && dropped_norm > 0.0 {
2078 dot / (kept_norm * dropped_norm)
2079 } else {
2080 0.0
2081 };
2082 let coeff = if kept_norm > 0.0 {
2083 dot / (kept_norm * kept_norm)
2084 } else {
2085 0.0
2086 };
2087 if cos.abs() > best_abs_cos {
2088 best_abs_cos = cos.abs();
2089 best_target = Some((kept_idx, coeff));
2090 }
2091 }
2092 if best_abs_cos >= PARALLEL_COS_TOL {
2098 if let Some((target, coeff)) = best_target {
2099 let &out_idx = orig_to_out
2100 .get(&target)
2101 .expect("merge target must be a kept row");
2102 for &active_pos in &groups[dropped_idx] {
2103 multiplier_dependence[out_idx].push(ActiveRowDependence { active_pos, coeff });
2104 }
2105 if coeff > 0.0 {
2106 groups_out[out_idx].extend_from_slice(&groups[dropped_idx]);
2107 }
2108 }
2109 }
2110 }
2111
2112 for group in &mut groups_out {
2113 group.sort_unstable();
2114 group.dedup();
2115 }
2116 for dependencies in &mut multiplier_dependence {
2117 dependencies.sort_unstable_by_key(|dependency| dependency.active_pos);
2118 dependencies.dedup_by_key(|dependency| dependency.active_pos);
2119 }
2120
2121 let mut row_order: Vec<usize> = (0..groups_out.len()).collect();
2122 row_order.sort_by_key(|&idx| groups_out[idx].first().copied().unwrap_or(usize::MAX));
2123 if row_order.iter().enumerate().any(|(idx, &orig)| idx != orig) {
2124 let mut a_sorted = Array2::<f64>::zeros((rank, p));
2125 let mut b_sorted = Array1::<f64>::zeros(rank);
2126 let mut groups_sorted = Vec::with_capacity(rank);
2127 let mut dependence_sorted = Vec::with_capacity(rank);
2128 for (out_idx, orig_idx) in row_order.into_iter().enumerate() {
2129 a_sorted.row_mut(out_idx).assign(&a_out.row(orig_idx));
2130 b_sorted[out_idx] = b_out[orig_idx];
2131 groups_sorted.push(groups_out[orig_idx].clone());
2132 dependence_sorted.push(multiplier_dependence[orig_idx].clone());
2133 }
2134 a_out = a_sorted;
2135 b_out = b_sorted;
2136 groups_out = groups_sorted;
2137 multiplier_dependence = dependence_sorted;
2138 }
2139
2140 if rank < k {
2141 log::debug!(
2142 "rank-reduced active constraints from {} to {} rows (rank deficiency {})",
2143 k,
2144 rank,
2145 k - rank
2146 );
2147 }
2148
2149 (a_out, b_out, groups_out, multiplier_dependence)
2150}
2151
2152pub(crate) fn working_set_kkt_diagnostics_from_multipliers(
2153 x: &Array1<f64>,
2154 gradient: &Array1<f64>,
2155 working_constraints: &LinearInequalityConstraints,
2156 lambda_active_true: &Array1<f64>,
2157 n_total_constraints: usize,
2158) -> Result<ConstraintKktDiagnostics, EstimationError> {
2159 let p = working_constraints.a.ncols();
2160 if x.len() != p || gradient.len() != p {
2161 crate::bail_invalid_estim!("working-set KKT diagnostic dimension mismatch");
2162 }
2163 if lambda_active_true.len() != working_constraints.a.nrows() {
2164 crate::bail_invalid_estim!(
2165 "working-set KKT multiplier length mismatch: got {}, expected {}",
2166 lambda_active_true.len(),
2167 working_constraints.a.nrows()
2168 );
2169 }
2170 let m = working_constraints.a.nrows();
2181 let mut slack = Array1::<f64>::zeros(m);
2182 let mut primal_feasibility: f64 = 0.0;
2183 for i in 0..m {
2184 let s_i = scaled_constraint_slack(x, working_constraints, i);
2185 slack[i] = s_i;
2186 primal_feasibility = primal_feasibility.max((-s_i).max(0.0));
2187 }
2188
2189 let lambda = lambda_active_true.to_owned();
2190
2191 let mut dual_feasibility: f64 = 0.0;
2192 let mut complementarity: f64 = 0.0;
2193 for i in 0..m {
2194 dual_feasibility = dual_feasibility.max((-lambda[i]).max(0.0));
2195 let norm_i = working_constraints
2203 .a
2204 .row(i)
2205 .dot(&working_constraints.a.row(i))
2206 .sqrt();
2207 complementarity = complementarity.max((norm_i * lambda[i] * slack[i]).abs());
2208 }
2209 let stationarity = {
2210 let mut resid = gradient.to_owned();
2211 resid -= &working_constraints.a.t().dot(&lambda);
2212 resid.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()))
2213 };
2214
2215 Ok(ConstraintKktDiagnostics {
2216 n_constraints: n_total_constraints,
2217 n_active: m,
2218 primal_feasibility,
2219 dual_feasibility,
2220 complementarity,
2221 stationarity,
2222 active_tolerance: ACTIVE_SET_PRIMAL_FEASIBILITY_TOL,
2223 working_set_rank_deficient: false,
2230 gradient_scale: gradient_inf_norm(gradient),
2231 })
2232}
2233
2234fn log_active_set_transition(
2235 event: &str,
2236 iteration: usize,
2237 active_len: usize,
2238 constraint: Option<usize>,
2239) {
2240 log::debug!(
2241 "[active-set/QP] iter={} event={} active={} constraint={}",
2242 iteration,
2243 event,
2244 active_len,
2245 constraint
2246 .map(|idx| idx.to_string())
2247 .unwrap_or_else(|| "NA".to_string()),
2248 );
2249}
2250
2251fn record_active_working_set(
2262 visited: &mut HashSet<(Vec<usize>, Vec<u64>)>,
2263 active: &[usize],
2264 x: &Array1<f64>,
2265 iteration: usize,
2266) -> bool {
2267 let mut active_key = active.to_vec();
2268 active_key.sort_unstable();
2269 let point_key = x.iter().map(|value| value.to_bits()).collect::<Vec<_>>();
2270 if visited.insert((active_key.clone(), point_key)) {
2271 return true;
2272 }
2273 log::debug!(
2274 "[active-set/QP] iter={iteration} repeated working set at the identical primal point ({} rows); \
2275 deferring to the post-loop KKT exit gate",
2276 active_key.len()
2277 );
2278 false
2279}
2280
2281struct ConstraintSetOps<'a> {
2302 set: &'a ConstraintSet,
2303 norms: Vec<f64>,
2304 bounds: Vec<f64>,
2305 scaled_margin: f64,
2306}
2307
2308impl<'a> ConstraintSetOps<'a> {
2309 fn new(set: &'a ConstraintSet, scaled_margin: f64) -> Result<Self, EstimationError> {
2310 let m = set.nrows();
2311 let mut norms = Vec::with_capacity(m);
2312 let mut bounds = Vec::with_capacity(m);
2313 for row in 0..m {
2314 norms.push(set.row_norm(row).map_err(|e| {
2315 EstimationError::ParameterConstraintViolation(format!(
2316 "constraint-set row norm: {e}"
2317 ))
2318 })?);
2319 bounds.push(set.bound(row).map_err(|e| {
2320 EstimationError::ParameterConstraintViolation(format!(
2321 "constraint-set row bound: {e}"
2322 ))
2323 })?);
2324 }
2325 Ok(Self {
2326 set,
2327 norms,
2328 bounds,
2329 scaled_margin,
2330 })
2331 }
2332
2333 fn tangent_face(set: &'a ConstraintSet, beta: &Array1<f64>) -> Result<Self, EstimationError> {
2339 let mut ops = Self::new(set, 0.0)?;
2340 let values = ops.values(beta)?;
2341 for row in 0..ops.nrows() {
2342 if ops.norms[row] <= 0.0 {
2343 if ops.bounds[row] > 0.0 {
2344 crate::bail_invalid_estim!(
2345 "infeasible zero-norm constraint row {} entered tangent-face projection",
2346 row
2347 );
2348 }
2349 ops.bounds[row] = 0.0;
2350 continue;
2351 }
2352 let is_tight = ops.scaled_slack(&values, row) <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL;
2353 ops.bounds[row] = 0.0;
2356 if !is_tight {
2357 ops.norms[row] = 0.0;
2358 }
2359 }
2360 Ok(ops)
2361 }
2362
2363 fn nrows(&self) -> usize {
2364 self.norms.len()
2365 }
2366
2367 fn values(&self, x: &Array1<f64>) -> Result<Array1<f64>, EstimationError> {
2368 self.set.values(x.view()).map_err(|e| {
2369 EstimationError::ParameterConstraintViolation(format!("constraint-set values: {e}"))
2370 })
2371 }
2372
2373 #[inline]
2376 fn scaled_slack(&self, values: &Array1<f64>, row: usize) -> f64 {
2377 let norm = self.norms[row];
2378 if norm > 0.0 {
2379 (values[row] - self.bounds[row]) / norm - self.scaled_margin
2380 } else if self.bounds[row] > 0.0 {
2381 f64::NEG_INFINITY
2382 } else {
2383 f64::INFINITY
2384 }
2385 }
2386
2387 fn max_violation(&self, values: &Array1<f64>) -> (f64, usize) {
2388 let mut worst = 0.0_f64;
2389 let mut worst_row = 0usize;
2390 for row in 0..self.nrows() {
2391 let violation = (-self.scaled_slack(values, row)).max(0.0);
2392 if violation > worst {
2393 worst = violation;
2394 worst_row = row;
2395 }
2396 }
2397 (worst, worst_row)
2398 }
2399
2400 fn gather_unit_rows(
2405 &self,
2406 rows: &[usize],
2407 ) -> Result<LinearInequalityConstraints, EstimationError> {
2408 let mut gathered = self.set.gather_rows(rows).map_err(|e| {
2409 EstimationError::ParameterConstraintViolation(format!(
2410 "constraint-set working-row gather: {e}"
2411 ))
2412 })?;
2413 for (out_row, &row) in rows.iter().enumerate() {
2414 let norm = self.norms[row];
2415 if norm <= 0.0 {
2416 crate::bail_invalid_estim!(
2417 "vacuous zero-norm constraint row {} entered the working set",
2418 row
2419 );
2420 }
2421 let inv = 1.0 / norm;
2422 gathered.a.row_mut(out_row).mapv_inplace(|v| v * inv);
2423 gathered.b[out_row] = self.bounds[row] * inv + self.scaled_margin;
2424 }
2425 Ok(gathered)
2426 }
2427
2428 fn compress_working(
2430 &self,
2431 active: &[usize],
2432 ) -> Result<CompressedActiveWorkingSet, EstimationError> {
2433 let gathered = self.gather_unit_rows(active)?;
2434 let groups: Vec<Vec<usize>> = (0..active.len()).map(|pos| vec![pos]).collect();
2435 let (a_out, b_out, groups_out, _) =
2438 rank_reduce_rows_pivoted_qr_with_dependence(gathered.a, gathered.b, groups);
2439 Ok(CompressedActiveWorkingSet {
2440 constraints: LinearInequalityConstraints::new(a_out, b_out)
2441 .expect("compressed operator working-set shape invariant"),
2442 groups: groups_out,
2443 original_active_count: active.len(),
2444 })
2445 }
2446}
2447
2448fn independent_violated_operator_rows(
2466 ops: &ConstraintSetOps<'_>,
2467 values: &Array1<f64>,
2468 active: &[usize],
2469 is_active: &[bool],
2470 banned: &[bool],
2471 max_new: usize,
2472) -> Result<Vec<usize>, EstimationError> {
2473 let p = ops.set.ncols();
2474 if max_new == 0 {
2475 return Ok(Vec::new());
2476 }
2477 if values.len() != ops.nrows()
2478 || is_active.len() != ops.nrows()
2479 || banned.len() != ops.nrows()
2480 {
2481 crate::bail_invalid_estim!(
2482 "operator batch-separation dimension mismatch: values={}, active_mask={}, \
2483 banned_mask={}, constraints={}",
2484 values.len(),
2485 is_active.len(),
2486 banned.len(),
2487 ops.nrows(),
2488 );
2489 }
2490
2491 let mut candidates = Vec::<(usize, f64)>::new();
2492 for row in 0..ops.nrows() {
2493 if is_active[row] || banned[row] || ops.norms[row] <= 0.0 {
2494 continue;
2495 }
2496 let violation = (-ops.scaled_slack(values, row)).max(0.0);
2497 if violation > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
2498 candidates.push((row, violation));
2499 }
2500 }
2501 candidates.sort_unstable_by(|(left_row, left_violation), (right_row, right_violation)| {
2502 right_violation
2503 .total_cmp(left_violation)
2504 .then_with(|| left_row.cmp(right_row))
2505 });
2506 if candidates.is_empty() {
2507 return Ok(Vec::new());
2508 }
2509
2510 let rank_tolerance = 100.0 * f64::EPSILON * p.max(1) as f64;
2514 let mut basis = Vec::<Array1<f64>>::with_capacity(p);
2515 if !active.is_empty() {
2516 let active_rows = ops.gather_unit_rows(active)?;
2517 for row in active_rows.a.rows() {
2518 extend_operator_normal_basis(&mut basis, row, rank_tolerance);
2519 }
2520 }
2521
2522 let chunk_size = p.max(32);
2523 let mut selected = Vec::with_capacity(max_new.min(p.saturating_sub(basis.len())));
2524 for chunk in candidates.chunks(chunk_size) {
2525 let chunk_ids = chunk.iter().map(|(row, _)| *row).collect::<Vec<_>>();
2526 let gathered = ops.gather_unit_rows(&chunk_ids)?;
2527 for (position, &row) in chunk_ids.iter().enumerate() {
2528 if extend_operator_normal_basis(
2529 &mut basis,
2530 gathered.a.row(position),
2531 rank_tolerance,
2532 ) {
2533 selected.push(row);
2534 if selected.len() == max_new || basis.len() == p {
2535 return Ok(selected);
2536 }
2537 }
2538 }
2539 }
2540 Ok(selected)
2541}
2542
2543fn extend_operator_normal_basis(
2547 basis: &mut Vec<Array1<f64>>,
2548 row: ArrayView1<'_, f64>,
2549 rank_tolerance: f64,
2550) -> bool {
2551 let mut residual = row.to_owned();
2552 for _ in 0..2 {
2555 for direction in basis.iter() {
2556 let projection = residual.dot(direction);
2557 residual.scaled_add(-projection, direction);
2558 }
2559 }
2560 let residual_norm = residual.dot(&residual).sqrt();
2561 if !(residual_norm.is_finite() && residual_norm > rank_tolerance) {
2562 return false;
2563 }
2564 residual /= residual_norm;
2565 basis.push(residual);
2566 true
2567}
2568
2569pub fn constraint_set_rows_tight_at_point(
2578 set: &ConstraintSet,
2579 beta: &Array1<f64>,
2580 candidate_rows: &[usize],
2581) -> Result<Vec<usize>, EstimationError> {
2582 if set.ncols() != beta.len() {
2583 crate::bail_invalid_estim!(
2584 "active-face point dimension mismatch: set has {} columns, beta has {}",
2585 set.ncols(),
2586 beta.len()
2587 );
2588 }
2589 let mut seen = HashSet::with_capacity(candidate_rows.len());
2590 let mut unique = Vec::with_capacity(candidate_rows.len());
2591 for &row in candidate_rows {
2592 if row < set.nrows() && seen.insert(row) {
2593 unique.push(row);
2594 }
2595 }
2596 if unique.is_empty() {
2597 return Ok(Vec::new());
2598 }
2599 let gathered = set.gather_rows(&unique).map_err(|error| {
2600 EstimationError::ParameterConstraintViolation(format!(
2601 "active-face candidate-row gather failed: {error}"
2602 ))
2603 })?;
2604 let mut tight = Vec::with_capacity(unique.len());
2605 for (position, &row) in unique.iter().enumerate() {
2606 let constraint_row = gathered.a.row(position);
2607 let norm = constraint_row.dot(&constraint_row).sqrt();
2608 if norm > 0.0 {
2609 let scaled_slack = (constraint_row.dot(beta) - gathered.b[position]) / norm;
2610 if scaled_slack <= ACTIVE_SET_WORKING_FACE_TOL {
2611 tight.push(row);
2612 }
2613 }
2614 }
2615 Ok(tight)
2616}
2617
2618pub fn project_stationarity_residual_on_constraint_set(
2628 residual: &Array1<f64>,
2629 beta: &Array1<f64>,
2630 set: &ConstraintSet,
2631 seed_active: &[usize],
2632) -> Option<(Array1<f64>, Vec<usize>)> {
2633 let p = residual.len();
2634 if beta.len() != p || set.ncols() != p {
2635 return None;
2636 }
2637 match set {
2638 ConstraintSet::KhatriRaoCone(cone) if cone.p_left() != 1 || cone.coupled_rows() != &[0] => {
2639 let p_cov = cone.factor().ncols();
2645 let n = cone.factor().nrows();
2646 let mut projected = residual.clone();
2647 let mut active = Vec::new();
2648 for (slot, &coefficient_row) in cone.coupled_rows().iter().enumerate() {
2649 let start = coefficient_row * p_cov;
2650 let end = start + p_cov;
2651 let local_residual = residual.slice(s![start..end]).to_owned();
2652 let local_beta = beta.slice(s![start..end]).to_owned();
2653 let local_set = ConstraintSet::KhatriRaoCone(cone.single_coupled_slot(slot).ok()?);
2654 let row_start = slot * n;
2655 let row_end = row_start + n;
2656 let local_seed: Vec<usize> = seed_active
2657 .iter()
2658 .copied()
2659 .filter(|&row| row >= row_start && row < row_end)
2660 .map(|row| row - row_start)
2661 .collect();
2662 let (local_projected, local_active) =
2663 project_stationarity_residual_on_constraint_set(
2664 &local_residual,
2665 &local_beta,
2666 &local_set,
2667 &local_seed,
2668 )?;
2669 projected.slice_mut(s![start..end]).assign(&local_projected);
2670 active.extend(local_active.into_iter().map(|row| row_start + row));
2671 }
2672 Some((projected, active))
2673 }
2674 ConstraintSet::BlockDiagonal { blocks, .. } => {
2675 let mut projected = residual.clone();
2679 let mut active = Vec::new();
2680 let mut row_offset = 0usize;
2681 for block in blocks {
2682 let width = block.set.ncols();
2683 let start = block.col_start;
2684 let end = start + width;
2685 let local_residual = residual.slice(s![start..end]).to_owned();
2686 let local_beta = beta.slice(s![start..end]).to_owned();
2687 let row_end = row_offset + block.set.nrows();
2688 let local_seed: Vec<usize> = seed_active
2689 .iter()
2690 .copied()
2691 .filter(|&row| row >= row_offset && row < row_end)
2692 .map(|row| row - row_offset)
2693 .collect();
2694 let (local_projected, local_active) =
2695 project_stationarity_residual_on_constraint_set(
2696 &local_residual,
2697 &local_beta,
2698 &block.set,
2699 &local_seed,
2700 )?;
2701 projected.slice_mut(s![start..end]).assign(&local_projected);
2702 active.extend(local_active.into_iter().map(|row| row_offset + row));
2703 row_offset = row_end;
2704 }
2705 Some((projected, active))
2706 }
2707 _ => project_stationarity_residual_on_constraint_set_undivided(
2708 residual,
2709 beta,
2710 set,
2711 seed_active,
2712 ),
2713 }
2714}
2715
2716fn project_stationarity_residual_on_constraint_set_undivided(
2717 residual: &Array1<f64>,
2718 beta: &Array1<f64>,
2719 set: &ConstraintSet,
2720 seed_active: &[usize],
2721) -> Option<(Array1<f64>, Vec<usize>)> {
2722 let ops = ConstraintSetOps::tangent_face(set, beta).ok()?;
2723 let (multipliers, projected) = nonnegative_cone_projection_by_rows(
2724 &ops.norms,
2725 residual,
2726 |candidate| ops.values(candidate).ok(),
2727 |rows| ops.set.gather_rows(rows).ok().map(|gathered| gathered.a),
2728 )?;
2729 let mut active: Vec<usize> = multipliers.into_iter().map(|(row, _)| row).collect();
2730 for &row in seed_active {
2731 if row < ops.nrows() && ops.norms[row] > 0.0 && !active.contains(&row) {
2732 active.push(row);
2733 }
2734 }
2735 Some((projected, active))
2736}
2737
2738fn fallback_projected_gradient_direction_with_constraint_set(
2752 beta: &Array1<f64>,
2753 x: &Array1<f64>,
2754 d_total: &Array1<f64>,
2755 gradient: &Array1<f64>,
2756 active: &[usize],
2757 ops: &ConstraintSetOps<'_>,
2758) -> Result<Option<(Array1<f64>, Vec<usize>)>, EstimationError> {
2759 let p = gradient.len();
2760 if x.len() != p || d_total.len() != p || beta.len() != p || ops.set.ncols() != p {
2761 crate::bail_invalid_estim!("operator projected-gradient fallback dimension mismatch");
2762 }
2763
2764 let values_x = ops.values(x)?;
2765 let Some((stationarity_residual, mut tangent_active)) =
2766 project_stationarity_residual_on_constraint_set(gradient, x, ops.set, active)
2767 else {
2768 return Ok(None);
2769 };
2770 let tangent_direction = -stationarity_residual;
2771 let step_inf = tangent_direction
2772 .iter()
2773 .fold(0.0_f64, |acc, &value| acc.max(value.abs()));
2774 if step_inf <= 1e-12 {
2775 let (worst, _) = ops.max_violation(&values_x);
2776 if worst > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
2777 let Some(projected) = project_point_strictly_into_feasible_constraint_set(x, ops.set)
2778 .ok()
2779 .filter(|candidate| {
2780 ops.values(candidate)
2781 .map(|candidate_values| {
2782 ops.max_violation(&candidate_values).0
2783 <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
2784 })
2785 .unwrap_or(false)
2786 })
2787 else {
2788 return Ok(None);
2789 };
2790 let repair = &projected - x;
2791 let new_direction = d_total + &repair;
2792 let candidate = beta + &new_direction;
2796 let candidate_values = ops.values(&candidate)?;
2797 if ops.max_violation(&candidate_values).0 > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
2798 return Ok(None);
2799 }
2800 return Ok(Some((new_direction, Vec::new())));
2801 }
2802 return Ok(Some((d_total.clone(), tangent_active)));
2803 }
2804
2805 let directional_derivative = gradient.dot(&tangent_direction);
2806 if !directional_derivative.is_finite() || directional_derivative >= 0.0 {
2807 return Ok(None);
2808 }
2809 let values_direction = ops.values(&tangent_direction)?;
2810 let mut alpha = 1.0_f64;
2811 let mut blocking_row = None;
2812 for row in 0..ops.nrows() {
2813 if ops.norms[row] <= 0.0 {
2814 continue;
2815 }
2816 let slack = ops.scaled_slack(&values_x, row);
2817 let rate = values_direction[row] / ops.norms[row];
2818 if let Some(candidate) = active_set_boundary_hit_step_fraction(slack, rate, alpha) {
2819 alpha = candidate;
2820 blocking_row = Some(row);
2821 }
2822 }
2823 if !alpha.is_finite() || alpha <= 0.0 {
2824 return Ok(None);
2825 }
2826 let fallback_step = tangent_direction * alpha;
2827 let new_direction = d_total + &fallback_step;
2828 let new_x = beta + &new_direction;
2831 let new_values = ops.values(&new_x)?;
2832 if ops.max_violation(&new_values).0 > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
2833 return Ok(None);
2834 }
2835 if let Some(row) = blocking_row
2836 && !tangent_active.contains(&row)
2837 {
2838 tangent_active.push(row);
2839 }
2840 tangent_active.retain(|&row| ops.scaled_slack(&new_values, row) <= 1e-10);
2841 Ok(Some((new_direction, tangent_active)))
2842}
2843
2844fn solve_newton_direction_with_constraint_set_impl(
2845 hessian: &Array2<f64>,
2846 gradient: &Array1<f64>,
2847 beta: &Array1<f64>,
2848 ops: &ConstraintSetOps<'_>,
2849 direction_out: &mut Array1<f64>,
2850 mut active_hint: Option<&mut Vec<usize>>,
2851 max_iterations: usize,
2852 allow_projected_gradient_fallback: bool,
2853) -> Result<(), EstimationError> {
2854 let p = gradient.len();
2855 if direction_out.len() != p {
2856 *direction_out = Array1::zeros(p);
2857 }
2858 let m = ops.nrows();
2859 if ops.set.ncols() != p || beta.len() != p {
2860 crate::bail_invalid_estim!(
2861 "constraint-set shape mismatch: set={}x{}, p={}",
2862 m,
2863 ops.set.ncols(),
2864 p
2865 );
2866 }
2867
2868 let tol_active = ACTIVE_SET_WORKING_FACE_TOL;
2869 let tol_step = 1e-12;
2870 let tol_dual = 1e-10;
2871 let mut x = beta.to_owned();
2872 let mut d_total = Array1::<f64>::zeros(p);
2873 let mut g_cur = gradient.to_owned();
2874 let mut values_x = ops.values(&x)?;
2875
2876 if let Some(hint) = active_hint.as_mut() {
2883 hint.retain(|&idx| {
2884 idx < m && ops.norms[idx] > 0.0 && ops.scaled_slack(&values_x, idx) <= tol_active
2885 });
2886 }
2887
2888 let has_active_hint = active_hint
2889 .as_ref()
2890 .map(|hint| !hint.is_empty())
2891 .unwrap_or(false);
2892 if !has_active_hint && solve_newton_direction_dense(hessian, gradient, direction_out).is_ok() {
2893 let candidate = beta + &*direction_out;
2894 let candidate_values = ops.values(&candidate)?;
2895 let feasible = (0..m).all(|row| ops.scaled_slack(&candidate_values, row) >= -tol_active);
2896 if feasible {
2897 return Ok(());
2904 }
2905 }
2906
2907 let mut active: Vec<usize> = Vec::new();
2908 let mut is_active = vec![false; m];
2909 if let Some(hint) = active_hint.as_ref() {
2910 for &idx in hint.iter() {
2911 if idx < m && !is_active[idx] && ops.norms[idx] > 0.0 {
2912 active.push(idx);
2913 is_active[idx] = true;
2914 log_active_set_transition("warm-add", 0, active.len(), Some(idx));
2915 }
2916 }
2917 }
2918 let mut visited_working_sets: HashSet<(Vec<usize>, Vec<u64>)> = HashSet::new();
2933 record_active_working_set(&mut visited_working_sets, &active, &x, 0);
2934
2935 let mut count_blocking_add = 0usize;
2940 let mut count_stationary_add = 0usize;
2941 let mut count_release = 0usize;
2942 let mut ws_repeat_break = false;
2943 let mut iterations_used = 0usize;
2944 let mut face_minimized = false;
2953
2954 for iteration in 0..max_iterations {
2955 iterations_used = iteration + 1;
2956 let adjudicate_face = face_minimized;
2957 face_minimized = false;
2958 let compressed_working = ops.compress_working(&active)?;
2959 let mut residualw = Array1::<f64>::zeros(compressed_working.constraints.a.nrows());
2960 for r in 0..compressed_working.constraints.a.nrows() {
2961 residualw[r] = compressed_working.constraints.b[r]
2962 - compressed_working.constraints.a.row(r).dot(&x);
2963 }
2964 let (d, lambdaw) = solve_kkt_direction(
2965 hessian,
2966 &g_cur,
2967 &compressed_working.constraints.a,
2968 Some(&residualw),
2969 )?;
2970 let step_norm = d.iter().map(|v| v * v).sum::<f64>().sqrt();
2971 if step_norm <= tol_step || adjudicate_face {
2972 let (worst, worst_row) = ops.max_violation(&values_x);
2973 if worst > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL && !is_active[worst_row] {
2974 active.push(worst_row);
2975 is_active[worst_row] = true;
2976 count_stationary_add += 1;
2977 log_active_set_transition(
2978 "stationary-infeasible-add",
2979 iteration,
2980 active.len(),
2981 Some(worst_row),
2982 );
2983 if !record_active_working_set(&mut visited_working_sets, &active, &x, iteration) {
2984 ws_repeat_break = true;
2985 break;
2986 }
2987 continue;
2988 }
2989 if worst > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
2990 let worst_pos = active.iter().position(|&idx| idx == worst_row);
3000 let enforced =
3001 worst_pos.is_some_and(|pos| compressed_working.position_enforced(pos));
3002 if !enforced {
3003 let violated_unit = ops.gather_unit_rows(&[worst_row])?;
3004 if let Some(mut group) = compressed_working
3005 .over_complete_release_group(violated_unit.a.row(0), &active)
3006 {
3007 group.sort_unstable_by(|a, b| b.cmp(a));
3008 let mut released = None;
3009 for active_pos in group {
3010 let idx = active.remove(active_pos);
3011 is_active[idx] = false;
3012 count_release += 1;
3013 released = Some(idx);
3014 }
3015 log_active_set_transition(
3016 "release-over-complete-face",
3017 iteration,
3018 active.len(),
3019 released,
3020 );
3021 if !record_active_working_set(
3022 &mut visited_working_sets,
3023 &active,
3024 &x,
3025 iteration,
3026 ) {
3027 ws_repeat_break = true;
3028 break;
3029 }
3030 continue;
3031 }
3032 }
3033 break;
3034 }
3035 if compressed_working.groups.is_empty() {
3036 direction_out.assign(&d_total);
3037 return Ok(());
3038 }
3039 let remove_group =
3040 compressed_working.negative_representative_group(&lambdaw, tol_dual, &active);
3041 if let Some(mut group) = remove_group {
3042 group.sort_unstable_by(|a, b| b.cmp(a));
3046 let mut released = None;
3047 for active_pos in group {
3048 let idx = active.remove(active_pos);
3049 is_active[idx] = false;
3050 count_release += 1;
3051 released = Some(idx);
3052 }
3053 log_active_set_transition(
3054 "release-negative-representative",
3055 iteration,
3056 active.len(),
3057 released,
3058 );
3059 if !record_active_working_set(&mut visited_working_sets, &active, &x, iteration) {
3060 ws_repeat_break = true;
3061 break;
3062 }
3063 continue;
3064 }
3065 if let Some(hint) = active_hint.as_mut() {
3066 hint.clear();
3067 let compressed = ops.compress_working(&active)?;
3068 for group in &compressed.groups {
3069 if let Some(&active_pos) = group.first() {
3070 hint.push(active[active_pos]);
3071 }
3072 }
3073 }
3074 direction_out.assign(&d_total);
3075 return Ok(());
3076 }
3077
3078 let values_d = ops.values(&d)?;
3079 let mut alpha = 1.0_f64;
3080 let mut blocking_row: Option<usize> = None;
3081 for row in 0..m {
3082 if is_active[row] || ops.norms[row] <= 0.0 {
3083 continue;
3084 }
3085 let slack = ops.scaled_slack(&values_x, row);
3086 let rate = values_d[row] / ops.norms[row];
3087 if let Some(cand) = active_set_boundary_hit_step_fraction(slack, rate, alpha) {
3088 alpha = cand;
3089 blocking_row = Some(row);
3090 }
3091 }
3092
3093 ndarray::Zip::from(&mut d_total)
3094 .and(&d)
3095 .for_each(|dt_i, &d_i| {
3096 *dt_i += alpha * d_i;
3097 });
3098 x = beta + &d_total;
3103 g_cur = gradient + &hessian.dot(&d_total);
3104 values_x = ops.values(&x)?;
3105
3106 let mut added_new_active = false;
3107 let mut working_set_repeated = false;
3108 if let Some(row) = blocking_row {
3109 active.push(row);
3110 is_active[row] = true;
3111 added_new_active = true;
3112 count_blocking_add += 1;
3113 log_active_set_transition("blocking-add", iteration, active.len(), Some(row));
3114 working_set_repeated =
3115 !record_active_working_set(&mut visited_working_sets, &active, &x, iteration);
3116 } else {
3117 face_minimized = true;
3121 }
3122 if working_set_repeated {
3123 ws_repeat_break = true;
3124 break;
3125 }
3126
3127 let primal_step_norm = alpha.abs() * step_norm;
3145 let dependent_blocker = if added_new_active {
3146 let expanded_face = ops.compress_working(&active)?;
3147 expanded_face.constraints.a.nrows() <= compressed_working.constraints.a.nrows()
3148 } else {
3149 false
3150 };
3151 if allow_projected_gradient_fallback
3152 && added_new_active
3153 && (dependent_blocker || primal_step_norm <= tol_step)
3154 {
3155 if let Some((fallback_direction, fallback_active)) =
3156 fallback_projected_gradient_direction_with_constraint_set(
3157 beta, &x, &d_total, &g_cur, &active, ops,
3158 )?
3159 {
3160 if let Some(hint) = active_hint.as_mut() {
3161 hint.clear();
3162 hint.extend(fallback_active);
3163 }
3164 direction_out.assign(&fallback_direction);
3165 return Ok(());
3166 }
3167 }
3168
3169 if active.is_empty() && !added_new_active {
3170 if let Some(hint) = active_hint.as_mut() {
3171 hint.clear();
3172 }
3173 direction_out.assign(&d_total);
3174 return Ok(());
3175 }
3176 }
3177
3178 let compressed_working = ops.compress_working(&active)?;
3181 let mut residualw = Array1::<f64>::zeros(compressed_working.constraints.a.nrows());
3182 for r in 0..compressed_working.constraints.a.nrows() {
3183 residualw[r] =
3184 compressed_working.constraints.b[r] - compressed_working.constraints.a.row(r).dot(&x);
3185 }
3186 let (_, lambdaw) = solve_kkt_direction(
3187 hessian,
3188 &g_cur,
3189 &compressed_working.constraints.a,
3190 Some(&residualw),
3191 )?;
3192 let lambda_true = lambdaw.mapv(|lam_sys| -lam_sys);
3193 let (worst, row) = ops.max_violation(&values_x);
3194 let working_kkt = working_set_kkt_diagnostics_from_multipliers(
3195 &x,
3196 &g_cur,
3197 &compressed_working.constraints,
3198 &lambda_true,
3199 m,
3200 )?;
3201 let grad_inf = gradient_inf_norm(&g_cur);
3202 let stationarity_rel = working_kkt.stationarity / grad_inf.max(1.0);
3203 let step_inf = d_total.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
3204 let hd_total = hessian.dot(&d_total);
3205 let predicted_delta = gradient.dot(&d_total)
3206 + 0.5
3207 * d_total
3208 .iter()
3209 .zip(hd_total.iter())
3210 .map(|(a, b)| a * b)
3211 .sum::<f64>();
3212 let kkt_strong_ok = (working_kkt.stationarity <= ACTIVE_SET_KKT_STATIONARITY_TOL
3213 || stationarity_rel <= ACTIVE_SET_KKT_STATIONARITY_TOL)
3214 && working_kkt.complementarity <= ACTIVE_SET_KKT_COMPLEMENTARITY_TOL;
3215 let model_descent_ok =
3216 predicted_delta <= -ACTIVE_SET_MODEL_DESCENT_REL_TOL * (1.0 + grad_inf * step_inf);
3217 let degenerate_boundary_ok = compressed_working.is_degenerate_face()
3218 && worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
3219 && working_kkt.primal_feasibility <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
3220 && working_kkt.complementarity <= ACTIVE_SET_KKT_COMPLEMENTARITY_TOL
3221 && (working_kkt.stationarity <= ACTIVE_SET_KKT_DEGENERATE_STATIONARITY_TOL
3222 || stationarity_rel <= ACTIVE_SET_KKT_STATIONARITY_TOL);
3223 let strong_path_accepts =
3229 kkt_strong_ok && working_kkt.dual_feasibility <= ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL;
3230 let mut nnls_closure: Option<(f64, usize)> = None;
3231 let nnls_certified = worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL && !strong_path_accepts && {
3232 let tight: Vec<usize> = (0..m)
3233 .filter(|&i| {
3234 ops.norms[i] > 0.0 && (values_x[i] - ops.bounds[i]) / ops.norms[i] <= tol_active
3235 })
3236 .collect();
3237 let tight_len = tight.len();
3238 match ops.set.gather_rows(&tight) {
3239 Ok(gathered) => nonnegative_cone_multipliers(&gathered.a, &g_cur)
3240 .map(|(_, projected)| {
3241 let closure = projected.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
3242 nnls_closure = Some((closure, tight_len));
3243 closure <= ACTIVE_SET_KKT_STATIONARITY_TOL
3244 || closure / grad_inf.max(1.0) <= ACTIVE_SET_KKT_STATIONARITY_TOL
3245 })
3246 .unwrap_or(false),
3247 Err(_) => false,
3248 }
3249 };
3250 if worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
3251 && ((working_kkt.dual_feasibility <= ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL
3252 && (kkt_strong_ok || (allow_projected_gradient_fallback && model_descent_ok)))
3253 || degenerate_boundary_ok
3254 || nnls_certified)
3255 {
3256 if let Some(hint) = active_hint.as_mut() {
3257 hint.clear();
3258 for group in &compressed_working.groups {
3259 if let Some(&active_pos) = group.first() {
3260 hint.push(active[active_pos]);
3261 }
3262 }
3263 }
3264 direction_out.assign(&d_total);
3265 return Ok(());
3266 }
3267 let nnls_diag = match nnls_closure {
3268 Some((closure, tight_len)) => format!(
3269 "nnls_closure={closure:.3e} (tol={ACTIVE_SET_KKT_STATIONARITY_TOL:.1e}) over {tight_len} tight rows"
3270 ),
3271 None => "nnls_closure=not-evaluated".to_string(),
3272 };
3273 let churn_diag = format!(
3274 "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}"
3275 );
3276 if !allow_projected_gradient_fallback {
3277 return Err(EstimationError::ParameterConstraintViolation(format!(
3278 "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}",
3279 working_kkt.primal_feasibility,
3280 working_kkt.dual_feasibility,
3281 working_kkt.complementarity,
3282 working_kkt.stationarity,
3283 working_kkt.n_active,
3284 working_kkt.n_constraints,
3285 )));
3286 }
3287 if let Some((fallback_direction, fallback_active)) =
3288 fallback_projected_gradient_direction_with_constraint_set(
3289 beta, &x, &d_total, &g_cur, &active, ops,
3290 )?
3291 {
3292 if let Some(hint) = active_hint.as_mut() {
3293 hint.clear();
3294 hint.extend(fallback_active);
3295 }
3296 direction_out.assign(&fallback_direction);
3297 return Ok(());
3298 }
3299 Err(EstimationError::ParameterConstraintViolation(format!(
3300 "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",
3301 working_kkt.primal_feasibility,
3302 working_kkt.dual_feasibility,
3303 working_kkt.complementarity,
3304 working_kkt.stationarity,
3305 working_kkt.n_active,
3306 working_kkt.n_constraints,
3307 )))
3308}
3309
3310pub fn project_point_strictly_into_feasible_constraint_set(
3322 point: &Array1<f64>,
3323 set: &ConstraintSet,
3324) -> Result<Array1<f64>, EstimationError> {
3325 match set {
3326 ConstraintSet::Dense(dense) => {
3327 project_point_strictly_into_feasible_cone(point, dense).ok_or_else(|| {
3331 EstimationError::ParameterConstraintViolation(
3332 "dense strict-interior projection could not certify a feasible point"
3333 .to_string(),
3334 )
3335 })
3336 }
3337 _ => {
3338 let repair_guard = FeasibilityRepairGuard::enter().ok_or_else(|| {
3339 EstimationError::ParameterConstraintViolation(format!(
3340 "strict-interior projection exceeded feasibility-repair depth {MAX_FEASIBILITY_REPAIR_DEPTH}"
3341 ))
3342 })?;
3343 let p = point.len();
3344 if set.ncols() != p {
3345 return Err(EstimationError::ParameterConstraintViolation(format!(
3346 "strict-interior projection dimension mismatch: point length {p} != constraint columns {}",
3347 set.ncols()
3348 )));
3349 }
3350 let ops = ConstraintSetOps::new(set, ACTIVE_SET_INTERIOR_SEED_MARGIN)?;
3351 let identity = Array2::<f64>::eye(p);
3352 let mut direction = Array1::<f64>::zeros(p);
3355 let gradient = Array1::<f64>::zeros(p);
3356 let max_iterations = (p + set.nrows() + 8) * 4;
3357 solve_newton_direction_with_constraint_set_impl(
3358 &identity,
3359 &gradient,
3360 point,
3361 &ops,
3362 &mut direction,
3363 None,
3364 max_iterations,
3365 true,
3366 )?;
3367 let beta = point + &direction;
3368 if beta.iter().any(|v| !v.is_finite()) {
3369 return Err(EstimationError::ParameterConstraintViolation(
3370 "strict-interior projection produced a non-finite iterate".to_string(),
3371 ));
3372 }
3373 const SEED_FEASIBILITY_TOL: f64 = 1e-9;
3376 let unshifted = ConstraintSetOps::new(set, 0.0)?;
3377 let values = unshifted.values(&beta)?;
3378 let half_margin = 0.5 * ACTIVE_SET_INTERIOR_SEED_MARGIN - SEED_FEASIBILITY_TOL;
3379 for row in 0..unshifted.nrows() {
3380 if unshifted.norms[row] <= 0.0 {
3381 continue;
3382 }
3383 let slack = unshifted.scaled_slack(&values, row);
3384 if slack < half_margin {
3385 return Err(EstimationError::ParameterConstraintViolation(format!(
3386 "strict-interior projection could not clear the half-margin at row {row}: \
3387 scaled slack {slack:.3e} < {half_margin:.3e}"
3388 )));
3389 }
3390 }
3391 drop(repair_guard);
3392 Ok(beta)
3393 }
3394 }
3395}
3396
3397fn refine_operator_metric_face(
3407 hessian: &Array2<f64>,
3408 unconstrained: &Array1<f64>,
3409 ops: &ConstraintSetOps<'_>,
3410 active: &mut Vec<usize>,
3411 is_active: &mut [bool],
3412 transitions: &mut usize,
3413) -> Result<(Array1<f64>, Array1<f64>), EstimationError> {
3414 let p = unconstrained.len();
3415 loop {
3416 if active.is_empty() {
3417 return Ok((unconstrained.clone(), Array1::zeros(0)));
3418 }
3419 let rows = ops.gather_unit_rows(active)?;
3420 let active_residual = &rows.b - &rows.a.dot(unconstrained);
3421 let zero_gradient = Array1::<f64>::zeros(p);
3422 let (correction, system_multipliers) = solve_kkt_direction(
3423 hessian,
3424 &zero_gradient,
3425 &rows.a,
3426 Some(&active_residual),
3427 )?;
3428 let refined_multipliers = -system_multipliers;
3429 let leaving_position = refined_multipliers
3430 .iter()
3431 .enumerate()
3432 .filter(|(_, value)| {
3433 !value.is_finite() || **value < -ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL
3434 })
3435 .min_by_key(|(position, _)| active[*position])
3436 .map(|(position, _)| position);
3437 let Some(leaving_position) = leaving_position else {
3438 return Ok((unconstrained + &correction, refined_multipliers));
3439 };
3440 let leaving_row = active.remove(leaving_position);
3441 is_active[leaving_row] = false;
3442 *transitions += 1;
3443 }
3444}
3445
3446const ACTIVE_SET_DUAL_DEPENDENCE_TOL: f64 = 1e-11;
3458
3459const ACTIVE_SET_DUAL_CONDITIONING_ROUNDS: usize = 4;
3465
3466fn thin_qr_reorthogonalized(
3474 columns: &[Array1<f64>],
3475 rank_tolerance: f64,
3476) -> Option<(Vec<Array1<f64>>, Array2<f64>)> {
3477 let k = columns.len();
3478 let mut q: Vec<Array1<f64>> = Vec::with_capacity(k);
3479 let mut r = Array2::<f64>::zeros((k, k));
3480 for (column_index, column) in columns.iter().enumerate() {
3481 let scale = column.dot(column).sqrt();
3482 let mut residual = column.clone();
3483 for _ in 0..2 {
3486 for (basis_index, basis) in q.iter().enumerate() {
3487 let projection = residual.dot(basis);
3488 r[[basis_index, column_index]] += projection;
3489 residual.scaled_add(-projection, basis);
3490 }
3491 }
3492 let norm = residual.dot(&residual).sqrt();
3493 if !(norm.is_finite() && scale.is_finite() && norm > rank_tolerance * scale.max(1.0)) {
3494 return None;
3495 }
3496 r[[column_index, column_index]] = norm;
3497 residual /= norm;
3498 q.push(residual);
3499 }
3500 Some((q, r))
3501}
3502
3503fn upper_triangular_back_substitution(r: &Array2<f64>, y: &Array1<f64>) -> Option<Array1<f64>> {
3505 let k = y.len();
3506 if r.nrows() != k || r.ncols() != k {
3507 return None;
3508 }
3509 let mut x = Array1::<f64>::zeros(k);
3510 for row in (0..k).rev() {
3511 let mut sum = y[row];
3512 for column in (row + 1)..k {
3513 sum -= r[[row, column]] * x[column];
3514 }
3515 let pivot = r[[row, row]];
3516 if !(pivot.is_finite() && pivot != 0.0) {
3517 return None;
3518 }
3519 x[row] = sum / pivot;
3520 }
3521 if array_is_finite(&x) { Some(x) } else { None }
3522}
3523
3524struct ViolatedConstraintRow {
3526 row: usize,
3527 violation: f64,
3528}
3529
3530fn scan_operator_violations(
3533 ops: &ConstraintSetOps<'_>,
3534 values: &Array1<f64>,
3535 is_active: &[bool],
3536) -> Result<(f64, usize, Vec<ViolatedConstraintRow>), EstimationError> {
3537 if values.len() != ops.nrows() || is_active.len() != ops.nrows() {
3538 crate::bail_invalid_estim!(
3539 "operator violation scan dimension mismatch: values={}, active_mask={}, rows={}",
3540 values.len(),
3541 is_active.len(),
3542 ops.nrows(),
3543 );
3544 }
3545 let mut worst = 0.0_f64;
3546 let mut worst_row = 0usize;
3547 let mut violated = Vec::<ViolatedConstraintRow>::new();
3548 for row in 0..ops.nrows() {
3549 if ops.norms[row] <= 0.0 {
3550 if ops.bounds[row] > 0.0 {
3553 return Err(EstimationError::ParameterConstraintViolation(format!(
3554 "operator metric projection has an infeasible zero-norm constraint row {row} \
3555 with bound {:.3e}",
3556 ops.bounds[row]
3557 )));
3558 }
3559 continue;
3560 }
3561 let violation = (-ops.scaled_slack(values, row)).max(0.0);
3562 if violation > worst {
3563 worst = violation;
3564 worst_row = row;
3565 }
3566 if violation > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL && !is_active[row] {
3567 violated.push(ViolatedConstraintRow { row, violation });
3568 }
3569 }
3570 Ok((worst, worst_row, violated))
3571}
3572
3573fn solve_operator_metric_projection_dual_active_set(
3624 hessian: &Array2<f64>,
3625 rhs: &Array1<f64>,
3626 unconstrained: &Array1<f64>,
3627 factor: &gam_linalg::faer_ndarray::FaerCholeskyFactor,
3628 ops: &ConstraintSetOps<'_>,
3629 warm_rows: &[usize],
3630) -> Result<(Array1<f64>, Vec<usize>), EstimationError> {
3631 use gam_linalg::triangular::{
3632 back_substitution_lower_transpose, forward_substitution_lower_vector,
3633 };
3634
3635 let p = unconstrained.len();
3636 let m = ops.nrows();
3637 let lower = factor.lower_triangular();
3638 let face_rank_tolerance = 100.0 * f64::EPSILON * (p.max(1) as f64);
3639
3640 let mut beta = unconstrained.clone();
3641 let mut active = Vec::<usize>::new();
3642 let mut is_active = vec![false; m];
3643 let mut whitened_active = Vec::<Array1<f64>>::new();
3645 let mut multipliers = Vec::<f64>::new();
3646 let mut queue = std::collections::VecDeque::<usize>::new();
3647 for &row in warm_rows {
3648 if row < m && ops.norms[row] > 0.0 && !queue.contains(&row) {
3649 queue.push_back(row);
3650 }
3651 }
3652
3653 let max_transitions = 8usize
3659 .saturating_mul(p.saturating_add(2))
3660 .saturating_mul(p.saturating_add(2))
3661 .saturating_add(64);
3662 let max_refills = 4usize.saturating_mul(p).saturating_add(32);
3663 let mut transitions = 0usize;
3664 let mut refills = 0usize;
3665 let mut conditioning_rounds = 0usize;
3666 let mut refine_transitions = 0usize;
3667
3668 let (candidate, refined_multipliers) = loop {
3669 'dual: loop {
3670 let Some(entering) = queue.pop_front() else {
3671 let values = ops.values(&beta)?;
3672 let (_, _, violated) = scan_operator_violations(ops, &values, &is_active)?;
3673 if violated.is_empty() {
3674 break 'dual;
3675 }
3676 refills += 1;
3677 if refills > max_refills {
3678 return Err(EstimationError::ParameterConstraintViolation(format!(
3679 "operator metric projection exceeded {max_refills} separator scans with \
3680 {} rows still violated (worst {:.3e}); the dual iteration is not closing",
3681 violated.len(),
3682 violated
3683 .iter()
3684 .map(|entry| entry.violation)
3685 .fold(0.0_f64, f64::max),
3686 )));
3687 }
3688 let no_bans = vec![false; m];
3694 let batch = independent_violated_operator_rows(
3695 ops,
3696 &values,
3697 &active,
3698 &is_active,
3699 &no_bans,
3700 p.saturating_sub(active.len()),
3701 )?;
3702 if batch.is_empty() {
3703 let mut ordered = violated;
3704 ordered.sort_unstable_by(|left, right| {
3705 right
3706 .violation
3707 .total_cmp(&left.violation)
3708 .then_with(|| left.row.cmp(&right.row))
3709 });
3710 queue.extend(
3711 ordered
3712 .iter()
3713 .take(p.saturating_add(8))
3714 .map(|entry| entry.row),
3715 );
3716 } else {
3717 queue.extend(batch);
3718 }
3719 continue 'dual;
3720 };
3721 if is_active[entering] || ops.norms[entering] <= 0.0 {
3722 continue 'dual;
3723 }
3724 let entering_rows = ops.gather_unit_rows(&[entering])?;
3725 let normal = entering_rows.a.row(0).to_owned();
3726 let bound = entering_rows.b[0];
3727 let whitened_normal = forward_substitution_lower_vector(lower.view(), normal.view());
3728 let whitened_scale = whitened_normal.dot(&whitened_normal).sqrt();
3729 if !(array_is_finite(&whitened_normal) && whitened_scale > 0.0) {
3730 crate::bail_invalid_estim!(
3731 "operator metric projection whitened entering row {entering} to a degenerate \
3732 normal (scale {whitened_scale:.3e})"
3733 );
3734 }
3735
3736 loop {
3739 let violation = bound - normal.dot(&beta);
3740 if violation <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
3741 continue 'dual;
3744 }
3745 let (dual_direction, tangent) = if active.is_empty() {
3746 (Array1::<f64>::zeros(0), whitened_normal.clone())
3747 } else {
3748 let Some((q, r)) =
3749 thin_qr_reorthogonalized(&whitened_active, face_rank_tolerance)
3750 else {
3751 crate::bail_invalid_estim!(
3752 "operator metric projection lost independence of its {} active normals",
3753 active.len()
3754 );
3755 };
3756 let projections =
3757 Array1::from_iter(q.iter().map(|basis| basis.dot(&whitened_normal)));
3758 let Some(dual_direction) =
3759 upper_triangular_back_substitution(&r, &projections)
3760 else {
3761 crate::bail_invalid_estim!(
3762 "operator metric projection could not solve its {}-row dual direction",
3763 active.len()
3764 );
3765 };
3766 let mut tangent = whitened_normal.clone();
3767 for (basis, projection) in q.iter().zip(projections.iter()) {
3768 tangent.scaled_add(-projection, basis);
3769 }
3770 (dual_direction, tangent)
3771 };
3772
3773 let rate = tangent.dot(&tangent);
3776 let dependence_floor = ACTIVE_SET_DUAL_DEPENDENCE_TOL * whitened_scale;
3777 let full_step = if rate > dependence_floor * dependence_floor {
3778 violation / rate
3779 } else {
3780 f64::INFINITY
3781 };
3782
3783 let mut partial_step = f64::INFINITY;
3786 let mut blocking: Option<usize> = None;
3787 for (position, &direction) in dual_direction.iter().enumerate() {
3788 if !(direction > 0.0) {
3789 continue;
3790 }
3791 let ratio = (multipliers[position] / direction).max(0.0);
3792 let replaces = match blocking {
3793 None => true,
3794 Some(current) => {
3795 ratio < partial_step
3796 || (ratio == partial_step && active[position] < active[current])
3797 }
3798 };
3799 if replaces {
3800 partial_step = ratio;
3801 blocking = Some(position);
3802 }
3803 }
3804
3805 if !full_step.is_finite() && blocking.is_none() {
3806 return Err(EstimationError::ParameterConstraintViolation(format!(
3807 "operator metric projection proved its constraint set infeasible: row \
3808 {entering} is violated by {violation:.3e} and lies in the span of the \
3809 {} active normals with no releasable multiplier",
3810 active.len(),
3811 )));
3812 }
3813 let step = full_step.min(partial_step);
3814 if !step.is_finite() {
3815 crate::bail_invalid_estim!(
3816 "operator metric projection produced a non-finite dual step for row \
3817 {entering}"
3818 );
3819 }
3820 if step > 0.0 {
3821 let primal_direction =
3822 back_substitution_lower_transpose(lower.view(), tangent.view());
3823 beta.scaled_add(step, &primal_direction);
3824 if !array_is_finite(&beta) {
3825 crate::bail_invalid_estim!(
3826 "operator metric projection iterate left the finite range"
3827 );
3828 }
3829 for (multiplier, direction) in
3830 multipliers.iter_mut().zip(dual_direction.iter())
3831 {
3832 *multiplier = (*multiplier - step * direction).max(0.0);
3833 }
3834 }
3835
3836 transitions += 1;
3837 if transitions > max_transitions {
3838 return Err(EstimationError::ParameterConstraintViolation(format!(
3839 "operator metric projection exceeded {max_transitions} dual transitions \
3840 with {} active rows; a strictly increasing dual objective cannot revisit \
3841 a face, so this is a floating-point breakdown",
3842 active.len(),
3843 )));
3844 }
3845
3846 if full_step <= partial_step {
3847 active.push(entering);
3848 is_active[entering] = true;
3849 whitened_active.push(whitened_normal);
3850 multipliers.push(step);
3851 continue 'dual;
3852 }
3853 let leaving = blocking.expect("a finite partial step names a blocking row");
3854 let leaving_row = active.remove(leaving);
3855 whitened_active.remove(leaving);
3856 multipliers.remove(leaving);
3857 is_active[leaving_row] = false;
3858 }
3859 }
3860
3861 let refined = refine_operator_metric_face(
3870 hessian,
3871 unconstrained,
3872 ops,
3873 &mut active,
3874 &mut is_active,
3875 &mut refine_transitions,
3876 )?;
3877 let values = ops.values(&refined.0)?;
3878 let (worst, worst_row, violated) = scan_operator_violations(ops, &values, &is_active)?;
3879 if worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
3880 break refined;
3881 }
3882 conditioning_rounds += 1;
3883 if conditioning_rounds > ACTIVE_SET_DUAL_CONDITIONING_ROUNDS {
3884 let worst_membership = if is_active[worst_row] {
3892 "ACTIVE"
3893 } else {
3894 "inactive"
3895 };
3896 return Err(EstimationError::ParameterConstraintViolation(format!(
3897 "operator metric projection could not condition its terminal face: scaled \
3898 violation {worst:.3e} at row {worst_row} ({worst_membership}) survives \
3899 {ACTIVE_SET_DUAL_CONDITIONING_ROUNDS} conditioned re-solves over {} active rows",
3900 active.len(),
3901 )));
3902 }
3903 beta = refined.0;
3907 multipliers = refined.1.to_vec();
3908 whitened_active.clear();
3909 if !active.is_empty() {
3910 let face_rows = ops.gather_unit_rows(&active)?;
3911 for position in 0..active.len() {
3912 whitened_active.push(forward_substitution_lower_vector(
3913 lower.view(),
3914 face_rows.a.row(position),
3915 ));
3916 }
3917 }
3918 queue.clear();
3919 queue.extend(violated.iter().map(|entry| entry.row));
3920 };
3921
3922 let active_ids = active.clone();
3923 let gradient = hessian.dot(&candidate) - rhs;
3924 let (stationarity, complementarity, dual_violation) = if active_ids.is_empty() {
3925 (gradient_inf_norm(&gradient), 0.0, 0.0)
3926 } else {
3927 let rows = ops.gather_unit_rows(&active_ids)?;
3928 let residual = &gradient - &rows.a.t().dot(&refined_multipliers);
3929 let complementarity = refined_multipliers
3930 .iter()
3931 .enumerate()
3932 .map(|(position, multiplier)| {
3933 let slack = rows.a.row(position).dot(&candidate) - rows.b[position];
3934 (multiplier * slack).abs()
3935 })
3936 .fold(0.0_f64, f64::max);
3937 let dual_violation = refined_multipliers
3938 .iter()
3939 .map(|multiplier| (-multiplier).max(0.0))
3940 .fold(0.0_f64, f64::max);
3941 (
3942 gradient_inf_norm(&residual),
3943 complementarity,
3944 dual_violation,
3945 )
3946 };
3947 let gradient_scale = gradient_inf_norm(&gradient).max(1.0);
3948 if stationarity > ACTIVE_SET_KKT_STATIONARITY_TOL
3949 && stationarity / gradient_scale > ACTIVE_SET_KKT_STATIONARITY_TOL
3950 {
3951 return Err(EstimationError::ParameterConstraintViolation(format!(
3952 "operator metric projection failed stationarity certification: \
3953 residual={stationarity:.3e}, relative={:.3e}, active={}, transitions={transitions}",
3954 stationarity / gradient_scale,
3955 active_ids.len(),
3956 )));
3957 }
3958 if dual_violation > ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL
3959 || complementarity > ACTIVE_SET_KKT_COMPLEMENTARITY_TOL
3960 {
3961 return Err(EstimationError::ParameterConstraintViolation(format!(
3962 "operator metric projection failed dual/complementarity certification: \
3963 dual={dual_violation:.3e}, complementarity={complementarity:.3e}, active={}",
3964 active_ids.len(),
3965 )));
3966 }
3967 Ok((candidate, active_ids))
3968}
3969
3970fn solve_strictly_convex_quadratic_with_constraint_set_dual(
3990 hessian: &Array2<f64>,
3991 rhs: &Array1<f64>,
3992 beta_start: &Array1<f64>,
3993 set: &ConstraintSet,
3994 warm_active_set: Option<&[usize]>,
3995) -> Result<(Array1<f64>, Vec<usize>), EstimationError> {
3996 let p = rhs.len();
3997 if p == 0
3998 || hessian.nrows() != p
3999 || hessian.ncols() != p
4000 || beta_start.len() != p
4001 || set.ncols() != p
4002 || hessian.iter().any(|value| !value.is_finite())
4003 || rhs.iter().any(|value| !value.is_finite())
4004 || beta_start.iter().any(|value| !value.is_finite())
4005 {
4006 crate::bail_invalid_estim!("operator metric-projection dimension/finite contract failed");
4007 }
4008 let factor = hessian.cholesky(Side::Lower).map_err(|error| {
4009 EstimationError::InvalidInput(format!(
4010 "operator metric projection requires a strictly positive-definite Hessian: {error}"
4011 ))
4012 })?;
4013 let unconstrained = factor.solvevec(rhs);
4014 if !array_is_finite(&unconstrained) {
4015 crate::bail_invalid_estim!("operator metric-projection free solve is non-finite");
4016 }
4017
4018 let ops = ConstraintSetOps::new(set, 0.0)?;
4019 let warm_tight =
4025 constraint_set_rows_tight_at_point(set, beta_start, warm_active_set.unwrap_or(&[]))?;
4026 solve_operator_metric_projection_dual_active_set(
4027 hessian,
4028 rhs,
4029 &unconstrained,
4030 &factor,
4031 &ops,
4032 &warm_tight,
4033 )
4034}
4035
4036pub fn solve_quadratic_with_constraint_set(
4037 hessian: &Array2<f64>,
4038 rhs: &Array1<f64>,
4039 beta_start: &Array1<f64>,
4040 set: &ConstraintSet,
4041 warm_active_set: Option<&[usize]>,
4042) -> Result<(Array1<f64>, Vec<usize>), EstimationError> {
4043 match set {
4044 ConstraintSet::Dense(dense) => solve_quadratic_with_linear_constraints(
4045 hessian,
4046 rhs,
4047 beta_start,
4048 dense,
4049 warm_active_set,
4050 ),
4051 _ => {
4052 if hessian.ncols() != hessian.nrows()
4053 || rhs.len() != hessian.nrows()
4054 || beta_start.len() != hessian.nrows()
4055 || set.ncols() != hessian.nrows()
4056 {
4057 crate::bail_invalid_estim!(
4058 "operator-constrained quadratic solve: system dimension mismatch"
4059 );
4060 }
4061 solve_strictly_convex_quadratic_with_constraint_set_dual(
4062 hessian,
4063 rhs,
4064 beta_start,
4065 set,
4066 warm_active_set,
4067 )
4068 }
4069 }
4070}
4071
4072pub(crate) fn solve_newton_direction_with_linear_constraints(
4073 hessian: &Array2<f64>,
4074 gradient: &Array1<f64>,
4075 beta: &Array1<f64>,
4076 constraints: &LinearInequalityConstraints,
4077 direction_out: &mut Array1<f64>,
4078 active_hint: Option<&mut Vec<usize>>,
4079) -> Result<(), EstimationError> {
4080 if hessian.nrows() != hessian.ncols()
4081 || gradient.len() != hessian.nrows()
4082 || beta.len() != hessian.nrows()
4083 || constraints.a.ncols() != hessian.nrows()
4084 {
4085 crate::bail_invalid_estim!("linear-constrained Newton system dimension mismatch");
4086 }
4087 let rhs = hessian.dot(beta) - gradient;
4093 let warm_active = active_hint.as_ref().map(|hint| hint.as_slice());
4094 let (candidate, active) = solve_quadratic_with_linear_constraints(
4095 hessian,
4096 &rhs,
4097 beta,
4098 constraints,
4099 warm_active,
4100 )?;
4101 if direction_out.len() != beta.len() {
4102 *direction_out = Array1::zeros(beta.len());
4103 }
4104 direction_out.assign(&(&candidate - beta));
4105 if let Some(hint) = active_hint {
4106 hint.clear();
4107 hint.extend(active);
4108 }
4109 Ok(())
4110}
4111
4112pub fn solve_quadratic_with_linear_constraints(
4113 hessian: &Array2<f64>,
4114 rhs: &Array1<f64>,
4115 beta_start: &Array1<f64>,
4116 constraints: &LinearInequalityConstraints,
4117 warm_active_set: Option<&[usize]>,
4118) -> Result<(Array1<f64>, Vec<usize>), EstimationError> {
4119 if hessian.ncols() != hessian.nrows()
4120 || rhs.len() != hessian.nrows()
4121 || beta_start.len() != hessian.nrows()
4122 || constraints.a.ncols() != hessian.nrows()
4123 {
4124 crate::bail_invalid_estim!("constrained quadratic solve: system dimension mismatch");
4125 }
4126 let constraints = constraints.canonicalized().map_err(|e| {
4132 EstimationError::ParameterConstraintViolation(format!(
4133 "constrained quadratic solve: invalid constraint system: {e}"
4134 ))
4135 })?;
4136 let set = ConstraintSet::Dense(constraints);
4145 solve_strictly_convex_quadratic_with_constraint_set_dual(
4146 hessian,
4147 rhs,
4148 beta_start,
4149 &set,
4150 warm_active_set,
4151 )
4152}
4153
4154#[cfg(test)]
4155mod tests {
4156 use super::{
4157 ACTIVE_SET_INTERIOR_SEED_MARGIN, ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL,
4158 ACTIVE_SET_PRIMAL_FEASIBILITY_TOL, ConstraintRowId, ConstraintSet, ConstraintSetOps,
4159 ConstraintSetReducedFace, LinearInequalityConstraints, active_set_boundary_hit_step_fraction,
4160 array_is_finite, certify_active_equalities, compute_constraint_kkt_diagnostics,
4161 constraint_set_rows_tight_at_point,
4162 fallback_projected_gradient_direction_with_constraint_set, independent_violated_operator_rows,
4163 khatri_rao_cone_reduced_face, least_squares_min_norm_any_shape,
4164 nonnegative_cone_multipliers,
4165 project_point_strictly_into_feasible_cone,
4166 project_point_strictly_into_feasible_constraint_set,
4167 project_stationarity_residual_on_constraint_cone,
4168 project_stationarity_residual_on_constraint_set,
4169 rank_reduce_rows_pivoted_qr_with_dependence, record_active_working_set,
4170 scaled_constraint_slack, solve_kkt_direction,
4171 solve_newton_direction_with_linear_constraints, solve_quadratic_with_constraint_set,
4172 solve_quadratic_with_linear_constraints,
4173 working_set_kkt_diagnostics_from_multipliers,
4174 };
4175 use crate::estimate::EstimationError;
4176 use approx::assert_relative_eq;
4177 use gam_problem::KhatriRaoConeConstraints;
4178 use ndarray::{Array1, Array2, array, s};
4179
4180 fn gather_linear_constraint_rows(
4181 constraints: &LinearInequalityConstraints,
4182 rows: &[usize],
4183 ) -> Result<LinearInequalityConstraints, EstimationError> {
4184 let p = constraints.a.ncols();
4185 let mut a = Array2::<f64>::zeros((rows.len(), p));
4186 let mut b = Array1::<f64>::zeros(rows.len());
4187 for (out, &row) in rows.iter().enumerate() {
4188 if row >= constraints.a.nrows() {
4189 crate::bail_invalid_estim!(
4190 "active constraint row {} out of bounds for {} rows",
4191 row,
4192 constraints.a.nrows()
4193 );
4194 }
4195 a.row_mut(out).assign(&constraints.a.row(row));
4196 b[out] = constraints.b[row];
4197 }
4198 LinearInequalityConstraints::new(a, b)
4199 .map_err(|error| EstimationError::ParameterConstraintViolation(error.to_string()))
4200 }
4201
4202 fn moreau_projection_via_strict_qp(
4203 residual: &Array1<f64>,
4204 active_a: &Array2<f64>,
4205 ) -> Option<(Array1<f64>, Array1<f64>)> {
4206 let p = residual.len();
4207 let m = active_a.nrows();
4208 let constraints =
4209 LinearInequalityConstraints::new(active_a.clone(), Array1::<f64>::zeros(m))
4210 .ok()?
4211 .canonicalized()
4212 .ok()?;
4213
4214 let identity = Array2::<f64>::eye(p);
4217 let origin = Array1::<f64>::zeros(p);
4218 let rhs = -residual;
4219 let (tangent_direction, tangent_active) = solve_quadratic_with_linear_constraints(
4220 &identity,
4221 &rhs,
4222 &origin,
4223 &constraints,
4224 None,
4225 )
4226 .ok()?;
4227 if !array_is_finite(&tangent_direction) {
4228 return None;
4229 }
4230 let projected = -&tangent_direction;
4231
4232 let mut lambda_canonical = Array1::<f64>::zeros(m);
4233 if !tangent_active.is_empty() {
4234 let gathered = gather_linear_constraint_rows(&constraints, &tangent_active).ok()?;
4235 let design = gathered.a.t().to_owned();
4236 let solved =
4237 least_squares_min_norm_any_shape(&design, &(residual + &tangent_direction))?;
4238 let scale = residual
4239 .iter()
4240 .fold(0.0_f64, |acc, &value| acc.max(value.abs()))
4241 .max(1.0);
4242 let tol = 100.0 * f64::EPSILON * (p.max(m) as f64) * scale;
4243 for (position, &row) in tangent_active.iter().enumerate() {
4244 let value = solved[position];
4245 if !value.is_finite() || value < -tol {
4246 return None;
4247 }
4248 lambda_canonical[row] = value.max(0.0);
4249 }
4250 }
4251 let reconstructed = residual - &constraints.a.t().dot(&lambda_canonical);
4252 let reconstruction_error = reconstructed
4253 .iter()
4254 .zip(projected.iter())
4255 .fold(0.0_f64, |acc, (&left, &right)| {
4256 acc.max((left - right).abs())
4257 });
4258 let scale = residual
4259 .iter()
4260 .fold(0.0_f64, |acc, &value| acc.max(value.abs()))
4261 .max(1.0);
4262 if reconstruction_error > 1e-8 * scale || !array_is_finite(&lambda_canonical) {
4263 return None;
4264 }
4265
4266 let mut lambda = Array1::<f64>::zeros(m);
4267 for row in 0..m {
4268 let norm = active_a.row(row).dot(&active_a.row(row)).sqrt();
4269 if norm > 0.0 {
4270 lambda[row] = lambda_canonical[row] / norm;
4271 }
4272 }
4273 Some((projected, lambda))
4274 }
4275
4276 #[test]
4277 fn working_set_cycle_detection_requires_the_same_primal_point() {
4278 let mut visited = std::collections::HashSet::new();
4279 let x0 = array![0.0_f64, 1.0];
4280 let x1 = array![0.5_f64, 1.0];
4281
4282 assert!(record_active_working_set(&mut visited, &[3, 1], &x0, 0));
4283 assert!(record_active_working_set(&mut visited, &[1, 3], &x1, 1));
4284 assert!(!record_active_working_set(&mut visited, &[3, 1], &x1, 2));
4285 }
4286
4287 #[test]
4288 fn boundary_ratio_lands_on_the_exact_boundary_and_blocks_at_it() {
4289 let alpha = active_set_boundary_hit_step_fraction(0.1, -1.0, 1.0)
4293 .expect("a strictly feasible row moving toward its boundary must clip");
4294 assert_relative_eq!(alpha, 0.1, epsilon = 0.0);
4295 assert_relative_eq!(0.1 + alpha * -1.0, 0.0, epsilon = 0.0);
4296
4297 let blocked = active_set_boundary_hit_step_fraction(-2.5e-15, -1.0, 1.0)
4303 .expect("an at-boundary outward-moving row must block");
4304 assert_eq!(blocked, 0.0);
4305 }
4306
4307 #[test]
4308 fn active_equality_certificate_rejects_public_tolerance_band_drift() {
4309 let active_a = array![[1.0, 0.0]];
4314 let rhs = array![0.0];
4315 let direction = array![8.604942e-9, 0.0];
4316 let certificate = certify_active_equalities(&active_a, &rhs, &direction);
4317 assert!(
4318 !certificate.is_certified(),
4319 "a tolerance-band endpoint is not a roundoff-resolved active equality"
4320 );
4321 assert_eq!(certificate.worst_row, 0);
4322 assert_relative_eq!(certificate.residual, 8.604942e-9, epsilon = 0.0);
4323 assert!(certificate.residual > 1.0e6 * certificate.allowed);
4324 }
4325
4326 #[test]
4327 fn active_equality_certificate_uses_the_solve_scale_not_the_collapsed_row_scale() {
4328 let active_a = array![[0.0, 0.0, 1.0, 0.0], [1.0, 0.0, 0.0, 0.0]];
4340 let rhs = array![0.0, 0.5];
4341 let collapsed = array![0.5, 0.3, 1.0e-33, 0.0];
4342 let certificate = certify_active_equalities(&active_a, &rhs, &collapsed);
4343 assert!(
4344 certificate.is_certified(),
4345 "an equality residual {:.3e} that is 1e-33 of the solve scale is \
4346 roundoff-resolved, not a face defect (allowed {:.3e})",
4347 certificate.residual,
4348 certificate.allowed
4349 );
4350
4351 let drifted = array![0.5, 0.3, 1.0e-9, 0.0];
4355 let certificate = certify_active_equalities(&active_a, &rhs, &drifted);
4356 assert!(
4357 !certificate.is_certified(),
4358 "a 1e-9 equality drift against an O(1) solve scale is a real defect"
4359 );
4360 assert_eq!(certificate.worst_row, 0);
4361 }
4362
4363 #[test]
4364 fn stiff_null_space_solve_returns_roundoff_resolved_active_equality() {
4365 let hessian = array![[1.0e16, 1.0e8], [1.0e8, 2.0]];
4370 let gradient = array![1.0e8, -3.0];
4371 let active_a = array![[0.6, 0.8]];
4372 let active_residual = array![1.0e-4];
4373 let (direction, multiplier) =
4374 solve_kkt_direction(&hessian, &gradient, &active_a, Some(&active_residual))
4375 .expect("stiff null-space constrained solve");
4376
4377 let certificate =
4378 certify_active_equalities(&active_a, &active_residual, &direction);
4379 assert!(
4380 certificate.is_certified(),
4381 "active equality residual {:.3e} exceeds its roundoff bound {:.3e}",
4382 certificate.residual,
4383 certificate.allowed,
4384 );
4385 assert!(multiplier.iter().all(|value| value.is_finite()));
4386 }
4387
4388 #[test]
4389 fn dependent_active_equalities_share_one_null_space() {
4390 let hessian = array![
4394 [1.0e12, 0.0, 0.0],
4395 [0.0, 3.0, 0.5],
4396 [0.0, 0.5, 2.0],
4397 ];
4398 let gradient = array![2.0e5, -4.0, 1.0];
4399 let active_a = array![[1.0, 2.0, 0.0], [2.0, 4.0, 0.0]];
4400 let active_residual = array![1.0e-4, 2.0e-4];
4401 let (direction, multiplier) =
4402 solve_kkt_direction(&hessian, &gradient, &active_a, Some(&active_residual))
4403 .expect("rank-deficient active face must have one certified null space");
4404
4405 let residual = &active_a.dot(&direction) - &active_residual;
4406 assert!(
4407 residual.iter().all(|value| value.abs() <= 1.0e-14),
4408 "dependent active equations were not resolved: {residual:?}"
4409 );
4410 assert!(multiplier.iter().all(|value| value.is_finite()));
4411 }
4412
4413 #[test]
4414 fn warm_face_rows_are_point_local_for_dense_and_operator_constraints() {
4415 let hessian = array![[1.0_f64]];
4422 let rhs = array![2.0_f64];
4423 let interior = array![1.0_f64];
4424 let dense = LinearInequalityConstraints::new(array![[1.0]], array![0.0])
4425 .expect("one-dimensional half-line");
4426 let (dense_solution, dense_active) =
4427 solve_quadratic_with_linear_constraints(&hessian, &rhs, &interior, &dense, Some(&[0]))
4428 .expect("dense stale-face solve");
4429 assert_relative_eq!(dense_solution[0], 2.0, epsilon = 1e-12);
4430 assert!(dense_active.is_empty());
4431
4432 let factor = std::sync::Arc::new(array![[1.0_f64]]);
4433 let cone = KhatriRaoConeConstraints::new(factor, vec![0], 1)
4434 .expect("one-dimensional factored half-line");
4435 let operator = ConstraintSet::KhatriRaoCone(cone);
4436 let stale_terminal_face = constraint_set_rows_tight_at_point(&operator, &interior, &[0])
4437 .expect("terminal face classification");
4438 assert!(stale_terminal_face.is_empty());
4439 let (operator_solution, operator_active) =
4440 solve_quadratic_with_constraint_set(&hessian, &rhs, &interior, &operator, Some(&[0]))
4441 .expect("operator stale-face solve");
4442 assert_relative_eq!(operator_solution[0], 2.0, epsilon = 1e-12);
4443 assert!(operator_active.is_empty());
4444 }
4445
4446 #[test]
4455 fn strict_interior_projection_lifts_vertex_seed_off_every_constraint_row() {
4456 let p = 5usize;
4459 let rows = p - 2;
4460 let mut a = Array2::<f64>::zeros((rows, p));
4461 for i in 0..rows {
4462 a[[i, i]] = -1.0;
4463 a[[i, i + 1]] = 2.0;
4464 a[[i, i + 2]] = -1.0;
4465 }
4466 let constraints = LinearInequalityConstraints::new(a, Array1::zeros(rows))
4467 .expect("test constraint shape invariant");
4468
4469 let vertex = Array1::<f64>::zeros(p);
4470 for i in 0..rows {
4472 assert!(
4473 scaled_constraint_slack(&vertex, &constraints, i).abs() < 1e-12,
4474 "vertex seed should sit exactly on row {i}"
4475 );
4476 }
4477
4478 let interior = project_point_strictly_into_feasible_cone(&vertex, &constraints)
4479 .expect("strict-interior projection of the vertex must succeed");
4480 let min_slack = (0..rows)
4481 .map(|i| scaled_constraint_slack(&interior, &constraints, i))
4482 .fold(f64::INFINITY, f64::min);
4483 assert!(
4484 min_slack >= 0.5 * ACTIVE_SET_INTERIOR_SEED_MARGIN,
4485 "projected seed must be strictly interior on every row; min scaled slack = {min_slack:.3e}"
4486 );
4487 }
4488
4489 #[test]
4499 fn strict_interior_projection_keeps_equality_pairs_tight_with_shape_bounds() {
4500 let p = 5usize;
4501 let m = 3 + 2;
4504 let mut a = Array2::<f64>::zeros((m, p));
4505 a[[0, 2]] = 1.0;
4506 a[[1, 3]] = 1.0;
4507 a[[2, 4]] = 1.0;
4508 a[[3, 0]] = 1.0;
4509 a[[4, 0]] = -1.0;
4510 let constraints = LinearInequalityConstraints::new(a, Array1::zeros(m))
4511 .expect("test constraint shape invariant");
4512
4513 let point = Array1::from_vec(vec![0.7, -0.2, -0.5, -0.3, -0.1]);
4516 let seed = project_point_strictly_into_feasible_cone(&point, &constraints).expect(
4517 "strict-interior projection must succeed when an equality pair is present, \
4518 not collapse to the empty set and fall back to the vertex",
4519 );
4520
4521 for i in 0..3 {
4523 assert!(
4524 scaled_constraint_slack(&seed, &constraints, i)
4525 >= 0.4 * ACTIVE_SET_INTERIOR_SEED_MARGIN,
4526 "shape row {i} not strictly interior: scaled slack = {:.3e}",
4527 scaled_constraint_slack(&seed, &constraints, i)
4528 );
4529 }
4530 assert!(
4533 seed[0].abs() <= 1e-6,
4534 "boundary equality must be enforced, got β_0 = {:.3e}",
4535 seed[0]
4536 );
4537 }
4538
4539 #[test]
4543 fn strict_interior_projection_preserves_a_curvature_carrying_seed() {
4544 let p = 5usize;
4545 let rows = p - 2;
4546 let mut a = Array2::<f64>::zeros((rows, p));
4547 for i in 0..rows {
4548 a[[i, i]] = -1.0;
4549 a[[i, i + 1]] = 2.0;
4550 a[[i, i + 2]] = -1.0;
4551 }
4552 let constraints = LinearInequalityConstraints::new(a, Array1::zeros(rows))
4553 .expect("test constraint shape invariant");
4554 let seed = Array1::from_iter((0..p).map(|j| -((j as f64 - 2.0).powi(2))));
4558 let projected = project_point_strictly_into_feasible_cone(&seed, &constraints)
4559 .expect("already-interior seed must project");
4560 let max_move = seed
4561 .iter()
4562 .zip(projected.iter())
4563 .map(|(a, b)| (a - b).abs())
4564 .fold(0.0_f64, f64::max);
4565 assert!(
4566 max_move < 1e-3,
4567 "strictly-interior curvature-carrying seed should be preserved; max move = {max_move:.3e}"
4568 );
4569 }
4570
4571 #[test]
4572 fn dense_dual_newton_returns_the_exact_boundary_solution() {
4573 let hessian = array![[1.0]];
4574 let gradient = array![-1.0];
4575 let beta = array![0.0];
4576 let constraints = LinearInequalityConstraints {
4577 a: array![[-1.0]],
4578 b: array![-0.1],
4579 };
4580 let mut direction = Array1::zeros(1);
4581 let mut active_hint = Vec::new();
4582
4583 solve_newton_direction_with_linear_constraints(
4584 &hessian,
4585 &gradient,
4586 &beta,
4587 &constraints,
4588 &mut direction,
4589 Some(&mut active_hint),
4590 )
4591 .expect("finite dual solve should return the unique boundary solution");
4592
4593 assert_relative_eq!(direction[0], 0.1, epsilon = 1e-12);
4594 assert_eq!(active_hint, vec![0]);
4595 }
4596
4597 #[test]
4598 fn dense_dual_releases_a_boundary_with_negative_multiplier() {
4599 let hessian = array![[1.0_f64]];
4604 let beta = array![0.0_f64];
4605 let gradient = array![-1.0_f64];
4606 let constraints =
4607 LinearInequalityConstraints::new(array![[1.0]], array![0.0]).expect("one-sided bound");
4608 let mut direction = Array1::<f64>::zeros(1);
4609 let mut active = vec![0];
4610 solve_newton_direction_with_linear_constraints(
4611 &hessian,
4612 &gradient,
4613 &beta,
4614 &constraints,
4615 &mut direction,
4616 Some(&mut active),
4617 )
4618 .expect("negative-multiplier face must be released");
4619
4620 assert_relative_eq!(direction[0], 1.0, epsilon = 1e-12);
4621 assert!(gradient.dot(&direction) < 0.0);
4622 assert!(active.is_empty(), "descent moves strictly into the cone");
4623 }
4624
4625 #[test]
4626 fn rank_reduce_zero_rows_returns_empty_working_set() {
4627 let a = array![[0.0, 0.0], [0.0, 0.0],];
4628 let b = array![0.0, 0.0];
4629 let groups = vec![vec![0], vec![1]];
4630
4631 let (a_out, b_out, groups_out, _) =
4632 rank_reduce_rows_pivoted_qr_with_dependence(a, b, groups);
4633
4634 assert_eq!(a_out.nrows(), 0);
4635 assert_eq!(a_out.ncols(), 2);
4636 assert_eq!(b_out.len(), 0);
4637 assert!(groups_out.is_empty());
4638 }
4639
4640 #[test]
4641 fn cone_projection_solves_nonnegative_least_squares_not_one_way_pruning() {
4642 let active_a = array![
4643 [0.85258593, -0.77270261],
4644 [-1.22152485, 2.05129351],
4645 [0.22794844, 1.56987265],
4646 ];
4647 let residual = array![-0.50524761, -1.10104911];
4648
4649 let (projected, multipliers) =
4650 project_stationarity_residual_on_constraint_cone(&residual, &active_a)
4651 .expect("cone projection should solve");
4652
4653 let row0 = active_a.row(0);
4654 let expected_mu0 = row0.dot(&residual) / row0.dot(&row0);
4655 assert_relative_eq!(multipliers[0], expected_mu0, epsilon = 1e-8);
4656 assert_relative_eq!(multipliers[1], 0.0, epsilon = 1e-10);
4657 assert_relative_eq!(multipliers[2], 0.0, epsilon = 1e-10);
4658
4659 let raw_norm2 = residual.dot(&residual);
4660 let projected_norm2 = projected.dot(&projected);
4661 assert!(
4662 projected_norm2 < raw_norm2 - 0.1,
4663 "NNLS projection should keep the improving active row: raw={raw_norm2:.6e}, projected={projected_norm2:.6e}"
4664 );
4665 let dual = active_a.dot(&projected);
4666 for (idx, (&mu, &w)) in multipliers.iter().zip(dual.iter()).enumerate() {
4667 if mu <= 1e-10 {
4668 assert!(
4669 w <= 1e-8,
4670 "inactive cone generator {idx} has positive reduced gradient {w:.3e}"
4671 );
4672 }
4673 }
4674 }
4675
4676 #[test]
4680 fn nnls_moreau_projection_matches_strict_qp_route() {
4681 let cases: Vec<(Array2<f64>, Array1<f64>)> = vec![
4682 (
4683 array![
4684 [0.85258593, -0.77270261],
4685 [-1.22152485, 2.05129351],
4686 [0.22794844, 1.56987265],
4687 ],
4688 array![-0.50524761, -1.10104911],
4689 ),
4690 (array![[1.0, 0.0], [0.0, 1.0]], array![3.0, -2.0]),
4691 (
4692 array![[1.0, 1.0, 0.0], [1.0, -1.0, 0.0], [2.0, 2.0, 0.0]],
4693 array![1.5, 0.25, -0.75],
4694 ),
4695 ];
4696 for (rows, target) in cases {
4697 let qp = moreau_projection_via_strict_qp(&target, &rows)
4698 .expect("strict QP route must solve these well-posed instances");
4699 let (lambda, projected) = nonnegative_cone_multipliers(&rows, &target)
4700 .expect("LH route must solve the same instances");
4701 for (left, right) in qp.0.iter().zip(projected.iter()) {
4702 assert_relative_eq!(left, right, epsilon = 1e-8);
4703 }
4704 assert!(lambda.iter().all(|&v| v >= 0.0));
4706 let reconstructed = &target - &rows.t().dot(&lambda);
4707 for (left, right) in reconstructed.iter().zip(projected.iter()) {
4708 assert_relative_eq!(left, right, epsilon = 1e-12);
4709 }
4710 }
4711 }
4712
4713 #[test]
4714 fn nnls_projects_axis_cone_exactly() {
4715 let rows = array![[1.0, 0.0], [0.0, 1.0]];
4716 let target = array![3.0, -2.0];
4717 let (lambda, projected) =
4718 nonnegative_cone_multipliers(&rows, &target).expect("axis cone NNLS");
4719 assert_relative_eq!(lambda[0], 3.0, epsilon = 1e-10);
4720 assert_relative_eq!(lambda[1], 0.0, epsilon = 1e-10);
4721 assert_relative_eq!(projected[0], 0.0, epsilon = 1e-10);
4722 assert_relative_eq!(projected[1], -2.0, epsilon = 1e-10);
4723 }
4724
4725 #[test]
4731 fn nnls_closes_stationarity_on_weakly_aligned_dependent_face() {
4732 let eps = 1e-8_f64;
4733 let rows = array![[1.0, eps], [-1.0, eps], [0.0, 1.0]];
4734 let target = array![0.0, 1.0];
4735 let (lambda, projected) =
4736 nonnegative_cone_multipliers(&rows, &target).expect("dependent-face NNLS");
4737 let closure = projected.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
4738 assert!(
4739 closure <= 1e-10,
4740 "λ = e3 closes stationarity exactly; got closure {closure:.3e}"
4741 );
4742 assert!(lambda.iter().all(|&v| v >= 0.0));
4743 }
4744
4745 #[test]
4750 fn degenerate_face_with_weak_alignment_certifies_instead_of_cycling() {
4751 let eps = 1e-8_f64;
4752 let a = array![[1.0, eps], [-1.0, eps], [0.0, 1.0]];
4753 let b = array![0.0, 0.0, 0.0];
4754 let constraints = LinearInequalityConstraints::new(a.clone(), b).expect("constraints");
4755 let hessian = Array2::<f64>::eye(2);
4756 let gradient = array![0.0, 1.0];
4758 let beta = array![0.0, 0.0];
4759 let mut direction = Array1::<f64>::zeros(2);
4760 solve_newton_direction_with_linear_constraints(
4761 &hessian,
4762 &gradient,
4763 &beta,
4764 &constraints,
4765 &mut direction,
4766 None,
4767 )
4768 .expect("the vertex is a certified KKT point; refusal is the #2298 defect");
4769 let step = direction.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
4770 assert!(
4771 step <= 1e-8,
4772 "optimum is the vertex itself; got |d|∞ = {step:.3e}"
4773 );
4774 }
4775
4776 #[test]
4780 fn operator_nnls_certifies_pinned_degenerate_vertex_projection_979() {
4781 let a = array![
4785 [1.0_f64, 0.0, 0.0],
4786 [0.0, 1.0, 0.0],
4787 [0.0, 0.0, 1.0],
4788 [1.0, 1.0, 0.0],
4789 ];
4790 let b = array![0.0_f64, 0.0, 0.0, 0.0];
4791 let set = ConstraintSet::Dense(
4792 LinearInequalityConstraints::new(a, b).expect("degenerate vertex cone"),
4793 );
4794 let beta = array![0.0_f64, 0.0, 0.0];
4795 let residual = array![3.0_f64, 2.0, 0.0]; let (projected, active) = project_stationarity_residual_on_constraint_set(
4797 &residual,
4798 &beta,
4799 &set,
4800 &[0, 1],
4801 )
4802 .expect("operator NNLS must solve the degenerate vertex");
4803 let closure = projected.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
4804 assert!(
4805 closure <= 1e-9,
4806 "residual is in the cone; projection must close to zero, got {closure:.3e}"
4807 );
4808 assert!(!active.is_empty(), "a supported face must be reported");
4809
4810 let outside = array![1.0_f64, 0.0, -1.0];
4812 let (projected_outside, _) =
4813 project_stationarity_residual_on_constraint_set(&outside, &beta, &set, &[])
4814 .expect("operator NNLS must solve the outside-component case");
4815 assert_relative_eq!(projected_outside[0], 0.0, epsilon = 1e-9);
4816 assert_relative_eq!(projected_outside[1], 0.0, epsilon = 1e-9);
4817 assert_relative_eq!(projected_outside[2], -1.0, epsilon = 1e-9);
4818 }
4819
4820 #[test]
4825 fn operator_nnls_excludes_rows_not_tight_at_beta() {
4826 let a = array![[1.0_f64, 0.0], [0.0, 1.0]];
4827 let b = array![0.0_f64, -1.0]; let set = ConstraintSet::Dense(
4829 LinearInequalityConstraints::new(a, b).expect("half-tight system"),
4830 );
4831 let beta = array![0.0_f64, 0.0];
4832 let residual = array![0.0_f64, 1.0];
4833 let (projected, active) =
4834 project_stationarity_residual_on_constraint_set(&residual, &beta, &set, &[])
4835 .expect("operator NNLS must solve the half-tight system");
4836 assert_relative_eq!(projected[1], 1.0, epsilon = 1e-12);
4837 assert!(
4838 !active.contains(&1),
4839 "slack row 1 must not appear in the certified face"
4840 );
4841 }
4842
4843 #[test]
4844 fn cone_projection_preserves_original_multiplier_units_after_row_canonicalization() {
4845 let residual = array![2.0, -1.0];
4846 let unit_row = array![[1.0, 0.0]];
4847 let scaled_row = array![[4.0, 0.0]];
4848
4849 let (projected_unit, multiplier_unit) =
4850 project_stationarity_residual_on_constraint_cone(&residual, &unit_row)
4851 .expect("unit-row cone projection should solve");
4852 let (projected_scaled, multiplier_scaled) =
4853 project_stationarity_residual_on_constraint_cone(&residual, &scaled_row)
4854 .expect("scaled-row cone projection should solve");
4855
4856 assert_relative_eq!(projected_unit[0], 0.0, epsilon = 1e-12);
4857 assert_relative_eq!(projected_unit[1], -1.0, epsilon = 1e-12);
4858 assert_relative_eq!(projected_scaled[0], projected_unit[0], epsilon = 1e-12);
4859 assert_relative_eq!(projected_scaled[1], projected_unit[1], epsilon = 1e-12);
4860 assert_relative_eq!(multiplier_unit[0], 2.0, epsilon = 1e-12);
4861 assert_relative_eq!(multiplier_scaled[0], 0.5, epsilon = 1e-12);
4862
4863 let reconstructed_unit = &residual - &unit_row.t().dot(&multiplier_unit);
4864 let reconstructed_scaled = &residual - &scaled_row.t().dot(&multiplier_scaled);
4865 assert_relative_eq!(reconstructed_unit[0], projected_unit[0], epsilon = 1e-12);
4866 assert_relative_eq!(
4867 reconstructed_scaled[0],
4868 projected_scaled[0],
4869 epsilon = 1e-12
4870 );
4871 }
4872
4873 #[test]
4880 fn kkt_primal_is_per_row_scale_invariant() {
4881 let geometric_violation = 2.071e-8_f64;
4884 let gradient = Array1::<f64>::zeros(2);
4885
4886 let beta_unit = array![-geometric_violation, 0.0];
4888 let unit = LinearInequalityConstraints {
4889 a: array![[1.0, 0.0]],
4890 b: array![0.0],
4891 };
4892 let diag_unit = compute_constraint_kkt_diagnostics(&beta_unit, &gradient, &unit);
4893
4894 let beta_big = array![-geometric_violation, 0.0];
4897 let big = LinearInequalityConstraints {
4898 a: array![[1000.0, 0.0]],
4899 b: array![0.0],
4900 };
4901 let diag_big = compute_constraint_kkt_diagnostics(&beta_big, &gradient, &big);
4902
4903 assert_relative_eq!(
4904 diag_unit.primal_feasibility,
4905 geometric_violation,
4906 epsilon = 1e-14
4907 );
4908 assert_relative_eq!(
4909 diag_big.primal_feasibility,
4910 geometric_violation,
4911 epsilon = 1e-14
4912 );
4913 assert!(
4915 diag_big.primal_feasibility < 1e-7,
4916 "scaled primal {:.3e} should pass a 1e-7 gate; raw slack would be {:.3e}",
4917 diag_big.primal_feasibility,
4918 1000.0 * geometric_violation
4919 );
4920 }
4921
4922 #[test]
4930 fn opposing_inequality_pair_pins_equality_to_target() {
4931 let hessian = array![
4935 [1.0, 0.0, 0.0, 0.0],
4936 [0.0, 1.0, 0.0, 0.0],
4937 [0.0, 0.0, 1.0, 0.0],
4938 [0.0, 0.0, 0.0, 1.0],
4939 ];
4940 let rhs = array![5.0, 5.0, 0.0, 0.0];
4941 let beta_start = Array1::<f64>::zeros(4);
4942 let constraints = LinearInequalityConstraints {
4943 a: array![[1.0, 1.0, 0.0, 0.0], [-1.0, -1.0, 0.0, 0.0]],
4944 b: array![0.0, 0.0],
4945 };
4946
4947 let (beta, _active) = solve_quadratic_with_linear_constraints(
4948 &hessian,
4949 &rhs,
4950 &beta_start,
4951 &constraints,
4952 None,
4953 )
4954 .expect("opposing-inequality equality QP must solve");
4955
4956 let a_dot_beta = beta[0] + beta[1];
4957 assert!(
4958 a_dot_beta.abs() < 1e-8,
4959 "opposing inequalities must pin a·β to 0, got {a_dot_beta:.6e} (β = {beta:?})"
4960 );
4961 }
4962
4963 #[test]
4967 fn opposing_inequality_pair_pins_scaled_equality_to_nonzero_target() {
4968 let hessian = array![
4969 [1.0, 0.0, 0.0, 0.0],
4970 [0.0, 1.0, 0.0, 0.0],
4971 [0.0, 0.0, 1.0, 0.0],
4972 [0.0, 0.0, 0.0, 1.0],
4973 ];
4974 let rhs = array![5.0, 5.0, 0.0, 0.0];
4975 let beta_start = Array1::<f64>::zeros(4);
4976 let constraints = LinearInequalityConstraints {
4979 a: array![[1000.0, 1000.0, 0.0, 0.0], [-1000.0, -1000.0, 0.0, 0.0]],
4980 b: array![3000.0, -3000.0],
4981 };
4982
4983 let (beta, _active) = solve_quadratic_with_linear_constraints(
4984 &hessian,
4985 &rhs,
4986 &beta_start,
4987 &constraints,
4988 None,
4989 )
4990 .expect("scaled opposing-inequality equality QP must solve");
4991
4992 let a_dot_beta = 1000.0 * (beta[0] + beta[1]);
4993 assert!(
4994 (a_dot_beta - 3000.0).abs() < 1e-5,
4995 "opposing inequalities must pin a·β to 3000, got {a_dot_beta:.6e} (β = {beta:?})"
4996 );
4997 }
4998
4999 #[test]
5004 fn two_opposing_inequality_equalities_both_pinned() {
5005 let hessian = array![
5006 [1.0, 0.0, 0.0, 0.0],
5007 [0.0, 1.0, 0.0, 0.0],
5008 [0.0, 0.0, 1.0, 0.0],
5009 [0.0, 0.0, 0.0, 1.0],
5010 ];
5011 let rhs = array![5.0, 5.0, 5.0, 5.0];
5012 let beta_start = Array1::<f64>::zeros(4);
5013 let constraints = LinearInequalityConstraints {
5015 a: array![
5016 [1.0, 1.0, 0.0, 0.0],
5017 [-1.0, -1.0, 0.0, 0.0],
5018 [0.0, 0.0, 1.0, 1.0],
5019 [0.0, 0.0, -1.0, -1.0],
5020 ],
5021 b: array![0.0, 0.0, 0.0, 0.0],
5022 };
5023
5024 let (beta, _active) = solve_quadratic_with_linear_constraints(
5025 &hessian,
5026 &rhs,
5027 &beta_start,
5028 &constraints,
5029 None,
5030 )
5031 .expect("two-equality QP must solve");
5032
5033 assert!(
5034 (beta[0] + beta[1]).abs() < 1e-8,
5035 "equality A not pinned: β0+β1 = {:.6e}",
5036 beta[0] + beta[1]
5037 );
5038 assert!(
5039 (beta[2] + beta[3]).abs() < 1e-8,
5040 "equality B not pinned: β2+β3 = {:.6e}",
5041 beta[2] + beta[3]
5042 );
5043 }
5044
5045 #[test]
5052 fn opposing_inequality_equalities_pinned_under_ill_conditioned_penalty() {
5053 let lam = 1.0e8_f64;
5056 let hessian = array![
5057 [1.0, 0.0, 0.0, 0.0],
5058 [0.0, 1.0, 0.0, 0.0],
5059 [0.0, 0.0, lam, 0.0],
5060 [0.0, 0.0, 0.0, lam],
5061 ];
5062 let rhs = array![5.0, 5.0, 5.0, 5.0];
5063 let beta_start = Array1::<f64>::zeros(4);
5064 let constraints = LinearInequalityConstraints {
5068 a: array![
5069 [1.0, 0.0, 1.0, 0.0],
5070 [-1.0, 0.0, -1.0, 0.0],
5071 [0.0, 1.0, 0.0, 1.0],
5072 [0.0, -1.0, 0.0, -1.0],
5073 ],
5074 b: array![0.0, 0.0, 0.0, 0.0],
5075 };
5076
5077 let (beta, _active) = solve_quadratic_with_linear_constraints(
5078 &hessian,
5079 &rhs,
5080 &beta_start,
5081 &constraints,
5082 None,
5083 )
5084 .expect("ill-conditioned two-equality QP must solve");
5085
5086 assert!(
5087 (beta[0] + beta[2]).abs() < 1e-6,
5088 "equality A not pinned under ill-conditioning: β0+β2 = {:.6e}",
5089 beta[0] + beta[2]
5090 );
5091 assert!(
5092 (beta[1] + beta[3]).abs() < 1e-6,
5093 "equality B not pinned under ill-conditioning: β1+β3 = {:.6e}",
5094 beta[1] + beta[3]
5095 );
5096 }
5097
5098 fn small_cone() -> KhatriRaoConeConstraints {
5104 let psi = array![[1.0_f64, 0.2], [1.0, -0.4], [1.0, 1.3], [1.0, 0.8],];
5105 KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1, 2], 3).expect("small cone")
5106 }
5107
5108 #[test]
5111 fn cone_reduced_face_collapses_parallel_rows_to_lowest_index() {
5112 let psi = array![[1.0_f64, 0.0], [0.0, 1.0], [2.0, 0.0]];
5114 let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1], 2)
5115 .expect("parallel cone");
5116 let beta = Array1::<f64>::zeros(2 * 2);
5118 let face = khatri_rao_cone_reduced_face(&cone, beta.view(), 1e-8).expect("reduce");
5119 assert_eq!(face.tight_rows, rows(&[0, 1, 2]));
5120 assert_eq!(face.representatives, rows(&[0, 1]));
5122 assert_eq!(face.dependence.len(), 2);
5123 assert_eq!(face.dependence[0].len(), 1);
5125 assert_eq!(face.dependence[0][0].row.index(), 2);
5126 assert!((face.dependence[0][0].coeff - 2.0).abs() < 1e-12);
5127 assert!(face.dependence[1].is_empty());
5128 }
5129
5130 #[test]
5132 fn cone_reduced_face_full_rank_has_no_dependence() {
5133 let psi = array![[1.0_f64, 0.0], [0.0, 1.0]];
5134 let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1], 2)
5135 .expect("full-rank cone");
5136 let beta = Array1::<f64>::zeros(2 * 2);
5137 let face = khatri_rao_cone_reduced_face(&cone, beta.view(), 1e-8).expect("reduce");
5138 assert_eq!(face.representatives, rows(&[0, 1]));
5139 assert!(face.dependence.iter().all(|d| d.is_empty()));
5140 assert_eq!(face.tight_rows, rows(&[0, 1]));
5141 }
5142
5143 #[test]
5147 fn cone_reduced_face_general_combination_gets_no_dependence_entry() {
5148 let psi = array![[1.0_f64, 0.0], [0.0, 1.0], [1.0, 1.0]];
5150 let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1], 2)
5151 .expect("general-combo cone");
5152 let beta = Array1::<f64>::zeros(2 * 2);
5153 let face = khatri_rao_cone_reduced_face(&cone, beta.view(), 1e-8).expect("reduce");
5154 assert_eq!(face.representatives, rows(&[0, 1])); assert_eq!(face.tight_rows, rows(&[0, 1, 2])); assert!(
5157 face.dependence.iter().all(|d| d.is_empty()),
5158 "a general-position drop must carry no distributed multiplier"
5159 );
5160 }
5161
5162 #[test]
5166 fn cone_reduced_face_reduces_each_shape_block_independently() {
5167 let psi = array![[1.0_f64, 0.0], [0.0, 1.0]];
5168 let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1, 2], 3)
5169 .expect("two-block cone");
5170 let beta = Array1::<f64>::zeros(3 * 2);
5171 let face = khatri_rao_cone_reduced_face(&cone, beta.view(), 1e-8).expect("reduce");
5172 assert_eq!(face.representatives, rows(&[0, 1, 2, 3]));
5174 assert!(face.dependence.iter().all(|d| d.is_empty()));
5175 assert_eq!(face.tight_rows, rows(&[0, 1, 2, 3]));
5176 }
5177
5178 #[test]
5182 fn dense_reduced_face_via_dispatcher_collapses_parallel_rows() {
5183 let a = array![[1.0_f64, 0.0], [0.0, 1.0], [2.0, 0.0]];
5186 let set = ConstraintSet::Dense(
5187 LinearInequalityConstraints::new(a, Array1::<f64>::zeros(3)).expect("dense"),
5188 );
5189 let beta = Array1::<f64>::zeros(2);
5190 let face = set.reduced_face(beta.view(), 1e-8).expect("reduce");
5191 assert_eq!(face.tight_rows, rows(&[0, 1, 2]));
5192 assert_eq!(face.representatives, rows(&[0, 1]));
5193 assert_eq!(face.dependence[0].len(), 1);
5194 assert_eq!(face.dependence[0][0].row.index(), 2);
5195 assert!((face.dependence[0][0].coeff - 2.0).abs() < 1e-12);
5196 assert!(face.dependence[1].is_empty());
5197 }
5198
5199 fn rows(ids: &[usize]) -> Vec<ConstraintRowId> {
5201 ids.iter().copied().map(ConstraintRowId).collect()
5202 }
5203
5204 fn mixed_width_block_diagonal() -> ConstraintSet {
5212 let narrow = gam_problem::PlacedConstraintBlock {
5213 col_start: 0,
5214 set: ConstraintSet::Dense(
5215 LinearInequalityConstraints::new(
5216 array![[1.0_f64, 0.0, 0.0]],
5217 Array1::<f64>::zeros(1),
5218 )
5219 .expect("narrow block"),
5220 ),
5221 };
5222 let square = gam_problem::PlacedConstraintBlock {
5223 col_start: 3,
5224 set: ConstraintSet::Dense(
5225 LinearInequalityConstraints::new(
5226 array![[1.0_f64, 0.0], [2.0, 0.0]],
5227 Array1::<f64>::zeros(2),
5228 )
5229 .expect("square block"),
5230 ),
5231 };
5232 ConstraintSet::block_diagonal(vec![narrow, square], 5).expect("block-diagonal")
5233 }
5234
5235 #[test]
5242 fn block_diagonal_reduced_face_row_ids_address_the_joint_constraint_row_space() {
5243 let set = mixed_width_block_diagonal();
5244 let beta = Array1::<f64>::zeros(5);
5245 let values = set.values(beta.view()).expect("values");
5246 let face = set.reduced_face(beta.view(), 1e-8).expect("reduce");
5247
5248 assert_eq!(set.nrows(), 3);
5250 assert_eq!(face.tight_rows, rows(&[0, 1, 2]));
5251 assert_eq!(face.representatives, rows(&[0, 1]));
5253 assert_eq!(face.dependence[1][0].row.index(), 2);
5254
5255 for id in &face.tight_rows {
5256 let row = id.index();
5257 assert!(row < set.nrows(), "id {row} outside the joint row space");
5258 let norm = set.row_norm(row).expect("row norm resolves");
5259 let bound = set.bound(row).expect("bound resolves");
5260 assert!(
5261 (values[row] - bound) / norm <= 1e-8,
5262 "row {row} reported tight but has slack {}",
5263 (values[row] - bound) / norm
5264 );
5265 }
5266 }
5267
5268 #[test]
5277 fn block_diagonal_reduced_face_row_ids_are_not_beta_coordinates() {
5278 let set = mixed_width_block_diagonal();
5279 let beta = Array1::<f64>::zeros(5);
5280 let face = set.reduced_face(beta.view(), 1e-8).expect("reduce");
5281
5282 let block1_rep = face.representatives[1];
5283 assert_eq!(block1_rep.index(), 1);
5284 assert_eq!(
5285 set.row_column_support(block1_rep).expect("support"),
5286 vec![3],
5287 "block 1's row acts on the joint column 3 (col_start 3 + local 0)"
5288 );
5289 assert!(block1_rep.index() < 3, "id 1 falls inside block 0's columns");
5292
5293 assert_eq!(
5296 set.row_column_support(face.representatives[0])
5297 .expect("support"),
5298 vec![0]
5299 );
5300 }
5301
5302 #[test]
5306 fn block_diagonal_reduced_face_concatenates_member_row_ids() {
5307 let make = |c0: usize| gam_problem::PlacedConstraintBlock {
5310 col_start: c0,
5311 set: ConstraintSet::Dense(
5312 LinearInequalityConstraints::new(
5313 array![[1.0_f64, 0.0], [2.0, 0.0]],
5314 Array1::<f64>::zeros(2),
5315 )
5316 .expect("dense block"),
5317 ),
5318 };
5319 let set = ConstraintSet::block_diagonal(vec![make(0), make(2)], 4).expect("block-diagonal");
5320 let beta = Array1::<f64>::zeros(4);
5321 let face = set.reduced_face(beta.view(), 1e-8).expect("reduce");
5322 assert_eq!(face.tight_rows, rows(&[0, 1, 2, 3]));
5323 assert_eq!(face.representatives, rows(&[0, 2]));
5324 assert_eq!(face.dependence[0][0].row.index(), 1);
5325 assert_eq!(face.dependence[1][0].row.index(), 3);
5326 }
5327
5328 fn coupled_pd_hessian(p: usize) -> Array2<f64> {
5331 let mut h = Array2::<f64>::eye(p) * 2.0;
5332 for i in 0..p {
5333 for j in 0..p {
5334 if i != j {
5335 h[[i, j]] = 0.3 / (1.0 + (i as f64 - j as f64).abs());
5336 }
5337 }
5338 }
5339 h
5340 }
5341
5342 #[test]
5343 fn operator_cone_qp_matches_dense_oracle_when_constraints_bind() {
5344 let cone = small_cone();
5345 let set = ConstraintSet::KhatriRaoCone(cone.clone());
5346 let dense = cone.to_dense().expect("dense oracle");
5347 let p = set.ncols();
5348 let hessian = coupled_pd_hessian(p);
5349 let rhs = array![0.5_f64, -0.3, -2.0, 1.0, -1.5, -0.7];
5352 let beta_start = array![0.0_f64, 0.0, 1.0, 0.1, 1.0, 0.1];
5355
5356 let (beta_op, mut active_op) =
5357 solve_quadratic_with_constraint_set(&hessian, &rhs, &beta_start, &set, None)
5358 .expect("operator solve");
5359 let (beta_dense, mut active_dense) =
5360 solve_quadratic_with_linear_constraints(&hessian, &rhs, &beta_start, &dense, None)
5361 .expect("dense solve");
5362
5363 for j in 0..p {
5364 assert!(
5365 (beta_op[j] - beta_dense[j]).abs() < 1e-7,
5366 "operator/dense coefficient {j} mismatch: {} vs {}",
5367 beta_op[j],
5368 beta_dense[j]
5369 );
5370 }
5371 active_op.sort_unstable();
5378 active_dense.sort_unstable();
5379 let values_at_solution = set.values(beta_op.view()).expect("values at solution");
5380 let tight_at_solution: Vec<usize> = (0..set.nrows())
5381 .filter(|&row| {
5382 let norm = set.row_norm(row).expect("norm");
5383 norm > 0.0 && values_at_solution[row] / norm <= 1e-7
5384 })
5385 .collect();
5386 for &row in active_op.iter().chain(active_dense.iter()) {
5387 assert!(
5388 tight_at_solution.contains(&row),
5389 "reported active row {row} is not tight at the common solution \
5390 (op face {active_op:?}, dense face {active_dense:?}, tight {tight_at_solution:?})"
5391 );
5392 }
5393 assert_eq!(
5394 active_op.len(),
5395 active_dense.len(),
5396 "carriers disagree on the face dimension: op {active_op:?} vs dense {active_dense:?}"
5397 );
5398 assert!(
5399 !active_op.is_empty(),
5400 "fixture must actually bind at least one cone row"
5401 );
5402 let values = set.values(beta_op.view()).expect("values");
5404 let (worst, _) = set.max_scaled_violation(beta_op.view()).expect("violation");
5405 assert!(worst <= 1e-8, "operator answer infeasible: {worst:.3e}");
5406 assert_eq!(values.len(), 8);
5407 }
5408
5409 #[test]
5410 fn operator_metric_dual_solves_the_non_diagonal_projection() {
5411 let psi = array![[1.0_f64, 0.0], [0.0, 1.0]];
5420 let cone =
5421 KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![0], 1)
5422 .expect("nonnegative quadrant");
5423 let set = ConstraintSet::KhatriRaoCone(cone);
5424 let hessian = array![[4.0_f64, 1.0], [1.0, 2.0]];
5425 let rhs = array![-1.0_f64, 2.0];
5426 let beta_start = array![0.0_f64, 0.0];
5427
5428 let (candidate, active) =
5429 solve_quadratic_with_constraint_set(&hessian, &rhs, &beta_start, &set, None)
5430 .expect("strict metric projection");
5431
5432 assert_relative_eq!(candidate[0], 0.0, epsilon = 1e-12);
5433 assert_relative_eq!(candidate[1], 1.0, epsilon = 1e-12);
5434 assert_eq!(active, vec![0]);
5435 let gradient = hessian.dot(&candidate) - rhs;
5436 assert_relative_eq!(gradient[0], 2.0, epsilon = 1e-12);
5437 assert_relative_eq!(gradient[1], 0.0, epsilon = 1e-12);
5438 }
5439
5440 #[test]
5447 fn operator_metric_dual_uses_the_certificate_multiplier_cone_2432() {
5448 let psi = array![[1.0_f64, 0.0], [0.0, 1.0]];
5449 let cone =
5450 KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![0], 1)
5451 .expect("nonnegative quadrant");
5452 let set = ConstraintSet::KhatriRaoCone(cone);
5453 let hessian = Array2::<f64>::eye(2);
5454 let epsilon = 0.5 * ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL;
5455 let rhs = array![epsilon, 1.0];
5456 let beta_start = array![0.0_f64, 0.0];
5457
5458 let (candidate, active) = solve_quadratic_with_constraint_set(
5459 &hessian,
5460 &rhs,
5461 &beta_start,
5462 &set,
5463 Some(&[0]),
5464 )
5465 .expect("warm face must not perturb the unique cone projection");
5466
5467 assert!(
5468 active.is_empty(),
5469 "the exact interior optimum has no active cone row"
5470 );
5471 assert_relative_eq!(candidate[0], epsilon, epsilon = 1e-14);
5472 assert_relative_eq!(candidate[1], 1.0, epsilon = 1e-14);
5473 let gradient = hessian.dot(&candidate) - rhs;
5474 assert_relative_eq!(gradient[0], 0.0, epsilon = 1e-14);
5475 assert_relative_eq!(gradient[1], 0.0, epsilon = 1e-14);
5476 }
5477
5478 #[test]
5486 fn dense_metric_dual_leaves_feasible_nonstationary_three_of_332_face_2432() {
5487 let p = 5usize;
5488 let m = 332usize;
5489 let mut a = Array2::<f64>::zeros((m, p));
5490 let mut b = Array1::<f64>::from_elem(m, -100.0);
5491 for row in 0..3 {
5492 a[[row, row]] = 1.0;
5493 b[row] = 0.0;
5494 }
5495 for row in 3..m {
5499 a[[row, (row - 3) % p]] = 1.0;
5500 }
5501 let constraints =
5502 LinearInequalityConstraints::new(a, b).expect("332-row dense constraint system");
5503 let hessian = Array2::from_diag(&array![1.0_f64, 2.0, 3.0, 1.0, 4.0]);
5504 let rhs = array![0.6987_f64, -2.0, -3.0, -11.3, 0.0];
5505 let wrong_face_point = Array1::<f64>::zeros(p);
5506 let gradient_at_wrong_face = hessian.dot(&wrong_face_point) - &rhs;
5507 let wrong_face = LinearInequalityConstraints::new(
5508 constraints.a.slice(s![0..3, ..]).to_owned(),
5509 constraints.b.slice(s![0..3]).to_owned(),
5510 )
5511 .expect("three-row wrong face");
5512 let wrong_face_multipliers = array![-0.6987_f64, 2.0, 3.0];
5515 let wrong = working_set_kkt_diagnostics_from_multipliers(
5516 &wrong_face_point,
5517 &gradient_at_wrong_face,
5518 &wrong_face,
5519 &wrong_face_multipliers,
5520 m,
5521 )
5522 .expect("wrong-face diagnostic");
5523 assert_eq!(wrong.n_active, 3);
5524 assert_eq!(wrong.n_constraints, 332);
5525 assert_relative_eq!(wrong.primal_feasibility, 0.0, epsilon = 0.0);
5526 assert_relative_eq!(wrong.dual_feasibility, 0.6987, epsilon = 1e-15);
5527 assert_relative_eq!(wrong.complementarity, 0.0, epsilon = 0.0);
5528 assert_relative_eq!(wrong.stationarity, 11.3, epsilon = 1e-14);
5529
5530 let (cold, cold_active) = solve_quadratic_with_linear_constraints(
5531 &hessian,
5532 &rhs,
5533 &wrong_face_point,
5534 &constraints,
5535 None,
5536 )
5537 .expect("cold finite dual solve");
5538 let (warm, warm_active) = solve_quadratic_with_linear_constraints(
5539 &hessian,
5540 &rhs,
5541 &wrong_face_point,
5542 &constraints,
5543 Some(&[0, 1, 2]),
5544 )
5545 .expect("wrong-face warm hint must affect ordering only");
5546
5547 assert!(
5548 cold.iter()
5549 .zip(warm.iter())
5550 .all(|(&left, &right)| left.to_bits() == right.to_bits()),
5551 "strictly-convex QP answer must be bitwise warm-history independent: \
5552 cold={cold:?}, warm={warm:?}"
5553 );
5554 assert_eq!(cold_active, vec![1, 2]);
5555 assert_eq!(warm_active, vec![1, 2]);
5556 let expected = array![0.6987_f64, 0.0, 0.0, -11.3, 0.0];
5557 for (&actual, &target) in cold.iter().zip(expected.iter()) {
5558 assert_relative_eq!(actual, target, epsilon = 1e-13);
5559 }
5560
5561 let gradient = hessian.dot(&cold) - &rhs;
5562 let active_rows = LinearInequalityConstraints::new(
5563 constraints.a.select(ndarray::Axis(0), &[1, 2]),
5564 constraints.b.select(ndarray::Axis(0), &[1, 2]),
5565 )
5566 .expect("true active face");
5567 let (_, system_multipliers) =
5568 solve_kkt_direction(&hessian, &gradient, &active_rows.a, None)
5569 .expect("true-face multiplier reconstruction");
5570 let multipliers = -system_multipliers;
5571 assert_relative_eq!(multipliers[0], 2.0, epsilon = 1e-13);
5572 assert_relative_eq!(multipliers[1], 3.0, epsilon = 1e-13);
5573 assert!(
5574 multipliers.iter().all(|&value| value > 0.0),
5575 "the returned face must carry nonnegative KKT multipliers"
5576 );
5577 let certified = compute_constraint_kkt_diagnostics(&cold, &gradient, &constraints);
5578 assert!(certified.primal_feasibility <= 1e-14);
5579 assert!(certified.dual_feasibility <= 1e-14);
5580 assert!(certified.complementarity <= 1e-14);
5581 assert!(certified.stationarity <= 1e-13);
5582 }
5583
5584 #[test]
5585 fn separable_khatri_rao_tangent_projection_matches_dense_oracle() {
5586 let cone = small_cone();
5587 let set = ConstraintSet::KhatriRaoCone(cone.clone());
5588 let dense = cone.to_dense().expect("dense projection oracle");
5589 let beta = Array1::<f64>::zeros(set.ncols());
5590 let residual = array![0.4_f64, -0.2, 1.1, -0.7, -0.9, 0.8];
5591
5592 let (operator_projected, _) =
5593 project_stationarity_residual_on_constraint_set(&residual, &beta, &set, &[])
5594 .expect("separable operator projection");
5595 let (dense_projected, _) =
5596 project_stationarity_residual_on_constraint_cone(&residual, &dense.a)
5597 .expect("dense cone projection");
5598
5599 for index in 0..residual.len() {
5600 assert_relative_eq!(
5601 operator_projected[index],
5602 dense_projected[index],
5603 epsilon = 1e-8
5604 );
5605 }
5606 }
5607
5608 #[test]
5613 fn operator_moreau_projection_has_coefficient_sized_support_979() {
5614 let rows = 24_000;
5615 let psi = Array2::from_shape_fn((rows, 3), |(row, column)| {
5616 let axis = (row % 6) / 2;
5617 if column == axis {
5618 if row % 2 == 0 { 1.0 } else { -1.0 }
5619 } else {
5620 0.0
5621 }
5622 });
5623 let cone =
5624 KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![0], 1)
5625 .expect("many-row low-dimensional cone");
5626 let dense = cone.to_dense().expect("dense parity oracle");
5627 let set = ConstraintSet::KhatriRaoCone(cone);
5628 let beta = Array1::<f64>::zeros(3);
5629 let residual = array![3.0_f64, -2.0, 1.0];
5630
5631 let (operator_projected, active) =
5632 project_stationarity_residual_on_constraint_set(&residual, &beta, &set, &[])
5633 .expect("operator Moreau projection");
5634 let (_, dense_projected) =
5635 nonnegative_cone_multipliers(&dense.a, &residual).expect("dense NNLS oracle");
5636
5637 for index in 0..residual.len() {
5638 assert_relative_eq!(
5639 operator_projected[index],
5640 dense_projected[index],
5641 epsilon = 1e-10
5642 );
5643 assert_relative_eq!(operator_projected[index], 0.0, epsilon = 1e-10);
5644 }
5645 assert!(
5646 active.len() <= residual.len(),
5647 "a three-dimensional cone projection gathered {} supported rows",
5648 active.len()
5649 );
5650 }
5651
5652 #[test]
5657 fn operator_metric_projection_batches_a_partial_warm_face_979() {
5658 let rows = 24_000;
5659 let p = 24;
5660 let psi = Array2::from_shape_fn((rows, p), |(row, column)| {
5661 if column == row % p { 1.0 } else { 0.0 }
5662 });
5663 let cone =
5664 KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![0], 1)
5665 .expect("many-row coordinate cone");
5666 let set = ConstraintSet::KhatriRaoCone(cone);
5667 let hessian = Array2::<f64>::eye(p);
5668 let rhs = Array1::<f64>::from_elem(p, -1.0);
5669 let beta_start = Array1::<f64>::zeros(p);
5670 let warm = [0usize, 1, 2, 3];
5671
5672 let ops = ConstraintSetOps::new(&set, 0.0).expect("operator geometry");
5673 let unconstrained = rhs.clone();
5674 let values = ops.values(&unconstrained).expect("free values");
5675 let mut is_active = vec![false; rows];
5676 for &row in &warm {
5677 is_active[row] = true;
5678 }
5679 let banned = vec![false; rows];
5680 let selected = independent_violated_operator_rows(
5681 &ops,
5682 &values,
5683 &warm,
5684 &is_active,
5685 &banned,
5686 p - warm.len(),
5687 )
5688 .expect("batch separation");
5689 assert_eq!(
5690 selected.len(),
5691 p - warm.len(),
5692 "one scan must recover every coefficient-space direction missing from the warm face"
5693 );
5694
5695 let (candidate, active) = solve_quadratic_with_constraint_set(
5696 &hessian,
5697 &rhs,
5698 &beta_start,
5699 &set,
5700 Some(&warm),
5701 )
5702 .expect("batched metric projection");
5703 assert!(
5704 candidate.iter().all(|value| value.abs() <= 1e-12),
5705 "projection onto the repeated coordinate cone must be the origin: {candidate:?}"
5706 );
5707 assert_eq!(
5708 active.len(),
5709 p,
5710 "the returned face must contain one representative per independent coordinate"
5711 );
5712 }
5713
5714 #[test]
5715 fn operator_cone_qp_takes_unconstrained_path_when_interior() {
5716 let cone = small_cone();
5717 let set = ConstraintSet::KhatriRaoCone(cone);
5718 let p = set.ncols();
5719 let hessian = coupled_pd_hessian(p);
5720 let rhs = array![0.2_f64, 0.1, 3.0, 0.2, 2.5, 0.1];
5723 let beta_start = array![0.0_f64, 0.0, 1.0, 0.0, 1.0, 0.0];
5724 let (beta_op, active_op) =
5725 solve_quadratic_with_constraint_set(&hessian, &rhs, &beta_start, &set, None)
5726 .expect("operator solve");
5727 let mut beta_unconstrained = Array1::<f64>::zeros(p);
5729 super::solve_newton_direction_dense(
5730 &hessian,
5731 &(hessian.dot(&beta_start) - &rhs),
5732 &mut beta_unconstrained,
5733 )
5734 .expect("unconstrained newton");
5735 let beta_unconstrained = &beta_start + &beta_unconstrained;
5736 for j in 0..p {
5737 assert!(
5738 (beta_op[j] - beta_unconstrained[j]).abs() < 1e-8,
5739 "interior operator solve must match unconstrained optimum at {j}"
5740 );
5741 }
5742 assert!(
5743 active_op.is_empty(),
5744 "interior optimum must have empty face"
5745 );
5746 }
5747
5748 #[test]
5749 fn operator_projection_returns_strictly_interior_point() {
5750 let cone = small_cone();
5751 let set = ConstraintSet::KhatriRaoCone(cone);
5752 let point = array![0.4_f64, -0.2, -1.0, -0.5, 0.3, 0.05];
5754 let projected = project_point_strictly_into_feasible_constraint_set(&point, &set)
5755 .expect("projection must succeed on a one-sided homogeneous cone");
5756 let values = set.values(projected.view()).expect("values");
5757 for row in 0..set.nrows() {
5758 let norm = set.row_norm(row).expect("norm");
5759 if norm <= 0.0 {
5760 continue;
5761 }
5762 let slack = values[row] / norm;
5763 assert!(
5764 slack >= 0.5 * ACTIVE_SET_INTERIOR_SEED_MARGIN - 1e-9,
5765 "projected point not strictly interior on row {row}: slack {slack:.3e}"
5766 );
5767 }
5768 assert!((projected[0] - point[0]).abs() < 1e-8);
5774 assert!((projected[1] - point[1]).abs() < 1e-8);
5775 }
5776
5777 #[test]
5787 fn operator_projection_adjudicates_the_over_complete_face_2378() {
5788 let cone = small_cone();
5789 let set = ConstraintSet::KhatriRaoCone(cone.clone());
5790 let point = array![0.4_f64, -0.2, -1.0, -0.5, 0.3, 0.05];
5793 let projected = project_point_strictly_into_feasible_constraint_set(&point, &set)
5794 .expect("operator projection must certify the over-complete-face vertex");
5795
5796 let dense = ConstraintSet::Dense(cone.to_dense().expect("dense oracle"));
5799 let dense_proj = project_point_strictly_into_feasible_constraint_set(&point, &dense)
5800 .expect("dense projection oracle");
5801 for j in 0..point.len() {
5802 assert!(
5803 (projected[j] - dense_proj[j]).abs() < 1e-7,
5804 "operator projection diverged from the dense oracle at {j}: \
5805 op={:.9e} dense={:.9e}",
5806 projected[j],
5807 dense_proj[j]
5808 );
5809 }
5810
5811 let values = set.values(projected.view()).expect("values");
5815 let scaled = |row: usize| values[row] / set.row_norm(row).expect("norm");
5816 for row in [1usize, 2] {
5818 assert!(
5819 scaled(row) < ACTIVE_SET_INTERIOR_SEED_MARGIN + 1e-7,
5820 "block-1 row {row} should bind, scaled slack {:.3e}",
5821 scaled(row)
5822 );
5823 }
5824 for row in [0usize, 3] {
5826 assert!(
5827 scaled(row) > scaled(2) + 1e-9,
5828 "non-binding row {row} (slack {:.3e}) must exceed the binding \
5829 row 2 (slack {:.3e})",
5830 scaled(row),
5831 scaled(2)
5832 );
5833 }
5834 }
5835
5836 #[test]
5841 fn operator_cone_qp_over_complete_face_matches_dense_oracle_2378() {
5842 let cone = small_cone();
5843 let set = ConstraintSet::KhatriRaoCone(cone.clone());
5844 let dense = cone.to_dense().expect("dense oracle");
5845 let p = set.ncols();
5846 let hessian = coupled_pd_hessian(p);
5847 let rhs = array![0.3_f64, -0.1, -2.5, -1.2, -0.4, 0.2];
5851 let beta_start = array![0.0_f64, 0.0, 1.0, 0.1, 1.0, 0.1];
5852
5853 let (beta_op, active_op) =
5854 solve_quadratic_with_constraint_set(&hessian, &rhs, &beta_start, &set, None)
5855 .expect("operator QP solve over an over-complete face");
5856 let (beta_dense, _active_dense) =
5857 solve_quadratic_with_linear_constraints(&hessian, &rhs, &beta_start, &dense, None)
5858 .expect("dense QP oracle");
5859
5860 for j in 0..p {
5861 assert!(
5862 (beta_op[j] - beta_dense[j]).abs() < 1e-7,
5863 "operator/dense coefficient {j} mismatch: {} vs {}",
5864 beta_op[j],
5865 beta_dense[j]
5866 );
5867 }
5868 let values = set.values(beta_op.view()).expect("values");
5870 for row in 0..set.nrows() {
5871 let norm = set.row_norm(row).expect("norm");
5872 if norm > 0.0 {
5873 assert!(
5874 values[row] / norm >= -ACTIVE_SET_PRIMAL_FEASIBILITY_TOL,
5875 "row {row} violated at the operator optimum: {:.3e}",
5876 values[row] / norm
5877 );
5878 }
5879 }
5880 assert!(
5881 active_op.len() <= p,
5882 "operator passive face must contain at most one row per coefficient-space direction: \
5883 active={}, p={p}",
5884 active_op.len()
5885 );
5886 }
5887
5888 #[test]
5889 fn operator_cone_does_not_materialize_a_whole_tight_face() {
5890 let mut psi = Array2::<f64>::zeros((4096, 2));
5896 psi.column_mut(0).fill(1.0);
5897 let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1], 2)
5898 .expect("repeated-row cone");
5899 let set = ConstraintSet::KhatriRaoCone(cone);
5900 let hessian = Array2::<f64>::eye(4);
5901 let rhs = array![0.3_f64, -0.2, -1.0, 0.0];
5902 let beta_start = Array1::<f64>::zeros(4);
5903
5904 let warm_row = 2048usize;
5908 let (beta, active) = solve_quadratic_with_constraint_set(
5909 &hessian,
5910 &rhs,
5911 &beta_start,
5912 &set,
5913 Some(&[warm_row]),
5914 )
5915 .expect("vertex solve");
5916
5917 assert_eq!(
5918 active,
5919 vec![warm_row],
5920 "the compact point-tight warm representative was discarded or redundant rows entered"
5921 );
5922 assert!(beta[2].abs() <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL);
5923 assert!((beta[0] - 0.3).abs() < 1e-10);
5924 assert!((beta[1] + 0.2).abs() < 1e-10);
5925 }
5926
5927 #[test]
5928 fn operator_cycle_escape_is_descending_feasible_and_sparse() {
5929 let psi = array![[1.0_f64, 0.0], [1.0, 1.0], [1.0, 2.0]];
5936 let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1], 2)
5937 .expect("cycle-escape cone");
5938 let set = ConstraintSet::KhatriRaoCone(cone);
5939 let ops = ConstraintSetOps::new(&set, 0.0).expect("operator geometry");
5940 let x = Array1::<f64>::zeros(4);
5941 let d_total = Array1::<f64>::zeros(4);
5942 let gradient = array![0.0_f64, 0.0, 0.0, -1.0];
5946 let (direction, active) = fallback_projected_gradient_direction_with_constraint_set(
5947 &x,
5948 &x,
5949 &d_total,
5950 &gradient,
5951 &[0],
5952 &ops,
5953 )
5954 .expect("operator fallback evaluation")
5955 .expect("a certified tangent descent direction must exist");
5956
5957 assert!(
5958 gradient.dot(&direction) < 0.0,
5959 "escape must be a strict descent direction"
5960 );
5961 let candidate = &x + &direction;
5962 let (worst, _) = set
5963 .max_scaled_violation(candidate.view())
5964 .expect("full-set feasibility");
5965 assert!(
5966 worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL,
5967 "escape must remain feasible on every operator row: {worst:.3e}"
5968 );
5969 assert_eq!(
5970 active,
5971 vec![0],
5972 "operator escape expanded one sparse face row into all tight rows"
5973 );
5974 }
5975
5976 #[test]
5977 fn dependent_blocker_triggers_geometry_complete_stationarity_979() {
5978 let psi = array![[1.0_f64, 0.0], [0.0, 1.0], [1.0, 1.0]];
5984 let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1], 2)
5985 .expect("dependent-blocker cone");
5986 let set = ConstraintSet::KhatriRaoCone(cone);
5987 let ops = ConstraintSetOps::new(&set, 0.0).expect("operator geometry");
5988 let current = ops.compress_working(&[0, 1]).expect("current face");
5989 let expanded = ops
5990 .compress_working(&[0, 1, 2])
5991 .expect("expanded face");
5992 assert_eq!(current.constraints.a.nrows(), 2);
5993 assert_eq!(
5994 expanded.constraints.a.nrows(),
5995 current.constraints.a.nrows(),
5996 "the dependent blocker must not masquerade as a new tangent dimension"
5997 );
5998
5999 let beta = Array1::<f64>::zeros(4);
6004 let gradient = array![0.0_f64, 0.0, 1.0, 1.0];
6005 let (direction, _) = fallback_projected_gradient_direction_with_constraint_set(
6006 &beta,
6007 &beta,
6008 &Array1::<f64>::zeros(4),
6009 &gradient,
6010 &[0, 1, 2],
6011 &ops,
6012 )
6013 .expect("operator stationarity projection")
6014 .expect("dependent face has a certified projected endpoint");
6015 assert!(
6016 direction.iter().all(|value| *value == 0.0),
6017 "stationary dependent face returned a spurious direction: {direction:?}"
6018 );
6019 }
6020
6021 #[test]
6022 fn operator_tangent_projection_does_not_constrain_interior_rows() {
6023 let psi = array![[1.0_f64, 0.0], [1.0, 1.0], [1.0, -1.0]];
6024 let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1], 2)
6025 .expect("interior tangent cone");
6026 let set = ConstraintSet::KhatriRaoCone(cone);
6027 let beta = array![0.0_f64, 0.0, 1.0, 0.0];
6032 let residual = array![0.0_f64, 0.0, 1.0, 0.0];
6033 let (projected, active) =
6034 project_stationarity_residual_on_constraint_set(&residual, &beta, &set, &[])
6035 .expect("interior tangent projection");
6036
6037 for index in 0..residual.len() {
6038 assert_relative_eq!(projected[index], residual[index], epsilon = 1e-12);
6039 }
6040 assert!(active.is_empty(), "interior rows entered the tangent face");
6041 }
6042
6043 #[test]
6044 fn operator_tangent_projection_homogenizes_an_affine_boundary() {
6045 let set = ConstraintSet::Dense(
6046 LinearInequalityConstraints::new(array![[1.0_f64, 0.0]], array![2.0])
6047 .expect("affine half-space"),
6048 );
6049 let beta = array![2.0_f64, 0.0];
6050 let residual = array![1.0_f64, -1.0];
6051 let (projected, active) =
6052 project_stationarity_residual_on_constraint_set(&residual, &beta, &set, &[0])
6053 .expect("affine-boundary tangent projection");
6054
6055 assert_relative_eq!(projected[0], 0.0, epsilon = 1e-12);
6056 assert_relative_eq!(projected[1], -1.0, epsilon = 1e-12);
6057 assert_eq!(active, vec![0]);
6058 }
6059
6060 #[test]
6061 fn operator_cycle_escape_discovers_a_zero_step_tangent_separator() {
6062 let psi = array![[1.0_f64, 0.0], [1.0, 1.0], [1.0, -1.0]];
6067 let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1], 2)
6068 .expect("separator cone");
6069 let set = ConstraintSet::KhatriRaoCone(cone);
6070 let ops = ConstraintSetOps::new(&set, 0.0).expect("operator geometry");
6071 let x = Array1::<f64>::zeros(4);
6072 let d_total = Array1::<f64>::zeros(4);
6073 let gradient = array![0.0_f64, 0.0, 0.0, -1.0];
6074 let (direction, active) = fallback_projected_gradient_direction_with_constraint_set(
6075 &x,
6076 &x,
6077 &d_total,
6078 &gradient,
6079 &[0],
6080 &ops,
6081 )
6082 .expect("operator separator evaluation")
6083 .expect("one omitted tight separator must not defeat the escape");
6084
6085 assert!(gradient.dot(&direction) < 0.0);
6086 let candidate = &x + &direction;
6087 let (worst, _) = set
6088 .max_scaled_violation(candidate.view())
6089 .expect("full-set feasibility");
6090 assert!(worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL);
6091 assert!(
6092 active.len() <= 2,
6093 "separator discovery expanded a three-row vertex: {active:?}"
6094 );
6095 }
6096}