use std::cell::Cell;
use std::time::Instant;
#[cfg(test)]
use crate::ScopedDisable;
use super::core::run_ipm_with_user_eps;
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, f64) -> 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))
}
fn outcome_proves_optimal(outcome: &IpmOutcome, view: &ProblemView<'_>, user_eps: f64) -> bool {
if !outcome.satisfies_eps(user_eps) {
return false;
}
prove_optimal(
view,
&outcome.solution,
&outcome.dual_solution,
&outcome.bound_duals,
outcome.duality_gap_rel,
user_eps,
)
.is_ok()
}
fn outcome_certificate_score(outcome: &IpmOutcome, view: &ProblemView<'_>, user_eps: f64) -> f64 {
if outcome.solution.is_empty() || outcome.numerical_failure {
return f64::INFINITY;
}
match prove_optimal(
view,
&outcome.solution,
&outcome.dual_solution,
&outcome.bound_duals,
outcome.duality_gap_rel,
user_eps,
) {
Ok(_) => 0.0,
Err(not_proven) => outcome
.quality_score()
.max(not_proven.stationarity_rel.abs())
.max(not_proven.primal_residual_rel.abs())
.max(not_proven.bound_violation.abs())
.max(not_proven.complementarity_rel.abs())
.max(not_proven.duality_gap_rel.abs())
.max(not_proven.dual_sign_violation.abs()),
}
}
fn outcome_is_better_candidate(
candidate: &IpmOutcome,
incumbent: &IpmOutcome,
view: &ProblemView<'_>,
user_eps: f64,
) -> bool {
match (
candidate.infeasibility_status.is_some(),
incumbent.infeasibility_status.is_some(),
) {
(true, false) => return false,
(false, true) => return true,
(true, true) => return false,
(false, false) => {}
}
let candidate_score = outcome_certificate_score(candidate, view, user_eps);
let incumbent_score = outcome_certificate_score(incumbent, view, user_eps);
match candidate_score.total_cmp(&incumbent_score) {
std::cmp::Ordering::Less => true,
std::cmp::Ordering::Greater => false,
std::cmp::Ordering::Equal => candidate.objective.total_cmp(&incumbent.objective).is_lt(),
}
}
fn fallback_can_replace_unproven(
fallback: &IpmOutcome,
incumbent: &IpmOutcome,
view: &ProblemView<'_>,
user_eps: f64,
) -> bool {
if incumbent.infeasibility_status.is_some() {
return false;
}
outcome_is_better_candidate(fallback, incumbent, view, user_eps)
&& fallback.objective.total_cmp(&incumbent.objective).is_le()
}
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_with_user_eps);
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_with_user_eps);
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, user_eps);
let outcome_satisfies = outcome.satisfies_eps(user_eps);
let outcome_proven = outcome_satisfies && outcome_proves_optimal(&outcome, &view, user_eps);
let charged = if outcome_satisfies {
outcome.iterations
} else {
per_attempt_cap
};
iter_used = iter_used.saturating_add(charged);
if outcome_proven {
best = Some(outcome);
break;
}
match &best {
None => best = Some(outcome),
Some(prev) if outcome_is_better_candidate(&outcome, prev, &view, user_eps) => {
best = Some(outcome);
}
_ => {}
}
}
let best_ok = best
.as_ref()
.map(|b| outcome_proves_optimal(b, &view, 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, user_eps);
let fb_satisfies = fb.satisfies_eps(user_eps);
let fb_proven = fb_satisfies && outcome_proves_optimal(&fb, &view, user_eps);
let charged_fb = if fb_satisfies {
fb.iterations
} else {
per_attempt_cap
};
iter_used = iter_used.saturating_add(charged_fb);
if fb_proven {
best = Some(fb);
break;
}
if best
.as_ref()
.is_some_and(|prev| fallback_can_replace_unproven(&fb, prev, &view, user_eps))
{
best = Some(fb);
}
}
}
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;
use std::sync::atomic::{AtomicUsize, Ordering};
static GAP_ACCEPTANCE_CALLS: AtomicUsize = AtomicUsize::new(0);
static INFEAS_RETRY_CALLS: AtomicUsize = AtomicUsize::new(0);
fn runner_gap_fail_then_proven(
_problem: &QpProblem,
_presolve: &QpPresolveResult,
_options: &SolverOptions,
_user_eps: f64,
) -> IpmOutcome {
let call = GAP_ACCEPTANCE_CALLS.fetch_add(1, Ordering::SeqCst);
IpmOutcome {
solution: vec![0.0],
dual_solution: vec![],
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: if call == 0 { 1e-3 } else { 0.0 },
numerical_failure: false,
infeasibility_status: None,
is_locally_optimal: false,
postsolve_krylov_ir_skipped: false,
timing: None,
}
}
fn runner_infeasible_then_fallback_suboptimal(
_problem: &QpProblem,
presolve: &QpPresolveResult,
_options: &SolverOptions,
_user_eps: f64,
) -> IpmOutcome {
if presolve.ruiz_scaler.is_some() {
return IpmOutcome::infeasibility(crate::problem::SolveStatus::Infeasible);
}
IpmOutcome {
solution: vec![0.0],
dual_solution: vec![],
bound_duals: vec![0.0, 0.0],
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: 1e-3,
numerical_failure: false,
infeasibility_status: None,
is_locally_optimal: false,
postsolve_krylov_ir_skipped: false,
timing: None,
}
}
fn runner_infeasible_then_finite_retry(
_problem: &QpProblem,
_presolve: &QpPresolveResult,
_options: &SolverOptions,
_user_eps: f64,
) -> IpmOutcome {
let call = INFEAS_RETRY_CALLS.fetch_add(1, Ordering::SeqCst);
if call == 0 {
return IpmOutcome::infeasibility(crate::problem::SolveStatus::Infeasible);
}
IpmOutcome {
solution: vec![0.0],
dual_solution: vec![],
bound_duals: vec![0.0, 0.0],
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: 1e-3,
numerical_failure: false,
infeasibility_status: None,
is_locally_optimal: false,
postsolve_krylov_ir_skipped: false,
timing: None,
}
}
fn runner_incumbent_then_worse_fallback(
_problem: &QpProblem,
presolve: &QpPresolveResult,
_options: &SolverOptions,
_user_eps: f64,
) -> IpmOutcome {
let is_fallback = presolve.ruiz_scaler.is_none();
IpmOutcome {
solution: vec![0.0],
dual_solution: vec![],
bound_duals: vec![0.0, 0.0],
objective: if is_fallback { 1.0e9 } else { 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: if is_fallback { 1e-3 } else { 1e-2 },
numerical_failure: false,
infeasibility_status: None,
is_locally_optimal: false,
postsolve_krylov_ir_skipped: false,
timing: None,
}
}
#[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 attempt_acceptance_requires_prove_optimal_gap() {
let prob = make_convex_plus_empty_col_qp();
let mask = vec![false, 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 outcome = IpmOutcome {
solution: vec![0.0, 0.0],
dual_solution: vec![],
bound_duals: vec![0.0, 1.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: 1e-3,
numerical_failure: false,
infeasibility_status: None,
is_locally_optimal: false,
postsolve_krylov_ir_skipped: false,
timing: None,
};
assert!(
outcome.satisfies_eps(1e-6),
"loose satisfies_eps still accepts gap below promotion tolerance"
);
assert!(
!outcome_proves_optimal(&outcome, &view, 1e-6),
"attempt acceptance must match prove_optimal gap<=user_eps"
);
assert!(
outcome_certificate_score(&outcome, &view, 1e-6) >= 1e-3,
"certificate score must include user-eps gap failure"
);
}
#[test]
fn attempt_acceptance_requires_prove_optimal_dual_sign() {
use crate::problem::ConstraintType;
let q = CscMatrix::from_triplets(&[0], &[0], &[1.0_f64], 1, 1).unwrap();
let a = CscMatrix::from_triplets(&[0], &[0], &[1.0_f64], 1, 1).unwrap();
let prob = QpProblem::new(
q,
vec![0.0],
a,
vec![0.0],
vec![(f64::NEG_INFINITY, f64::INFINITY)],
vec![ConstraintType::Le],
)
.unwrap();
let view = ProblemView::from_problem(&prob);
let outcome = IpmOutcome {
solution: vec![1.0],
dual_solution: vec![-1.0],
bound_duals: vec![],
objective: 0.5,
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),
"satisfies_eps intentionally has no dual-sign check"
);
assert!(
!outcome_proves_optimal(&outcome, &view, 1e-6),
"attempt acceptance must not stop on a dual-sign-invalid point"
);
assert!(
outcome_certificate_score(&outcome, &view, 1e-6) > 0.0,
"certificate score must include dual-sign failure"
);
}
#[test]
fn candidate_order_prefers_certificate_then_objective() {
let q = CscMatrix::from_triplets(&[0], &[0], &[1.0_f64], 1, 1).unwrap();
let prob = QpProblem::new_all_le(
q,
vec![0.0],
CscMatrix::new(0, 1),
vec![],
vec![(f64::NEG_INFINITY, f64::INFINITY)],
)
.unwrap();
let view = ProblemView::from_problem(&prob);
let mut incumbent = IpmOutcome {
solution: vec![0.0],
dual_solution: vec![],
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: 1e-2,
numerical_failure: false,
infeasibility_status: None,
is_locally_optimal: false,
postsolve_krylov_ir_skipped: false,
timing: None,
};
let mut better_cert = incumbent.clone();
better_cert.objective = 1.0e9;
better_cert.duality_gap_rel = 1e-3;
assert!(
outcome_is_better_candidate(&better_cert, &incumbent, &view, 1e-6),
"certificate residual improvement is the primary ordering key"
);
let mut better_obj = incumbent.clone();
better_obj.objective = -1.0;
incumbent.objective = 1.0;
assert!(
outcome_is_better_candidate(&better_obj, &incumbent, &view, 1e-6),
"objective is the tie-breaker when certificate residuals are equal"
);
let mut worse_obj_better_cert = incumbent.clone();
worse_obj_better_cert.objective = 1.0e9;
worse_obj_better_cert.duality_gap_rel = 1e-3;
assert!(
!fallback_can_replace_unproven(&worse_obj_better_cert, &incumbent, &view, 1e-6),
"unproven fallback must not replace an incumbent when objective gets worse"
);
}
#[test]
fn candidate_order_preserves_structural_status_over_unproven_iterate() {
let q = CscMatrix::from_triplets(&[0], &[0], &[1.0_f64], 1, 1).unwrap();
let prob = QpProblem::new_all_le(
q,
vec![0.0],
CscMatrix::new(0, 1),
vec![],
vec![(0.0, f64::INFINITY)],
)
.unwrap();
let view = ProblemView::from_problem(&prob);
let structural = IpmOutcome::infeasibility(crate::problem::SolveStatus::Infeasible);
let finite_unproven = IpmOutcome {
solution: vec![0.0],
dual_solution: vec![],
bound_duals: vec![0.0],
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: 1e-3,
numerical_failure: false,
infeasibility_status: None,
is_locally_optimal: false,
postsolve_krylov_ir_skipped: false,
timing: None,
};
assert!(
outcome_is_better_candidate(&finite_unproven, &structural, &view, 1e-6),
"a finite retry candidate must displace a retry-local infeasibility status"
);
assert!(
!outcome_is_better_candidate(&structural, &finite_unproven, &view, 1e-6),
"a retry-local infeasibility status must not displace a finite incumbent"
);
assert!(
!fallback_can_replace_unproven(&finite_unproven, &structural, &view, 1e-6),
"an unproven no-presolve fallback must not displace an incumbent infeasibility status"
);
}
#[test]
fn attempt_loop_does_not_stop_on_satisfies_only_gap_failure() {
GAP_ACCEPTANCE_CALLS.store(0, Ordering::SeqCst);
let q = CscMatrix::from_triplets(&[0], &[0], &[1.0_f64], 1, 1).unwrap();
let prob = QpProblem::new_all_le(
q,
vec![0.0],
CscMatrix::new(0, 1),
vec![],
vec![(f64::NEG_INFINITY, f64::INFINITY)],
)
.unwrap();
let mut options = SolverOptions {
presolve: false,
use_ruiz_scaling: false,
..SolverOptions::default()
};
options.ipm.eps = 1e-6;
options.ipm.max_iter = MAX_ITER_PER_ATTEMPT;
let (result, _) = solve_ipm_with_runner(&prob, &options, runner_gap_fail_then_proven);
assert_eq!(
GAP_ACCEPTANCE_CALLS.load(Ordering::SeqCst),
2,
"first satisfies_eps-only outcome must not terminate the attempt loop"
);
assert_eq!(
result.status,
crate::problem::SolveStatus::Optimal,
"second proven outcome must be the accepted result"
);
}
#[test]
fn no_presolve_fallback_does_not_replace_structural_infeasible() {
let q = CscMatrix::from_triplets(&[0], &[0], &[1.0_f64], 1, 1).unwrap();
let prob = QpProblem::new_all_le(
q,
vec![0.0],
CscMatrix::new(0, 1),
vec![],
vec![(0.0, f64::INFINITY)],
)
.unwrap();
let mut options = SolverOptions {
presolve: true,
use_ruiz_scaling: true,
..SolverOptions::default()
};
options.ipm.eps = 1e-6;
options.ipm.max_iter = MAX_ITER_PER_ATTEMPT * 4 + 2;
let (result, _) =
solve_ipm_with_runner(&prob, &options, runner_infeasible_then_fallback_suboptimal);
assert_eq!(
result.status,
crate::problem::SolveStatus::Infeasible,
"no-presolve fallback must not replace a structural infeasibility certificate with an unproven finite iterate"
);
}
#[test]
fn attempt_loop_keeps_finite_retry_over_infeasibility_status() {
INFEAS_RETRY_CALLS.store(0, Ordering::SeqCst);
let q = CscMatrix::from_triplets(&[0], &[0], &[1.0_f64], 1, 1).unwrap();
let prob = QpProblem::new_all_le(
q,
vec![0.0],
CscMatrix::new(0, 1),
vec![],
vec![(0.0, f64::INFINITY)],
)
.unwrap();
let mut options = SolverOptions {
presolve: false,
use_ruiz_scaling: false,
..SolverOptions::default()
};
options.ipm.eps = 1e-6;
options.ipm.max_iter = MAX_ITER_PER_ATTEMPT * 2;
let (result, _) =
solve_ipm_with_runner(&prob, &options, runner_infeasible_then_finite_retry);
assert!(
INFEAS_RETRY_CALLS.load(Ordering::SeqCst) >= 2,
"retry-local infeasibility must not terminate before a finite retry is considered"
);
assert_eq!(
result.status,
crate::problem::SolveStatus::SuboptimalSolution,
"finite unproven retry must be returned instead of a retry-local infeasibility status"
);
assert_eq!(result.objective, 0.0);
}
#[test]
fn no_presolve_fallback_does_not_replace_better_objective_incumbent() {
let q = CscMatrix::from_triplets(&[0], &[0], &[1.0_f64], 1, 1).unwrap();
let prob = QpProblem::new_all_le(
q,
vec![0.0],
CscMatrix::new(0, 1),
vec![],
vec![(0.0, f64::INFINITY)],
)
.unwrap();
let mut options = SolverOptions {
presolve: true,
use_ruiz_scaling: true,
..SolverOptions::default()
};
options.ipm.eps = 1e-6;
options.ipm.max_iter = MAX_ITER_PER_ATTEMPT * 4 + 2;
let (result, _) =
solve_ipm_with_runner(&prob, &options, runner_incumbent_then_worse_fallback);
assert_eq!(
result.status,
crate::problem::SolveStatus::SuboptimalSolution
);
assert_eq!(
result.objective, 0.0,
"unproven fallback with a worse objective must not displace the incumbent"
);
}
#[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,
_: f64,
) -> 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
);
}
#[test]
fn runner_receives_user_eps_separate_from_attempt_eps() {
use std::cell::Cell;
thread_local! {
static SEEN_ATTEMPT_EPS: Cell<f64> = const { Cell::new(f64::NAN) };
static SEEN_USER_EPS: Cell<f64> = const { Cell::new(f64::NAN) };
}
fn mock_runner(
_: &QpProblem,
_: &QpPresolveResult,
options: &SolverOptions,
user_eps: f64,
) -> IpmOutcome {
SEEN_ATTEMPT_EPS.with(|c| c.set(options.ipm.eps));
SEEN_USER_EPS.with(|c| c.set(user_eps));
IpmOutcome::empty()
}
let prob = make_simple_eq_qp();
let mut opts = SolverOptions::default();
opts.ipm.eps = 1e-6;
opts.ipm.max_iter = 1;
opts.presolve = false;
SEEN_ATTEMPT_EPS.with(|c| c.set(f64::NAN));
SEEN_USER_EPS.with(|c| c.set(f64::NAN));
let _ = solve_ipm_with_runner(&prob, &opts, mock_runner);
let attempt_eps = SEEN_ATTEMPT_EPS.with(|c| c.get());
let user_eps = SEEN_USER_EPS.with(|c| c.get());
assert!(
attempt_eps < user_eps,
"attempt eps must be tightened below user eps; attempt={attempt_eps:e} user={user_eps:e}"
);
assert_eq!(
user_eps, 1e-6,
"runner must receive the external user eps, not the tightened attempt eps"
);
}
#[test]
fn fallback_runner_receives_user_eps_after_tolerance_clear() {
use std::cell::Cell;
thread_local! {
static FALLBACK_ATTEMPT_EPS: Cell<f64> = const { Cell::new(f64::NAN) };
static FALLBACK_USER_EPS: Cell<f64> = const { Cell::new(f64::NAN) };
}
fn mock_runner(
_: &QpProblem,
presolve: &QpPresolveResult,
options: &SolverOptions,
user_eps: f64,
) -> IpmOutcome {
if presolve.ruiz_scaler.is_none() {
FALLBACK_ATTEMPT_EPS.with(|c| c.set(options.ipm.eps));
FALLBACK_USER_EPS.with(|c| c.set(user_eps));
}
IpmOutcome {
solution: vec![0.0],
dual_solution: vec![],
bound_duals: vec![0.0],
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: 1e-3,
numerical_failure: false,
infeasibility_status: None,
is_locally_optimal: false,
postsolve_krylov_ir_skipped: false,
timing: None,
}
}
let q = CscMatrix::from_triplets(&[0], &[0], &[1.0_f64], 1, 1).unwrap();
let prob = QpProblem::new_all_le(
q,
vec![0.0],
CscMatrix::new(0, 1),
vec![],
vec![(0.0, f64::INFINITY)],
)
.unwrap();
let mut opts = SolverOptions {
presolve: true,
use_ruiz_scaling: true,
..SolverOptions::default()
};
opts.ipm.eps = 1e-6;
opts.ipm.max_iter = MAX_ITER_PER_ATTEMPT * 4 + 2;
FALLBACK_ATTEMPT_EPS.with(|c| c.set(f64::NAN));
FALLBACK_USER_EPS.with(|c| c.set(f64::NAN));
let _ = solve_ipm_with_runner(&prob, &opts, mock_runner);
let attempt_eps = FALLBACK_ATTEMPT_EPS.with(|c| c.get());
let user_eps = FALLBACK_USER_EPS.with(|c| c.get());
assert!(
attempt_eps.is_finite() && attempt_eps < user_eps,
"fallback attempt eps must be tightened below user eps; attempt={attempt_eps:e} user={user_eps:e}"
);
assert_eq!(
user_eps, 1e-6,
"fallback runner must receive the external user eps after tolerance is cleared"
);
}
#[test]
fn q_diagonal_scaled_runner_receives_user_eps() {
use std::cell::Cell;
thread_local! {
static SEEN_ATTEMPT_EPS: Cell<f64> = const { Cell::new(f64::NAN) };
static SEEN_USER_EPS: Cell<f64> = const { Cell::new(f64::NAN) };
}
fn mock_runner(
_: &QpProblem,
_: &QpPresolveResult,
options: &SolverOptions,
user_eps: f64,
) -> IpmOutcome {
SEEN_ATTEMPT_EPS.with(|c| c.set(options.ipm.eps));
SEEN_USER_EPS.with(|c| c.set(user_eps));
IpmOutcome::empty()
}
let q = CscMatrix::from_triplets(&[0, 1], &[0, 1], &[1e-7_f64, 2.0], 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();
let (scaled_prob, col_scales) =
try_q_diagonal_scaling(&prob).expect("ill-conditioned diagonal Q must scale");
let mut scaled_opts = scale_warm_start_for_q_diag(&SolverOptions::default(), &col_scales);
scaled_opts.ipm.eps = 1e-6;
scaled_opts.ipm.max_iter = 1;
scaled_opts.presolve = false;
SEEN_ATTEMPT_EPS.with(|c| c.set(f64::NAN));
SEEN_USER_EPS.with(|c| c.set(f64::NAN));
let _ = solve_ipm_with_runner(&scaled_prob, &scaled_opts, mock_runner);
let attempt_eps = SEEN_ATTEMPT_EPS.with(|c| c.get());
let user_eps = SEEN_USER_EPS.with(|c| c.get());
assert!(
attempt_eps < user_eps,
"scaled attempt eps must be tightened below user eps; attempt={attempt_eps:e} user={user_eps:e}"
);
assert_eq!(
user_eps, 1e-6,
"Q-diagonal scaled runner must receive the external user eps"
);
}
}