use std::time::Instant;
use crate::options::SolverOptions;
use crate::problem::{ConstraintType, LpProblem, SolveRoute, SolveStatus, SolverResult};
use super::certificate::guard_lp_optimal;
use crate::sparse::CscMatrix;
use super::{ipm_solver, QpProblem};
const LP_IPM_FIRST_N: usize = 3_000;
const LP_IPM_FIRST_M: usize = 2_000;
const IPM_BUDGET_FRACTION: f64 = 0.5;
pub(crate) fn prefer_ipm_for_size(n: usize, m: usize) -> bool {
n > LP_IPM_FIRST_N || m > LP_IPM_FIRST_M
}
fn ipm_box_deadline(options: &SolverOptions, now: Instant) -> Option<Instant> {
options.deadline.map(|overall| {
let remaining = overall.saturating_duration_since(now);
now + remaining.mul_f64(IPM_BUDGET_FRACTION)
})
}
pub(crate) fn solve_as_lp_pub(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(_) => return SolverResult::infeasible(),
};
let dispatch_disabled = std::env::var("LP_DISPATCH_NOOP").ok().as_deref() == Some("1");
let mut ipm_subopt_candidate: Option<SolverResult> = None;
if !dispatch_disabled && prefer_ipm_for_size(problem.num_vars, problem.num_constraints) {
let mut ipm_opts = ipm_opts_for_lp(options);
ipm_opts.deadline = ipm_box_deadline(options, Instant::now()).or(ipm_opts.deadline);
let mut ipm_result = ipm_solver::solve_ipm(problem, &ipm_opts);
ipm_result.stats.route = SolveRoute::LpForwardedFromQp;
ipm_result.stats.lp_ipm_path = true;
match ipm_result.status {
SolveStatus::Optimal | SolveStatus::LocallyOptimal | SolveStatus::Infeasible => {
return guard_lp_optimal(ipm_result, &lp);
}
SolveStatus::Unbounded
| SolveStatus::Timeout
| SolveStatus::NumericalError
| SolveStatus::MaxIterations => {
if options.deadline.is_some_and(|d| Instant::now() >= d) {
return ipm_result;
}
}
SolveStatus::SuboptimalSolution => {
if options.deadline.is_some_and(|d| Instant::now() >= d) {
return ipm_result;
}
if let Some(ref_obj) = options.known_optimal_obj {
if crate::tolerances::obj_within_tol(
ipm_result.objective, ref_obj,
crate::tolerances::OBJ_MATCH_REL_TOL,
) && !ipm_result.solution.is_empty()
{
let promoted = SolverResult { status: SolveStatus::Optimal, ..ipm_result };
return guard_lp_optimal(promoted, &lp);
}
}
ipm_subopt_candidate = Some(ipm_result);
}
SolveStatus::NonConvex(_)
| SolveStatus::NonconvexLocal
| SolveStatus::NonconvexGlobal => {
}
SolveStatus::NotSupported(_) => {
return ipm_result;
}
}
}
let mut simplex_result = crate::lp::solve_lp_forwarded_from_qp(&lp, options);
simplex_result.objective += problem.obj_offset;
if simplex_result.status == SolveStatus::Timeout
&& simplex_result.solution.is_empty()
&& options.deadline.is_none_or(|d| Instant::now() < d)
&& verified_farkas_timeout_fallback(problem, options)
{
let mut certified = SolverResult::infeasible();
certified.iterations = simplex_result.iterations;
return certified;
}
pick_best_ipm_or_simplex(ipm_subopt_candidate, simplex_result)
}
pub fn pick_best_ipm_or_simplex(
ipm_candidate: Option<SolverResult>,
simplex_result: SolverResult,
) -> SolverResult {
let simplex_failed = matches!(
simplex_result.status,
SolveStatus::Timeout | SolveStatus::NumericalError | SolveStatus::MaxIterations
);
if let Some(ipm) = ipm_candidate {
if simplex_failed
&& matches!(ipm.status, SolveStatus::SuboptimalSolution | SolveStatus::LocallyOptimal)
&& !ipm.solution.is_empty()
{
return ipm;
}
}
simplex_result
}
fn ipm_opts_for_lp(options: &SolverOptions) -> SolverOptions {
let mut o = options.clone();
o.presolve = false;
o
}
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)
}
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)
}
const FARKAS_NORM_TOL: f64 = 1e-7;
const FARKAS_CTY_ROUNDOFF_PER_TERM: f64 = f64::EPSILON;
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
}
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() || y.iter().any(|&v| !v.is_finite()) {
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)]
mod tests {
use super::*;
use crate::sparse::CscMatrix;
use std::time::Duration;
#[test]
fn ipm_box_deadline_reserves_simplex_budget() {
let now = Instant::now();
let cases = [10.0_f64, 100.0, 1000.0];
for &total in &cases {
let opts = SolverOptions {
deadline: Some(now + Duration::from_secs_f64(total)),
..SolverOptions::default()
};
let box_dl = ipm_box_deadline(&opts, now).expect("deadline present → box");
let box_secs = box_dl.duration_since(now).as_secs_f64();
let expected = total * IPM_BUDGET_FRACTION;
assert!(
(box_secs - expected).abs() < 1e-6,
"box must be {IPM_BUDGET_FRACTION} of {total}s = {expected}s, got {box_secs}s",
);
assert!(box_secs < total, "box must leave budget for simplex");
}
let no_dl = SolverOptions { deadline: None, ..SolverOptions::default() };
assert!(ipm_box_deadline(&no_dl, now).is_none(), "no deadline → no box");
}
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");
}
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 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",
);
}
}