use crate::estimate::EstimationError;
use faer::linalg::solvers::{Lblt as FaerLblt, Solve as FaerSolve, SolveLstsq};
use faer::Side;
use gam_linalg::faer_ndarray::{FaerArrayView, FaerLinalgError, FaerSvd, array1_to_col_matmut};
use gam_linalg::utils::{StableSolver, array_is_finite, boundary_hit_step_fraction};
use gam_problem::{
ConstraintRowId, ConstraintSet, KhatriRaoConeConstraints, LinearInequalityConstraints,
};
use ndarray::{Array1, Array2, s};
use serde::{Deserialize, Serialize};
use std::cell::Cell;
use std::collections::HashSet;
pub const ACTIVE_SET_PRIMAL_FEASIBILITY_TOL: f64 = 1e-8;
pub const ACTIVE_SET_WORKING_FACE_TOL: f64 = 1e-10;
#[inline]
fn active_set_boundary_hit_step_fraction(
scaled_slack: f64,
scaled_directional_change: f64,
current_step_limit: f64,
) -> Option<f64> {
boundary_hit_step_fraction(
scaled_slack.max(0.0),
scaled_directional_change,
current_step_limit,
)
}
const ACTIVE_SET_KKT_STATIONARITY_TOL: f64 = 2e-6;
const ACTIVE_SET_KKT_COMPLEMENTARITY_TOL: f64 = 1e-6;
const ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL: f64 = 1e-8;
pub(crate) const ACTIVE_SET_KKT_DEGENERATE_STATIONARITY_TOL: f64 = 1e-3;
const ACTIVE_SET_MODEL_DESCENT_REL_TOL: f64 = 1e-10;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ConstraintKktDiagnostics {
pub n_constraints: usize,
pub n_active: usize,
pub primal_feasibility: f64,
pub dual_feasibility: f64,
pub complementarity: f64,
pub stationarity: f64,
pub active_tolerance: f64,
#[serde(default)]
pub working_set_rank_deficient: bool,
#[serde(default)]
pub gradient_scale: f64,
}
fn gradient_inf_norm(gradient: &Array1<f64>) -> f64 {
gradient.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()))
}
fn solve_newton_direction_dense(
hessian: &Array2<f64>,
gradient: &Array1<f64>,
direction_out: &mut Array1<f64>,
) -> Result<(), EstimationError> {
if direction_out.len() != gradient.len() {
*direction_out = Array1::zeros(gradient.len());
}
let factor = StableSolver::new()
.factorize(hessian)
.map_err(EstimationError::LinearSystemSolveFailed)?;
direction_out.assign(gradient);
let mut rhsview = array1_to_col_matmut(direction_out);
factor.solve_in_place(rhsview.as_mut());
direction_out.mapv_inplace(|v| -v);
if array_is_finite(direction_out) {
return Ok(());
}
Err(EstimationError::LinearSystemSolveFailed(
FaerLinalgError::FactorizationFailed {
context: "active-set newton direction non-finite solve",
},
))
}
fn solve_dense_system_via_pseudoinverse(
matrix: &Array2<f64>,
rhs: &Array1<f64>,
out: &mut Array1<f64>,
) -> Result<(), EstimationError> {
if matrix.nrows() != matrix.ncols() || rhs.len() != matrix.nrows() {
crate::bail_invalid_estim!("dense pseudoinverse solve dimension mismatch");
}
let (u_opt, singular, vt_opt) = matrix.svd(true, true).map_err(|_| {
EstimationError::InvalidInput("dense pseudoinverse solve SVD failed".to_string())
})?;
let (Some(u), Some(vt)) = (u_opt, vt_opt) else {
crate::bail_invalid_estim!("dense pseudoinverse solve missing singular vectors");
};
let max_singular = singular.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
let tol = 100.0
* f64::EPSILON
* (matrix.nrows().max(matrix.ncols()).max(1) as f64)
* max_singular.max(1.0);
let mut coeff = u.t().dot(rhs);
for (idx, value) in coeff.iter_mut().enumerate() {
let sigma = singular[idx];
if sigma.abs() > tol {
*value /= sigma;
} else {
*value = 0.0;
}
}
let solution = vt.t().dot(&coeff);
if !array_is_finite(&solution) {
crate::bail_invalid_estim!("dense pseudoinverse solve produced non-finite values");
}
if out.len() != solution.len() {
*out = Array1::zeros(solution.len());
}
out.assign(&solution);
Ok(())
}
fn least_squares_min_norm_any_shape(a: &Array2<f64>, b: &Array1<f64>) -> Option<Array1<f64>> {
let p = a.nrows();
let k = a.ncols();
if b.len() != p {
return None;
}
if k == 0 {
return Some(Array1::zeros(0));
}
if k <= p {
let mut rhs = Array2::<f64>::zeros((p, 1));
rhs.column_mut(0).assign(b);
let a_view = FaerArrayView::new(a);
let rhs_view = FaerArrayView::new(&rhs);
let solved = a_view.as_ref().col_piv_qr().solve_lstsq(rhs_view.as_ref());
let mut z = Array1::<f64>::zeros(k);
for c in 0..k {
let value = solved[(c, 0)];
if !value.is_finite() {
return None;
}
z[c] = value;
}
Some(z)
} else {
let gram = a.dot(&a.t());
let mut y = Array1::<f64>::zeros(p);
solve_dense_system_via_pseudoinverse(&gram, b, &mut y).ok()?;
let z = a.t().dot(&y);
if z.iter().any(|value| !value.is_finite()) {
return None;
}
Some(z)
}
}
pub(crate) fn compute_constraint_kkt_diagnostics(
beta: &Array1<f64>,
gradient: &Array1<f64>,
constraints: &LinearInequalityConstraints,
) -> ConstraintKktDiagnostics {
let m = constraints.a.nrows();
let active_tolerance = ACTIVE_SET_PRIMAL_FEASIBILITY_TOL;
let p = constraints.a.ncols();
let mut a_scaled = constraints.a.clone();
let mut b_scaled = constraints.b.clone();
for i in 0..m {
let n_i = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
if n_i > 0.0 {
let inv = 1.0 / n_i;
a_scaled.row_mut(i).mapv_inplace(|v| v * inv);
b_scaled[i] *= inv;
}
}
let mut slack = Array1::<f64>::zeros(m);
let mut primal_feasibility: f64 = 0.0;
for i in 0..m {
let s_i = a_scaled.row(i).dot(beta) - b_scaled[i];
slack[i] = s_i;
primal_feasibility = primal_feasibility.max((-s_i).max(0.0));
}
let active_idx: Vec<usize> = (0..m).filter(|&i| slack[i] <= active_tolerance).collect();
let mut lambda = Array1::<f64>::zeros(m);
let mut working_set_rank_deficient = false;
if !active_idx.is_empty() {
let n_active = active_idx.len();
let mut a_active = Array2::<f64>::zeros((n_active, p));
for (r, &idx) in active_idx.iter().enumerate() {
a_active.row_mut(r).assign(&a_scaled.row(idx));
}
if let Some((_, lambda_active)) =
project_stationarity_residual_on_constraint_cone(gradient, &a_active)
{
for (r, &idx) in active_idx.iter().enumerate() {
lambda[idx] = lambda_active[r];
}
}
working_set_rank_deficient = if n_active > p {
true
} else if n_active > 1 {
let groups: Vec<Vec<usize>> = (0..n_active).map(|i| vec![i]).collect();
let b_dummy = Array1::<f64>::zeros(n_active);
let (reduced_a, _, _, _) =
rank_reduce_rows_pivoted_qr_with_dependence(a_active, b_dummy, groups);
reduced_a.nrows() < n_active
} else {
false
};
}
let mut dual_feasibility: f64 = 0.0;
let mut complementarity: f64 = 0.0;
for i in 0..m {
dual_feasibility = dual_feasibility.max((-lambda[i]).max(0.0));
complementarity = complementarity.max((lambda[i] * slack[i]).abs());
}
let stationarity = {
let mut resid = gradient.to_owned();
resid -= &a_scaled.t().dot(&lambda);
resid.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()))
};
ConstraintKktDiagnostics {
n_constraints: m,
n_active: active_idx.len(),
primal_feasibility,
dual_feasibility,
complementarity,
stationarity,
active_tolerance,
working_set_rank_deficient,
gradient_scale: gradient_inf_norm(gradient),
}
}
pub(crate) fn nonnegative_cone_multipliers(
rows: &Array2<f64>,
target: &Array1<f64>,
) -> Option<(Array1<f64>, Array1<f64>)> {
let p = target.len();
let m = rows.nrows();
if rows.ncols() != p {
return None;
}
if m == 0 {
return Some((Array1::zeros(0), target.clone()));
}
if target.iter().any(|v| !v.is_finite()) || rows.iter().any(|v| !v.is_finite()) {
return None;
}
let mut norms = Array1::<f64>::zeros(m);
let mut unit = Array2::<f64>::zeros((m, p));
for i in 0..m {
let norm = rows.row(i).dot(&rows.row(i)).sqrt();
norms[i] = norm;
if norm > 0.0 {
unit.row_mut(i).assign(&(&rows.row(i) / norm));
}
}
let target_inf = target.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
if target_inf == 0.0 {
return Some((Array1::zeros(m), target.clone()));
}
let tol_w = 1e-10 * target_inf;
let lambda_floor = 1e-14 * target_inf;
let mut lambda_unit = Array1::<f64>::zeros(m);
let mut passive: Vec<usize> = Vec::new();
let mut in_passive = vec![false; m];
let mut residual = target.clone();
let mut banned = vec![false; m];
let solve_passive = |passive: &[usize]| -> Option<Array1<f64>> {
let k = passive.len();
let mut design = Array2::<f64>::zeros((p, k));
for (col, &row) in passive.iter().enumerate() {
design.column_mut(col).assign(&unit.row(row));
}
least_squares_min_norm_any_shape(&design, target)
};
let max_outer = 3 * m + 30;
for _ in 0..max_outer {
let mut best: Option<(usize, f64)> = None;
for i in 0..m {
if in_passive[i] || banned[i] || norms[i] <= 0.0 {
continue;
}
let w = unit.row(i).dot(&residual);
if w > tol_w && best.map(|(_, bw)| w > bw).unwrap_or(true) {
best = Some((i, w));
}
}
let Some((entering, _)) = best else {
break;
};
passive.push(entering);
in_passive[entering] = true;
let mut inner_ok = false;
for _ in 0..(m + 2) {
let Some(z) = solve_passive(&passive) else {
return None;
};
let min_z = z.iter().copied().fold(f64::INFINITY, f64::min);
if min_z > lambda_floor {
for (pos, &row) in passive.iter().enumerate() {
lambda_unit[row] = z[pos];
}
inner_ok = true;
break;
}
let mut alpha = 1.0_f64;
for (pos, &row) in passive.iter().enumerate() {
if z[pos] <= lambda_floor {
let current = lambda_unit[row];
let denom = current - z[pos];
if denom > 0.0 {
alpha = alpha.min((current / denom).clamp(0.0, 1.0));
} else {
alpha = 0.0;
}
}
}
for (pos, &row) in passive.iter().enumerate() {
lambda_unit[row] += alpha * (z[pos] - lambda_unit[row]);
}
let mut retained = Vec::with_capacity(passive.len());
for &row in &passive {
if lambda_unit[row] > lambda_floor {
retained.push(row);
} else {
lambda_unit[row] = 0.0;
in_passive[row] = false;
banned[row] = true;
}
}
if retained.len() == passive.len() {
inner_ok = true;
for (pos, &row) in passive.iter().enumerate() {
lambda_unit[row] = z[pos].max(0.0);
}
break;
}
passive = retained;
if passive.is_empty() {
break;
}
}
let mut fitted = Array1::<f64>::zeros(p);
for &row in &passive {
fitted.scaled_add(lambda_unit[row], &unit.row(row));
}
let new_residual = target - &fitted;
let moved = new_residual
.iter()
.zip(residual.iter())
.any(|(a, b)| (a - b).abs() > 1e-15 * target_inf);
residual = new_residual;
if moved {
banned.iter_mut().for_each(|b| *b = false);
} else if !inner_ok {
break;
}
}
let mut lambda = Array1::<f64>::zeros(m);
for i in 0..m {
if norms[i] > 0.0 {
lambda[i] = lambda_unit[i] / norms[i];
}
}
if !array_is_finite(&lambda) || !array_is_finite(&residual) {
return None;
}
Some((lambda, residual))
}
pub fn project_stationarity_residual_on_constraint_cone(
residual: &Array1<f64>,
active_a: &Array2<f64>,
) -> Option<(Array1<f64>, Array1<f64>)> {
let p = residual.len();
if active_a.ncols() != p {
return None;
}
if active_a.nrows() == 0 {
return Some((residual.clone(), Array1::zeros(0)));
}
if let Some(result) = moreau_projection_via_primal_qp(residual, active_a) {
return Some(result);
}
nonnegative_cone_multipliers(active_a, residual).map(|(lambda, projected)| (projected, lambda))
}
fn moreau_projection_via_primal_qp(
residual: &Array1<f64>,
active_a: &Array2<f64>,
) -> Option<(Array1<f64>, Array1<f64>)> {
let p = residual.len();
let m = active_a.nrows();
let constraints = LinearInequalityConstraints::new(active_a.clone(), Array1::<f64>::zeros(m))
.ok()?
.canonicalized()
.ok()?;
let identity = Array2::<f64>::eye(p);
let origin = Array1::<f64>::zeros(p);
let mut tangent_direction = Array1::<f64>::zeros(p);
let mut tangent_active = Vec::new();
let max_iterations = (p + m + 8) * 4;
solve_newton_direction_with_linear_constraints_impl(
&identity,
residual,
&origin,
&constraints,
&mut tangent_direction,
Some(&mut tangent_active),
max_iterations,
false,
)
.ok()?;
if !array_is_finite(&tangent_direction) {
return None;
}
let projected = -&tangent_direction;
let mut lambda_canonical = Array1::<f64>::zeros(m);
if !tangent_active.is_empty() {
let gathered = gather_linear_constraint_rows(&constraints, &tangent_active).ok()?;
let design = gathered.a.t().to_owned();
let solved = least_squares_min_norm_any_shape(&design, &(residual + &tangent_direction))?;
let scale = residual
.iter()
.fold(0.0_f64, |acc, &value| acc.max(value.abs()))
.max(1.0);
let tol = 100.0 * f64::EPSILON * (p.max(m) as f64) * scale;
for (position, &row) in tangent_active.iter().enumerate() {
let value = solved[position];
if !value.is_finite() || value < -tol {
return None;
}
lambda_canonical[row] = value.max(0.0);
}
}
let reconstructed = residual - &constraints.a.t().dot(&lambda_canonical);
let reconstruction_error = reconstructed
.iter()
.zip(projected.iter())
.fold(0.0_f64, |acc, (&left, &right)| {
acc.max((left - right).abs())
});
let scale = residual
.iter()
.fold(0.0_f64, |acc, &value| acc.max(value.abs()))
.max(1.0);
if reconstruction_error > 1e-8 * scale || !array_is_finite(&lambda_canonical) {
return None;
}
let mut lambda = Array1::<f64>::zeros(m);
for row in 0..m {
let norm = active_a.row(row).dot(&active_a.row(row)).sqrt();
if norm > 0.0 {
lambda[row] = lambda_canonical[row] / norm;
}
}
Some((projected, lambda))
}
pub(crate) fn feasible_point_for_linear_constraints(
constraints: &LinearInequalityConstraints,
p: usize,
) -> Option<Array1<f64>> {
if constraints.a.ncols() != p
|| constraints.a.nrows() == 0
|| constraints.b.len() != constraints.a.nrows()
{
return None;
}
let mut all_scaled_b_tiny = true;
for i in 0..constraints.a.nrows() {
let norm = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
if norm > 0.0 {
if constraints.b[i].abs() > 1e-14 * norm {
all_scaled_b_tiny = false;
}
} else if constraints.b[i] > 0.0 {
return None;
}
}
if all_scaled_b_tiny {
return Some(Array1::zeros(p));
}
let gram = constraints.a.dot(&constraints.a.t());
let (u_opt, singular, vt_opt) = gram.svd(true, true).ok()?;
let (Some(u), Some(vt)) = (u_opt, vt_opt) else {
return None;
};
let max_singular = singular.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
let tol = 100.0 * f64::EPSILON * constraints.a.nrows().max(1) as f64 * max_singular;
let mut coeff = u.t().dot(&constraints.b);
for (idx, value) in coeff.iter_mut().enumerate() {
let sigma = singular[idx];
if sigma.abs() > tol {
*value /= sigma;
} else {
*value = 0.0;
}
}
let dual = vt.t().dot(&coeff);
let beta = constraints.a.t().dot(&dual);
if beta.len() != p || beta.iter().any(|v| !v.is_finite()) {
return None;
}
let feasible = (0..constraints.a.nrows()).all(|i| {
let norm = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
if norm > 0.0 {
(constraints.a.row(i).dot(&beta) - constraints.b[i]) / norm >= -1e-8
} else {
constraints.b[i] <= 0.0
}
});
if feasible { Some(beta) } else { None }
}
const ACTIVE_SET_INTERIOR_SEED_MARGIN: f64 = 1e-6;
#[inline]
pub(crate) fn interior_seed_margin() -> f64 {
ACTIVE_SET_INTERIOR_SEED_MARGIN
}
const MAX_FEASIBILITY_REPAIR_DEPTH: u32 = 16;
thread_local! {
static FEASIBILITY_REPAIR_DEPTH: Cell<u32> = const { Cell::new(0) };
}
struct FeasibilityRepairGuard;
impl FeasibilityRepairGuard {
fn enter() -> Option<Self> {
FEASIBILITY_REPAIR_DEPTH.with(|depth| {
let current = depth.get();
if current >= MAX_FEASIBILITY_REPAIR_DEPTH {
None
} else {
depth.set(current + 1);
Some(Self)
}
})
}
}
impl Drop for FeasibilityRepairGuard {
fn drop(&mut self) {
FEASIBILITY_REPAIR_DEPTH.with(|depth| depth.set(depth.get().saturating_sub(1)));
}
}
pub fn project_point_strictly_into_feasible_cone(
point: &Array1<f64>,
constraints: &LinearInequalityConstraints,
) -> Option<Array1<f64>> {
let repair_guard = FeasibilityRepairGuard::enter()?;
let p = point.len();
let m = constraints.a.nrows();
if constraints.a.ncols() != p || m == 0 || constraints.b.len() != m {
return None;
}
let norms: Vec<f64> = (0..m)
.map(|i| constraints.a.row(i).dot(&constraints.a.row(i)).sqrt())
.collect();
const ANTIPARALLEL_COS_TOL: f64 = -1.0 + 1e-9;
const EQUALITY_WIDTH_TOL: f64 = 1e-9;
let mut is_equality_member = vec![false; m];
let mut equality_rows: Vec<usize> = Vec::new();
let mut margin = vec![ACTIVE_SET_INTERIOR_SEED_MARGIN; m];
for i in 0..m {
if norms[i] == 0.0 {
margin[i] = 0.0;
continue;
}
for j in (i + 1)..m {
if norms[j] == 0.0 {
continue;
}
let cos = constraints.a.row(i).dot(&constraints.a.row(j)) / (norms[i] * norms[j]);
if cos > ANTIPARALLEL_COS_TOL {
continue;
}
let width = -constraints.b[j] / norms[j] - constraints.b[i] / norms[i];
if width.abs() <= EQUALITY_WIDTH_TOL {
if !is_equality_member[i] && !is_equality_member[j] {
equality_rows.push(i);
}
is_equality_member[i] = true;
is_equality_member[j] = true;
} else {
let cap = (width / 3.0).max(0.0);
margin[i] = margin[i].min(cap);
margin[j] = margin[j].min(cap);
}
}
}
let ineq_rows: Vec<usize> = (0..m).filter(|&i| !is_equality_member[i]).collect();
let mut a_ineq = Array2::<f64>::zeros((ineq_rows.len(), p));
let mut b_ineq = Array1::<f64>::zeros(ineq_rows.len());
for (r, &i) in ineq_rows.iter().enumerate() {
a_ineq.row_mut(r).assign(&constraints.a.row(i));
b_ineq[r] = constraints.b[i] + margin[i] * norms[i];
}
let beta = if equality_rows.is_empty() {
let interior = LinearInequalityConstraints::new(a_ineq, b_ineq)
.expect("shifted interior constraint shape invariant");
let identity = Array2::<f64>::eye(p);
solve_quadratic_with_linear_constraints(&identity, point, point, &interior, None)
.ok()?
.0
} else {
let k = equality_rows.len();
let mut e_mat = Array2::<f64>::zeros((k, p));
let mut e_rhs = Array1::<f64>::zeros(k);
for (r, &i) in equality_rows.iter().enumerate() {
e_mat.row_mut(r).assign(&constraints.a.row(i));
e_rhs[r] = constraints.b[i];
}
let (u_opt, sing, vt_opt) = e_mat.svd(true, true).ok()?;
let (u_mat, vt) = (u_opt?, vt_opt?);
let smax = sing.iter().fold(0.0_f64, |acc, &v| acc.max(v));
let rank_tol = smax.max(1.0) * (k.max(p) as f64) * f64::EPSILON * 100.0;
let rank = sing.iter().filter(|&&s| s > rank_tol).count();
if rank == 0 || rank >= p {
return None;
}
let mut beta_p = Array1::<f64>::zeros(p);
for idx in 0..rank {
let coeff = u_mat.column(idx).dot(&e_rhs) / sing[idx];
beta_p.scaled_add(coeff, &vt.row(idx));
}
let mut basis: Vec<Array1<f64>> = (0..rank).map(|i| vt.row(i).to_owned()).collect();
let mut z = Array2::<f64>::zeros((p, p - rank));
let mut collected = 0usize;
for axis in 0..p {
if collected == p - rank {
break;
}
let mut v = Array1::<f64>::zeros(p);
v[axis] = 1.0;
for q in basis.iter() {
let c = q.dot(&v);
v.scaled_add(-c, q);
}
let nrm = v.dot(&v).sqrt();
if nrm > 1e-8 {
v /= nrm;
z.column_mut(collected).assign(&v);
basis.push(v);
collected += 1;
}
}
if collected != p - rank {
return None;
}
let a_red = a_ineq.dot(&z);
let b_red = &b_ineq - &a_ineq.dot(&beta_p);
let u0 = z.t().dot(&(point - &beta_p));
let reduced = LinearInequalityConstraints::new(a_red, b_red)
.expect("reduced constraint shape invariant");
let identity = Array2::<f64>::eye(z.ncols());
let (u_sol, _active) =
solve_quadratic_with_linear_constraints(&identity, &u0, &u0, &reduced, None).ok()?;
&beta_p + &z.dot(&u_sol)
};
if beta.len() != p || beta.iter().any(|v| !v.is_finite()) {
return None;
}
const SEED_FEASIBILITY_TOL: f64 = 1e-9;
for i in 0..m {
let s = scaled_constraint_slack(&beta, constraints, i);
let lower = if is_equality_member[i] {
-SEED_FEASIBILITY_TOL
} else {
0.5 * margin[i] - SEED_FEASIBILITY_TOL
};
if s < lower {
return None;
}
}
drop(repair_guard);
Some(beta)
}
fn max_linear_constraint_violation(
beta: &Array1<f64>,
constraints: &LinearInequalityConstraints,
) -> (f64, usize) {
let mut worst = 0.0_f64;
let mut worst_row = 0usize;
for i in 0..constraints.a.nrows() {
let slack = scaled_constraint_slack(beta, constraints, i);
let viol = (-slack).max(0.0);
if viol > worst {
worst = viol;
worst_row = i;
}
}
(worst, worst_row)
}
#[inline]
fn scaled_constraint_slack(
beta: &Array1<f64>,
constraints: &LinearInequalityConstraints,
i: usize,
) -> f64 {
let norm = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
if norm > 0.0 {
(constraints.a.row(i).dot(beta) - constraints.b[i]) / norm
} else if constraints.b[i] > 0.0 {
f64::NEG_INFINITY
} else {
f64::INFINITY
}
}
pub(crate) fn solve_kkt_direction(
hessian: &Array2<f64>,
gradient: &Array1<f64>,
active_a: &Array2<f64>,
active_residual: Option<&Array1<f64>>,
) -> Result<(Array1<f64>, Array1<f64>), EstimationError> {
let p = hessian.nrows();
let m = active_a.nrows();
if hessian.ncols() != p || gradient.len() != p || active_a.ncols() != p {
crate::bail_invalid_estim!("KKT solve dimension mismatch");
}
if let Some(residual) = active_residual
&& residual.len() != m
{
crate::bail_invalid_estim!(
"KKT active residual length mismatch: got {}, expected {}",
residual.len(),
m
);
}
if m == 0 {
let mut d = Array1::<f64>::zeros(p);
solve_newton_direction_dense(hessian, gradient, &mut d)?;
return Ok((d, Array1::zeros(0)));
}
let mut kkt = Array2::<f64>::zeros((p + m, p + m));
kkt.slice_mut(s![0..p, 0..p]).assign(hessian);
kkt.slice_mut(s![0..p, p..(p + m)]).assign(&active_a.t());
kkt.slice_mut(s![p..(p + m), 0..p]).assign(active_a);
let mut rhs = Array1::<f64>::zeros(p + m);
for i in 0..p {
rhs[i] = -gradient[i];
}
if let Some(residual) = active_residual {
for i in 0..m {
rhs[p + i] = residual[i];
}
}
let rhs_target = rhs.clone();
let kkt_view = FaerArrayView::new(&kkt);
let factor = FaerLblt::new(kkt_view.as_ref(), Side::Lower);
let mut rhs_col = array1_to_col_matmut(&mut rhs);
factor.solve_in_place(rhs_col.as_mut());
if !rhs.iter().all(|v| v.is_finite()) {
solve_dense_system_via_pseudoinverse(&kkt, &rhs_target, &mut rhs)?;
}
let d = rhs.slice(s![0..p]).to_owned();
let lambda = rhs.slice(s![p..(p + m)]).to_owned();
Ok((d, lambda))
}
#[derive(Clone, Debug)]
pub(crate) struct CompressedActiveWorkingSet {
pub(crate) constraints: LinearInequalityConstraints,
pub(crate) groups: Vec<Vec<usize>>,
pub(crate) original_active_count: usize,
}
#[derive(Clone, Copy, Debug)]
pub struct ActiveRowDependence {
pub active_pos: usize,
pub coeff: f64,
}
#[derive(Clone, Copy, Debug)]
pub struct ConstraintRowDependence {
pub row: ConstraintRowId,
pub coeff: f64,
}
#[derive(Clone, Debug)]
pub struct ReducedFace {
pub representatives: Vec<ConstraintRowId>,
pub dependence: Vec<Vec<ConstraintRowDependence>>,
pub tight_rows: Vec<ConstraintRowId>,
}
pub fn khatri_rao_cone_reduced_face(
cone: &KhatriRaoConeConstraints,
beta: ndarray::ArrayView1<'_, f64>,
membership_tol: f64,
) -> Result<ReducedFace, EstimationError> {
let psi = cone.factor();
let n = psi.nrows();
let p_cov = psi.ncols();
let coupled = cone.coupled_rows();
let values = cone.values(beta).map_err(|error| {
EstimationError::ParameterConstraintViolation(format!(
"Khatri-Rao cone reduced-face values: {error}"
))
})?;
let row_norms: Vec<f64> = (0..n)
.map(|i| {
let row = psi.row(i);
row.dot(&row).sqrt()
})
.collect();
const RANK_ALPHA: f64 = 100.0;
const PARALLEL_COS_TOL: f64 = 1.0 - 1e-9;
let mut representatives: Vec<ConstraintRowId> = Vec::new();
let mut dependence: Vec<Vec<ConstraintRowDependence>> = Vec::new();
let mut tight_rows: Vec<ConstraintRowId> = Vec::new();
for slot in 0..coupled.len() {
let mut tight_obs: Vec<usize> = Vec::new();
for i in 0..n {
let norm_i = row_norms[i];
if norm_i <= 0.0 {
continue;
}
let scaled_slack = values[slot * n + i] / norm_i;
if scaled_slack <= membership_tol {
tight_rows.push(ConstraintRowId(slot * n + i));
tight_obs.push(i);
}
}
if tight_obs.is_empty() {
continue;
}
let max_norm = tight_obs
.iter()
.map(|&i| row_norms[i])
.fold(0.0_f64, f64::max);
let rank_tol =
RANK_ALPHA * f64::EPSILON * (tight_obs.len().max(p_cov).max(1) as f64) * max_norm;
let mut ortho_basis: Vec<Array1<f64>> = Vec::new();
let mut kept: Vec<(usize, Array1<f64>, usize)> = Vec::new();
for &i in &tight_obs {
let psi_i = psi.row(i).to_owned();
let mut resid = psi_i.clone();
for q in &ortho_basis {
let proj = resid.dot(q);
resid.scaled_add(-proj, q);
}
let resid_norm = resid.dot(&resid).sqrt();
let flat = ConstraintRowId(slot * n + i);
if resid_norm > rank_tol {
ortho_basis.push(&resid / resid_norm);
let out_idx = representatives.len();
representatives.push(flat);
dependence.push(Vec::new());
kept.push((i, psi_i, out_idx));
} else {
let mut best_abs_cos = 0.0_f64;
let mut best: Option<(usize, f64)> = None;
for (rep_obs, rep_psi, rep_out_idx) in &kept {
let rep_norm = row_norms[*rep_obs];
let dot = psi_i.dot(rep_psi);
let cos = if rep_norm > 0.0 {
dot / (row_norms[i] * rep_norm)
} else {
0.0
};
if cos.abs() > best_abs_cos {
best_abs_cos = cos.abs();
best = Some((*rep_out_idx, dot / (rep_norm * rep_norm)));
}
}
if best_abs_cos >= PARALLEL_COS_TOL {
if let Some((out_idx, coeff)) = best {
dependence[out_idx].push(ConstraintRowDependence {
row: flat,
coeff,
});
}
}
}
}
}
Ok(ReducedFace {
representatives,
dependence,
tight_rows,
})
}
pub fn dense_reduced_face(
lin: &LinearInequalityConstraints,
beta: ndarray::ArrayView1<'_, f64>,
membership_tol: f64,
) -> Result<ReducedFace, EstimationError> {
let a = &lin.a;
let b = &lin.b;
let n = a.nrows();
let p = a.ncols();
let row_norms: Vec<f64> = (0..n)
.map(|i| {
let row = a.row(i);
row.dot(&row).sqrt()
})
.collect();
const RANK_ALPHA: f64 = 100.0;
const PARALLEL_COS_TOL: f64 = 1.0 - 1e-9;
let mut tight: Vec<usize> = Vec::new();
for i in 0..n {
let norm_i = row_norms[i];
if norm_i <= 0.0 {
continue;
}
let scaled_slack = (a.row(i).dot(&beta) - b[i]) / norm_i;
if scaled_slack <= membership_tol {
tight.push(i);
}
}
let mut representatives: Vec<ConstraintRowId> = Vec::new();
let mut dependence: Vec<Vec<ConstraintRowDependence>> = Vec::new();
if tight.is_empty() {
return Ok(ReducedFace {
representatives,
dependence,
tight_rows: Vec::new(),
});
}
let max_norm = tight
.iter()
.map(|&i| row_norms[i])
.fold(0.0_f64, f64::max);
let rank_tol = RANK_ALPHA * f64::EPSILON * (tight.len().max(p).max(1) as f64) * max_norm;
let mut ortho_basis: Vec<Array1<f64>> = Vec::new();
let mut kept: Vec<(usize, Array1<f64>, usize)> = Vec::new();
for &i in &tight {
let a_i = a.row(i).to_owned();
let mut resid = a_i.clone();
for q in &ortho_basis {
let proj = resid.dot(q);
resid.scaled_add(-proj, q);
}
let resid_norm = resid.dot(&resid).sqrt();
if resid_norm > rank_tol {
ortho_basis.push(&resid / resid_norm);
let out_idx = representatives.len();
representatives.push(ConstraintRowId(i));
dependence.push(Vec::new());
kept.push((i, a_i, out_idx));
} else {
let mut best_abs_cos = 0.0_f64;
let mut best: Option<(usize, f64)> = None;
for (rep_row, rep_a, rep_out_idx) in &kept {
let rep_norm = row_norms[*rep_row];
let dot = a_i.dot(rep_a);
let cos = if rep_norm > 0.0 {
dot / (row_norms[i] * rep_norm)
} else {
0.0
};
if cos.abs() > best_abs_cos {
best_abs_cos = cos.abs();
best = Some((*rep_out_idx, dot / (rep_norm * rep_norm)));
}
}
if best_abs_cos >= PARALLEL_COS_TOL {
if let Some((out_idx, coeff)) = best {
dependence[out_idx].push(ConstraintRowDependence {
row: ConstraintRowId(i),
coeff,
});
}
}
}
}
Ok(ReducedFace {
representatives,
dependence,
tight_rows: tight.into_iter().map(ConstraintRowId).collect(),
})
}
#[inline]
fn lift_member_row(local: ConstraintRowId, row_offset: usize) -> ConstraintRowId {
ConstraintRowId(local.index() + row_offset)
}
pub trait ConstraintSetReducedFace {
fn reduced_face(
&self,
beta: ndarray::ArrayView1<'_, f64>,
membership_tol: f64,
) -> Result<ReducedFace, EstimationError>;
}
impl ConstraintSetReducedFace for ConstraintSet {
fn reduced_face(
&self,
beta: ndarray::ArrayView1<'_, f64>,
membership_tol: f64,
) -> Result<ReducedFace, EstimationError> {
match self {
ConstraintSet::Dense(lin) => dense_reduced_face(lin, beta, membership_tol),
ConstraintSet::KhatriRaoCone(cone) => {
khatri_rao_cone_reduced_face(cone, beta, membership_tol)
}
ConstraintSet::BlockDiagonal { blocks, .. } => {
let mut representatives: Vec<ConstraintRowId> = Vec::new();
let mut dependence: Vec<Vec<ConstraintRowDependence>> = Vec::new();
let mut tight_rows: Vec<ConstraintRowId> = Vec::new();
let mut row_offset = 0usize;
for block in blocks {
let start = block.col_start;
let end = start + block.set.ncols();
let beta_block = beta.slice(ndarray::s![start..end]);
let sub = block.set.reduced_face(beta_block, membership_tol)?;
for r in sub.representatives {
representatives.push(lift_member_row(r, row_offset));
}
for deps in sub.dependence {
dependence.push(
deps.into_iter()
.map(|d| ConstraintRowDependence {
row: lift_member_row(d.row, row_offset),
coeff: d.coeff,
})
.collect(),
);
}
for t in sub.tight_rows {
tight_rows.push(lift_member_row(t, row_offset));
}
row_offset += block.set.nrows();
}
Ok(ReducedFace {
representatives,
dependence,
tight_rows,
})
}
}
}
}
impl CompressedActiveWorkingSet {
fn is_degenerate_face(&self) -> bool {
self.constraints.a.nrows() < self.original_active_count
|| self.groups.iter().any(|group| group.len() > 1)
}
fn negative_representative_group(
&self,
lambda_system: &Array1<f64>,
tol_dual: f64,
active: &[usize],
) -> Option<Vec<usize>> {
self.groups
.iter()
.enumerate()
.filter(|&(group_pos, _)| {
lambda_system
.get(group_pos)
.is_some_and(|&value| -value < -tol_dual)
})
.min_by_key(|&(_, group)| {
let first = group.first().copied().unwrap_or(usize::MAX);
(active.get(first).copied().unwrap_or(usize::MAX), first)
})
.map(|(_, group)| group.clone())
}
fn position_enforced(&self, pos: usize) -> bool {
self.groups.iter().any(|group| group.contains(&pos))
}
fn over_complete_release_group(
&self,
violated: ndarray::ArrayView1<'_, f64>,
active: &[usize],
) -> Option<Vec<usize>> {
let v_norm = violated.dot(&violated).sqrt();
if !(v_norm > 0.0) {
return None;
}
const COS_TIE_TOL: f64 = 1e-12;
let mut best: Option<(f64, (usize, usize), usize)> = None;
for (group_pos, group) in self.groups.iter().enumerate() {
let rep = self.constraints.a.row(group_pos);
let rep_norm = rep.dot(&rep).sqrt();
if !(rep_norm > 0.0) {
continue;
}
let cos = rep.dot(&violated) / (rep_norm * v_norm);
if cos <= 0.0 {
continue;
}
let first = group.first().copied().unwrap_or(usize::MAX);
let key = (active.get(first).copied().unwrap_or(usize::MAX), first);
let take = match &best {
None => true,
Some((best_cos, best_key, _)) => {
cos > best_cos + COS_TIE_TOL
|| ((cos - best_cos).abs() <= COS_TIE_TOL && key < *best_key)
}
};
if take {
best = Some((cos, key, group_pos));
}
}
best.map(|(_, _, group_pos)| self.groups[group_pos].clone())
}
}
pub(crate) fn compress_active_working_set(
x: &Array1<f64>,
constraints: &LinearInequalityConstraints,
active: &[usize],
) -> Result<CompressedActiveWorkingSet, EstimationError> {
let p = constraints.a.ncols();
if x.len() != p {
crate::bail_invalid_estim!("active working-set compression dimension mismatch");
}
let mut a_out = Array2::<f64>::zeros((active.len(), p));
let mut b_out = Array1::<f64>::zeros(active.len());
let mut groups_out: Vec<Vec<usize>> = Vec::with_capacity(active.len());
for (pos, &idx) in active.iter().enumerate() {
if idx >= constraints.a.nrows() {
crate::bail_invalid_estim!(
"active working-set index {} out of bounds for {} constraints",
idx,
constraints.a.nrows()
);
}
a_out.row_mut(pos).assign(&constraints.a.row(idx));
b_out[pos] = constraints.b[idx];
groups_out.push(vec![pos]);
}
let (a_out, b_out, groups_out, _) =
rank_reduce_rows_pivoted_qr_with_dependence(a_out, b_out, groups_out);
Ok(CompressedActiveWorkingSet {
constraints: LinearInequalityConstraints::new(a_out, b_out)
.expect("compressed active constraint shape invariant"),
groups: groups_out,
original_active_count: active.len(),
})
}
fn identity_multiplier_dependence(groups: &[Vec<usize>]) -> Vec<Vec<ActiveRowDependence>> {
groups
.iter()
.map(|group| {
group
.iter()
.copied()
.map(|active_pos| ActiveRowDependence {
active_pos,
coeff: 1.0,
})
.collect()
})
.collect()
}
pub fn rank_reduce_rows_pivoted_qr_with_dependence(
a: Array2<f64>,
b: Array1<f64>,
groups: Vec<Vec<usize>>,
) -> (
Array2<f64>,
Array1<f64>,
Vec<Vec<usize>>,
Vec<Vec<ActiveRowDependence>>,
) {
let k = a.nrows();
let p = a.ncols();
if k <= 1 {
let multiplier_dependence = identity_multiplier_dependence(&groups);
return (a, b, groups, multiplier_dependence);
}
const RANK_ALPHA: f64 = 100.0;
let max_row_norm = (0..k)
.map(|r| {
let row = a.row(r);
row.dot(&row).sqrt()
})
.fold(0.0_f64, f64::max);
let tol = RANK_ALPHA * f64::EPSILON * (k.max(p).max(1) as f64) * max_row_norm;
let mut ortho_basis: Vec<Array1<f64>> = Vec::new();
let mut kept_orig: Vec<usize> = Vec::new();
let mut dropped_orig: Vec<usize> = Vec::new();
for r in 0..k {
let mut resid = a.row(r).to_owned();
for q in &ortho_basis {
let proj = resid.dot(q);
resid.scaled_add(-proj, q);
}
let resid_norm = resid.dot(&resid).sqrt();
if resid_norm > tol {
kept_orig.push(r);
ortho_basis.push(&resid / resid_norm);
} else {
dropped_orig.push(r);
}
}
let rank = kept_orig.len();
if rank >= k {
let multiplier_dependence = identity_multiplier_dependence(&groups);
return (a, b, groups, multiplier_dependence);
}
if rank == 0 {
log::debug!(
"rank-reduced active constraints from {} to 0 rows (all active rows numerically zero)",
k
);
return (
Array2::<f64>::zeros((0, p)),
Array1::<f64>::zeros(0),
Vec::new(),
Vec::new(),
);
}
let mut orig_to_out = std::collections::HashMap::with_capacity(rank);
let mut a_out = Array2::<f64>::zeros((rank, p));
let mut b_out = Array1::<f64>::zeros(rank);
let mut groups_out: Vec<Vec<usize>> = Vec::with_capacity(rank);
let mut multiplier_dependence: Vec<Vec<ActiveRowDependence>> = Vec::with_capacity(rank);
for (out_idx, &orig_idx) in kept_orig.iter().enumerate() {
a_out.row_mut(out_idx).assign(&a.row(orig_idx));
b_out[out_idx] = b[orig_idx];
groups_out.push(groups[orig_idx].clone());
multiplier_dependence.push(
groups[orig_idx]
.iter()
.copied()
.map(|active_pos| ActiveRowDependence {
active_pos,
coeff: 1.0,
})
.collect(),
);
orig_to_out.insert(orig_idx, out_idx);
}
const PARALLEL_COS_TOL: f64 = 1.0 - 1e-9;
for &dropped_idx in &dropped_orig {
let dropped_row = a.row(dropped_idx);
let dropped_norm = dropped_row.dot(&dropped_row).sqrt();
let mut best_abs_cos = 0.0_f64;
let mut best_target: Option<(usize, f64)> = None;
for &kept_idx in &kept_orig {
let kept_row = a.row(kept_idx);
let kept_norm = kept_row.dot(&kept_row).sqrt();
let dot = kept_row.dot(&dropped_row);
let cos = if kept_norm > 0.0 && dropped_norm > 0.0 {
dot / (kept_norm * dropped_norm)
} else {
0.0
};
let coeff = if kept_norm > 0.0 {
dot / (kept_norm * kept_norm)
} else {
0.0
};
if cos.abs() > best_abs_cos {
best_abs_cos = cos.abs();
best_target = Some((kept_idx, coeff));
}
}
if best_abs_cos >= PARALLEL_COS_TOL {
if let Some((target, coeff)) = best_target {
let &out_idx = orig_to_out
.get(&target)
.expect("merge target must be a kept row");
for &active_pos in &groups[dropped_idx] {
multiplier_dependence[out_idx].push(ActiveRowDependence { active_pos, coeff });
}
if coeff > 0.0 {
groups_out[out_idx].extend_from_slice(&groups[dropped_idx]);
}
}
}
}
for group in &mut groups_out {
group.sort_unstable();
group.dedup();
}
for dependencies in &mut multiplier_dependence {
dependencies.sort_unstable_by_key(|dependency| dependency.active_pos);
dependencies.dedup_by_key(|dependency| dependency.active_pos);
}
let mut row_order: Vec<usize> = (0..groups_out.len()).collect();
row_order.sort_by_key(|&idx| groups_out[idx].first().copied().unwrap_or(usize::MAX));
if row_order.iter().enumerate().any(|(idx, &orig)| idx != orig) {
let mut a_sorted = Array2::<f64>::zeros((rank, p));
let mut b_sorted = Array1::<f64>::zeros(rank);
let mut groups_sorted = Vec::with_capacity(rank);
let mut dependence_sorted = Vec::with_capacity(rank);
for (out_idx, orig_idx) in row_order.into_iter().enumerate() {
a_sorted.row_mut(out_idx).assign(&a_out.row(orig_idx));
b_sorted[out_idx] = b_out[orig_idx];
groups_sorted.push(groups_out[orig_idx].clone());
dependence_sorted.push(multiplier_dependence[orig_idx].clone());
}
a_out = a_sorted;
b_out = b_sorted;
groups_out = groups_sorted;
multiplier_dependence = dependence_sorted;
}
if rank < k {
log::debug!(
"rank-reduced active constraints from {} to {} rows (rank deficiency {})",
k,
rank,
k - rank
);
}
(a_out, b_out, groups_out, multiplier_dependence)
}
pub(crate) fn working_set_kkt_diagnostics_from_multipliers(
x: &Array1<f64>,
gradient: &Array1<f64>,
working_constraints: &LinearInequalityConstraints,
lambda_active_true: &Array1<f64>,
n_total_constraints: usize,
) -> Result<ConstraintKktDiagnostics, EstimationError> {
let p = working_constraints.a.ncols();
if x.len() != p || gradient.len() != p {
crate::bail_invalid_estim!("working-set KKT diagnostic dimension mismatch");
}
if lambda_active_true.len() != working_constraints.a.nrows() {
crate::bail_invalid_estim!(
"working-set KKT multiplier length mismatch: got {}, expected {}",
lambda_active_true.len(),
working_constraints.a.nrows()
);
}
let m = working_constraints.a.nrows();
let mut slack = Array1::<f64>::zeros(m);
let mut primal_feasibility: f64 = 0.0;
for i in 0..m {
let s_i = scaled_constraint_slack(x, working_constraints, i);
slack[i] = s_i;
primal_feasibility = primal_feasibility.max((-s_i).max(0.0));
}
let lambda = lambda_active_true.to_owned();
let mut dual_feasibility: f64 = 0.0;
let mut complementarity: f64 = 0.0;
for i in 0..m {
dual_feasibility = dual_feasibility.max((-lambda[i]).max(0.0));
let norm_i = working_constraints
.a
.row(i)
.dot(&working_constraints.a.row(i))
.sqrt();
complementarity = complementarity.max((norm_i * lambda[i] * slack[i]).abs());
}
let stationarity = {
let mut resid = gradient.to_owned();
resid -= &working_constraints.a.t().dot(&lambda);
resid.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()))
};
Ok(ConstraintKktDiagnostics {
n_constraints: n_total_constraints,
n_active: m,
primal_feasibility,
dual_feasibility,
complementarity,
stationarity,
active_tolerance: ACTIVE_SET_PRIMAL_FEASIBILITY_TOL,
working_set_rank_deficient: false,
gradient_scale: gradient_inf_norm(gradient),
})
}
fn canonicalize_active_constraint_ids(
x: &Array1<f64>,
constraints: &LinearInequalityConstraints,
active: &[usize],
) -> Result<Vec<usize>, EstimationError> {
if active.is_empty() {
return Ok(Vec::new());
}
let compressed_working = compress_active_working_set(x, constraints, active)?;
let mut canonical = Vec::with_capacity(compressed_working.groups.len());
for group in &compressed_working.groups {
if let Some(&active_pos) = group.first() {
canonical.push(active[active_pos]);
}
}
Ok(canonical)
}
fn gather_linear_constraint_rows(
constraints: &LinearInequalityConstraints,
rows: &[usize],
) -> Result<LinearInequalityConstraints, EstimationError> {
let p = constraints.a.ncols();
let mut a = Array2::<f64>::zeros((rows.len(), p));
let mut b = Array1::<f64>::zeros(rows.len());
for (out, &row) in rows.iter().enumerate() {
if row >= constraints.a.nrows() {
crate::bail_invalid_estim!(
"active constraint row {} out of bounds for {} rows",
row,
constraints.a.nrows()
);
}
a.row_mut(out).assign(&constraints.a.row(row));
b[out] = constraints.b[row];
}
LinearInequalityConstraints::new(a, b)
.map_err(|error| EstimationError::ParameterConstraintViolation(error.to_string()))
}
fn fallback_projected_gradient_direction(
beta: &Array1<f64>,
x: &Array1<f64>,
d_total: &Array1<f64>,
gradient: &Array1<f64>,
working_constraints: &LinearInequalityConstraints,
constraints: &LinearInequalityConstraints,
) -> Result<Option<(Array1<f64>, Vec<usize>)>, EstimationError> {
let p = gradient.len();
if x.len() != p || d_total.len() != p || beta.len() != p || constraints.a.ncols() != p {
crate::bail_invalid_estim!("projected-gradient fallback dimension mismatch");
}
let tangent_direction = if working_constraints.a.nrows() == 0 {
-gradient
} else {
let Some((stationarity_residual, _multipliers)) =
project_stationarity_residual_on_constraint_cone(gradient, &working_constraints.a)
else {
return Ok(None);
};
-stationarity_residual
};
if !array_is_finite(&tangent_direction) {
return Ok(None);
}
let step_inf = tangent_direction
.iter()
.fold(0.0_f64, |acc, &value| acc.max(value.abs()));
if step_inf <= 1e-12 {
let (worst, _) = max_linear_constraint_violation(x, constraints);
if worst > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
let projected = project_point_strictly_into_feasible_cone(x, constraints)
.or_else(|| {
let identity = Array2::<f64>::eye(p);
solve_quadratic_with_linear_constraints(&identity, x, x, constraints, None)
.ok()
.map(|(beta, _active)| beta)
})
.filter(|p_candidate| {
max_linear_constraint_violation(p_candidate, constraints).0
<= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
});
let Some(projected) = projected else {
return Ok(None);
};
let repair = &projected - x;
let new_direction = d_total + &repair;
let candidate = beta + &new_direction;
if max_linear_constraint_violation(&candidate, constraints).0
> ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
{
return Ok(None);
}
let active = canonicalize_active_constraint_ids(&candidate, constraints, &[])?;
return Ok(Some((new_direction, active)));
}
let active = canonicalize_active_constraint_ids(x, constraints, &[])?;
return Ok(Some((d_total.clone(), active)));
}
let directional_derivative = gradient.dot(&tangent_direction);
if !directional_derivative.is_finite() || directional_derivative >= 0.0 {
return Ok(None);
}
let mut alpha = 1.0_f64;
for i in 0..constraints.a.nrows() {
let norm = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
let inv = if norm > 0.0 { 1.0 / norm } else { 0.0 };
let slack = (constraints.a.row(i).dot(x) - constraints.b[i]) * inv;
let ai_d = constraints.a.row(i).dot(&tangent_direction) * inv;
if let Some(candidate) = active_set_boundary_hit_step_fraction(slack, ai_d, alpha) {
alpha = candidate;
}
}
if !alpha.is_finite() || alpha <= 0.0 {
return Ok(None);
}
let fallback_step = tangent_direction * alpha;
let new_direction = d_total + &fallback_step;
let new_x = beta + &new_direction;
let (worst, _) = max_linear_constraint_violation(&new_x, constraints);
if worst > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
return Ok(None);
}
let active = (0..constraints.a.nrows())
.filter(|&i| scaled_constraint_slack(&new_x, constraints, i) <= 1e-10)
.collect::<Vec<_>>();
let active = canonicalize_active_constraint_ids(&new_x, constraints, &active)?;
Ok(Some((new_direction, active)))
}
fn log_active_set_transition(
event: &str,
iteration: usize,
active_len: usize,
constraint: Option<usize>,
) {
log::debug!(
"[active-set/QP] iter={} event={} active={} constraint={}",
iteration,
event,
active_len,
constraint
.map(|idx| idx.to_string())
.unwrap_or_else(|| "NA".to_string()),
);
}
fn record_active_working_set(
visited: &mut HashSet<(Vec<usize>, Vec<u64>)>,
active: &[usize],
x: &Array1<f64>,
iteration: usize,
) -> bool {
let mut active_key = active.to_vec();
active_key.sort_unstable();
let point_key = x.iter().map(|value| value.to_bits()).collect::<Vec<_>>();
if visited.insert((active_key.clone(), point_key)) {
return true;
}
log::debug!(
"[active-set/QP] iter={iteration} repeated working set at the identical primal point ({} rows); \
deferring to the post-loop KKT exit gate",
active_key.len()
);
false
}
fn solve_newton_direction_with_linear_constraints_impl(
hessian: &Array2<f64>,
gradient: &Array1<f64>,
beta: &Array1<f64>,
constraints: &LinearInequalityConstraints,
direction_out: &mut Array1<f64>,
mut active_hint: Option<&mut Vec<usize>>,
max_iterations: usize,
allow_projected_gradient_fallback: bool,
) -> Result<(), EstimationError> {
let p = gradient.len();
if direction_out.len() != p {
*direction_out = Array1::zeros(p);
}
let m = constraints.a.nrows();
if constraints.a.ncols() != p || constraints.b.len() != m || beta.len() != p {
crate::bail_invalid_estim!(
"linear constraint shape mismatch: A={}x{}, b={}, p={}",
constraints.a.nrows(),
constraints.a.ncols(),
constraints.b.len(),
p
);
}
let tol_active = ACTIVE_SET_WORKING_FACE_TOL;
let tol_step = 1e-12;
let tol_dual = 1e-10;
let mut x = beta.to_owned();
let mut d_total = Array1::<f64>::zeros(p);
let mut g_cur = gradient.to_owned();
if let Some(hint) = active_hint.as_mut() {
hint.retain(|&idx| idx < m && scaled_constraint_slack(&x, constraints, idx) <= tol_active);
}
let has_active_hint = active_hint
.as_ref()
.map(|hint| !hint.is_empty())
.unwrap_or(false);
if !has_active_hint && solve_newton_direction_dense(hessian, gradient, direction_out).is_ok() {
let candidate = beta + &*direction_out;
let mut feasible = true;
for i in 0..m {
let slack = scaled_constraint_slack(&candidate, constraints, i);
if slack < -tol_active {
feasible = false;
break;
}
}
if feasible {
if let Some(hint) = active_hint.as_mut() {
let mut tight: Vec<usize> = Vec::new();
for i in 0..m {
if scaled_constraint_slack(&candidate, constraints, i) <= tol_active {
tight.push(i);
}
}
hint.clear();
hint.extend(canonicalize_active_constraint_ids(
&candidate,
constraints,
&tight,
)?);
}
return Ok(());
}
}
let mut active: Vec<usize> = Vec::new();
let mut is_active = vec![false; m];
if let Some(hint) = active_hint.as_ref() {
for &idx in hint.iter() {
if idx < m && !is_active[idx] {
active.push(idx);
is_active[idx] = true;
log_active_set_transition("warm-add", 0, active.len(), Some(idx));
}
}
}
for i in 0..m {
let slack = scaled_constraint_slack(&x, constraints, i);
if slack <= tol_active && !is_active[i] {
active.push(i);
is_active[i] = true;
log_active_set_transition("initial-boundary-add", 0, active.len(), Some(i));
}
}
let mut visited_working_sets: HashSet<(Vec<usize>, Vec<u64>)> = HashSet::new();
record_active_working_set(&mut visited_working_sets, &active, &x, 0);
let mut face_minimized = false;
for iteration in 0..max_iterations {
let adjudicate_face = face_minimized;
face_minimized = false;
let compressed_working = compress_active_working_set(&x, constraints, &active)?;
let mut residualw = Array1::<f64>::zeros(compressed_working.constraints.a.nrows());
for r in 0..compressed_working.constraints.a.nrows() {
residualw[r] = compressed_working.constraints.b[r]
- compressed_working.constraints.a.row(r).dot(&x);
}
let (d, lambdaw) = solve_kkt_direction(
hessian,
&g_cur,
&compressed_working.constraints.a,
Some(&residualw),
)?;
let step_norm = d.iter().map(|v| v * v).sum::<f64>().sqrt();
if step_norm <= tol_step || adjudicate_face {
let (worst, worst_row) = max_linear_constraint_violation(&x, constraints);
if worst > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL && !is_active[worst_row] {
active.push(worst_row);
is_active[worst_row] = true;
log_active_set_transition(
"stationary-infeasible-add",
iteration,
active.len(),
Some(worst_row),
);
if !record_active_working_set(&mut visited_working_sets, &active, &x, iteration) {
break;
}
continue;
}
if worst > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
let worst_pos = active.iter().position(|&idx| idx == worst_row);
let enforced = worst_pos.is_some_and(|pos| compressed_working.position_enforced(pos));
if !enforced {
if let Some(mut group) = compressed_working
.over_complete_release_group(constraints.a.row(worst_row), &active)
{
group.sort_unstable_by(|a, b| b.cmp(a));
let mut released = None;
for active_pos in group {
let idx = active.remove(active_pos);
is_active[idx] = false;
released = Some(idx);
}
log_active_set_transition(
"release-over-complete-face",
iteration,
active.len(),
released,
);
if !record_active_working_set(
&mut visited_working_sets,
&active,
&x,
iteration,
) {
break;
}
continue;
}
}
break;
}
if compressed_working.groups.is_empty() {
direction_out.assign(&d_total);
return Ok(());
}
let remove_group =
compressed_working.negative_representative_group(&lambdaw, tol_dual, &active);
if let Some(mut group) = remove_group {
group.sort_unstable_by(|a, b| b.cmp(a));
let mut released = None;
for active_pos in group {
let idx = active.remove(active_pos);
is_active[idx] = false;
released = Some(idx);
}
log_active_set_transition(
"release-negative-representative",
iteration,
active.len(),
released,
);
if !record_active_working_set(&mut visited_working_sets, &active, &x, iteration) {
break;
}
continue;
}
if let Some(hint) = active_hint {
hint.clear();
hint.extend(canonicalize_active_constraint_ids(
&x,
constraints,
&active,
)?);
}
direction_out.assign(&d_total);
return Ok(());
}
let mut alpha = 1.0_f64;
for i in 0..m {
if is_active[i] {
continue;
}
let norm = constraints.a.row(i).dot(&constraints.a.row(i)).sqrt();
let inv = if norm > 0.0 { 1.0 / norm } else { 0.0 };
let slack = (constraints.a.row(i).dot(&x) - constraints.b[i]) * inv;
let ai_d = constraints.a.row(i).dot(&d) * inv;
if let Some(cand) = active_set_boundary_hit_step_fraction(slack, ai_d, alpha) {
alpha = cand;
}
}
ndarray::Zip::from(&mut d_total)
.and(&d)
.for_each(|dt_i, &d_i| {
*dt_i += alpha * d_i;
});
x = beta + &d_total;
g_cur = gradient + &hessian.dot(&d_total);
let mut added_new_active = false;
let mut working_set_repeated = false;
for i in 0..m {
if is_active[i] {
continue;
}
let slack = scaled_constraint_slack(&x, constraints, i);
if slack <= tol_active {
active.push(i);
is_active[i] = true;
added_new_active = true;
log_active_set_transition("blocking-add", iteration, active.len(), Some(i));
working_set_repeated =
!record_active_working_set(&mut visited_working_sets, &active, &x, iteration);
break;
}
}
if !added_new_active {
face_minimized = true;
}
if working_set_repeated {
break;
}
if active.is_empty() && !added_new_active {
if let Some(hint) = active_hint {
hint.clear();
}
direction_out.assign(&d_total);
return Ok(());
}
}
let compressed_working = compress_active_working_set(&x, constraints, &active)?;
let mut residualw = Array1::<f64>::zeros(compressed_working.constraints.a.nrows());
for r in 0..compressed_working.constraints.a.nrows() {
residualw[r] =
compressed_working.constraints.b[r] - compressed_working.constraints.a.row(r).dot(&x);
}
let (_, lambdaw) = solve_kkt_direction(
hessian,
&g_cur,
&compressed_working.constraints.a,
Some(&residualw),
)?;
let lambda_true = lambdaw.mapv(|lam_sys| -lam_sys);
let (worst, row) = max_linear_constraint_violation(&x, constraints);
let working_kkt = working_set_kkt_diagnostics_from_multipliers(
&x,
&g_cur,
&compressed_working.constraints,
&lambda_true,
m,
)?;
let grad_inf = gradient_inf_norm(&g_cur);
let stationarity_rel = working_kkt.stationarity / grad_inf.max(1.0);
let step_inf = d_total.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
let hd_total = hessian.dot(&d_total);
let predicted_delta = gradient.dot(&d_total)
+ 0.5
* d_total
.iter()
.zip(hd_total.iter())
.map(|(a, b)| a * b)
.sum::<f64>();
let kkt_strong_ok = (working_kkt.stationarity <= ACTIVE_SET_KKT_STATIONARITY_TOL
|| stationarity_rel <= ACTIVE_SET_KKT_STATIONARITY_TOL)
&& working_kkt.complementarity <= ACTIVE_SET_KKT_COMPLEMENTARITY_TOL;
let model_descent_ok =
predicted_delta <= -ACTIVE_SET_MODEL_DESCENT_REL_TOL * (1.0 + grad_inf * step_inf);
let degenerate_boundary_ok = compressed_working.is_degenerate_face()
&& worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
&& working_kkt.primal_feasibility <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
&& working_kkt.complementarity <= ACTIVE_SET_KKT_COMPLEMENTARITY_TOL
&& (working_kkt.stationarity <= ACTIVE_SET_KKT_DEGENERATE_STATIONARITY_TOL
|| stationarity_rel <= ACTIVE_SET_KKT_STATIONARITY_TOL);
let strong_path_accepts =
kkt_strong_ok && working_kkt.dual_feasibility <= ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL;
let nnls_certified = worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL && !strong_path_accepts && {
let tight: Vec<usize> = (0..m)
.filter(|&i| scaled_constraint_slack(&x, constraints, i) <= tol_active)
.collect();
match gather_linear_constraint_rows(constraints, &tight) {
Ok(gathered) => nonnegative_cone_multipliers(&gathered.a, &g_cur)
.map(|(_, projected)| {
let closure = projected.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
closure <= ACTIVE_SET_KKT_STATIONARITY_TOL
|| closure / grad_inf.max(1.0) <= ACTIVE_SET_KKT_STATIONARITY_TOL
})
.unwrap_or(false),
Err(_) => false,
}
};
if worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
&& ((working_kkt.dual_feasibility <= ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL
&& (kkt_strong_ok || (allow_projected_gradient_fallback && model_descent_ok)))
|| degenerate_boundary_ok
|| nnls_certified)
{
if let Some(hint) = active_hint {
hint.clear();
hint.extend(canonicalize_active_constraint_ids(
&x,
constraints,
&active,
)?);
}
direction_out.assign(&d_total);
return Ok(());
}
if !allow_projected_gradient_fallback {
return Err(EstimationError::ParameterConstraintViolation(format!(
"linear-constrained Newton active-set did not certify the strict-convex projection QP; max(Aβ-b violation)={worst:.3e} at row {row}; KKT[primal={:.3e}, dual={:.3e}, comp={:.3e}, stat={:.3e}, active={}/{}]",
working_kkt.primal_feasibility,
working_kkt.dual_feasibility,
working_kkt.complementarity,
working_kkt.stationarity,
working_kkt.n_active,
working_kkt.n_constraints,
)));
}
let kkt = compute_constraint_kkt_diagnostics(&x, &g_cur, constraints);
let fallback_working = gather_linear_constraint_rows(constraints, &active)?;
if let Some((fallback_direction, fallback_active)) = fallback_projected_gradient_direction(
beta,
&x,
&d_total,
&g_cur,
&fallback_working,
constraints,
)? {
if let Some(hint) = active_hint {
hint.clear();
hint.extend(fallback_active);
}
direction_out.assign(&fallback_direction);
return Ok(());
}
Err(EstimationError::ParameterConstraintViolation(format!(
"linear-constrained Newton active-set failed to converge; max(Aβ-b violation)={worst:.3e} at row {row}; KKT[primal={:.3e}, dual={:.3e}, comp={:.3e}, stat={:.3e}, active={}/{}]; diagnostic-reconstruction[dual={:.3e}, stat={:.3e}]",
working_kkt.primal_feasibility,
working_kkt.dual_feasibility,
working_kkt.complementarity,
working_kkt.stationarity,
working_kkt.n_active,
working_kkt.n_constraints,
kkt.dual_feasibility,
kkt.stationarity
)))
}
struct ConstraintSetOps<'a> {
set: &'a ConstraintSet,
norms: Vec<f64>,
bounds: Vec<f64>,
scaled_margin: f64,
}
impl<'a> ConstraintSetOps<'a> {
fn new(set: &'a ConstraintSet, scaled_margin: f64) -> Result<Self, EstimationError> {
let m = set.nrows();
let mut norms = Vec::with_capacity(m);
let mut bounds = Vec::with_capacity(m);
for row in 0..m {
norms.push(set.row_norm(row).map_err(|e| {
EstimationError::ParameterConstraintViolation(format!(
"constraint-set row norm: {e}"
))
})?);
bounds.push(set.bound(row).map_err(|e| {
EstimationError::ParameterConstraintViolation(format!(
"constraint-set row bound: {e}"
))
})?);
}
Ok(Self {
set,
norms,
bounds,
scaled_margin,
})
}
fn tangent_face(set: &'a ConstraintSet, beta: &Array1<f64>) -> Result<Self, EstimationError> {
let mut ops = Self::new(set, 0.0)?;
let values = ops.values(beta)?;
for row in 0..ops.nrows() {
if ops.norms[row] <= 0.0 {
if ops.bounds[row] > 0.0 {
crate::bail_invalid_estim!(
"infeasible zero-norm constraint row {} entered tangent-face projection",
row
);
}
ops.bounds[row] = 0.0;
continue;
}
let is_tight = ops.scaled_slack(&values, row) <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL;
ops.bounds[row] = 0.0;
if !is_tight {
ops.norms[row] = 0.0;
}
}
Ok(ops)
}
fn nrows(&self) -> usize {
self.norms.len()
}
fn values(&self, x: &Array1<f64>) -> Result<Array1<f64>, EstimationError> {
self.set.values(x.view()).map_err(|e| {
EstimationError::ParameterConstraintViolation(format!("constraint-set values: {e}"))
})
}
#[inline]
fn scaled_slack(&self, values: &Array1<f64>, row: usize) -> f64 {
let norm = self.norms[row];
if norm > 0.0 {
(values[row] - self.bounds[row]) / norm - self.scaled_margin
} else if self.bounds[row] > 0.0 {
f64::NEG_INFINITY
} else {
f64::INFINITY
}
}
fn max_violation(&self, values: &Array1<f64>) -> (f64, usize) {
let mut worst = 0.0_f64;
let mut worst_row = 0usize;
for row in 0..self.nrows() {
let violation = (-self.scaled_slack(values, row)).max(0.0);
if violation > worst {
worst = violation;
worst_row = row;
}
}
(worst, worst_row)
}
fn gather_unit_rows(
&self,
rows: &[usize],
) -> Result<LinearInequalityConstraints, EstimationError> {
let mut gathered = self.set.gather_rows(rows).map_err(|e| {
EstimationError::ParameterConstraintViolation(format!(
"constraint-set working-row gather: {e}"
))
})?;
for (out_row, &row) in rows.iter().enumerate() {
let norm = self.norms[row];
if norm <= 0.0 {
crate::bail_invalid_estim!(
"vacuous zero-norm constraint row {} entered the working set",
row
);
}
let inv = 1.0 / norm;
gathered.a.row_mut(out_row).mapv_inplace(|v| v * inv);
gathered.b[out_row] = self.bounds[row] * inv + self.scaled_margin;
}
Ok(gathered)
}
fn compress_working(
&self,
active: &[usize],
) -> Result<CompressedActiveWorkingSet, EstimationError> {
let gathered = self.gather_unit_rows(active)?;
let groups: Vec<Vec<usize>> = (0..active.len()).map(|pos| vec![pos]).collect();
let (a_out, b_out, groups_out, _) =
rank_reduce_rows_pivoted_qr_with_dependence(gathered.a, gathered.b, groups);
Ok(CompressedActiveWorkingSet {
constraints: LinearInequalityConstraints::new(a_out, b_out)
.expect("compressed operator working-set shape invariant"),
groups: groups_out,
original_active_count: active.len(),
})
}
}
pub fn constraint_set_rows_tight_at_point(
set: &ConstraintSet,
beta: &Array1<f64>,
candidate_rows: &[usize],
) -> Result<Vec<usize>, EstimationError> {
if set.ncols() != beta.len() {
crate::bail_invalid_estim!(
"active-face point dimension mismatch: set has {} columns, beta has {}",
set.ncols(),
beta.len()
);
}
let mut seen = HashSet::with_capacity(candidate_rows.len());
let mut unique = Vec::with_capacity(candidate_rows.len());
for &row in candidate_rows {
if row < set.nrows() && seen.insert(row) {
unique.push(row);
}
}
if unique.is_empty() {
return Ok(Vec::new());
}
let gathered = set.gather_rows(&unique).map_err(|error| {
EstimationError::ParameterConstraintViolation(format!(
"active-face candidate-row gather failed: {error}"
))
})?;
let mut tight = Vec::with_capacity(unique.len());
for (position, &row) in unique.iter().enumerate() {
let constraint_row = gathered.a.row(position);
let norm = constraint_row.dot(&constraint_row).sqrt();
if norm > 0.0 {
let scaled_slack = (constraint_row.dot(beta) - gathered.b[position]) / norm;
if scaled_slack <= ACTIVE_SET_WORKING_FACE_TOL {
tight.push(row);
}
}
}
Ok(tight)
}
pub fn project_stationarity_residual_on_constraint_set(
residual: &Array1<f64>,
beta: &Array1<f64>,
set: &ConstraintSet,
seed_active: &[usize],
) -> Option<(Array1<f64>, Vec<usize>)> {
let p = residual.len();
if beta.len() != p || set.ncols() != p {
return None;
}
match set {
ConstraintSet::KhatriRaoCone(cone) if cone.p_left() != 1 || cone.coupled_rows() != &[0] => {
let p_cov = cone.factor().ncols();
let n = cone.factor().nrows();
let mut projected = residual.clone();
let mut active = Vec::new();
for (slot, &coefficient_row) in cone.coupled_rows().iter().enumerate() {
let start = coefficient_row * p_cov;
let end = start + p_cov;
let local_residual = residual.slice(s![start..end]).to_owned();
let local_beta = beta.slice(s![start..end]).to_owned();
let local_set = ConstraintSet::KhatriRaoCone(cone.single_coupled_slot(slot).ok()?);
let row_start = slot * n;
let row_end = row_start + n;
let local_seed: Vec<usize> = seed_active
.iter()
.copied()
.filter(|&row| row >= row_start && row < row_end)
.map(|row| row - row_start)
.collect();
let (local_projected, local_active) =
project_stationarity_residual_on_constraint_set(
&local_residual,
&local_beta,
&local_set,
&local_seed,
)?;
projected.slice_mut(s![start..end]).assign(&local_projected);
active.extend(local_active.into_iter().map(|row| row_start + row));
}
Some((projected, active))
}
ConstraintSet::BlockDiagonal { blocks, .. } => {
let mut projected = residual.clone();
let mut active = Vec::new();
let mut row_offset = 0usize;
for block in blocks {
let width = block.set.ncols();
let start = block.col_start;
let end = start + width;
let local_residual = residual.slice(s![start..end]).to_owned();
let local_beta = beta.slice(s![start..end]).to_owned();
let row_end = row_offset + block.set.nrows();
let local_seed: Vec<usize> = seed_active
.iter()
.copied()
.filter(|&row| row >= row_offset && row < row_end)
.map(|row| row - row_offset)
.collect();
let (local_projected, local_active) =
project_stationarity_residual_on_constraint_set(
&local_residual,
&local_beta,
&block.set,
&local_seed,
)?;
projected.slice_mut(s![start..end]).assign(&local_projected);
active.extend(local_active.into_iter().map(|row| row_offset + row));
row_offset = row_end;
}
Some((projected, active))
}
_ => project_stationarity_residual_on_constraint_set_undivided(
residual,
beta,
set,
seed_active,
),
}
}
fn project_stationarity_residual_on_constraint_set_undivided(
residual: &Array1<f64>,
beta: &Array1<f64>,
set: &ConstraintSet,
seed_active: &[usize],
) -> Option<(Array1<f64>, Vec<usize>)> {
let p = residual.len();
let ops = ConstraintSetOps::tangent_face(set, beta).ok()?;
let mut active = Vec::with_capacity(seed_active.len().min(p));
for &row in seed_active {
if row < ops.nrows() && ops.norms[row] > 0.0 && !active.contains(&row) {
active.push(row);
}
}
let identity = Array2::<f64>::eye(p);
let origin = Array1::<f64>::zeros(p);
let mut tangent_direction = Array1::<f64>::zeros(p);
let max_iterations = (p + active.len() + 8) * 4;
if let Err(error) = solve_newton_direction_with_constraint_set_impl(
&identity,
residual,
&origin,
&ops,
&mut tangent_direction,
Some(&mut active),
max_iterations,
false,
) {
log::warn!(
"factored tangent-cone projection QP failed \
(p={p}, rows={}, seed_active_rows={}, residual_inf={:.6e}): {error}; \
attempting the direct Lawson-Hanson Moreau fallback on the final working face",
ops.nrows(),
seed_active.len(),
residual
.iter()
.fold(0.0_f64, |scale, value| scale.max(value.abs())),
);
return nnls_tangent_cone_projection_fallback(residual, beta, set, &active, seed_active);
}
if !array_is_finite(&tangent_direction) {
return nnls_tangent_cone_projection_fallback(residual, beta, set, &active, seed_active);
}
Some((-tangent_direction, active))
}
fn nnls_tangent_cone_projection_fallback(
residual: &Array1<f64>,
beta: &Array1<f64>,
set: &ConstraintSet,
face_rows: &[usize],
seed_active: &[usize],
) -> Option<(Array1<f64>, Vec<usize>)> {
let mut candidates: Vec<usize> = Vec::with_capacity(face_rows.len() + seed_active.len());
for &row in face_rows.iter().chain(seed_active.iter()) {
if row < set.nrows() && !candidates.contains(&row) {
candidates.push(row);
}
}
if candidates.is_empty() {
return Some((residual.clone(), Vec::new()));
}
let candidate_rows = set.gather_rows(&candidates).ok()?;
let mut tight = Vec::with_capacity(candidates.len());
let mut tight_a = Vec::with_capacity(candidates.len());
for (position, &row) in candidates.iter().enumerate() {
let constraint_row = candidate_rows.a.row(position);
let norm = constraint_row.dot(&constraint_row).sqrt();
if norm > 0.0
&& (constraint_row.dot(beta) - candidate_rows.b[position]) / norm
<= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
{
tight.push(row);
tight_a.push(position);
}
}
if tight.is_empty() {
return Some((residual.clone(), Vec::new()));
}
let mut generators = Array2::<f64>::zeros((tight.len(), residual.len()));
for (out_row, &position) in tight_a.iter().enumerate() {
generators
.row_mut(out_row)
.assign(&candidate_rows.a.row(position));
}
let (lambda, projected) = nonnegative_cone_multipliers(&generators, residual)?;
let active: Vec<usize> = tight
.iter()
.zip(lambda.iter())
.filter(|&(_, &multiplier)| multiplier > 0.0)
.map(|(&row, _)| row)
.collect();
log::info!(
"tangent-cone Moreau fallback certified the projection the primal QP refused: \
face_rows={} tight_rows={} supported_rows={} projected_inf={:.6e}",
face_rows.len(),
tight.len(),
active.len(),
projected
.iter()
.fold(0.0_f64, |scale, value| scale.max(value.abs())),
);
Some((projected, active))
}
fn fallback_projected_gradient_direction_with_constraint_set(
beta: &Array1<f64>,
x: &Array1<f64>,
d_total: &Array1<f64>,
gradient: &Array1<f64>,
active: &[usize],
ops: &ConstraintSetOps<'_>,
) -> Result<Option<(Array1<f64>, Vec<usize>)>, EstimationError> {
let p = gradient.len();
if x.len() != p || d_total.len() != p || beta.len() != p || ops.set.ncols() != p {
crate::bail_invalid_estim!("operator projected-gradient fallback dimension mismatch");
}
let values_x = ops.values(x)?;
let Some((stationarity_residual, mut tangent_active)) =
project_stationarity_residual_on_constraint_set(gradient, x, ops.set, active)
else {
return Ok(None);
};
let tangent_direction = -stationarity_residual;
let step_inf = tangent_direction
.iter()
.fold(0.0_f64, |acc, &value| acc.max(value.abs()));
if step_inf <= 1e-12 {
let (worst, _) = ops.max_violation(&values_x);
if worst > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
let Some(projected) = project_point_strictly_into_feasible_constraint_set(x, ops.set)
.ok()
.filter(|candidate| {
ops.values(candidate)
.map(|candidate_values| {
ops.max_violation(&candidate_values).0
<= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
})
.unwrap_or(false)
})
else {
return Ok(None);
};
let repair = &projected - x;
let new_direction = d_total + &repair;
let candidate = beta + &new_direction;
let candidate_values = ops.values(&candidate)?;
if ops.max_violation(&candidate_values).0 > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
return Ok(None);
}
return Ok(Some((new_direction, Vec::new())));
}
return Ok(Some((d_total.clone(), tangent_active)));
}
let directional_derivative = gradient.dot(&tangent_direction);
if !directional_derivative.is_finite() || directional_derivative >= 0.0 {
return Ok(None);
}
let values_direction = ops.values(&tangent_direction)?;
let mut alpha = 1.0_f64;
let mut blocking_row = None;
for row in 0..ops.nrows() {
if ops.norms[row] <= 0.0 {
continue;
}
let slack = ops.scaled_slack(&values_x, row);
let rate = values_direction[row] / ops.norms[row];
if let Some(candidate) = active_set_boundary_hit_step_fraction(slack, rate, alpha) {
alpha = candidate;
blocking_row = Some(row);
}
}
if !alpha.is_finite() || alpha <= 0.0 {
return Ok(None);
}
let fallback_step = tangent_direction * alpha;
let new_direction = d_total + &fallback_step;
let new_x = beta + &new_direction;
let new_values = ops.values(&new_x)?;
if ops.max_violation(&new_values).0 > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
return Ok(None);
}
if let Some(row) = blocking_row
&& !tangent_active.contains(&row)
{
tangent_active.push(row);
}
tangent_active.retain(|&row| ops.scaled_slack(&new_values, row) <= 1e-10);
Ok(Some((new_direction, tangent_active)))
}
fn solve_newton_direction_with_constraint_set_impl(
hessian: &Array2<f64>,
gradient: &Array1<f64>,
beta: &Array1<f64>,
ops: &ConstraintSetOps<'_>,
direction_out: &mut Array1<f64>,
mut active_hint: Option<&mut Vec<usize>>,
max_iterations: usize,
allow_projected_gradient_fallback: bool,
) -> Result<(), EstimationError> {
let p = gradient.len();
if direction_out.len() != p {
*direction_out = Array1::zeros(p);
}
let m = ops.nrows();
if ops.set.ncols() != p || beta.len() != p {
crate::bail_invalid_estim!(
"constraint-set shape mismatch: set={}x{}, p={}",
m,
ops.set.ncols(),
p
);
}
let tol_active = ACTIVE_SET_WORKING_FACE_TOL;
let tol_step = 1e-12;
let tol_dual = 1e-10;
let mut x = beta.to_owned();
let mut d_total = Array1::<f64>::zeros(p);
let mut g_cur = gradient.to_owned();
let mut values_x = ops.values(&x)?;
if let Some(hint) = active_hint.as_mut() {
hint.retain(|&idx| {
idx < m && ops.norms[idx] > 0.0 && ops.scaled_slack(&values_x, idx) <= tol_active
});
}
let has_active_hint = active_hint
.as_ref()
.map(|hint| !hint.is_empty())
.unwrap_or(false);
if !has_active_hint && solve_newton_direction_dense(hessian, gradient, direction_out).is_ok() {
let candidate = beta + &*direction_out;
let candidate_values = ops.values(&candidate)?;
let feasible = (0..m).all(|row| ops.scaled_slack(&candidate_values, row) >= -tol_active);
if feasible {
return Ok(());
}
}
let mut active: Vec<usize> = Vec::new();
let mut is_active = vec![false; m];
if let Some(hint) = active_hint.as_ref() {
for &idx in hint.iter() {
if idx < m && !is_active[idx] && ops.norms[idx] > 0.0 {
active.push(idx);
is_active[idx] = true;
log_active_set_transition("warm-add", 0, active.len(), Some(idx));
}
}
}
let mut visited_working_sets: HashSet<(Vec<usize>, Vec<u64>)> = HashSet::new();
record_active_working_set(&mut visited_working_sets, &active, &x, 0);
let mut count_blocking_add = 0usize;
let mut count_stationary_add = 0usize;
let mut count_release = 0usize;
let mut ws_repeat_break = false;
let mut iterations_used = 0usize;
let mut face_minimized = false;
for iteration in 0..max_iterations {
iterations_used = iteration + 1;
let adjudicate_face = face_minimized;
face_minimized = false;
let compressed_working = ops.compress_working(&active)?;
let mut residualw = Array1::<f64>::zeros(compressed_working.constraints.a.nrows());
for r in 0..compressed_working.constraints.a.nrows() {
residualw[r] = compressed_working.constraints.b[r]
- compressed_working.constraints.a.row(r).dot(&x);
}
let (d, lambdaw) = solve_kkt_direction(
hessian,
&g_cur,
&compressed_working.constraints.a,
Some(&residualw),
)?;
let step_norm = d.iter().map(|v| v * v).sum::<f64>().sqrt();
if step_norm <= tol_step || adjudicate_face {
let (worst, worst_row) = ops.max_violation(&values_x);
if worst > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL && !is_active[worst_row] {
active.push(worst_row);
is_active[worst_row] = true;
count_stationary_add += 1;
log_active_set_transition(
"stationary-infeasible-add",
iteration,
active.len(),
Some(worst_row),
);
if !record_active_working_set(&mut visited_working_sets, &active, &x, iteration) {
ws_repeat_break = true;
break;
}
continue;
}
if worst > ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
let worst_pos = active.iter().position(|&idx| idx == worst_row);
let enforced =
worst_pos.is_some_and(|pos| compressed_working.position_enforced(pos));
if !enforced {
let violated_unit = ops.gather_unit_rows(&[worst_row])?;
if let Some(mut group) = compressed_working
.over_complete_release_group(violated_unit.a.row(0), &active)
{
group.sort_unstable_by(|a, b| b.cmp(a));
let mut released = None;
for active_pos in group {
let idx = active.remove(active_pos);
is_active[idx] = false;
count_release += 1;
released = Some(idx);
}
log_active_set_transition(
"release-over-complete-face",
iteration,
active.len(),
released,
);
if !record_active_working_set(
&mut visited_working_sets,
&active,
&x,
iteration,
) {
ws_repeat_break = true;
break;
}
continue;
}
}
break;
}
if compressed_working.groups.is_empty() {
direction_out.assign(&d_total);
return Ok(());
}
let remove_group =
compressed_working.negative_representative_group(&lambdaw, tol_dual, &active);
if let Some(mut group) = remove_group {
group.sort_unstable_by(|a, b| b.cmp(a));
let mut released = None;
for active_pos in group {
let idx = active.remove(active_pos);
is_active[idx] = false;
count_release += 1;
released = Some(idx);
}
log_active_set_transition(
"release-negative-representative",
iteration,
active.len(),
released,
);
if !record_active_working_set(&mut visited_working_sets, &active, &x, iteration) {
ws_repeat_break = true;
break;
}
continue;
}
if let Some(hint) = active_hint.as_mut() {
hint.clear();
let compressed = ops.compress_working(&active)?;
for group in &compressed.groups {
if let Some(&active_pos) = group.first() {
hint.push(active[active_pos]);
}
}
}
direction_out.assign(&d_total);
return Ok(());
}
let values_d = ops.values(&d)?;
let mut alpha = 1.0_f64;
let mut blocking_row: Option<usize> = None;
for row in 0..m {
if is_active[row] || ops.norms[row] <= 0.0 {
continue;
}
let slack = ops.scaled_slack(&values_x, row);
let rate = values_d[row] / ops.norms[row];
if let Some(cand) = active_set_boundary_hit_step_fraction(slack, rate, alpha) {
alpha = cand;
blocking_row = Some(row);
}
}
ndarray::Zip::from(&mut d_total)
.and(&d)
.for_each(|dt_i, &d_i| {
*dt_i += alpha * d_i;
});
x = beta + &d_total;
g_cur = gradient + &hessian.dot(&d_total);
values_x = ops.values(&x)?;
let mut added_new_active = false;
let mut working_set_repeated = false;
if let Some(row) = blocking_row {
active.push(row);
is_active[row] = true;
added_new_active = true;
count_blocking_add += 1;
log_active_set_transition("blocking-add", iteration, active.len(), Some(row));
working_set_repeated =
!record_active_working_set(&mut visited_working_sets, &active, &x, iteration);
} else {
face_minimized = true;
}
if working_set_repeated {
ws_repeat_break = true;
break;
}
let primal_step_norm = alpha.abs() * step_norm;
if allow_projected_gradient_fallback && added_new_active && primal_step_norm <= tol_step {
if let Some((fallback_direction, fallback_active)) =
fallback_projected_gradient_direction_with_constraint_set(
beta, &x, &d_total, &g_cur, &active, ops,
)?
{
if let Some(hint) = active_hint.as_mut() {
hint.clear();
hint.extend(fallback_active);
}
direction_out.assign(&fallback_direction);
return Ok(());
}
}
if active.is_empty() && !added_new_active {
if let Some(hint) = active_hint.as_mut() {
hint.clear();
}
direction_out.assign(&d_total);
return Ok(());
}
}
let compressed_working = ops.compress_working(&active)?;
let mut residualw = Array1::<f64>::zeros(compressed_working.constraints.a.nrows());
for r in 0..compressed_working.constraints.a.nrows() {
residualw[r] =
compressed_working.constraints.b[r] - compressed_working.constraints.a.row(r).dot(&x);
}
let (_, lambdaw) = solve_kkt_direction(
hessian,
&g_cur,
&compressed_working.constraints.a,
Some(&residualw),
)?;
let lambda_true = lambdaw.mapv(|lam_sys| -lam_sys);
let (worst, row) = ops.max_violation(&values_x);
let working_kkt = working_set_kkt_diagnostics_from_multipliers(
&x,
&g_cur,
&compressed_working.constraints,
&lambda_true,
m,
)?;
let grad_inf = gradient_inf_norm(&g_cur);
let stationarity_rel = working_kkt.stationarity / grad_inf.max(1.0);
let step_inf = d_total.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
let hd_total = hessian.dot(&d_total);
let predicted_delta = gradient.dot(&d_total)
+ 0.5
* d_total
.iter()
.zip(hd_total.iter())
.map(|(a, b)| a * b)
.sum::<f64>();
let kkt_strong_ok = (working_kkt.stationarity <= ACTIVE_SET_KKT_STATIONARITY_TOL
|| stationarity_rel <= ACTIVE_SET_KKT_STATIONARITY_TOL)
&& working_kkt.complementarity <= ACTIVE_SET_KKT_COMPLEMENTARITY_TOL;
let model_descent_ok =
predicted_delta <= -ACTIVE_SET_MODEL_DESCENT_REL_TOL * (1.0 + grad_inf * step_inf);
let degenerate_boundary_ok = compressed_working.is_degenerate_face()
&& worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
&& working_kkt.primal_feasibility <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
&& working_kkt.complementarity <= ACTIVE_SET_KKT_COMPLEMENTARITY_TOL
&& (working_kkt.stationarity <= ACTIVE_SET_KKT_DEGENERATE_STATIONARITY_TOL
|| stationarity_rel <= ACTIVE_SET_KKT_STATIONARITY_TOL);
let strong_path_accepts =
kkt_strong_ok && working_kkt.dual_feasibility <= ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL;
let mut nnls_closure: Option<(f64, usize)> = None;
let nnls_certified = worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL && !strong_path_accepts && {
let tight: Vec<usize> = (0..m)
.filter(|&i| {
ops.norms[i] > 0.0 && (values_x[i] - ops.bounds[i]) / ops.norms[i] <= tol_active
})
.collect();
let tight_len = tight.len();
match ops.set.gather_rows(&tight) {
Ok(gathered) => nonnegative_cone_multipliers(&gathered.a, &g_cur)
.map(|(_, projected)| {
let closure = projected.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
nnls_closure = Some((closure, tight_len));
closure <= ACTIVE_SET_KKT_STATIONARITY_TOL
|| closure / grad_inf.max(1.0) <= ACTIVE_SET_KKT_STATIONARITY_TOL
})
.unwrap_or(false),
Err(_) => false,
}
};
if worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
&& ((working_kkt.dual_feasibility <= ACTIVE_SET_KKT_DUAL_FEASIBILITY_TOL
&& (kkt_strong_ok || (allow_projected_gradient_fallback && model_descent_ok)))
|| degenerate_boundary_ok
|| nnls_certified)
{
if let Some(hint) = active_hint.as_mut() {
hint.clear();
for group in &compressed_working.groups {
if let Some(&active_pos) = group.first() {
hint.push(active[active_pos]);
}
}
}
direction_out.assign(&d_total);
return Ok(());
}
let nnls_diag = match nnls_closure {
Some((closure, tight_len)) => format!(
"nnls_closure={closure:.3e} (tol={ACTIVE_SET_KKT_STATIONARITY_TOL:.1e}) over {tight_len} tight rows"
),
None => "nnls_closure=not-evaluated".to_string(),
};
let churn_diag = format!(
"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}"
);
if !allow_projected_gradient_fallback {
return Err(EstimationError::ParameterConstraintViolation(format!(
"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}",
working_kkt.primal_feasibility,
working_kkt.dual_feasibility,
working_kkt.complementarity,
working_kkt.stationarity,
working_kkt.n_active,
working_kkt.n_constraints,
)));
}
if let Some((fallback_direction, fallback_active)) =
fallback_projected_gradient_direction_with_constraint_set(
beta, &x, &d_total, &g_cur, &active, ops,
)?
{
if let Some(hint) = active_hint.as_mut() {
hint.clear();
hint.extend(fallback_active);
}
direction_out.assign(&fallback_direction);
return Ok(());
}
Err(EstimationError::ParameterConstraintViolation(format!(
"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",
working_kkt.primal_feasibility,
working_kkt.dual_feasibility,
working_kkt.complementarity,
working_kkt.stationarity,
working_kkt.n_active,
working_kkt.n_constraints,
)))
}
pub fn project_point_strictly_into_feasible_constraint_set(
point: &Array1<f64>,
set: &ConstraintSet,
) -> Result<Array1<f64>, EstimationError> {
match set {
ConstraintSet::Dense(dense) => {
project_point_strictly_into_feasible_cone(point, dense).ok_or_else(|| {
EstimationError::ParameterConstraintViolation(
"dense strict-interior projection could not certify a feasible point"
.to_string(),
)
})
}
_ => {
let repair_guard = FeasibilityRepairGuard::enter().ok_or_else(|| {
EstimationError::ParameterConstraintViolation(format!(
"strict-interior projection exceeded feasibility-repair depth {MAX_FEASIBILITY_REPAIR_DEPTH}"
))
})?;
let p = point.len();
if set.ncols() != p {
return Err(EstimationError::ParameterConstraintViolation(format!(
"strict-interior projection dimension mismatch: point length {p} != constraint columns {}",
set.ncols()
)));
}
let ops = ConstraintSetOps::new(set, ACTIVE_SET_INTERIOR_SEED_MARGIN)?;
let identity = Array2::<f64>::eye(p);
let mut direction = Array1::<f64>::zeros(p);
let gradient = Array1::<f64>::zeros(p);
let max_iterations = (p + set.nrows() + 8) * 4;
solve_newton_direction_with_constraint_set_impl(
&identity,
&gradient,
point,
&ops,
&mut direction,
None,
max_iterations,
true,
)?;
let beta = point + &direction;
if beta.iter().any(|v| !v.is_finite()) {
return Err(EstimationError::ParameterConstraintViolation(
"strict-interior projection produced a non-finite iterate".to_string(),
));
}
const SEED_FEASIBILITY_TOL: f64 = 1e-9;
let unshifted = ConstraintSetOps::new(set, 0.0)?;
let values = unshifted.values(&beta)?;
let half_margin = 0.5 * ACTIVE_SET_INTERIOR_SEED_MARGIN - SEED_FEASIBILITY_TOL;
for row in 0..unshifted.nrows() {
if unshifted.norms[row] <= 0.0 {
continue;
}
let slack = unshifted.scaled_slack(&values, row);
if slack < half_margin {
return Err(EstimationError::ParameterConstraintViolation(format!(
"strict-interior projection could not clear the half-margin at row {row}: \
scaled slack {slack:.3e} < {half_margin:.3e}"
)));
}
}
drop(repair_guard);
Ok(beta)
}
}
}
pub fn solve_quadratic_with_constraint_set(
hessian: &Array2<f64>,
rhs: &Array1<f64>,
beta_start: &Array1<f64>,
set: &ConstraintSet,
warm_active_set: Option<&[usize]>,
) -> Result<(Array1<f64>, Vec<usize>), EstimationError> {
match set {
ConstraintSet::Dense(dense) => solve_quadratic_with_linear_constraints(
hessian,
rhs,
beta_start,
dense,
warm_active_set,
),
_ => {
if hessian.ncols() != hessian.nrows()
|| rhs.len() != hessian.nrows()
|| beta_start.len() != hessian.nrows()
|| set.ncols() != hessian.nrows()
{
crate::bail_invalid_estim!(
"operator-constrained quadratic solve: system dimension mismatch"
);
}
let ops = ConstraintSetOps::new(set, 0.0)?;
let gradient = hessian.dot(beta_start) - rhs;
let mut delta = Array1::<f64>::zeros(beta_start.len());
let mut active_hint = warm_active_set.map_or_else(Vec::new, |active| active.to_vec());
let max_iterations = (beta_start.len() + set.nrows() + 8) * 4;
solve_newton_direction_with_constraint_set_impl(
hessian,
&gradient,
beta_start,
&ops,
&mut delta,
Some(&mut active_hint),
max_iterations,
true,
)?;
let candidate = beta_start + δ
let candidate_values = ops.values(&candidate)?;
let (worst, _) = ops.max_violation(&candidate_values);
if worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
return Ok((candidate, active_hint));
}
let repaired = project_point_strictly_into_feasible_constraint_set(&candidate, set)
.ok()
.filter(|repaired_point| {
ops.values(repaired_point)
.map(|values| ops.max_violation(&values).0)
.map(|violation| violation <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL)
.unwrap_or(false)
});
match repaired {
Some(feasible) => {
let feasible_values = ops.values(&feasible)?;
let active: Vec<usize> = (0..ops.nrows())
.filter(|&row| {
ops.norms[row] > 0.0
&& ops.scaled_slack(&feasible_values, row)
<= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
})
.collect();
Ok((feasible, active))
}
None => Err(EstimationError::ParameterConstraintViolation(format!(
"operator-constrained quadratic solve returned an infeasible iterate \
(max scaled violation {worst:.3e}) and no feasible projection could be \
certified onto the constraint cone",
))),
}
}
}
}
pub(crate) fn solve_newton_direction_with_linear_constraints(
hessian: &Array2<f64>,
gradient: &Array1<f64>,
beta: &Array1<f64>,
constraints: &LinearInequalityConstraints,
direction_out: &mut Array1<f64>,
active_hint: Option<&mut Vec<usize>>,
) -> Result<(), EstimationError> {
let max_iterations = (gradient.len() + constraints.a.nrows() + 8) * 4;
solve_newton_direction_with_linear_constraints_impl(
hessian,
gradient,
beta,
constraints,
direction_out,
active_hint,
max_iterations,
true,
)
}
pub fn solve_quadratic_with_linear_constraints(
hessian: &Array2<f64>,
rhs: &Array1<f64>,
beta_start: &Array1<f64>,
constraints: &LinearInequalityConstraints,
warm_active_set: Option<&[usize]>,
) -> Result<(Array1<f64>, Vec<usize>), EstimationError> {
if hessian.ncols() != hessian.nrows()
|| rhs.len() != hessian.nrows()
|| beta_start.len() != hessian.nrows()
|| constraints.a.ncols() != hessian.nrows()
{
crate::bail_invalid_estim!("constrained quadratic solve: system dimension mismatch");
}
let constraints = constraints.canonicalized().map_err(|e| {
EstimationError::ParameterConstraintViolation(format!(
"constrained quadratic solve: invalid constraint system: {e}"
))
})?;
let constraints = &constraints;
let gradient = hessian.dot(beta_start) - rhs;
let mut delta = Array1::<f64>::zeros(beta_start.len());
let mut active_hint = warm_active_set.map_or_else(Vec::new, |active| active.to_vec());
solve_newton_direction_with_linear_constraints(
hessian,
&gradient,
beta_start,
constraints,
&mut delta,
Some(&mut active_hint),
)?;
let candidate = beta_start + δ
let (worst, _) = max_linear_constraint_violation(&candidate, constraints);
if worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL {
return Ok((candidate, active_hint));
}
let repaired = project_point_strictly_into_feasible_cone(&candidate, constraints).filter(|p| {
max_linear_constraint_violation(p, constraints).0 <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
});
match repaired {
Some(feasible) => {
let active = canonicalize_active_constraint_ids(&feasible, constraints, &[])?;
Ok((feasible, active))
}
None => Err(EstimationError::ParameterConstraintViolation(format!(
"constrained quadratic solve returned an infeasible iterate \
(max scaled violation {worst:.3e}) and no feasible projection could be \
certified onto the constraint cone",
))),
}
}
#[cfg(test)]
mod tests {
use super::{
ACTIVE_SET_INTERIOR_SEED_MARGIN, ACTIVE_SET_PRIMAL_FEASIBILITY_TOL, ConstraintSet,
ConstraintRowId, ConstraintSetOps, ConstraintSetReducedFace, LinearInequalityConstraints,
active_set_boundary_hit_step_fraction, compute_constraint_kkt_diagnostics,
constraint_set_rows_tight_at_point, fallback_projected_gradient_direction,
khatri_rao_cone_reduced_face,
fallback_projected_gradient_direction_with_constraint_set, moreau_projection_via_primal_qp,
nnls_tangent_cone_projection_fallback, nonnegative_cone_multipliers,
project_point_strictly_into_feasible_cone,
project_point_strictly_into_feasible_constraint_set,
project_stationarity_residual_on_constraint_cone,
project_stationarity_residual_on_constraint_set,
rank_reduce_rows_pivoted_qr_with_dependence, record_active_working_set,
scaled_constraint_slack, solve_newton_direction_with_linear_constraints_impl,
solve_quadratic_with_constraint_set, solve_quadratic_with_linear_constraints,
};
use approx::assert_relative_eq;
use gam_problem::KhatriRaoConeConstraints;
use ndarray::{Array1, Array2, array};
#[test]
fn working_set_cycle_detection_requires_the_same_primal_point() {
let mut visited = std::collections::HashSet::new();
let x0 = array![0.0_f64, 1.0];
let x1 = array![0.5_f64, 1.0];
assert!(record_active_working_set(&mut visited, &[3, 1], &x0, 0));
assert!(record_active_working_set(&mut visited, &[1, 3], &x1, 1));
assert!(!record_active_working_set(&mut visited, &[3, 1], &x1, 2));
}
#[test]
fn boundary_ratio_lands_on_the_exact_boundary_and_blocks_at_it() {
let alpha = active_set_boundary_hit_step_fraction(0.1, -1.0, 1.0)
.expect("a strictly feasible row moving toward its boundary must clip");
assert_relative_eq!(alpha, 0.1, epsilon = 0.0);
assert_relative_eq!(0.1 + alpha * -1.0, 0.0, epsilon = 0.0);
let blocked = active_set_boundary_hit_step_fraction(-2.5e-15, -1.0, 1.0)
.expect("an at-boundary outward-moving row must block");
assert_eq!(blocked, 0.0);
}
#[test]
fn warm_face_rows_are_point_local_for_dense_and_operator_constraints() {
let hessian = array![[1.0_f64]];
let rhs = array![2.0_f64];
let interior = array![1.0_f64];
let dense = LinearInequalityConstraints::new(array![[1.0]], array![0.0])
.expect("one-dimensional half-line");
let (dense_solution, dense_active) =
solve_quadratic_with_linear_constraints(&hessian, &rhs, &interior, &dense, Some(&[0]))
.expect("dense stale-face solve");
assert_relative_eq!(dense_solution[0], 2.0, epsilon = 1e-12);
assert!(dense_active.is_empty());
let factor = std::sync::Arc::new(array![[1.0_f64]]);
let cone = KhatriRaoConeConstraints::new(factor, vec![0], 1)
.expect("one-dimensional factored half-line");
let operator = ConstraintSet::KhatriRaoCone(cone);
let stale_terminal_face = constraint_set_rows_tight_at_point(&operator, &interior, &[0])
.expect("terminal face classification");
assert!(stale_terminal_face.is_empty());
let (operator_solution, operator_active) =
solve_quadratic_with_constraint_set(&hessian, &rhs, &interior, &operator, Some(&[0]))
.expect("operator stale-face solve");
assert_relative_eq!(operator_solution[0], 2.0, epsilon = 1e-12);
assert!(operator_active.is_empty());
}
#[test]
fn strict_interior_projection_lifts_vertex_seed_off_every_constraint_row() {
let p = 5usize;
let rows = p - 2;
let mut a = Array2::<f64>::zeros((rows, p));
for i in 0..rows {
a[[i, i]] = -1.0;
a[[i, i + 1]] = 2.0;
a[[i, i + 2]] = -1.0;
}
let constraints = LinearInequalityConstraints::new(a, Array1::zeros(rows))
.expect("test constraint shape invariant");
let vertex = Array1::<f64>::zeros(p);
for i in 0..rows {
assert!(
scaled_constraint_slack(&vertex, &constraints, i).abs() < 1e-12,
"vertex seed should sit exactly on row {i}"
);
}
let interior = project_point_strictly_into_feasible_cone(&vertex, &constraints)
.expect("strict-interior projection of the vertex must succeed");
let min_slack = (0..rows)
.map(|i| scaled_constraint_slack(&interior, &constraints, i))
.fold(f64::INFINITY, f64::min);
assert!(
min_slack >= 0.5 * ACTIVE_SET_INTERIOR_SEED_MARGIN,
"projected seed must be strictly interior on every row; min scaled slack = {min_slack:.3e}"
);
}
#[test]
fn strict_interior_projection_keeps_equality_pairs_tight_with_shape_bounds() {
let p = 5usize;
let m = 3 + 2;
let mut a = Array2::<f64>::zeros((m, p));
a[[0, 2]] = 1.0;
a[[1, 3]] = 1.0;
a[[2, 4]] = 1.0;
a[[3, 0]] = 1.0;
a[[4, 0]] = -1.0;
let constraints = LinearInequalityConstraints::new(a, Array1::zeros(m))
.expect("test constraint shape invariant");
let point = Array1::from_vec(vec![0.7, -0.2, -0.5, -0.3, -0.1]);
let seed = project_point_strictly_into_feasible_cone(&point, &constraints).expect(
"strict-interior projection must succeed when an equality pair is present, \
not collapse to the empty set and fall back to the vertex",
);
for i in 0..3 {
assert!(
scaled_constraint_slack(&seed, &constraints, i)
>= 0.4 * ACTIVE_SET_INTERIOR_SEED_MARGIN,
"shape row {i} not strictly interior: scaled slack = {:.3e}",
scaled_constraint_slack(&seed, &constraints, i)
);
}
assert!(
seed[0].abs() <= 1e-6,
"boundary equality must be enforced, got β_0 = {:.3e}",
seed[0]
);
}
#[test]
fn strict_interior_projection_preserves_a_curvature_carrying_seed() {
let p = 5usize;
let rows = p - 2;
let mut a = Array2::<f64>::zeros((rows, p));
for i in 0..rows {
a[[i, i]] = -1.0;
a[[i, i + 1]] = 2.0;
a[[i, i + 2]] = -1.0;
}
let constraints = LinearInequalityConstraints::new(a, Array1::zeros(rows))
.expect("test constraint shape invariant");
let seed = Array1::from_iter((0..p).map(|j| -((j as f64 - 2.0).powi(2))));
let projected = project_point_strictly_into_feasible_cone(&seed, &constraints)
.expect("already-interior seed must project");
let max_move = seed
.iter()
.zip(projected.iter())
.map(|(a, b)| (a - b).abs())
.fold(0.0_f64, f64::max);
assert!(
max_move < 1e-3,
"strictly-interior curvature-carrying seed should be preserved; max move = {max_move:.3e}"
);
}
#[test]
fn maxiter_accepts_current_boundary_solution() {
let hessian = array![[1.0]];
let gradient = array![-1.0];
let beta = array![0.0];
let constraints = LinearInequalityConstraints {
a: array![[-1.0]],
b: array![-0.1],
};
let mut direction = Array1::zeros(1);
let mut active_hint = Vec::new();
solve_newton_direction_with_linear_constraints_impl(
&hessian,
&gradient,
&beta,
&constraints,
&mut direction,
Some(&mut active_hint),
1,
true,
)
.expect("solver should accept the current boundary solution at the iteration limit");
assert_relative_eq!(direction[0], 0.1, epsilon = 1e-12);
assert_eq!(active_hint, vec![0]);
}
#[test]
fn projected_gradient_releases_a_boundary_with_negative_multiplier() {
let x = array![0.0_f64];
let d_total = array![0.0_f64];
let gradient = array![-1.0_f64];
let constraints =
LinearInequalityConstraints::new(array![[1.0]], array![0.0]).expect("one-sided bound");
let (direction, active) = fallback_projected_gradient_direction(
&x,
&x,
&d_total,
&gradient,
&constraints,
&constraints,
)
.expect("fallback evaluation")
.expect("negative-multiplier face must have a feasible descent escape");
assert_relative_eq!(direction[0], 1.0, epsilon = 1e-12);
assert!(gradient.dot(&direction) < 0.0);
assert!(active.is_empty(), "descent moves strictly into the cone");
}
#[test]
fn rank_reduce_zero_rows_returns_empty_working_set() {
let a = array![[0.0, 0.0], [0.0, 0.0],];
let b = array![0.0, 0.0];
let groups = vec![vec![0], vec![1]];
let (a_out, b_out, groups_out, _) =
rank_reduce_rows_pivoted_qr_with_dependence(a, b, groups);
assert_eq!(a_out.nrows(), 0);
assert_eq!(a_out.ncols(), 2);
assert_eq!(b_out.len(), 0);
assert!(groups_out.is_empty());
}
#[test]
fn cone_projection_solves_nonnegative_least_squares_not_one_way_pruning() {
let active_a = array![
[0.85258593, -0.77270261],
[-1.22152485, 2.05129351],
[0.22794844, 1.56987265],
];
let residual = array![-0.50524761, -1.10104911];
let (projected, multipliers) =
project_stationarity_residual_on_constraint_cone(&residual, &active_a)
.expect("cone projection should solve");
let row0 = active_a.row(0);
let expected_mu0 = row0.dot(&residual) / row0.dot(&row0);
assert_relative_eq!(multipliers[0], expected_mu0, epsilon = 1e-8);
assert_relative_eq!(multipliers[1], 0.0, epsilon = 1e-10);
assert_relative_eq!(multipliers[2], 0.0, epsilon = 1e-10);
let raw_norm2 = residual.dot(&residual);
let projected_norm2 = projected.dot(&projected);
assert!(
projected_norm2 < raw_norm2 - 0.1,
"NNLS projection should keep the improving active row: raw={raw_norm2:.6e}, projected={projected_norm2:.6e}"
);
let dual = active_a.dot(&projected);
for (idx, (&mu, &w)) in multipliers.iter().zip(dual.iter()).enumerate() {
if mu <= 1e-10 {
assert!(
w <= 1e-8,
"inactive cone generator {idx} has positive reduced gradient {w:.3e}"
);
}
}
}
#[test]
fn nnls_moreau_projection_matches_primal_qp_route() {
let cases: Vec<(Array2<f64>, Array1<f64>)> = vec![
(
array![
[0.85258593, -0.77270261],
[-1.22152485, 2.05129351],
[0.22794844, 1.56987265],
],
array![-0.50524761, -1.10104911],
),
(array![[1.0, 0.0], [0.0, 1.0]], array![3.0, -2.0]),
(
array![[1.0, 1.0, 0.0], [1.0, -1.0, 0.0], [2.0, 2.0, 0.0]],
array![1.5, 0.25, -0.75],
),
];
for (rows, target) in cases {
let qp = moreau_projection_via_primal_qp(&target, &rows)
.expect("primal QP route must solve these well-posed instances");
let (lambda, projected) = nonnegative_cone_multipliers(&rows, &target)
.expect("LH route must solve the same instances");
for (left, right) in qp.0.iter().zip(projected.iter()) {
assert_relative_eq!(left, right, epsilon = 1e-8);
}
assert!(lambda.iter().all(|&v| v >= 0.0));
let reconstructed = &target - &rows.t().dot(&lambda);
for (left, right) in reconstructed.iter().zip(projected.iter()) {
assert_relative_eq!(left, right, epsilon = 1e-12);
}
}
}
#[test]
fn nnls_projects_axis_cone_exactly() {
let rows = array![[1.0, 0.0], [0.0, 1.0]];
let target = array![3.0, -2.0];
let (lambda, projected) =
nonnegative_cone_multipliers(&rows, &target).expect("axis cone NNLS");
assert_relative_eq!(lambda[0], 3.0, epsilon = 1e-10);
assert_relative_eq!(lambda[1], 0.0, epsilon = 1e-10);
assert_relative_eq!(projected[0], 0.0, epsilon = 1e-10);
assert_relative_eq!(projected[1], -2.0, epsilon = 1e-10);
}
#[test]
fn nnls_closes_stationarity_on_weakly_aligned_dependent_face() {
let eps = 1e-8_f64;
let rows = array![[1.0, eps], [-1.0, eps], [0.0, 1.0]];
let target = array![0.0, 1.0];
let (lambda, projected) =
nonnegative_cone_multipliers(&rows, &target).expect("dependent-face NNLS");
let closure = projected.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
assert!(
closure <= 1e-10,
"λ = e3 closes stationarity exactly; got closure {closure:.3e}"
);
assert!(lambda.iter().all(|&v| v >= 0.0));
}
#[test]
fn degenerate_face_with_weak_alignment_certifies_instead_of_cycling() {
let eps = 1e-8_f64;
let a = array![[1.0, eps], [-1.0, eps], [0.0, 1.0]];
let b = array![0.0, 0.0, 0.0];
let constraints = LinearInequalityConstraints::new(a.clone(), b).expect("constraints");
let hessian = Array2::<f64>::eye(2);
let gradient = array![0.0, 1.0];
let beta = array![0.0, 0.0];
let mut direction = Array1::<f64>::zeros(2);
solve_newton_direction_with_linear_constraints_impl(
&hessian,
&gradient,
&beta,
&constraints,
&mut direction,
None,
64,
false,
)
.expect("the vertex is a certified KKT point; refusal is the #2298 defect");
let step = direction.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
assert!(
step <= 1e-8,
"optimum is the vertex itself; got |d|∞ = {step:.3e}"
);
}
#[test]
fn nnls_fallback_certifies_pinned_degenerate_vertex_projection_979() {
let a = array![
[1.0_f64, 0.0, 0.0],
[0.0, 1.0, 0.0],
[0.0, 0.0, 1.0],
[1.0, 1.0, 0.0],
];
let b = array![0.0_f64, 0.0, 0.0, 0.0];
let set = ConstraintSet::Dense(
LinearInequalityConstraints::new(a, b).expect("degenerate vertex cone"),
);
let beta = array![0.0_f64, 0.0, 0.0];
let residual = array![3.0_f64, 2.0, 0.0]; let (projected, active) =
nnls_tangent_cone_projection_fallback(&residual, &beta, &set, &[0, 1, 2, 3], &[0, 1])
.expect("fallback must solve the degenerate vertex");
let closure = projected.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
assert!(
closure <= 1e-9,
"residual is in the cone; projection must close to zero, got {closure:.3e}"
);
assert!(!active.is_empty(), "a supported face must be reported");
let outside = array![1.0_f64, 0.0, -1.0];
let (projected_outside, _) =
nnls_tangent_cone_projection_fallback(&outside, &beta, &set, &[0, 1, 2, 3], &[])
.expect("fallback must solve the outside-component case");
assert_relative_eq!(projected_outside[0], 0.0, epsilon = 1e-9);
assert_relative_eq!(projected_outside[1], 0.0, epsilon = 1e-9);
assert_relative_eq!(projected_outside[2], -1.0, epsilon = 1e-9);
}
#[test]
fn nnls_fallback_excludes_rows_not_tight_at_beta() {
let a = array![[1.0_f64, 0.0], [0.0, 1.0]];
let b = array![0.0_f64, -1.0]; let set = ConstraintSet::Dense(
LinearInequalityConstraints::new(a, b).expect("half-tight system"),
);
let beta = array![0.0_f64, 0.0];
let residual = array![0.0_f64, 1.0];
let (projected, active) =
nnls_tangent_cone_projection_fallback(&residual, &beta, &set, &[0, 1], &[])
.expect("fallback must solve the half-tight system");
assert_relative_eq!(projected[1], 1.0, epsilon = 1e-12);
assert!(
!active.contains(&1),
"slack row 1 must not appear in the certified face"
);
}
#[test]
fn cone_projection_preserves_original_multiplier_units_after_row_canonicalization() {
let residual = array![2.0, -1.0];
let unit_row = array![[1.0, 0.0]];
let scaled_row = array![[4.0, 0.0]];
let (projected_unit, multiplier_unit) =
project_stationarity_residual_on_constraint_cone(&residual, &unit_row)
.expect("unit-row cone projection should solve");
let (projected_scaled, multiplier_scaled) =
project_stationarity_residual_on_constraint_cone(&residual, &scaled_row)
.expect("scaled-row cone projection should solve");
assert_relative_eq!(projected_unit[0], 0.0, epsilon = 1e-12);
assert_relative_eq!(projected_unit[1], -1.0, epsilon = 1e-12);
assert_relative_eq!(projected_scaled[0], projected_unit[0], epsilon = 1e-12);
assert_relative_eq!(projected_scaled[1], projected_unit[1], epsilon = 1e-12);
assert_relative_eq!(multiplier_unit[0], 2.0, epsilon = 1e-12);
assert_relative_eq!(multiplier_scaled[0], 0.5, epsilon = 1e-12);
let reconstructed_unit = &residual - &unit_row.t().dot(&multiplier_unit);
let reconstructed_scaled = &residual - &scaled_row.t().dot(&multiplier_scaled);
assert_relative_eq!(reconstructed_unit[0], projected_unit[0], epsilon = 1e-12);
assert_relative_eq!(
reconstructed_scaled[0],
projected_scaled[0],
epsilon = 1e-12
);
}
#[test]
fn kkt_primal_is_per_row_scale_invariant() {
let geometric_violation = 2.071e-8_f64;
let gradient = Array1::<f64>::zeros(2);
let beta_unit = array![-geometric_violation, 0.0];
let unit = LinearInequalityConstraints {
a: array![[1.0, 0.0]],
b: array![0.0],
};
let diag_unit = compute_constraint_kkt_diagnostics(&beta_unit, &gradient, &unit);
let beta_big = array![-geometric_violation, 0.0];
let big = LinearInequalityConstraints {
a: array![[1000.0, 0.0]],
b: array![0.0],
};
let diag_big = compute_constraint_kkt_diagnostics(&beta_big, &gradient, &big);
assert_relative_eq!(
diag_unit.primal_feasibility,
geometric_violation,
epsilon = 1e-14
);
assert_relative_eq!(
diag_big.primal_feasibility,
geometric_violation,
epsilon = 1e-14
);
assert!(
diag_big.primal_feasibility < 1e-7,
"scaled primal {:.3e} should pass a 1e-7 gate; raw slack would be {:.3e}",
diag_big.primal_feasibility,
1000.0 * geometric_violation
);
}
#[test]
fn opposing_inequality_pair_pins_equality_to_target() {
let hessian = array![
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
];
let rhs = array![5.0, 5.0, 0.0, 0.0];
let beta_start = Array1::<f64>::zeros(4);
let constraints = LinearInequalityConstraints {
a: array![[1.0, 1.0, 0.0, 0.0], [-1.0, -1.0, 0.0, 0.0]],
b: array![0.0, 0.0],
};
let (beta, _active) = solve_quadratic_with_linear_constraints(
&hessian,
&rhs,
&beta_start,
&constraints,
None,
)
.expect("opposing-inequality equality QP must solve");
let a_dot_beta = beta[0] + beta[1];
assert!(
a_dot_beta.abs() < 1e-8,
"opposing inequalities must pin a·β to 0, got {a_dot_beta:.6e} (β = {beta:?})"
);
}
#[test]
fn opposing_inequality_pair_pins_scaled_equality_to_nonzero_target() {
let hessian = array![
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
];
let rhs = array![5.0, 5.0, 0.0, 0.0];
let beta_start = Array1::<f64>::zeros(4);
let constraints = LinearInequalityConstraints {
a: array![[1000.0, 1000.0, 0.0, 0.0], [-1000.0, -1000.0, 0.0, 0.0]],
b: array![3000.0, -3000.0],
};
let (beta, _active) = solve_quadratic_with_linear_constraints(
&hessian,
&rhs,
&beta_start,
&constraints,
None,
)
.expect("scaled opposing-inequality equality QP must solve");
let a_dot_beta = 1000.0 * (beta[0] + beta[1]);
assert!(
(a_dot_beta - 3000.0).abs() < 1e-5,
"opposing inequalities must pin a·β to 3000, got {a_dot_beta:.6e} (β = {beta:?})"
);
}
#[test]
fn two_opposing_inequality_equalities_both_pinned() {
let hessian = array![
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
];
let rhs = array![5.0, 5.0, 5.0, 5.0];
let beta_start = Array1::<f64>::zeros(4);
let constraints = LinearInequalityConstraints {
a: array![
[1.0, 1.0, 0.0, 0.0],
[-1.0, -1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 1.0],
[0.0, 0.0, -1.0, -1.0],
],
b: array![0.0, 0.0, 0.0, 0.0],
};
let (beta, _active) = solve_quadratic_with_linear_constraints(
&hessian,
&rhs,
&beta_start,
&constraints,
None,
)
.expect("two-equality QP must solve");
assert!(
(beta[0] + beta[1]).abs() < 1e-8,
"equality A not pinned: β0+β1 = {:.6e}",
beta[0] + beta[1]
);
assert!(
(beta[2] + beta[3]).abs() < 1e-8,
"equality B not pinned: β2+β3 = {:.6e}",
beta[2] + beta[3]
);
}
#[test]
fn opposing_inequality_equalities_pinned_under_ill_conditioned_penalty() {
let lam = 1.0e8_f64;
let hessian = array![
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, lam, 0.0],
[0.0, 0.0, 0.0, lam],
];
let rhs = array![5.0, 5.0, 5.0, 5.0];
let beta_start = Array1::<f64>::zeros(4);
let constraints = LinearInequalityConstraints {
a: array![
[1.0, 0.0, 1.0, 0.0],
[-1.0, 0.0, -1.0, 0.0],
[0.0, 1.0, 0.0, 1.0],
[0.0, -1.0, 0.0, -1.0],
],
b: array![0.0, 0.0, 0.0, 0.0],
};
let (beta, _active) = solve_quadratic_with_linear_constraints(
&hessian,
&rhs,
&beta_start,
&constraints,
None,
)
.expect("ill-conditioned two-equality QP must solve");
assert!(
(beta[0] + beta[2]).abs() < 1e-6,
"equality A not pinned under ill-conditioning: β0+β2 = {:.6e}",
beta[0] + beta[2]
);
assert!(
(beta[1] + beta[3]).abs() < 1e-6,
"equality B not pinned under ill-conditioning: β1+β3 = {:.6e}",
beta[1] + beta[3]
);
}
fn small_cone() -> KhatriRaoConeConstraints {
let psi = array![[1.0_f64, 0.2], [1.0, -0.4], [1.0, 1.3], [1.0, 0.8],];
KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1, 2], 3).expect("small cone")
}
#[test]
fn cone_reduced_face_collapses_parallel_rows_to_lowest_index() {
let psi = array![[1.0_f64, 0.0], [0.0, 1.0], [2.0, 0.0]];
let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1], 2)
.expect("parallel cone");
let beta = Array1::<f64>::zeros(2 * 2);
let face = khatri_rao_cone_reduced_face(&cone, beta.view(), 1e-8).expect("reduce");
assert_eq!(face.tight_rows, rows(&[0, 1, 2]));
assert_eq!(face.representatives, rows(&[0, 1]));
assert_eq!(face.dependence.len(), 2);
assert_eq!(face.dependence[0].len(), 1);
assert_eq!(face.dependence[0][0].row.index(), 2);
assert!((face.dependence[0][0].coeff - 2.0).abs() < 1e-12);
assert!(face.dependence[1].is_empty());
}
#[test]
fn cone_reduced_face_full_rank_has_no_dependence() {
let psi = array![[1.0_f64, 0.0], [0.0, 1.0]];
let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1], 2)
.expect("full-rank cone");
let beta = Array1::<f64>::zeros(2 * 2);
let face = khatri_rao_cone_reduced_face(&cone, beta.view(), 1e-8).expect("reduce");
assert_eq!(face.representatives, rows(&[0, 1]));
assert!(face.dependence.iter().all(|d| d.is_empty()));
assert_eq!(face.tight_rows, rows(&[0, 1]));
}
#[test]
fn cone_reduced_face_general_combination_gets_no_dependence_entry() {
let psi = array![[1.0_f64, 0.0], [0.0, 1.0], [1.0, 1.0]];
let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1], 2)
.expect("general-combo cone");
let beta = Array1::<f64>::zeros(2 * 2);
let face = khatri_rao_cone_reduced_face(&cone, beta.view(), 1e-8).expect("reduce");
assert_eq!(face.representatives, rows(&[0, 1])); assert_eq!(face.tight_rows, rows(&[0, 1, 2])); assert!(
face.dependence.iter().all(|d| d.is_empty()),
"a general-position drop must carry no distributed multiplier"
);
}
#[test]
fn cone_reduced_face_reduces_each_shape_block_independently() {
let psi = array![[1.0_f64, 0.0], [0.0, 1.0]];
let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1, 2], 3)
.expect("two-block cone");
let beta = Array1::<f64>::zeros(3 * 2);
let face = khatri_rao_cone_reduced_face(&cone, beta.view(), 1e-8).expect("reduce");
assert_eq!(face.representatives, rows(&[0, 1, 2, 3]));
assert!(face.dependence.iter().all(|d| d.is_empty()));
assert_eq!(face.tight_rows, rows(&[0, 1, 2, 3]));
}
#[test]
fn dense_reduced_face_via_dispatcher_collapses_parallel_rows() {
let a = array![[1.0_f64, 0.0], [0.0, 1.0], [2.0, 0.0]];
let set = ConstraintSet::Dense(
LinearInequalityConstraints::new(a, Array1::<f64>::zeros(3)).expect("dense"),
);
let beta = Array1::<f64>::zeros(2);
let face = set.reduced_face(beta.view(), 1e-8).expect("reduce");
assert_eq!(face.tight_rows, rows(&[0, 1, 2]));
assert_eq!(face.representatives, rows(&[0, 1]));
assert_eq!(face.dependence[0].len(), 1);
assert_eq!(face.dependence[0][0].row.index(), 2);
assert!((face.dependence[0][0].coeff - 2.0).abs() < 1e-12);
assert!(face.dependence[1].is_empty());
}
fn rows(ids: &[usize]) -> Vec<ConstraintRowId> {
ids.iter().copied().map(ConstraintRowId).collect()
}
fn mixed_width_block_diagonal() -> ConstraintSet {
let narrow = gam_problem::PlacedConstraintBlock {
col_start: 0,
set: ConstraintSet::Dense(
LinearInequalityConstraints::new(
array![[1.0_f64, 0.0, 0.0]],
Array1::<f64>::zeros(1),
)
.expect("narrow block"),
),
};
let square = gam_problem::PlacedConstraintBlock {
col_start: 3,
set: ConstraintSet::Dense(
LinearInequalityConstraints::new(
array![[1.0_f64, 0.0], [2.0, 0.0]],
Array1::<f64>::zeros(2),
)
.expect("square block"),
),
};
ConstraintSet::block_diagonal(vec![narrow, square], 5).expect("block-diagonal")
}
#[test]
fn block_diagonal_reduced_face_row_ids_address_the_joint_constraint_row_space() {
let set = mixed_width_block_diagonal();
let beta = Array1::<f64>::zeros(5);
let values = set.values(beta.view()).expect("values");
let face = set.reduced_face(beta.view(), 1e-8).expect("reduce");
assert_eq!(set.nrows(), 3);
assert_eq!(face.tight_rows, rows(&[0, 1, 2]));
assert_eq!(face.representatives, rows(&[0, 1]));
assert_eq!(face.dependence[1][0].row.index(), 2);
for id in &face.tight_rows {
let row = id.index();
assert!(row < set.nrows(), "id {row} outside the joint row space");
let norm = set.row_norm(row).expect("row norm resolves");
let bound = set.bound(row).expect("bound resolves");
assert!(
(values[row] - bound) / norm <= 1e-8,
"row {row} reported tight but has slack {}",
(values[row] - bound) / norm
);
}
}
#[test]
fn block_diagonal_reduced_face_row_ids_are_not_beta_coordinates() {
let set = mixed_width_block_diagonal();
let beta = Array1::<f64>::zeros(5);
let face = set.reduced_face(beta.view(), 1e-8).expect("reduce");
let block1_rep = face.representatives[1];
assert_eq!(block1_rep.index(), 1);
assert_eq!(
set.row_column_support(block1_rep).expect("support"),
vec![3],
"block 1's row acts on the joint column 3 (col_start 3 + local 0)"
);
assert!(block1_rep.index() < 3, "id 1 falls inside block 0's columns");
assert_eq!(
set.row_column_support(face.representatives[0])
.expect("support"),
vec![0]
);
}
#[test]
fn block_diagonal_reduced_face_concatenates_member_row_ids() {
let make = |c0: usize| gam_problem::PlacedConstraintBlock {
col_start: c0,
set: ConstraintSet::Dense(
LinearInequalityConstraints::new(
array![[1.0_f64, 0.0], [2.0, 0.0]],
Array1::<f64>::zeros(2),
)
.expect("dense block"),
),
};
let set = ConstraintSet::block_diagonal(vec![make(0), make(2)], 4).expect("block-diagonal");
let beta = Array1::<f64>::zeros(4);
let face = set.reduced_face(beta.view(), 1e-8).expect("reduce");
assert_eq!(face.tight_rows, rows(&[0, 1, 2, 3]));
assert_eq!(face.representatives, rows(&[0, 2]));
assert_eq!(face.dependence[0][0].row.index(), 1);
assert_eq!(face.dependence[1][0].row.index(), 3);
}
fn coupled_pd_hessian(p: usize) -> Array2<f64> {
let mut h = Array2::<f64>::eye(p) * 2.0;
for i in 0..p {
for j in 0..p {
if i != j {
h[[i, j]] = 0.3 / (1.0 + (i as f64 - j as f64).abs());
}
}
}
h
}
#[test]
fn operator_cone_qp_matches_dense_oracle_when_constraints_bind() {
let cone = small_cone();
let set = ConstraintSet::KhatriRaoCone(cone.clone());
let dense = cone.to_dense().expect("dense oracle");
let p = set.ncols();
let hessian = coupled_pd_hessian(p);
let rhs = array![0.5_f64, -0.3, -2.0, 1.0, -1.5, -0.7];
let beta_start = array![0.0_f64, 0.0, 1.0, 0.1, 1.0, 0.1];
let (beta_op, mut active_op) =
solve_quadratic_with_constraint_set(&hessian, &rhs, &beta_start, &set, None)
.expect("operator solve");
let (beta_dense, mut active_dense) =
solve_quadratic_with_linear_constraints(&hessian, &rhs, &beta_start, &dense, None)
.expect("dense solve");
for j in 0..p {
assert!(
(beta_op[j] - beta_dense[j]).abs() < 1e-7,
"operator/dense coefficient {j} mismatch: {} vs {}",
beta_op[j],
beta_dense[j]
);
}
active_op.sort_unstable();
active_dense.sort_unstable();
let values_at_solution = set.values(beta_op.view()).expect("values at solution");
let tight_at_solution: Vec<usize> = (0..set.nrows())
.filter(|&row| {
let norm = set.row_norm(row).expect("norm");
norm > 0.0 && values_at_solution[row] / norm <= 1e-7
})
.collect();
for &row in active_op.iter().chain(active_dense.iter()) {
assert!(
tight_at_solution.contains(&row),
"reported active row {row} is not tight at the common solution \
(op face {active_op:?}, dense face {active_dense:?}, tight {tight_at_solution:?})"
);
}
assert_eq!(
active_op.len(),
active_dense.len(),
"carriers disagree on the face dimension: op {active_op:?} vs dense {active_dense:?}"
);
assert!(
!active_op.is_empty(),
"fixture must actually bind at least one cone row"
);
let values = set.values(beta_op.view()).expect("values");
let (worst, _) = set.max_scaled_violation(beta_op.view()).expect("violation");
assert!(worst <= 1e-8, "operator answer infeasible: {worst:.3e}");
assert_eq!(values.len(), 8);
}
#[test]
fn separable_khatri_rao_tangent_projection_matches_dense_oracle() {
let cone = small_cone();
let set = ConstraintSet::KhatriRaoCone(cone.clone());
let dense = cone.to_dense().expect("dense projection oracle");
let beta = Array1::<f64>::zeros(set.ncols());
let residual = array![0.4_f64, -0.2, 1.1, -0.7, -0.9, 0.8];
let (operator_projected, _) =
project_stationarity_residual_on_constraint_set(&residual, &beta, &set, &[])
.expect("separable operator projection");
let (dense_projected, _) =
project_stationarity_residual_on_constraint_cone(&residual, &dense.a)
.expect("dense cone projection");
for index in 0..residual.len() {
assert_relative_eq!(
operator_projected[index],
dense_projected[index],
epsilon = 1e-8
);
}
}
#[test]
fn operator_cone_qp_takes_unconstrained_path_when_interior() {
let cone = small_cone();
let set = ConstraintSet::KhatriRaoCone(cone);
let p = set.ncols();
let hessian = coupled_pd_hessian(p);
let rhs = array![0.2_f64, 0.1, 3.0, 0.2, 2.5, 0.1];
let beta_start = array![0.0_f64, 0.0, 1.0, 0.0, 1.0, 0.0];
let (beta_op, active_op) =
solve_quadratic_with_constraint_set(&hessian, &rhs, &beta_start, &set, None)
.expect("operator solve");
let mut beta_unconstrained = Array1::<f64>::zeros(p);
super::solve_newton_direction_dense(
&hessian,
&(hessian.dot(&beta_start) - &rhs),
&mut beta_unconstrained,
)
.expect("unconstrained newton");
let beta_unconstrained = &beta_start + &beta_unconstrained;
for j in 0..p {
assert!(
(beta_op[j] - beta_unconstrained[j]).abs() < 1e-8,
"interior operator solve must match unconstrained optimum at {j}"
);
}
assert!(
active_op.is_empty(),
"interior optimum must have empty face"
);
}
#[test]
fn operator_projection_returns_strictly_interior_point() {
let cone = small_cone();
let set = ConstraintSet::KhatriRaoCone(cone);
let point = array![0.4_f64, -0.2, -1.0, -0.5, 0.3, 0.05];
let projected = project_point_strictly_into_feasible_constraint_set(&point, &set)
.expect("projection must succeed on a one-sided homogeneous cone");
let values = set.values(projected.view()).expect("values");
for row in 0..set.nrows() {
let norm = set.row_norm(row).expect("norm");
if norm <= 0.0 {
continue;
}
let slack = values[row] / norm;
assert!(
slack >= 0.5 * ACTIVE_SET_INTERIOR_SEED_MARGIN - 1e-9,
"projected point not strictly interior on row {row}: slack {slack:.3e}"
);
}
assert!((projected[0] - point[0]).abs() < 1e-8);
assert!((projected[1] - point[1]).abs() < 1e-8);
}
#[test]
fn operator_projection_adjudicates_the_over_complete_face_2378() {
let cone = small_cone();
let set = ConstraintSet::KhatriRaoCone(cone.clone());
let point = array![0.4_f64, -0.2, -1.0, -0.5, 0.3, 0.05];
let projected = project_point_strictly_into_feasible_constraint_set(&point, &set)
.expect("operator projection must certify the over-complete-face vertex");
let dense = ConstraintSet::Dense(cone.to_dense().expect("dense oracle"));
let dense_proj = project_point_strictly_into_feasible_constraint_set(&point, &dense)
.expect("dense projection oracle");
for j in 0..point.len() {
assert!(
(projected[j] - dense_proj[j]).abs() < 1e-7,
"operator projection diverged from the dense oracle at {j}: \
op={:.9e} dense={:.9e}",
projected[j],
dense_proj[j]
);
}
let values = set.values(projected.view()).expect("values");
let scaled = |row: usize| values[row] / set.row_norm(row).expect("norm");
for row in [1usize, 2] {
assert!(
scaled(row) < ACTIVE_SET_INTERIOR_SEED_MARGIN + 1e-7,
"block-1 row {row} should bind, scaled slack {:.3e}",
scaled(row)
);
}
for row in [0usize, 3] {
assert!(
scaled(row) > scaled(2) + 1e-9,
"non-binding row {row} (slack {:.3e}) must exceed the binding \
row 2 (slack {:.3e})",
scaled(row),
scaled(2)
);
}
}
#[test]
fn operator_cone_qp_over_complete_face_matches_dense_oracle_2378() {
let cone = small_cone();
let set = ConstraintSet::KhatriRaoCone(cone.clone());
let dense = cone.to_dense().expect("dense oracle");
let p = set.ncols();
let hessian = coupled_pd_hessian(p);
let rhs = array![0.3_f64, -0.1, -2.5, -1.2, -0.4, 0.2];
let beta_start = array![0.0_f64, 0.0, 1.0, 0.1, 1.0, 0.1];
let (beta_op, _active_op) =
solve_quadratic_with_constraint_set(&hessian, &rhs, &beta_start, &set, None)
.expect("operator QP solve over an over-complete face");
let (beta_dense, _active_dense) =
solve_quadratic_with_linear_constraints(&hessian, &rhs, &beta_start, &dense, None)
.expect("dense QP oracle");
for j in 0..p {
assert!(
(beta_op[j] - beta_dense[j]).abs() < 1e-7,
"operator/dense coefficient {j} mismatch: {} vs {}",
beta_op[j],
beta_dense[j]
);
}
let values = set.values(beta_op.view()).expect("values");
for row in 0..set.nrows() {
let norm = set.row_norm(row).expect("norm");
if norm > 0.0 {
assert!(
values[row] / norm >= -ACTIVE_SET_PRIMAL_FEASIBILITY_TOL,
"row {row} violated at the operator optimum: {:.3e}",
values[row] / norm
);
}
}
}
#[test]
fn operator_cone_does_not_materialize_a_whole_tight_face() {
let mut psi = Array2::<f64>::zeros((4096, 2));
psi.column_mut(0).fill(1.0);
let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1], 2)
.expect("repeated-row cone");
let set = ConstraintSet::KhatriRaoCone(cone);
let hessian = Array2::<f64>::eye(4);
let rhs = array![0.3_f64, -0.2, -1.0, 0.0];
let beta_start = Array1::<f64>::zeros(4);
let (beta, active) =
solve_quadratic_with_constraint_set(&hessian, &rhs, &beta_start, &set, Some(&[0]))
.expect("vertex solve");
assert_eq!(
active,
vec![0],
"redundant tight rows entered the working set"
);
assert!(beta[2].abs() <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL);
assert!((beta[0] - 0.3).abs() < 1e-10);
assert!((beta[1] + 0.2).abs() < 1e-10);
}
#[test]
fn operator_cycle_escape_is_descending_feasible_and_sparse() {
let psi = array![[1.0_f64, 0.0], [1.0, 1.0], [1.0, 2.0]];
let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1], 2)
.expect("cycle-escape cone");
let set = ConstraintSet::KhatriRaoCone(cone);
let ops = ConstraintSetOps::new(&set, 0.0).expect("operator geometry");
let x = Array1::<f64>::zeros(4);
let d_total = Array1::<f64>::zeros(4);
let gradient = array![0.0_f64, 0.0, 0.0, -1.0];
let (direction, active) = fallback_projected_gradient_direction_with_constraint_set(
&x,
&x,
&d_total,
&gradient,
&[0],
&ops,
)
.expect("operator fallback evaluation")
.expect("a certified tangent descent direction must exist");
assert!(
gradient.dot(&direction) < 0.0,
"escape must be a strict descent direction"
);
let candidate = &x + &direction;
let (worst, _) = set
.max_scaled_violation(candidate.view())
.expect("full-set feasibility");
assert!(
worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL,
"escape must remain feasible on every operator row: {worst:.3e}"
);
assert_eq!(
active,
vec![0],
"operator escape expanded one sparse face row into all tight rows"
);
}
#[test]
fn operator_tangent_projection_does_not_constrain_interior_rows() {
let psi = array![[1.0_f64, 0.0], [1.0, 1.0], [1.0, -1.0]];
let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1], 2)
.expect("interior tangent cone");
let set = ConstraintSet::KhatriRaoCone(cone);
let beta = array![0.0_f64, 0.0, 1.0, 0.0];
let residual = array![0.0_f64, 0.0, 1.0, 0.0];
let (projected, active) =
project_stationarity_residual_on_constraint_set(&residual, &beta, &set, &[])
.expect("interior tangent projection");
for index in 0..residual.len() {
assert_relative_eq!(projected[index], residual[index], epsilon = 1e-12);
}
assert!(active.is_empty(), "interior rows entered the tangent face");
}
#[test]
fn operator_tangent_projection_homogenizes_an_affine_boundary() {
let set = ConstraintSet::Dense(
LinearInequalityConstraints::new(array![[1.0_f64, 0.0]], array![2.0])
.expect("affine half-space"),
);
let beta = array![2.0_f64, 0.0];
let residual = array![1.0_f64, -1.0];
let (projected, active) =
project_stationarity_residual_on_constraint_set(&residual, &beta, &set, &[0])
.expect("affine-boundary tangent projection");
assert_relative_eq!(projected[0], 0.0, epsilon = 1e-12);
assert_relative_eq!(projected[1], -1.0, epsilon = 1e-12);
assert_eq!(active, vec![0]);
}
#[test]
fn operator_cycle_escape_discovers_a_zero_step_tangent_separator() {
let psi = array![[1.0_f64, 0.0], [1.0, 1.0], [1.0, -1.0]];
let cone = KhatriRaoConeConstraints::new(std::sync::Arc::new(psi), vec![1], 2)
.expect("separator cone");
let set = ConstraintSet::KhatriRaoCone(cone);
let ops = ConstraintSetOps::new(&set, 0.0).expect("operator geometry");
let x = Array1::<f64>::zeros(4);
let d_total = Array1::<f64>::zeros(4);
let gradient = array![0.0_f64, 0.0, 0.0, -1.0];
let (direction, active) = fallback_projected_gradient_direction_with_constraint_set(
&x,
&x,
&d_total,
&gradient,
&[0],
&ops,
)
.expect("operator separator evaluation")
.expect("one omitted tight separator must not defeat the escape");
assert!(gradient.dot(&direction) < 0.0);
let candidate = &x + &direction;
let (worst, _) = set
.max_scaled_violation(candidate.view())
.expect("full-set feasibility");
assert!(worst <= ACTIVE_SET_PRIMAL_FEASIBILITY_TOL);
assert!(
active.len() <= 2,
"separator discovery expanded a three-row vertex: {active:?}"
);
}
}