use std::cell::Cell;
use std::time::Instant;
#[cfg(test)]
use crate::ScopedDisable;
use super::core::run_ipm;
use super::kkt::{bound_violation, kkt_residual_rel, primal_residual_rel};
use super::outcome::{IpmOutcome, ProblemView};
use crate::options::SolverOptions;
use crate::presolve::QpPresolveResult;
use crate::presolve::{
qp_transforms::QpPresolveStatus, run_qp_presolve_phase1, run_qp_presolve_phase2,
};
use crate::problem::{SolveStatus, SolverResult};
use crate::qp::certificate::prove_optimal;
use crate::qp::problem::QpProblem;
use crate::tolerances::{Q_DIAG_RANGE_TRIGGER, Q_OFFDIAG_ABS, Q_OFFDIAG_REL, UNDERFLOW_GUARD};
const QP_GUARD_CATASTROPHIC_TOL: f64 = 1e-1;
thread_local! {
static QP_GUARD_DISABLED: Cell<bool> = const { Cell::new(false) };
}
#[cfg(test)]
pub(crate) fn with_qp_guard_disabled<F, R>(f: F) -> R
where
F: FnOnce() -> R,
{
let _guard = ScopedDisable::new(
|| QP_GUARD_DISABLED.with(|c| c.set(true)),
|| QP_GUARD_DISABLED.with(|c| c.set(false)),
);
f()
}
pub(crate) fn guard_qp_optimal(
result: SolverResult,
problem: &QpProblem,
eliminated_cols: &[bool],
) -> SolverResult {
if QP_GUARD_DISABLED.with(|c| c.get()) {
return result;
}
if !matches!(
result.status,
SolveStatus::Optimal | SolveStatus::LocallyOptimal
) {
return result;
}
if result.solution.is_empty() {
return result;
}
let view = ProblemView {
q: &problem.q,
a: &problem.a,
c: &problem.c,
b: &problem.b,
bounds: &problem.bounds,
constraint_types: &problem.constraint_types,
eliminated_cols,
};
let kkt = kkt_residual_rel(
&view,
&result.solution,
&result.dual_solution,
&result.bound_duals,
);
let pf = primal_residual_rel(&view, &result.solution);
let bv = bound_violation(&problem.bounds, &result.solution);
if kkt > QP_GUARD_CATASTROPHIC_TOL
|| pf > QP_GUARD_CATASTROPHIC_TOL
|| bv > QP_GUARD_CATASTROPHIC_TOL
{
SolverResult {
status: SolveStatus::NumericalError,
objective: f64::INFINITY,
iterations: result.iterations,
..Default::default()
}
} else {
result
}
}
const MAX_ITER_PER_ATTEMPT: usize = 500;
const NO_PRESOLVE_FALLBACK_LIMIT: usize = 10_000;
type IpmRunner = fn(&QpProblem, &QpPresolveResult, &SolverOptions) -> IpmOutcome;
fn dynamic_base_tighten(user_eps: f64) -> f64 {
const REF_EPS: f64 = 1e-8;
let ratio = user_eps / REF_EPS;
if ratio <= 1.0 {
return 1.0;
}
let pow = ratio.log10().ceil();
10_f64.powf(pow.min(3.0))
}
pub fn solve_ipm(problem: &QpProblem, options: &SolverOptions) -> SolverResult {
if options.validate().is_err() {
return SolverResult::numerical_error();
}
if let Some((scaled_problem, col_scales)) = try_q_diagonal_scaling(problem) {
let scaled_options = scale_warm_start_for_q_diag(options, &col_scales);
let (mut result, eliminated_cols) =
solve_ipm_with_runner(&scaled_problem, &scaled_options, run_ipm);
unscale_q_diagonal(&mut result, &col_scales, problem);
return guard_qp_optimal(result, problem, &eliminated_cols);
}
let (result, eliminated_cols) = solve_ipm_with_runner(problem, options, run_ipm);
guard_qp_optimal(result, problem, &eliminated_cols)
}
fn scale_warm_start_for_q_diag(options: &SolverOptions, col_scales: &[f64]) -> SolverOptions {
let mut scaled = options.clone();
if let Some(ws) = scaled.warm_start_qp.as_mut() {
if ws.x.len() == col_scales.len() {
for j in 0..col_scales.len() {
ws.x[j] /= col_scales[j];
}
} else {
log::warn!(
"warm_start_qp ignored: q_diag_scaling dim mismatch (x: {}, scales: {})",
ws.x.len(),
col_scales.len()
);
scaled.warm_start_qp = None;
}
}
scaled
}
fn try_q_diagonal_scaling(problem: &QpProblem) -> Option<(QpProblem, Vec<f64>)> {
let n = problem.num_vars;
if n == 0 {
return None;
}
let mut q_diag = vec![0.0_f64; n];
for col in 0..n {
let cs = problem.q.col_ptr[col];
let ce = problem.q.col_ptr[col + 1];
for k in cs..ce {
if problem.q.row_ind[k] == col {
q_diag[col] = problem.q.values[k];
}
}
}
for col in 0..n {
let cs = problem.q.col_ptr[col];
let ce = problem.q.col_ptr[col + 1];
for k in cs..ce {
let row = problem.q.row_ind[k];
if row != col {
let local_scale = q_diag[row].abs().min(q_diag[col].abs());
let offdiag_eps = Q_OFFDIAG_REL * local_scale + UNDERFLOW_GUARD;
if problem.q.values[k].abs() > offdiag_eps {
return None;
}
}
}
}
let mut q_pos_min = f64::INFINITY;
let mut q_pos_max = 0.0_f64;
for &v in &q_diag {
if v > Q_OFFDIAG_ABS {
q_pos_min = q_pos_min.min(v);
q_pos_max = q_pos_max.max(v);
}
}
if !q_pos_min.is_finite() || q_pos_max <= 0.0 {
return None;
}
if q_pos_max / q_pos_min < Q_DIAG_RANGE_TRIGGER {
return None;
}
let mut col_scales = vec![1.0_f64; n];
for j in 0..n {
if q_diag[j] > Q_OFFDIAG_ABS {
col_scales[j] = 1.0 / q_diag[j].sqrt();
}
}
let mut q_s = problem.q.clone();
for col in 0..n {
let cs = q_s.col_ptr[col];
let ce = q_s.col_ptr[col + 1];
for k in cs..ce {
let row = q_s.row_ind[k];
q_s.values[k] *= col_scales[row] * col_scales[col];
}
}
let mut a_s = problem.a.clone();
for col in 0..n {
let cs = a_s.col_ptr[col];
let ce = a_s.col_ptr[col + 1];
let s = col_scales[col];
for k in cs..ce {
a_s.values[k] *= s;
}
}
let c_s: Vec<f64> = problem
.c
.iter()
.enumerate()
.map(|(j, &v)| v * col_scales[j])
.collect();
let bounds_s: Vec<(f64, f64)> = problem
.bounds
.iter()
.enumerate()
.map(|(j, &(lb, ub))| (lb / col_scales[j], ub / col_scales[j]))
.collect();
let mut scaled = match QpProblem::new(
q_s,
c_s,
a_s,
problem.b.clone(),
bounds_s,
problem.constraint_types.clone(),
) {
Ok(p) => p,
Err(_) => return None,
};
scaled.obj_offset = problem.obj_offset;
Some((scaled, col_scales))
}
fn unscale_q_diagonal(result: &mut SolverResult, col_scales: &[f64], orig_problem: &QpProblem) {
let n = orig_problem.num_vars;
if result.solution.len() == n {
for j in 0..n {
result.solution[j] *= col_scales[j];
}
}
if !result.bound_duals.is_empty() {
let mut idx = 0_usize;
for (j, &(lb, _)) in orig_problem.bounds.iter().enumerate() {
if lb.is_finite() && idx < result.bound_duals.len() {
result.bound_duals[idx] /= col_scales[j];
idx += 1;
}
}
for (j, &(_, ub)) in orig_problem.bounds.iter().enumerate() {
if ub.is_finite() && idx < result.bound_duals.len() {
result.bound_duals[idx] /= col_scales[j];
idx += 1;
}
}
}
}
fn solve_ipm_with_runner(
problem: &QpProblem,
options: &SolverOptions,
runner: IpmRunner,
) -> (SolverResult, Vec<bool>) {
let start_time = Instant::now();
let mut opts = options.clone();
let n_orig = problem.num_vars;
if opts.deadline.is_none() {
if let Some(secs) = opts.timeout_secs {
opts.deadline = Some(start_time + std::time::Duration::from_secs_f64(secs));
opts.timeout_secs = None;
}
}
let total_deadline = opts.deadline;
let user_eps = opts.ipm_eps();
let presolve_result = if opts.presolve
&& problem.num_vars <= crate::tolerances::LARGE_PROBLEM_THRESHOLD
&& problem.num_constraints <= crate::tolerances::LARGE_PROBLEM_THRESHOLD
{
let phase1 = run_qp_presolve_phase1(problem, &opts);
if opts.presolve_phase2 {
run_qp_presolve_phase2(phase1, &opts)
} else {
phase1
}
} else {
crate::presolve::QpPresolveResult::no_reduction(problem)
};
let eliminated_cols: Vec<bool> = presolve_result
.col_map
.iter()
.map(|c| c.is_none())
.collect();
if presolve_result.presolve_status == QpPresolveStatus::Infeasible {
return (SolverResult::infeasible(), eliminated_cols);
}
if presolve_result.presolve_status == QpPresolveStatus::Unbounded {
return (SolverResult::unbounded(), eliminated_cols);
}
let view = ProblemView {
q: &problem.q,
a: &problem.a,
c: &problem.c,
b: &problem.b,
bounds: &problem.bounds,
constraint_types: &problem.constraint_types,
eliminated_cols: &eliminated_cols,
};
if total_deadline.is_some_and(|d| Instant::now() >= d) {
let r = finalize_outcome(
IpmOutcome::empty(),
user_eps,
n_orig,
total_deadline,
false,
&view,
);
return (r, eliminated_cols);
}
let presolve_did_ruiz = presolve_result.ruiz_scaler.is_some();
let mut best: Option<IpmOutcome> = None;
let user_max_iter = options.ipm.max_iter;
let mut iter_used: usize = 0;
let base_tighten = dynamic_base_tighten(user_eps);
let attempts: Vec<(bool, f64)> = if presolve_did_ruiz || !options.use_ruiz_scaling {
let mut v = vec![(false, base_tighten), (false, base_tighten * 10.0)];
if base_tighten > 10.0 {
v.push((false, base_tighten / 10.0));
}
if base_tighten > 1.0 {
v.push((false, 1.0));
}
v
} else {
let mut v = vec![
(true, base_tighten),
(false, base_tighten),
(true, base_tighten * 10.0),
(false, base_tighten * 10.0),
(true, base_tighten * 100.0),
(false, base_tighten * 100.0),
];
if base_tighten > 10.0 {
v.push((true, base_tighten / 10.0));
v.push((false, base_tighten / 10.0));
}
if base_tighten > 1.0 {
v.push((true, 1.0));
v.push((false, 1.0));
}
v
};
for &(use_ruiz, tighten) in attempts.iter() {
if let Some(d) = total_deadline {
if Instant::now() >= d {
break;
}
}
if iter_used >= user_max_iter {
break;
}
let remaining = user_max_iter.saturating_sub(iter_used);
let per_attempt_cap = MAX_ITER_PER_ATTEMPT.min(remaining);
opts.deadline = total_deadline;
opts.timeout_secs = None;
opts.ipm.max_iter = per_attempt_cap;
opts.use_ruiz_scaling = use_ruiz;
opts.ipm.eps = (user_eps / tighten).max(crate::qp::ipm_core::IPM_EPS_NOISE_FLOOR);
let outcome = runner(problem, &presolve_result, &opts);
let charged = if outcome.satisfies_eps(user_eps) {
outcome.iterations
} else {
per_attempt_cap
};
iter_used = iter_used.saturating_add(charged);
if outcome.satisfies_eps(user_eps) {
best = Some(outcome);
break;
}
match &best {
None => best = Some(outcome),
Some(prev) if outcome.quality_score() < prev.quality_score() => {
best = Some(outcome);
}
_ => {}
}
}
let best_ok = best
.as_ref()
.map(|b| b.satisfies_eps(user_eps))
.unwrap_or(false);
if !best_ok && presolve_did_ruiz && n_orig <= NO_PRESOLVE_FALLBACK_LIMIT {
let fallback_pre = QpPresolveResult::no_reduction(problem);
for use_ruiz_fb in [false, true] {
if total_deadline.is_some_and(|d| Instant::now() >= d) {
break;
}
if iter_used >= user_max_iter {
break;
}
let remaining = user_max_iter.saturating_sub(iter_used);
let per_attempt_cap = MAX_ITER_PER_ATTEMPT.min(remaining);
opts.deadline = total_deadline;
opts.timeout_secs = None;
opts.ipm.max_iter = per_attempt_cap;
opts.use_ruiz_scaling = use_ruiz_fb;
opts.tolerance = None;
opts.ipm.eps = (user_eps / base_tighten).max(crate::qp::ipm_core::IPM_EPS_NOISE_FLOOR);
let fb = runner(problem, &fallback_pre, &opts);
let charged_fb = if fb.satisfies_eps(user_eps) {
fb.iterations
} else {
per_attempt_cap
};
iter_used = iter_used.saturating_add(charged_fb);
if fb.satisfies_eps(user_eps) {
best = Some(fb);
break;
}
}
}
let mut outcome = best.unwrap_or_else(IpmOutcome::empty);
if outcome.is_locally_optimal {
let ic = crate::qp::ipm_core::kkt::compute_inertia_correction(&problem.q);
if ic == 0.0 {
outcome.is_locally_optimal = false;
}
}
let cancelled = options
.cancel_flag
.as_ref()
.is_some_and(|f| f.load(std::sync::atomic::Ordering::Relaxed));
let r = finalize_outcome(outcome, user_eps, n_orig, total_deadline, cancelled, &view);
(r, eliminated_cols)
}
fn finalize_outcome(
outcome: IpmOutcome,
user_eps: f64,
n_orig: usize,
total_deadline: Option<Instant>,
cancelled: bool,
view: &ProblemView<'_>,
) -> SolverResult {
let krylov_ir_skipped = outcome.postsolve_krylov_ir_skipped;
if let Some(infeas) = outcome.infeasibility_status {
let objective = match infeas {
SolveStatus::Infeasible => f64::INFINITY,
SolveStatus::Unbounded => f64::NEG_INFINITY,
_ => f64::NAN,
};
return SolverResult {
status: infeas,
objective,
iterations: outcome.iterations,
..Default::default()
};
}
let timed_out = cancelled || total_deadline.is_some_and(|d| Instant::now() >= d);
if outcome.numerical_failure {
return SolverResult {
status: SolveStatus::NumericalError,
objective: f64::INFINITY,
solution: Vec::new(),
dual_solution: Vec::new(),
bound_duals: Vec::new(),
iterations: outcome.iterations,
..Default::default()
};
}
if outcome.solution.is_empty() {
let status = if timed_out {
SolveStatus::Timeout
} else {
SolveStatus::NumericalError
};
return SolverResult {
status,
objective: f64::INFINITY,
solution: Vec::new(),
dual_solution: Vec::new(),
bound_duals: Vec::new(),
iterations: outcome.iterations,
..Default::default()
};
}
let status = if outcome.satisfies_eps(user_eps) {
let proven = prove_optimal(
view,
&outcome.solution,
&outcome.dual_solution,
&outcome.bound_duals,
outcome.duality_gap_rel,
user_eps,
);
if proven.is_ok() {
if outcome.is_locally_optimal {
SolveStatus::LocallyOptimal
} else {
SolveStatus::Optimal
}
} else {
SolveStatus::SuboptimalSolution
}
} else if timed_out {
SolveStatus::Timeout
} else {
SolveStatus::SuboptimalSolution
};
debug_assert_eq!(
outcome.solution.len(),
n_orig,
"outcome solution dimension mismatch"
);
let result = SolverResult {
status,
objective: outcome.objective,
solution: outcome.solution,
dual_solution: outcome.dual_solution,
bound_duals: outcome.bound_duals,
iterations: outcome.iterations,
timing_breakdown: outcome.timing,
stats: crate::problem::SolveStats {
postsolve_krylov_ir_skipped: krylov_ir_skipped,
..Default::default()
},
..Default::default()
};
debug_assert!(
result.reduced_costs.is_empty(),
"IPM SolverResult must never contain reduced_costs; got len={}",
result.reduced_costs.len(),
);
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sparse::CscMatrix;
#[test]
fn ipm_finalize_outcome_reduced_costs_empty() {
let q = CscMatrix::from_triplets(&[0], &[0], &[2.0], 1, 1).unwrap();
let a = CscMatrix::new(0, 1);
let prob = QpProblem::new_all_le(
q,
vec![0.0],
a,
vec![],
vec![(f64::NEG_INFINITY, f64::INFINITY)],
)
.unwrap();
let result = solve_ipm(&prob, &SolverOptions::default());
assert!(
result.reduced_costs.is_empty(),
"IPM result must never contain reduced_costs (len={})",
result.reduced_costs.len(),
);
}
fn make_convex_plus_empty_col_qp() -> QpProblem {
let q = CscMatrix::from_triplets(&[0], &[0], &[1.0], 2, 2).unwrap();
let a = CscMatrix::new(0, 2);
QpProblem::new_all_le(
q,
vec![0.0, 1.0],
a,
vec![],
vec![(-10.0, 10.0), (0.0, 5.0)],
)
.unwrap()
}
#[test]
fn empty_col_qp_solves_optimal_not_false_demoted() {
let prob = make_convex_plus_empty_col_qp();
let result = solve_ipm(&prob, &SolverOptions::default());
assert_eq!(
result.status,
crate::problem::SolveStatus::Optimal,
"isolated EmptyCol QP must not be false-demoted (got {:?})",
result.status,
);
assert!(
(result.objective - 0.0).abs() < 1e-6,
"obj={}",
result.objective
);
assert!(result.solution[0].abs() < 1e-6, "x0={}", result.solution[0]);
assert!(result.solution[1].abs() < 1e-6, "x1={}", result.solution[1]);
}
#[test]
fn empty_col_mask_noop_proof_in_finalize() {
let prob = make_convex_plus_empty_col_qp();
let outcome = IpmOutcome {
solution: vec![0.0, 0.0],
dual_solution: vec![],
bound_duals: vec![0.0, 0.0, 0.0, 0.0],
objective: 0.0,
iterations: 5,
kkt_residual_rel: 0.0,
primal_residual_rel: 0.0,
bound_violation: 0.0,
complementarity_residual_rel: 0.0,
duality_gap_rel: 0.0,
numerical_failure: false,
infeasibility_status: None,
is_locally_optimal: false,
postsolve_krylov_ir_skipped: false,
timing: None,
};
assert!(
outcome.satisfies_eps(1e-6),
"stored residuals must pass satisfies_eps"
);
let mask = vec![false, true];
let view_masked = ProblemView {
q: &prob.q,
a: &prob.a,
c: &prob.c,
b: &prob.b,
bounds: &prob.bounds,
constraint_types: &prob.constraint_types,
eliminated_cols: &mask,
};
let r_masked = finalize_outcome(outcome.clone(), 1e-6, 2, None, false, &view_masked);
assert_eq!(
r_masked.status,
crate::problem::SolveStatus::Optimal,
"masked view must accept the valid presolved Optimal",
);
let view_unmasked = ProblemView::from_problem(&prob);
let r_unmasked = finalize_outcome(outcome, 1e-6, 2, None, false, &view_unmasked);
assert_eq!(
r_unmasked.status,
crate::problem::SolveStatus::SuboptimalSolution,
"no-op proof: empty mask must false-demote (mask is load-bearing)",
);
}
#[test]
fn mask_does_not_hide_nonempty_col_false_optimal() {
use crate::problem::ConstraintType;
let q = CscMatrix::new(1, 1);
let a = CscMatrix::from_triplets(&[0], &[0], &[1.0_f64], 1, 1).unwrap();
let prob = QpProblem::new(
q,
vec![1.0],
a,
vec![5.0],
vec![(0.0, 10.0)],
vec![ConstraintType::Eq],
)
.unwrap();
let outcome = IpmOutcome {
solution: vec![0.0],
dual_solution: vec![0.0],
bound_duals: vec![0.0, 0.0],
objective: 0.0,
iterations: 5,
kkt_residual_rel: 0.0,
primal_residual_rel: 0.0,
bound_violation: 0.0,
complementarity_residual_rel: 0.0,
duality_gap_rel: 0.0,
numerical_failure: false,
infeasibility_status: None,
is_locally_optimal: false,
postsolve_krylov_ir_skipped: false,
timing: None,
};
let mask = vec![true];
let view = ProblemView {
q: &prob.q,
a: &prob.a,
c: &prob.c,
b: &prob.b,
bounds: &prob.bounds,
constraint_types: &prob.constraint_types,
eliminated_cols: &mask,
};
let result = finalize_outcome(outcome, 1e-6, 1, None, false, &view);
assert_eq!(
result.status,
crate::problem::SolveStatus::SuboptimalSolution,
"mask must NOT hide a non-empty column's genuine violation (AFIRO-safety)",
);
}
#[test]
fn try_q_diagonal_scaling_gate1_local_scale_sentinel() {
let q =
CscMatrix::from_triplets(&[0, 0, 1], &[0, 1, 1], &[1e9_f64, 5e-10, 1e3], 2, 2).unwrap();
let prob = QpProblem::new_all_le(
q,
vec![0.0, 0.0],
CscMatrix::new(0, 2),
vec![],
vec![(0.0, 1.0), (0.0, 1.0)],
)
.unwrap();
assert!(
try_q_diagonal_scaling(&prob).is_some(),
"Gate 1 local scale: 5e-10 < Q_OFFDIAG_REL×1e3=1e-9 → scaling must trigger"
);
}
#[test]
fn test_q_diagonal_scaling_skips_non_diagonal_q() {
let q = CscMatrix::from_triplets(&[0, 0, 1], &[0, 1, 1], &[2.0, 1.0, 2.0], 2, 2).unwrap();
let c = vec![0.0, 0.0];
let a = CscMatrix::new(0, 2);
let b = vec![];
let bounds = vec![(f64::NEG_INFINITY, f64::INFINITY); 2];
let prob = QpProblem::new_all_le(q, c, a, b, bounds).unwrap();
assert!(try_q_diagonal_scaling(&prob).is_none());
}
#[test]
fn test_q_diagonal_scaling_skips_uniform_diagonal() {
let q = CscMatrix::from_triplets(&[0, 1], &[0, 1], &[1.0, 2.0], 2, 2).unwrap();
let c = vec![0.0, 0.0];
let a = CscMatrix::new(0, 2);
let b = vec![];
let bounds = vec![(f64::NEG_INFINITY, f64::INFINITY); 2];
let prob = QpProblem::new_all_le(q, c, a, b, bounds).unwrap();
assert!(try_q_diagonal_scaling(&prob).is_none());
}
#[test]
fn test_q_diagonal_scaling_roundtrip() {
let q = CscMatrix::from_triplets(&[0, 1], &[0, 1], &[1e-7, 2.0], 2, 2).unwrap();
let c = vec![-3.0, -4.0];
let a = CscMatrix::from_triplets(&[0, 0], &[0, 1], &[1.0, 1.0], 1, 2).unwrap();
let b = vec![1.0];
let bounds = vec![(0.0, 100.0), (0.0, 100.0)];
let prob = QpProblem::new(
q.clone(),
c.clone(),
a.clone(),
b.clone(),
bounds.clone(),
vec![crate::problem::ConstraintType::Eq],
)
.unwrap();
let (scaled, col_scales) =
try_q_diagonal_scaling(&prob).expect("ill-cond diag Q must trigger");
let q_s = &scaled.q;
for col in 0..2 {
for k in q_s.col_ptr[col]..q_s.col_ptr[col + 1] {
if q_s.row_ind[k] == col {
assert!(
(q_s.values[k] - 1.0).abs() < 1e-12,
"got {} at col {}",
q_s.values[k],
col
);
}
}
}
assert!((col_scales[0] - 1.0 / (1e-7_f64).sqrt()).abs() < 1e-3);
assert!((col_scales[1] - 1.0 / 2.0_f64.sqrt()).abs() < 1e-12);
assert!((scaled.bounds[0].1 - 100.0 / col_scales[0]).abs() < 1e-9);
assert!((scaled.bounds[1].1 - 100.0 / col_scales[1]).abs() < 1e-6);
}
#[test]
fn test_q_diagonal_scaling_unscale_roundtrip() {
let q = CscMatrix::from_triplets(&[0, 1], &[0, 1], &[1e-12, 2.0], 2, 2).unwrap();
let c = vec![-3.0, -4.0];
let a = CscMatrix::from_triplets(&[0, 0], &[0, 1], &[1.0, 1.0], 1, 2).unwrap();
let b = vec![1.0];
let bounds = vec![(0.0, 100.0), (0.0, 100.0)];
let prob =
QpProblem::new(q, c, a, b, bounds, vec![crate::problem::ConstraintType::Eq]).unwrap();
let opts = SolverOptions::default();
let result = solve_ipm(&prob, &opts);
assert_eq!(result.status, crate::problem::SolveStatus::Optimal);
crate::test_kkt::assert_solver_invariants_qp(&result, &prob);
let ax = prob.a.mat_vec_mul(&result.solution).unwrap();
assert!((ax[0] - 1.0).abs() < 1e-6);
for j in 0..2 {
let (lb, ub) = prob.bounds[j];
assert!(result.solution[j] >= lb - 1e-9);
assert!(result.solution[j] <= ub + 1e-9);
}
}
fn make_simple_eq_qp() -> QpProblem {
let q = CscMatrix::new(1, 1);
let a = CscMatrix::from_triplets(&[0], &[0], &[1.0], 1, 1).unwrap();
QpProblem::new(
q,
vec![1.0],
a,
vec![1.0],
vec![(0.0, f64::INFINITY)],
vec![crate::problem::ConstraintType::Eq],
)
.unwrap()
}
#[test]
fn guard_qp_optimal_catches_catastrophic_result() {
let prob = make_simple_eq_qp();
let corrupt = SolverResult {
status: crate::problem::SolveStatus::Optimal,
objective: 1e12,
solution: vec![1e12],
dual_solution: vec![0.0],
bound_duals: vec![],
..Default::default()
};
let guarded = guard_qp_optimal(corrupt, &prob, &[]);
assert_eq!(
guarded.status,
crate::problem::SolveStatus::NumericalError,
"guard must demote catastrophic QP result: primal violation 1e12-1 >> 1e-1"
);
}
#[test]
fn guard_qp_optimal_no_op_proof() {
let prob = make_simple_eq_qp();
let corrupt = SolverResult {
status: crate::problem::SolveStatus::Optimal,
objective: 1e12,
solution: vec![1e12],
dual_solution: vec![0.0],
bound_duals: vec![],
..Default::default()
};
let unguarded = with_qp_guard_disabled(|| guard_qp_optimal(corrupt, &prob, &[]));
assert_eq!(
unguarded.status,
crate::problem::SolveStatus::Optimal,
"with_qp_guard_disabled must pass corrupt result through as Optimal"
);
}
#[test]
fn guard_qp_optimal_passthrough_valid() {
let q = CscMatrix::from_triplets(&[0, 1], &[0, 1], &[2.0, 2.0], 2, 2).unwrap();
let a = CscMatrix::from_triplets(&[0, 0], &[0, 1], &[1.0, 1.0], 1, 2).unwrap();
let prob = QpProblem::new(
q,
vec![0.0, 0.0],
a,
vec![1.0],
vec![(0.0, 100.0), (0.0, 100.0)],
vec![crate::problem::ConstraintType::Eq],
)
.unwrap();
let opts = SolverOptions::default();
let result = solve_ipm(&prob, &opts);
assert_eq!(result.status, crate::problem::SolveStatus::Optimal);
let re_guarded = guard_qp_optimal(result.clone(), &prob, &[]);
assert_eq!(
re_guarded.status,
crate::problem::SolveStatus::Optimal,
"guard must not demote a valid Optimal QP result"
);
}
#[test]
fn guard_qp_optimal_passthrough_non_optimal() {
let prob = make_simple_eq_qp();
for status in [
crate::problem::SolveStatus::Infeasible,
crate::problem::SolveStatus::Timeout,
crate::problem::SolveStatus::NumericalError,
crate::problem::SolveStatus::SuboptimalSolution,
] {
let r = SolverResult {
status: status.clone(),
..Default::default()
};
let out = guard_qp_optimal(r, &prob, &[]);
assert_eq!(out.status, status, "guard must pass through {status:?}");
}
}
#[test]
fn finalize_outcome_dual_sign_notproven_demotes_to_suboptimal() {
use crate::problem::ConstraintType;
use crate::sparse::CscMatrix;
let q = CscMatrix::new(1, 1);
let a = CscMatrix::from_triplets(&[0usize, 1], &[0, 0], &[1.0_f64, -1.0], 2, 1).unwrap();
let prob = QpProblem::new(
q,
vec![0.0],
a,
vec![1.0, -1.0],
vec![(f64::NEG_INFINITY, f64::INFINITY)],
vec![ConstraintType::Le, ConstraintType::Le],
)
.unwrap();
let view = ProblemView::from_problem(&prob);
let user_eps = 1e-6_f64;
let outcome = IpmOutcome {
solution: vec![1.0],
dual_solution: vec![-0.1, -0.1],
bound_duals: vec![],
objective: 0.0,
iterations: 1,
kkt_residual_rel: 0.0,
primal_residual_rel: 0.0,
bound_violation: 0.0,
complementarity_residual_rel: 0.0,
duality_gap_rel: 0.0,
numerical_failure: false,
infeasibility_status: None,
is_locally_optimal: false,
postsolve_krylov_ir_skipped: false,
timing: None,
};
assert!(
outcome.satisfies_eps(user_eps),
"satisfies_eps must pass: all residuals=0, gap=0 (dual_sign は未検査)"
);
let result = finalize_outcome(outcome, user_eps, 1, None, false, &view);
assert_eq!(
result.status,
crate::problem::SolveStatus::SuboptimalSolution,
"dual_sign 違反 → prove_optimal が Err → SuboptimalSolution に降格すべき"
);
}
#[test]
fn finalize_outcome_dual_sign_valid_returns_optimal() {
use crate::problem::ConstraintType;
use crate::sparse::CscMatrix;
let q = CscMatrix::new(1, 1);
let a = CscMatrix::from_triplets(&[0usize, 1], &[0, 0], &[1.0_f64, -1.0], 2, 1).unwrap();
let prob = QpProblem::new(
q,
vec![0.0],
a,
vec![1.0, -1.0],
vec![(f64::NEG_INFINITY, f64::INFINITY)],
vec![ConstraintType::Le, ConstraintType::Le],
)
.unwrap();
let view = ProblemView::from_problem(&prob);
let user_eps = 1e-6_f64;
let outcome = IpmOutcome {
solution: vec![1.0],
dual_solution: vec![0.1, 0.1],
bound_duals: vec![],
objective: 0.0,
iterations: 1,
kkt_residual_rel: 0.0,
primal_residual_rel: 0.0,
bound_violation: 0.0,
complementarity_residual_rel: 0.0,
duality_gap_rel: 0.0,
numerical_failure: false,
infeasibility_status: None,
is_locally_optimal: false,
postsolve_krylov_ir_skipped: false,
timing: None,
};
assert!(outcome.satisfies_eps(user_eps));
let result = finalize_outcome(outcome, user_eps, 1, None, false, &view);
assert_eq!(
result.status,
crate::problem::SolveStatus::Optimal,
"有効な dual は prove_optimal を通過し Optimal が返るべき"
);
}
#[test]
fn finalize_outcome_numerical_failure_maps_to_numerical_error() {
let prob = make_simple_eq_qp();
let view = ProblemView::from_problem(&prob);
let outcome = IpmOutcome {
solution: vec![1.0],
dual_solution: vec![0.0],
bound_duals: vec![0.0, 0.0],
objective: 1.0,
iterations: 3,
kkt_residual_rel: 0.0,
primal_residual_rel: 0.0,
bound_violation: 0.0,
complementarity_residual_rel: 0.0,
duality_gap_rel: 0.0,
numerical_failure: true,
infeasibility_status: None,
is_locally_optimal: false,
postsolve_krylov_ir_skipped: false,
timing: None,
};
let result = finalize_outcome(outcome, 1e-6, 1, None, false, &view);
assert_eq!(
result.status,
crate::problem::SolveStatus::NumericalError,
"numerical_failure=true は solution 非空でも NumericalError でなければならない",
);
}
#[test]
fn unscale_q_diagonal_reverses_x_and_bound_duals() {
use crate::sparse::CscMatrix;
let n = 3;
let q = CscMatrix::from_triplets(&[0, 1, 2], &[0, 1, 2], &[1.0, 4.0, 9.0], n, n).unwrap();
let prob = QpProblem::new_all_le(
q,
vec![1.0_f64; n],
CscMatrix::new(0, n),
vec![],
vec![(0.0, 5.0), (0.0, f64::INFINITY), (f64::NEG_INFINITY, 3.0)],
)
.unwrap();
let col_scales = vec![2.0_f64, 0.5, 4.0];
let mut result = SolverResult {
status: SolveStatus::Optimal,
solution: vec![1.0, 2.0, 3.0],
dual_solution: vec![],
bound_duals: vec![10.0, 20.0, 30.0, 40.0],
..SolverResult::default()
};
unscale_q_diagonal(&mut result, &col_scales, &prob);
assert!((result.solution[0] - 2.0).abs() < 1e-12);
assert!((result.solution[1] - 1.0).abs() < 1e-12);
assert!((result.solution[2] - 12.0).abs() < 1e-12);
assert!((result.bound_duals[0] - 5.0).abs() < 1e-12);
assert!((result.bound_duals[1] - 40.0).abs() < 1e-12);
assert!((result.bound_duals[2] - 15.0).abs() < 1e-12);
assert!((result.bound_duals[3] - 10.0).abs() < 1e-12);
}
#[test]
fn iter_guard_charges_per_attempt_cap_on_failed_attempt() {
use std::cell::Cell;
thread_local! {
static CALL_COUNT: Cell<usize> = const { Cell::new(0) };
}
fn mock_runner(_: &QpProblem, _: &QpPresolveResult, _: &SolverOptions) -> IpmOutcome {
CALL_COUNT.with(|c| c.set(c.get() + 1));
IpmOutcome::empty()
}
let prob = make_simple_eq_qp();
let mut opts = SolverOptions::default();
opts.ipm.max_iter = 2;
opts.presolve = false; CALL_COUNT.with(|c| c.set(0));
let _ = solve_ipm_with_runner(&prob, &opts, mock_runner);
let count = CALL_COUNT.with(|c| c.get());
assert_eq!(
count, 1,
"iter guard must stop after 1 attempt when per_attempt_cap charges full budget \
(got {} runner calls)",
count
);
}
}