use std::time::Instant;
use super::certificate::guard_lp_optimal;
use crate::options::SolverOptions;
use crate::presolve;
#[cfg(test)]
use crate::problem::ConstraintType;
use crate::problem::{LpProblem, SolveRoute, SolveStatus, SolverResult};
#[cfg(test)]
use crate::sparse::CscMatrix;
#[cfg(test)]
use crate::tolerances::any_nonfinite;
#[cfg(test)]
use super::ipm_solver;
use super::QpProblem;
#[cfg(test)]
thread_local! {
static INJECT_REDUCED_TIMEOUT_QP: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
#[cfg(test)]
const REDUCED_TIMEOUT_QP_INJECT_ITERS: usize = 6271;
fn timeout_result_lp_dispatch() -> SolverResult {
let mut timeout = SolverResult {
status: SolveStatus::Timeout,
objective: f64::INFINITY,
..Default::default()
};
timeout.stats.route = SolveRoute::LpForwardedFromQp;
timeout.stats.deadline_triggered = true;
timeout
}
pub(crate) fn solve_as_lp(problem: &QpProblem, options: &SolverOptions) -> SolverResult {
let opts_with_deadline;
let options: &SolverOptions = if options.deadline.is_none() {
if let Some(secs) = options.timeout_secs {
opts_with_deadline = {
let mut o = options.clone();
o.deadline = Some(Instant::now() + std::time::Duration::from_secs_f64(secs));
o.timeout_secs = None;
o
};
&opts_with_deadline
} else {
options
}
} else {
options
};
let lp = match LpProblem::new_general(
problem.c.clone(),
problem.a.clone(),
problem.b.clone(),
problem.constraint_types.clone(),
problem.bounds.clone(),
None,
) {
Ok(lp) => lp,
Err(_) => {
let mut r = SolverResult::numerical_error();
r.stats.route = SolveRoute::LpForwardedFromQp;
return r;
}
};
if options.presolve {
let t_presolve = Instant::now();
match presolve::run_presolve(&lp, options.deadline) {
Err(presolve::PresolveStatus::Infeasible) => {
let mut result = SolverResult::infeasible();
result.timing_breakdown = Some(crate::problem::TimingBreakdown {
presolve_us: t_presolve.elapsed().as_micros() as u64,
..Default::default()
});
result.stats.route = SolveRoute::LpForwardedFromQp;
return result;
}
Err(presolve::PresolveStatus::Unbounded) => {
let mut result = SolverResult {
status: SolveStatus::Unbounded,
objective: f64::NEG_INFINITY,
..Default::default()
};
result.timing_breakdown = Some(crate::problem::TimingBreakdown {
presolve_us: t_presolve.elapsed().as_micros() as u64,
..Default::default()
});
result.stats.route = SolveRoute::LpForwardedFromQp;
return result;
}
Ok(presolve_result) if presolve_result.was_reduced => {
let presolve_us = t_presolve.elapsed().as_micros() as u64;
return solve_reduced_lp_from_qp(
&lp,
problem.obj_offset,
presolve_result,
options,
presolve_us,
);
}
Ok(_) => {
let presolve_us = t_presolve.elapsed().as_micros() as u64;
if options.deadline.is_some_and(|d| Instant::now() >= d) {
let mut timeout = timeout_result_lp_dispatch();
timeout.timing_breakdown = Some(crate::problem::TimingBreakdown {
presolve_us,
solve_us: 0,
postsolve_us: 0,
..Default::default()
});
return timeout;
}
let t_solve = Instant::now();
let mut no_presolve_opts = options.clone();
no_presolve_opts.presolve = false;
let mut result = solve_unpresolved_lp_from_qp(&lp, problem, &no_presolve_opts);
if result.timing_breakdown.is_none() {
result.timing_breakdown = Some(crate::problem::TimingBreakdown {
presolve_us,
solve_us: t_solve.elapsed().as_micros() as u64,
postsolve_us: 0,
..Default::default()
});
}
return result;
}
}
}
if options.deadline.is_some_and(|d| Instant::now() >= d) {
return timeout_result_lp_dispatch();
}
solve_unpresolved_lp_from_qp(&lp, problem, options)
}
fn solve_unpresolved_lp_from_qp(
lp: &LpProblem,
problem: &QpProblem,
options: &SolverOptions,
) -> SolverResult {
let mut result = crate::lp::solve_lp_forwarded_from_qp(lp, options);
if matches!(
result.status,
SolveStatus::Optimal | SolveStatus::SuboptimalSolution | SolveStatus::Timeout
) {
result.objective += problem.obj_offset;
}
fill_lp_reduced_costs_from_dual(&mut result, lp);
result
}
fn solve_reduced_lp_from_qp(
original_lp: &LpProblem,
qp_obj_offset: f64,
presolve_result: presolve::transforms::PresolveResult,
options: &SolverOptions,
presolve_us: u64,
) -> SolverResult {
let reduced_lp = &presolve_result.reduced_problem;
let mut reduced_opts = options.clone();
reduced_opts.presolve = false;
reduced_opts.warm_start = None;
reduced_opts.warm_start_lp = None;
let t_solve = Instant::now();
let raw = solve_lp_backend_no_presolve(reduced_lp, &reduced_opts);
let solve_us = t_solve.elapsed().as_micros() as u64;
#[cfg(test)]
let raw = if INJECT_REDUCED_TIMEOUT_QP.with(|v| v.get()) {
SolverResult {
status: SolveStatus::Timeout,
solution: vec![0.0; reduced_lp.num_vars],
iterations: REDUCED_TIMEOUT_QP_INJECT_ITERS,
stats: crate::problem::SolveStats {
bounded_eq_ub_path: true,
..Default::default()
},
..Default::default()
}
} else {
raw
};
if (raw.status == SolveStatus::NumericalError
|| (raw.status == SolveStatus::SuboptimalSolution && raw.solution.is_empty()))
&& options.deadline.is_none_or(|d| Instant::now() < d)
{
let mut fallback_opts = options.clone();
fallback_opts.presolve = false;
fallback_opts.warm_start = None;
fallback_opts.warm_start_lp = None;
let t_fallback = Instant::now();
let mut fallback = solve_original_lp_direct_retry(original_lp, &fallback_opts);
fill_lp_reduced_costs_from_dual(&mut fallback, original_lp);
add_qp_obj_offset(&mut fallback, qp_obj_offset);
fallback.timing_breakdown = Some(crate::problem::TimingBreakdown {
presolve_us,
solve_us: t_fallback.elapsed().as_micros() as u64,
postsolve_us: 0,
..Default::default()
});
return fallback;
}
if matches!(
raw.status,
SolveStatus::Optimal
| SolveStatus::LocallyOptimal
| SolveStatus::SuboptimalSolution
| SolveStatus::Timeout
) && (!raw.solution.is_empty() || reduced_lp.num_vars == 0)
{
let deadline_expired = options.deadline.is_some_and(|d| Instant::now() >= d);
#[cfg(test)]
let deadline_expired = deadline_expired || INJECT_REDUCED_TIMEOUT_QP.with(|v| v.get());
if raw.status == SolveStatus::Timeout && deadline_expired {
let mut timeout = SolverResult {
status: SolveStatus::Timeout,
objective: f64::INFINITY,
solution: vec![],
dual_solution: vec![],
reduced_costs: vec![],
slack: vec![],
warm_start_basis: None,
iterations: raw.iterations,
timing_breakdown: Some(crate::problem::TimingBreakdown {
presolve_us,
solve_us,
postsolve_us: 0,
..Default::default()
}),
stats: raw.stats.clone(),
..Default::default()
};
timeout.stats.route = SolveRoute::LpForwardedFromQp;
timeout.stats.deadline_triggered = true;
return timeout;
}
let t_postsolve = Instant::now();
let mut lifted = presolve::postsolve::run_postsolve(
&raw,
&presolve_result,
original_lp,
options.deadline,
options.recover_warm_start_basis,
);
lifted.stats = raw.stats.clone();
lifted.stats.route = SolveRoute::LpForwardedFromQp;
lifted.stats.deadline_triggered = matches!(lifted.status, SolveStatus::Timeout);
lifted = guard_lp_optimal(lifted, original_lp);
let postsolve_us = t_postsolve.elapsed().as_micros() as u64;
lifted.timing_breakdown = Some(crate::problem::TimingBreakdown {
presolve_us,
solve_us,
postsolve_us,
..Default::default()
});
if postsolved_lp_needs_direct_retry(&lifted)
&& options.deadline.is_none_or(|d| Instant::now() < d)
{
let keep_lifted = lifted.clone();
let mut fallback_opts = options.clone();
fallback_opts.presolve = false;
fallback_opts.warm_start = None;
fallback_opts.warm_start_lp = None;
let t_fallback = Instant::now();
let mut fallback = solve_original_lp_direct_retry(original_lp, &fallback_opts);
fill_lp_reduced_costs_from_dual(&mut fallback, original_lp);
add_qp_obj_offset(&mut fallback, qp_obj_offset);
if fallback.status == SolveStatus::Optimal {
fallback.timing_breakdown = Some(crate::problem::TimingBreakdown {
presolve_us,
solve_us: t_fallback.elapsed().as_micros() as u64,
postsolve_us,
..Default::default()
});
return fallback;
}
lifted = keep_lifted;
}
add_qp_obj_offset(&mut lifted, qp_obj_offset);
return lifted;
}
raw
}
fn solve_lp_backend_no_presolve(lp: &LpProblem, options: &SolverOptions) -> SolverResult {
let mut result = crate::lp::solve_lp_forwarded_from_qp(lp, options);
fill_lp_reduced_costs_from_dual(&mut result, lp);
result
}
fn solve_original_lp_direct_retry(lp: &LpProblem, options: &SolverOptions) -> SolverResult {
let mut retry_opts = options.clone();
retry_opts.presolve = false;
retry_opts.simplex_method = crate::options::SimplexMethod::Primal;
crate::lp::solve_lp_forwarded_from_qp(lp, &retry_opts)
}
fn add_qp_obj_offset(result: &mut SolverResult, qp_obj_offset: f64) {
if matches!(
result.status,
SolveStatus::Optimal
| SolveStatus::LocallyOptimal
| SolveStatus::SuboptimalSolution
| SolveStatus::Timeout
) {
result.objective += qp_obj_offset;
}
}
fn fill_lp_reduced_costs_from_dual(result: &mut SolverResult, problem: &LpProblem) {
if result.reduced_costs.len() == problem.num_vars {
return;
}
if result.solution.len() != problem.num_vars
|| result.dual_solution.len() != problem.num_constraints
{
return;
}
if !matches!(
result.status,
SolveStatus::Optimal | SolveStatus::LocallyOptimal | SolveStatus::SuboptimalSolution
) {
return;
}
let Some(rc) =
best_lp_reduced_costs_from_dual(problem, &result.solution, &result.dual_solution)
else {
result.reduced_costs.clear();
return;
};
result.reduced_costs = rc;
result.bound_duals.clear();
}
fn best_lp_reduced_costs_from_dual(
problem: &LpProblem,
solution: &[f64],
dual_solution: &[f64],
) -> Option<Vec<f64>> {
if solution.len() != problem.num_vars || dual_solution.len() != problem.num_constraints {
return None;
}
let mut rc_minus = problem.c.clone();
let mut rc_plus = problem.c.clone();
for j in 0..problem.num_vars {
let Ok((rows, vals)) = problem.a.get_column(j) else {
return None;
};
for (k, &row) in rows.iter().enumerate() {
let term = vals[k] * dual_solution[row];
rc_minus[j] -= term;
rc_plus[j] += term;
}
}
let df_minus = lp_reduced_cost_bound_violation(problem, solution, &rc_minus);
let df_plus = lp_reduced_cost_bound_violation(problem, solution, &rc_plus);
Some(if df_plus < df_minus {
rc_plus
} else {
rc_minus
})
}
fn lp_reduced_cost_bound_violation(problem: &LpProblem, x: &[f64], rc: &[f64]) -> f64 {
let n = problem.num_vars.min(x.len()).min(rc.len());
let mut max_rel = 0.0_f64;
for j in 0..n {
let (lb, ub) = problem.bounds[j];
let fixed = lb.is_finite() && ub.is_finite() && (ub - lb).abs() < 1e-6;
if fixed {
continue;
}
let at_lb = lb.is_finite() && (x[j] - lb).abs() < 1e-6;
let at_ub = ub.is_finite() && (x[j] - ub).abs() < 1e-6;
let r = rc[j];
let viol = if at_lb && !at_ub {
f64::max(0.0, -r)
} else if at_ub && !at_lb {
f64::max(0.0, r)
} else {
0.0
};
let scale = 1.0 + r.abs() + problem.c[j].abs();
max_rel = max_rel.max(viol / scale);
}
max_rel
}
fn postsolved_lp_needs_direct_retry(result: &SolverResult) -> bool {
matches!(
result.status,
SolveStatus::Optimal | SolveStatus::SuboptimalSolution
) && (result
.postsolve_dfeas
.is_some_and(|d| d > crate::tolerances::PIVOT_TOL)
|| result.status == SolveStatus::SuboptimalSolution)
}
#[cfg(test)]
fn ipm_opts_for_lp(options: &SolverOptions) -> SolverOptions {
let mut o = options.clone();
o.presolve = false;
o
}
#[cfg(test)]
fn verified_farkas_timeout_fallback(problem: &QpProblem, options: &SolverOptions) -> bool {
if !problem
.bounds
.iter()
.all(|&(lb, ub)| lb == 0.0 && ub == f64::INFINITY)
{
return false;
}
let (cert_cols_by_row, cert_rhs) = normalized_farkas_rows(problem);
if cert_rhs.is_empty() {
return false;
}
let mut rows = Vec::new();
let mut cols = Vec::new();
let mut vals = Vec::new();
for j in 0..problem.num_vars {
let Ok((a_rows, a_vals)) = problem.a.get_column(j) else {
return false;
};
for (k, &i) in a_rows.iter().enumerate() {
for &(cert_col, sign) in &cert_cols_by_row[i] {
rows.push(j);
cols.push(cert_col);
vals.push(sign * a_vals[k]);
}
}
}
for (cert_col, &rhs) in cert_rhs.iter().enumerate() {
rows.push(problem.num_vars);
cols.push(cert_col);
vals.push(rhs);
}
let Ok(cert_a) =
CscMatrix::from_triplets(&rows, &cols, &vals, problem.num_vars + 1, cert_rhs.len())
else {
return false;
};
let mut cert_b = vec![0.0; problem.num_vars];
cert_b.push(1.0);
let mut cert_types = vec![ConstraintType::Le; problem.num_vars];
cert_types.push(ConstraintType::Ge);
let Ok(cert_qp) = QpProblem::new(
CscMatrix::new(cert_rhs.len(), cert_rhs.len()),
vec![0.0; cert_rhs.len()],
cert_a,
cert_b,
vec![(0.0, f64::INFINITY); cert_rhs.len()],
cert_types,
) else {
return false;
};
let result = ipm_solver::solve_ipm(&cert_qp, &ipm_opts_for_lp(options));
result.status == SolveStatus::Optimal
&& result.solution.len() == cert_rhs.len()
&& verify_normalized_farkas(problem, &cert_cols_by_row, &cert_rhs, &result.solution)
}
#[cfg(test)]
fn normalized_farkas_rows(problem: &QpProblem) -> (Vec<Vec<(usize, f64)>>, Vec<f64>) {
let mut cert_cols_by_row = vec![Vec::<(usize, f64)>::new(); problem.num_constraints];
let mut cert_rhs = Vec::new();
for (i, &kind) in problem.constraint_types.iter().enumerate() {
let mut push_col = |sign: f64| {
let col = cert_rhs.len();
cert_cols_by_row[i].push((col, sign));
cert_rhs.push(sign * problem.b[i]);
};
match kind {
ConstraintType::Ge => push_col(1.0),
ConstraintType::Le => push_col(-1.0),
ConstraintType::Eq => {
push_col(1.0);
push_col(-1.0);
}
}
}
(cert_cols_by_row, cert_rhs)
}
#[cfg(test)]
const FARKAS_NORM_TOL: f64 = 1e-7;
#[cfg(test)]
const FARKAS_CTY_ROUNDOFF_PER_TERM: f64 = f64::EPSILON;
#[cfg(test)]
fn cty_slack_within_noise(aty: f64, term_mag: f64, n_terms: usize) -> bool {
let roundoff_floor = (n_terms as f64) * FARKAS_CTY_ROUNDOFF_PER_TERM * term_mag;
aty <= roundoff_floor
}
#[cfg(test)]
fn verify_normalized_farkas(
problem: &QpProblem,
cert_cols_by_row: &[Vec<(usize, f64)>],
cert_rhs: &[f64],
y: &[f64],
) -> bool {
if y.len() != cert_rhs.len() || any_nonfinite(y) {
return false;
}
let yp = |col: usize| y[col].max(0.0);
let rhs_dot = cert_rhs
.iter()
.enumerate()
.map(|(col, &d)| d * yp(col))
.sum::<f64>();
if !rhs_dot.is_finite() || rhs_dot < 1.0 - FARKAS_NORM_TOL {
return false;
}
for j in 0..problem.num_vars {
let Ok((a_rows, a_vals)) = problem.a.get_column(j) else {
return false;
};
let mut aty = 0.0;
let mut term_mag = 0.0;
let mut n_terms = 0usize;
for (k, &i) in a_rows.iter().enumerate() {
for &(cert_col, sign) in &cert_cols_by_row[i] {
let term = sign * a_vals[k] * yp(cert_col);
aty += term;
term_mag += term.abs();
n_terms += 1;
}
}
if !aty.is_finite() || !cty_slack_within_noise(aty, term_mag, n_terms) {
return false;
}
}
true
}
#[cfg(test)]
const LP_IPM_FIRST_N: usize = 3_000;
#[cfg(test)]
mod tests {
use super::*;
use crate::sparse::CscMatrix;
use std::time::Duration;
fn eq_lp_fixture(n: usize, m: usize) -> LpProblem {
let mut rows = Vec::new();
let mut cols = Vec::new();
let mut vals = Vec::new();
for i in 0..m {
rows.push(i);
cols.push(i);
vals.push(1.0);
rows.push(i);
cols.push(i + m);
vals.push(1.0);
}
let a = CscMatrix::from_triplets(&rows, &cols, &vals, m, n).unwrap();
let b = vec![2.0_f64; m];
let c = vec![1.0_f64; n];
let ctypes = vec![crate::problem::ConstraintType::Eq; m];
let bounds = vec![(0.0_f64, f64::INFINITY); n];
LpProblem::new_general(c, a, b, ctypes, bounds, None).unwrap()
}
#[test]
fn parallel_solve_stats_independent() {
use crate::options::SolverOptions;
use crate::problem::SolveRoute;
let lp = eq_lp_fixture(3500, 200);
let lp2 = eq_lp_fixture(3600, 180);
let opts = SolverOptions::default();
let r1 = crate::lp::solve_lp_with(&lp, &opts);
let r2 = crate::lp::solve_lp_with(&lp2, &opts);
assert_eq!(
r1.stats.route,
SolveRoute::LpDirect,
"r1 route must be LpDirect"
);
assert_eq!(
r2.stats.route,
SolveRoute::LpDirect,
"r2 route must be LpDirect"
);
}
#[test]
fn qp_zero_path_presolve_reduces_before_ipm_dispatch() {
use crate::options::SolverOptions;
use crate::problem::{SolveRoute, SolveStatus};
let n = LP_IPM_FIRST_N + 1;
let a = CscMatrix::from_triplets(&[0], &[0], &[1.0], 1, n).unwrap();
let mut problem = QpProblem::new(
CscMatrix::new(n, n),
vec![2.0; n],
a,
vec![1.0],
vec![(0.0, f64::INFINITY); n],
vec![ConstraintType::Eq],
)
.unwrap();
problem.obj_offset = 5.0;
let result = solve_as_lp(&problem, &SolverOptions::default());
assert_eq!(result.status, SolveStatus::Optimal);
assert_eq!(result.stats.route, SolveRoute::LpForwardedFromQp);
assert!(
!result.stats.lp_ipm_path,
"presolve must reduce; LP path never sets lp_ipm_path"
);
assert_eq!(result.solution.len(), n);
assert!((result.solution[0] - 1.0).abs() < 1e-9);
assert!(result.solution[1..].iter().all(|&x| x.abs() < 1e-9));
assert!(
(result.objective - 7.0).abs() < 1e-9,
"objective must include presolve contribution and QP obj_offset"
);
let timing = result
.timing_breakdown
.expect("LP-dispatched QP presolve/postsolve path must keep timing");
assert!(timing.presolve_us > 0, "presolve timing must be recorded");
assert!(timing.postsolve_us > 0, "postsolve timing must be recorded");
}
#[test]
fn large_lp_dispatch_stays_on_simplex_path() {
use crate::options::SolverOptions;
use crate::problem::{SolveRoute, SolveStatus};
let n = LP_IPM_FIRST_N + 1;
let a = CscMatrix::from_triplets(&[0], &[0], &[1.0], 1, n).unwrap();
let problem = QpProblem::new(
CscMatrix::new(n, n),
vec![1.0; n],
a,
vec![1.0],
vec![(0.0, f64::INFINITY); n],
vec![ConstraintType::Eq],
)
.unwrap();
let opts = SolverOptions {
presolve: false,
..SolverOptions::default()
};
let result = solve_as_lp(&problem, &opts);
assert_eq!(
result.status,
SolveStatus::Optimal,
"large LP must solve via simplex; got {:?}",
result.status
);
assert_eq!(result.stats.route, SolveRoute::LpForwardedFromQp);
assert!(
!result.stats.lp_ipm_path,
"IPM 撤廃後 LP は simplex 一本: lp_ipm_path must be false for n>{LP_IPM_FIRST_N}"
);
assert_eq!(result.solution.len(), n);
assert!((result.objective - 1.0).abs() < 1e-6);
}
#[test]
fn qp_zero_path_expired_deadline_after_presolve_returns_timeout() {
use crate::options::SolverOptions;
use crate::problem::{SolveRoute, SolveStatus};
let a = CscMatrix::from_triplets(&[0], &[0], &[1.0], 1, 1).unwrap();
let problem = QpProblem::new(
CscMatrix::new(1, 1),
vec![1.0],
a,
vec![1.0],
vec![(0.0, f64::INFINITY)],
vec![ConstraintType::Eq],
)
.unwrap();
let opts = SolverOptions {
deadline: Some(Instant::now() - Duration::from_millis(1)),
presolve: true,
..SolverOptions::default()
};
let result = solve_as_lp(&problem, &opts);
assert_eq!(result.status, SolveStatus::Timeout);
assert_eq!(result.stats.route, SolveRoute::LpForwardedFromQp);
assert!(result.stats.deadline_triggered);
}
fn nonneg_qp(a_rows: &[Vec<f64>], b: &[f64], types: &[ConstraintType]) -> QpProblem {
let m = a_rows.len();
let n = a_rows.first().map_or(0, |r| r.len());
let mut rows = Vec::new();
let mut cols = Vec::new();
let mut vals = Vec::new();
for (i, row) in a_rows.iter().enumerate() {
assert_eq!(row.len(), n, "rows must be rectangular");
for (j, &v) in row.iter().enumerate() {
if v != 0.0 {
rows.push(i);
cols.push(j);
vals.push(v);
}
}
}
let a = CscMatrix::from_triplets(&rows, &cols, &vals, m, n).unwrap();
QpProblem::new(
CscMatrix::new(n, n),
vec![0.0; n],
a,
b.to_vec(),
vec![(0.0, f64::INFINITY); n],
types.to_vec(),
)
.unwrap()
}
const LEGACY_IPM_TOL_FLOOR: f64 = 1e-11;
#[test]
fn cty_slack_within_noise_separates_real_slack_from_roundoff() {
let cases = [
(1e-9, 8.76, 2, false, "d=1e9 normalized feasible"),
(1.8626e-9, 2.0, 2, false, "d=1e9 (dᵀy≈1.86)"),
(1.49e-8, 2.0, 2, false, "d=1e8 normalized feasible"),
(1.455e-11, 2.0, 2, false, "K=1e11 false-cert residual"),
(1.137e-13, 2.0, 2, false, "K=1e13 false-cert residual"),
(-4.1e-6, 986.0, 4, true, "klein3 genuine cert"),
(-1.0, 3.0, 2, true, "strict negative residual"),
(0.0, 5.0, 2, true, "exact zero residual"),
(1e-15, 8.76, 2, true, "roundoff-level positive"),
(2e-16, 1.0, 2, true, "near machine eps"),
(1e-12, 1.0, 2, false, "small n: above roundoff floor"),
(
1e-12,
1.0,
10_000,
true,
"large n: within accumulated roundoff",
),
];
for (aty, mag, n_terms, expect, label) in cases {
assert_eq!(
cty_slack_within_noise(aty, mag, n_terms),
expect,
"case `{label}`: aty={aty:e}, mag={mag:e}, n_terms={n_terms}",
);
}
for &(aty, mag, n_terms) in &[(1.455e-11, 2.0, 2usize), (1.137e-13, 2.0, 2)] {
assert!(
aty <= LEGACY_IPM_TOL_FLOOR * mag,
"premise: IPM-tol floor would have accepted aty={aty:e}",
);
assert!(
!cty_slack_within_noise(aty, mag, n_terms),
"roundoff floor must reject IPM-residual slack aty={aty:e}",
);
}
}
#[test]
fn farkas_rejects_large_magnitude_feasible() {
let patterns = [
(1e9, 2.0_f64.powi(-29), false), (1e11, 2.0_f64.powi(-36), true), (1e12, 2.0_f64.powi(-39), true), (1e13, 2.0_f64.powi(-43), true), ];
for (k, g, legacy_would_accept) in patterns {
let problem = nonneg_qp(&[vec![1.0, 1.0]], &[k], &[ConstraintType::Eq]);
let (cols, rhs) = normalized_farkas_rows(&problem);
assert_eq!(rhs, vec![k, -k], "Eq → ±K の cert RHS");
let y = vec![1.0 + g, 1.0];
let cty = g; let dty = k * g;
let term_mag = (1.0 + g) + 1.0; assert!(
dty >= 1.0 - FARKAS_NORM_TOL,
"premise: dᵀy={dty} must clear norm"
);
assert_eq!(
cty <= LEGACY_IPM_TOL_FLOOR * term_mag,
legacy_would_accept,
"premise: IPM-tol floor accept(Cᵀy={cty:e}) for K={k:e}",
);
assert!(
!verify_normalized_farkas(&problem, &cols, &rhs, &y),
"feasible x1+x2={k:e} must NOT be certified infeasible",
);
}
}
#[test]
fn verified_farkas_rejects_feasible_large_magnitude_end_to_end() {
use crate::options::SolverOptions;
let opts = SolverOptions::default();
for k in [1e9, 1e11, 1e12, 1e13] {
let eq = nonneg_qp(&[vec![1.0, 1.0]], &[k], &[ConstraintType::Eq]);
assert!(
!verified_farkas_timeout_fallback(&eq, &opts),
"feasible x1+x2={k:e} must NOT be certified infeasible",
);
let ge = nonneg_qp(&[vec![2.0]], &[k], &[ConstraintType::Ge]);
assert!(
!verified_farkas_timeout_fallback(&ge, &opts),
"feasible 2x1 ≥ {k:e} must NOT be certified infeasible",
);
}
}
#[test]
fn farkas_certifies_genuine_infeasible() {
let problem = nonneg_qp(
&[vec![1.0], vec![-2.0]],
&[1.0, 1.0],
&[ConstraintType::Ge, ConstraintType::Ge],
);
let (cols, rhs) = normalized_farkas_rows(&problem);
assert_eq!(rhs, vec![1.0, 1.0]);
let y = vec![1.0, 1.0];
assert!(
verify_normalized_farkas(&problem, &cols, &rhs, &y),
"genuine infeasible must remain certified",
);
}
#[test]
fn infeasible_lp_dispatch_obj_offset_not_added() {
use crate::options::SolverOptions;
use crate::problem::SolveStatus;
let a = CscMatrix::from_triplets(&[0, 1], &[0, 0], &[1.0, 1.0], 2, 1).unwrap();
let mut problem = QpProblem::new(
CscMatrix::new(1, 1),
vec![1.0],
a,
vec![2.0, 1.0],
vec![(0.0, f64::INFINITY)],
vec![ConstraintType::Ge, ConstraintType::Le],
)
.unwrap();
problem.obj_offset = 42.5;
let result = solve_as_lp(&problem, &SolverOptions::default());
assert_eq!(
result.status,
SolveStatus::Infeasible,
"expected Infeasible, got {:?}",
result.status
);
assert!(
result.objective.is_infinite() && result.objective.is_sign_positive(),
"Infeasible objective must be +INFINITY (convention); got {} (obj_offset={})",
result.objective,
problem.obj_offset,
);
}
#[test]
fn farkas_rejects_modest_feasible() {
let problem = nonneg_qp(&[vec![1.0, 1.0]], &[2.0], &[ConstraintType::Eq]);
let (cols, rhs) = normalized_farkas_rows(&problem);
let y = vec![0.5, 0.0];
assert!(
!verify_normalized_farkas(&problem, &cols, &rhs, &y),
"modest feasible must NOT be certified infeasible",
);
}
#[test]
fn farkas_false_on_non_nonneg_bounds() {
use crate::options::SolverOptions;
let opts = SolverOptions::default();
let neg_lb = QpProblem::new(
CscMatrix::new(1, 1),
vec![0.0],
CscMatrix::from_triplets(&[0], &[0], &[-1.0], 1, 1).unwrap(),
vec![1.0],
vec![(-2.0, f64::INFINITY)],
vec![ConstraintType::Ge],
)
.unwrap();
assert!(
!verified_farkas_timeout_fallback(&neg_lb, &opts),
"lb < 0 must return false (non-nonneg bounds)",
);
let finite_ub = QpProblem::new(
CscMatrix::new(1, 1),
vec![0.0],
CscMatrix::from_triplets(&[0], &[0], &[-1.0], 1, 1).unwrap(),
vec![1.0],
vec![(0.0, 10.0)],
vec![ConstraintType::Ge],
)
.unwrap();
assert!(
!verified_farkas_timeout_fallback(&finite_ub, &opts),
"finite ub must return false (non-nonneg bounds)",
);
let lb_positive = QpProblem::new(
CscMatrix::new(1, 1),
vec![0.0],
CscMatrix::from_triplets(&[0], &[0], &[-1.0], 1, 1).unwrap(),
vec![1.0],
vec![(0.5, f64::INFINITY)],
vec![ConstraintType::Ge],
)
.unwrap();
assert!(
!verified_farkas_timeout_fallback(&lb_positive, &opts),
"lb=0.5 must return false (non-nonneg bounds)",
);
}
#[test]
fn test_qp_simplex_dispatch_timeout_includes_obj_offset() {
use std::sync::{atomic::AtomicBool, Arc};
const OBJ_OFFSET: f64 = 42.0;
let a = CscMatrix::from_triplets(&[0], &[0], &[1.0], 1, 1).unwrap();
let mut problem = QpProblem::new(
CscMatrix::new(1, 1),
vec![0.0],
a,
vec![1.0],
vec![(0.0, f64::INFINITY)],
vec![ConstraintType::Ge],
)
.unwrap();
problem.obj_offset = OBJ_OFFSET;
let opts = SolverOptions {
cancel_flag: Some(Arc::new(AtomicBool::new(true))),
presolve: false,
..SolverOptions::default()
};
let result = solve_as_lp(&problem, &opts);
assert_eq!(
result.status,
SolveStatus::Timeout,
"cancel_flag=true must produce Timeout; got {:?}",
result.status,
);
assert!(
(result.objective - OBJ_OFFSET).abs() < 1e-9,
"Timeout objective must include obj_offset {OBJ_OFFSET}; got {} \
(sentinel: removing Timeout from match yields 0.0 ≠ {OBJ_OFFSET})",
result.objective,
);
}
#[test]
fn farkas_false_on_empty_constraints() {
use crate::options::SolverOptions;
let opts = SolverOptions::default();
let zero_constraints = QpProblem::new(
CscMatrix::new(1, 1),
vec![0.0],
CscMatrix::new(0, 1),
vec![],
vec![(0.0, f64::INFINITY)],
vec![],
)
.unwrap();
assert!(
!verified_farkas_timeout_fallback(&zero_constraints, &opts),
"zero constraints → empty cert_rhs must return false",
);
}
#[test]
fn presolve_timeout_solution_never_leaks_reduced_space_in_qp_path() {
use crate::options::SolverOptions;
use crate::problem::SolveStatus;
let a = CscMatrix::from_triplets(
&[0, 1, 2, 1, 2],
&[0, 1, 1, 2, 2],
&[1.0, 2.0, 1.0, 1.0, 2.0],
3,
3,
)
.unwrap();
let problem = QpProblem::new(
CscMatrix::new(3, 3),
vec![1.0, 1.0, 1.0],
a,
vec![5.0, 3.0, 3.0],
vec![(0.0, f64::INFINITY); 3],
vec![ConstraintType::Eq, ConstraintType::Ge, ConstraintType::Ge],
)
.unwrap();
let orig_n = problem.num_vars;
let r = solve_as_lp(
&problem,
&SolverOptions {
presolve: true,
..Default::default()
},
);
assert_eq!(r.status, SolveStatus::Optimal);
assert_eq!(
r.solution.len(),
orig_n,
"Optimal: solution.len() must equal orig_n={orig_n}"
);
INJECT_REDUCED_TIMEOUT_QP.with(|v| v.set(true));
let r = solve_as_lp(
&problem,
&SolverOptions {
presolve: true,
..Default::default()
},
);
INJECT_REDUCED_TIMEOUT_QP.with(|v| v.set(false));
let n = r.solution.len();
assert_eq!(
r.status,
SolveStatus::Timeout,
"injected path must return Timeout"
);
assert!(
n == 0 || n == orig_n,
"injected Timeout: solution.len()={n} must be 0 or {orig_n}, \
never 2 (reduced — lp_dispatch reduced-space leak)",
);
}
#[test]
fn reduced_timeout_preserves_iteration_count_in_qp_path() {
use crate::options::SolverOptions;
use crate::problem::SolveStatus;
let a = CscMatrix::from_triplets(
&[0, 1, 2, 1, 2],
&[0, 1, 1, 2, 2],
&[1.0, 2.0, 1.0, 1.0, 2.0],
3,
3,
)
.unwrap();
let problem = QpProblem::new(
CscMatrix::new(3, 3),
vec![1.0, 1.0, 1.0],
a,
vec![5.0, 3.0, 3.0],
vec![(0.0, f64::INFINITY); 3],
vec![ConstraintType::Eq, ConstraintType::Ge, ConstraintType::Ge],
)
.unwrap();
INJECT_REDUCED_TIMEOUT_QP.with(|v| v.set(true));
let r = solve_as_lp(
&problem,
&SolverOptions {
presolve: true,
..Default::default()
},
);
INJECT_REDUCED_TIMEOUT_QP.with(|v| v.set(false));
assert_eq!(
r.status,
SolveStatus::Timeout,
"injected path must return Timeout"
);
assert_eq!(
r.iterations, REDUCED_TIMEOUT_QP_INJECT_ITERS,
"QP→LP-dispatch reduced Timeout must carry raw.iterations ({}); got {} \
— dropping it reports a misleading iters=0 (pds-20 artifact)",
REDUCED_TIMEOUT_QP_INJECT_ITERS, r.iterations
);
assert!(
r.stats.bounded_eq_ub_path,
"QP→LP-dispatch reduced Timeout must carry raw route stats"
);
}
}