use super::qp_transforms::{QpPostsolveStep, QpPresolveResult};
use crate::problem::SolverResult;
use crate::qp::QpProblem;
use crate::tolerances::DROP_TOL;
const SINGULARITY_TOL: f64 = DROP_TOL;
pub fn postsolve_qp(
presolve_result: &QpPresolveResult,
reduced_sol: &SolverResult,
) -> SolverResult {
let n = presolve_result.orig_num_vars;
let m = presolve_result.orig_num_constraints;
let mut solution = vec![0.0f64; n];
for (j, &maybe_jj) in presolve_result.col_map.iter().enumerate() {
if let Some(jj) = maybe_jj {
if jj < reduced_sol.solution.len() {
solution[j] = reduced_sol.solution[jj];
}
}
}
let mut reduced_dual = reduced_sol.dual_solution.clone();
for step in presolve_result.postsolve_stack.steps.iter().rev() {
match step {
QpPostsolveStep::FixedVar { idx, val, .. } => {
solution[*idx] = *val;
}
QpPostsolveStep::SingletonRow { col, val, .. } => {
solution[*col] = *val;
}
QpPostsolveStep::SingletonIneqToBound { .. } => {
}
QpPostsolveStep::EmptyCol { idx, val } => {
solution[*idx] = *val;
}
QpPostsolveStep::LargeCoeffRowScale { row_scales } => {
for (i, y) in reduced_dual.iter_mut().enumerate() {
if i < row_scales.len() {
*y *= row_scales[i];
}
}
}
}
}
let mut dual_solution = vec![0.0f64; m];
for (i, &maybe_ii) in presolve_result.row_map.iter().enumerate() {
if let Some(ii) = maybe_ii {
if ii < reduced_dual.len() {
dual_solution[i] = reduced_dual[ii];
}
}
}
let objective = reduced_sol.objective + presolve_result.obj_offset;
let reduced_costs = if !reduced_sol.reduced_costs.is_empty() {
let mut rc = vec![0.0f64; n];
for (j, &maybe_jj) in presolve_result.col_map.iter().enumerate() {
if let Some(jj) = maybe_jj {
if jj < reduced_sol.reduced_costs.len() {
rc[j] = reduced_sol.reduced_costs[jj];
}
}
}
rc
} else if presolve_result.reduced.num_vars == 0 && n > 0 {
vec![0.0f64; n]
} else {
vec![]
};
SolverResult {
status: reduced_sol.status.clone(),
objective,
solution,
dual_solution,
bound_duals: reduced_sol.bound_duals.clone(),
iterations: reduced_sol.iterations,
final_residuals: reduced_sol.final_residuals,
duality_gap_rel: reduced_sol.duality_gap_rel,
reduced_costs,
slack: reduced_sol.slack.clone(),
warm_start_basis: reduced_sol.warm_start_basis.clone(),
timing_breakdown: reduced_sol.timing_breakdown,
postsolve_dfeas: None,
stats: reduced_sol.stats.clone(),
bound_gap_cert: None,
opt_cert: None,
}
}
pub fn postsolve_qp_with_dual_recovery(
presolve_result: &QpPresolveResult,
reduced_sol: &SolverResult,
orig_problem: &QpProblem,
) -> SolverResult {
let mut sol = postsolve_qp(presolve_result, reduced_sol);
if sol.solution.len() != orig_problem.num_vars {
return sol;
}
for step in presolve_result.postsolve_stack.steps.iter().rev() {
match step {
QpPostsolveStep::SingletonRow { row, col, .. } => {
recover_y_for_singleton_row_with_bound(*row, *col, orig_problem, &mut sol, 0.0);
}
QpPostsolveStep::SingletonIneqToBound { row, col, ct, .. } => {
recover_y_for_singleton_row_with_bound(*row, *col, orig_problem, &mut sol, 0.0);
let y = sol.dual_solution[*row];
sol.dual_solution[*row] = match ct {
crate::problem::ConstraintType::Le => y.max(0.0),
crate::problem::ConstraintType::Ge => y.min(0.0),
_ => y,
};
}
_ => {}
}
}
sol
}
pub(crate) fn recover_y_for_singleton_row_with_bound(
row: usize,
col: usize,
orig: &QpProblem,
sol: &mut SolverResult,
bound_contrib_col: f64,
) {
if row >= orig.num_constraints || col >= orig.num_vars {
return;
}
if sol.dual_solution.len() != orig.num_constraints {
return;
}
let mut a_row_col = 0.0_f64;
let s = orig.a.col_ptr[col];
let e = orig.a.col_ptr[col + 1];
for k in s..e {
if orig.a.row_ind[k] == row {
a_row_col = orig.a.values[k];
break;
}
}
if a_row_col.abs() < SINGULARITY_TOL {
return;
}
use twofloat::TwoFloat;
let qx_col = compute_qx_at(&orig.q, &sol.solution, col);
let mut aty_others_dd = TwoFloat::from(0.0);
for k in s..e {
let r = orig.a.row_ind[k];
if r == row {
continue;
}
aty_others_dd += TwoFloat::new_mul(orig.a.values[k], sol.dual_solution[r]);
}
let aty_col_others = f64::from(aty_others_dd);
let target = -(qx_col + orig.c[col] + aty_col_others + bound_contrib_col);
let y_new = target / a_row_col;
if y_new.is_finite() {
sol.dual_solution[row] = y_new;
}
}
pub(crate) fn bound_contrib_at_var(bounds: &[(f64, f64)], bound_duals: &[f64], var: usize) -> f64 {
if bound_duals.is_empty() {
return 0.0;
}
let n_lb_total = bounds.iter().filter(|&&(lb, _)| lb.is_finite()).count();
let mut contrib = 0.0_f64;
let mut lb_idx = 0_usize;
let mut ub_idx = n_lb_total;
for (j, &(lb, ub)) in bounds.iter().enumerate() {
if lb.is_finite() {
if j == var && lb_idx < bound_duals.len() {
contrib -= bound_duals[lb_idx];
}
lb_idx += 1;
}
if ub.is_finite() {
if j == var && ub_idx < bound_duals.len() {
contrib += bound_duals[ub_idx];
}
ub_idx += 1;
}
}
contrib
}
fn compute_qx_at(q: &crate::sparse::CscMatrix, x: &[f64], col: usize) -> f64 {
use twofloat::TwoFloat;
let mut sum = TwoFloat::from(0.0);
let s = q.col_ptr[col];
let e = q.col_ptr[col + 1];
for ptr in s..e {
let k = q.row_ind[ptr];
sum += TwoFloat::new_mul(q.values[ptr], x[k]);
}
f64::from(sum)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::options::SolverOptions;
use crate::presolve::qp_transforms::run_qp_presolve_phase1;
use crate::problem::SolveStatus;
use crate::qp::{solve_qp_with, QpProblem};
use crate::sparse::CscMatrix;
#[test]
fn test_postsolve_fixed_var() {
let n = 2usize;
let q = CscMatrix::from_triplets(&[0, 1], &[0, 1], &[2.0, 2.0], n, n).unwrap();
let c = vec![0.0, 0.0];
let a = CscMatrix::new(0, n);
let b = vec![];
let bounds = vec![(0.0_f64, 1.0_f64), (0.5_f64, 0.5_f64)]; let prob = QpProblem::new_all_le(q, c, a, b, bounds).unwrap();
let opts = SolverOptions::default();
let presolve_result = run_qp_presolve_phase1(&prob, &opts);
assert_eq!(presolve_result.reduced.num_vars, 1, "y fixed → 1 var");
let reduced_sol = solve_qp_with(&presolve_result.reduced, &opts);
assert_eq!(
reduced_sol.status,
SolveStatus::Optimal,
"reduced sol optimal"
);
let final_sol = postsolve_qp(&presolve_result, &reduced_sol);
assert_eq!(final_sol.solution.len(), 2, "restored to 2 vars");
assert!((final_sol.solution[1] - 0.5).abs() < 1e-8, "y=0.5 restored");
assert_eq!(final_sol.status, SolveStatus::Optimal);
}
#[test]
fn test_postsolve_dual_row_map() {
let n = 2usize;
let m = 2usize;
let q = CscMatrix::from_triplets(&[0, 1], &[0, 1], &[1.0, 1.0], n, n).unwrap();
let c = vec![0.0f64; n];
let a = CscMatrix::from_triplets(&[0, 0], &[0, 1], &[1.0, 1.0], m, n).unwrap();
let b = vec![3.0f64, 5.0f64];
let bounds = vec![(f64::NEG_INFINITY, f64::INFINITY); n];
let prob = QpProblem::new_all_le(q, c, a, b, bounds).unwrap();
let opts = SolverOptions::default();
let presolve_result = run_qp_presolve_phase1(&prob, &opts);
assert_eq!(presolve_result.orig_num_constraints, 2, "orig m=2");
let m_reduced = presolve_result.reduced.num_constraints;
assert_eq!(m_reduced, 1, "empty row removed → m_reduced=1");
let final_sol = solve_qp_with(&prob, &opts);
assert_eq!(final_sol.status, SolveStatus::Optimal);
assert_eq!(
final_sol.dual_solution.len(),
2,
"dual_solution must have orig_num_constraints length after postsolve"
);
}
#[test]
fn test_postsolve_dual_value_correctness() {
let n = 2usize;
let m = 1usize;
let q = CscMatrix::from_triplets(&[0, 1], &[0, 1], &[1.0, 1.0], n, n).unwrap();
let c = vec![-2.0f64, -2.0f64]; let a = CscMatrix::from_triplets(&[0, 0], &[0, 1], &[1.0, 1.0], m, n).unwrap();
let b = vec![2.0f64];
let bounds = vec![(0.0_f64, f64::INFINITY); n];
let prob = QpProblem::new_all_le(q, c, a, b, bounds).unwrap();
let opts = SolverOptions::default();
let result = solve_qp_with(&prob, &opts);
assert_eq!(result.status, SolveStatus::Optimal);
assert!((result.solution[0] - 1.0).abs() < 1e-6, "x=1");
assert!((result.solution[1] - 1.0).abs() < 1e-6, "y=1");
assert_eq!(result.dual_solution.len(), 1, "dual len=1");
assert!(result.dual_solution[0] >= -1e-6, "dual >= 0");
assert!(
(result.dual_solution[0] - 1.0).abs() < 1e-4,
"dual ≈ 1.0, got {}",
result.dual_solution[0]
);
}
#[test]
fn test_postsolve_singleton_row_dual_recovery() {
let n = 3usize;
let m = 2usize;
let q = CscMatrix::from_triplets(&[1, 2], &[1, 2], &[1.0, 1.0], n, n).unwrap();
let c = vec![0.0, -2.0, -2.0];
let a = CscMatrix::from_triplets(&[0, 1, 1, 1], &[0, 0, 1, 2], &[3.0, 1.0, 1.0, 1.0], m, n)
.unwrap();
let b = vec![6.0, 5.0];
let bounds = vec![(f64::NEG_INFINITY, f64::INFINITY); n]; let constraint_types = vec![
crate::problem::ConstraintType::Eq,
crate::problem::ConstraintType::Eq,
];
let prob = QpProblem::new(q, c, a, b, bounds, constraint_types).unwrap();
let opts = SolverOptions::default();
let result = solve_qp_with(&prob, &opts);
assert_eq!(result.status, SolveStatus::Optimal, "should converge");
assert!(
(result.solution[0] - 2.0).abs() < 1e-5,
"x≈2 (fixed by singleton), got {}",
result.solution[0]
);
assert!(
(result.solution[1] - 1.5).abs() < 1e-5,
"y≈1.5, got {}",
result.solution[1]
);
assert!(
(result.solution[2] - 1.5).abs() < 1e-5,
"z≈1.5, got {}",
result.solution[2]
);
let qx = prob.q.mat_vec_mul(&result.solution).unwrap();
let aty = prob
.a
.transpose()
.mat_vec_mul(&result.dual_solution)
.unwrap();
let mut max_rd = 0.0_f64;
for j in 0..n {
let r = (qx[j] + prob.c[j] + aty[j]).abs();
max_rd = max_rd.max(r);
}
assert!(
max_rd < 1e-6,
"max KKT residual should be ≈ 0, got {} (bug: postsolve dropped singleton-row dual)",
max_rd
);
}
#[test]
fn test_postsolve_dual_recovery_ill_scaled() {
let n = 4usize;
let m = 3usize;
let q = CscMatrix::from_triplets(&[1, 2, 3], &[1, 2, 3], &[1.0, 1.0, 1.0], n, n).unwrap();
let c = vec![0.0, -2.0, -2.0, -2.0];
let a = CscMatrix::from_triplets(
&[0, 1, 1, 2, 2],
&[0, 0, 1, 2, 3],
&[1e-3, 1e6, 1.0, 1.0, 1.0],
m,
n,
)
.unwrap();
let b = vec![1e-3, 1e6 + 1.0, 2.0];
let bounds = vec![(f64::NEG_INFINITY, f64::INFINITY); n];
let constraint_types = vec![crate::problem::ConstraintType::Eq; 3];
let prob = QpProblem::new(q, c, a, b, bounds, constraint_types).unwrap();
let opts = SolverOptions::default();
let result = solve_qp_with(&prob, &opts);
assert_eq!(result.status, SolveStatus::Optimal);
assert!((result.solution[0] - 1.0).abs() < 1e-5);
assert!((result.solution[1] - 1.0).abs() < 1e-5);
assert!((result.solution[2] - 1.0).abs() < 1e-5);
assert!((result.solution[3] - 1.0).abs() < 1e-5);
let qx = prob.q.mat_vec_mul(&result.solution).unwrap();
let aty = prob
.a
.transpose()
.mat_vec_mul(&result.dual_solution)
.unwrap();
let mut max_rd = 0.0_f64;
let mut max_aty = 0.0_f64;
for j in 0..n {
let r = (qx[j] + prob.c[j] + aty[j]).abs();
max_rd = max_rd.max(r);
max_aty = max_aty.max(aty[j].abs());
}
let denom = (1.0_f64).max(max_aty);
let rel = max_rd / denom;
assert!(
rel < 1e-5,
"ill-scaled でも relative KKT 残差は 1e-5 以下を維持すべき (got {})",
rel
);
}
#[test]
fn test_postsolve_preserves_residuals() {
let n = 2usize;
let m = 1usize;
let q = CscMatrix::from_triplets(&[0, 1], &[0, 1], &[1.0, 1.0], n, n).unwrap();
let c = vec![-2.0f64, -2.0f64];
let a = CscMatrix::from_triplets(&[0, 0], &[0, 1], &[1.0, 1.0], m, n).unwrap();
let b = vec![2.0f64];
let bounds = vec![(0.0_f64, f64::INFINITY); n];
let prob = QpProblem::new_all_le(q, c, a, b, bounds).unwrap();
let opts = SolverOptions::default();
let presolve_result = run_qp_presolve_phase1(&prob, &opts);
let reduced_sol = solve_qp_with(&presolve_result.reduced, &opts);
assert_eq!(reduced_sol.status, SolveStatus::Optimal);
let final_sol = postsolve_qp(&presolve_result, &reduced_sol);
assert_eq!(
final_sol.final_residuals, reduced_sol.final_residuals,
"final_residuals must be preserved after postsolve"
);
}
#[test]
fn test_recover_y_with_bound_zeroes_stationarity() {
use crate::problem::ConstraintType;
let n = 1usize;
let m = 2usize;
let q = CscMatrix::new(n, n);
let c = vec![5.0_f64];
let a = CscMatrix::from_triplets(&[0, 1], &[0, 0], &[2.0_f64, 1.0], m, n).unwrap();
let b = vec![4.0_f64, 10.0];
let bounds = vec![(f64::NEG_INFINITY, f64::INFINITY)];
let cts = vec![ConstraintType::Eq, ConstraintType::Le];
let prob = QpProblem::new(q, c, a, b, bounds, cts).unwrap();
let mut sol = SolverResult {
status: SolveStatus::Optimal,
solution: vec![2.0],
dual_solution: vec![0.0, 0.0],
bound_duals: vec![],
..SolverResult::default()
};
recover_y_for_singleton_row_with_bound(0, 0, &prob, &mut sol, 0.0);
assert!(
(sol.dual_solution[0] - (-2.5)).abs() < 1e-12,
"y[0] should be -2.5, got {}",
sol.dual_solution[0]
);
let aty0 = 2.0 * sol.dual_solution[0] + 1.0 * sol.dual_solution[1];
let stat = 0.0 + 5.0 + aty0;
assert!(
stat.abs() < 1e-12,
"stationarity zeroed after recovery, got {}",
stat
);
}
#[test]
fn test_sentinel_c1_boundary_col_needs_bound_contrib() {
use crate::problem::ConstraintType;
let n = 1usize;
let m = 1usize;
let q = CscMatrix::new(n, n);
let c = vec![2.0_f64];
let a = CscMatrix::from_triplets(&[0], &[0], &[1.0], m, n).unwrap();
let b = vec![1.0];
let bounds = vec![(1.0_f64, f64::INFINITY)];
let cts = vec![ConstraintType::Eq];
let prob = QpProblem::new(q, c, a, b, bounds, cts).unwrap();
let mut sol_zero = SolverResult {
status: SolveStatus::Optimal,
solution: vec![1.0],
dual_solution: vec![0.0],
bound_duals: vec![],
..SolverResult::default()
};
recover_y_for_singleton_row_with_bound(0, 0, &prob, &mut sol_zero, 0.0);
let resid_zero = (0.0 + 2.0 + sol_zero.dual_solution[0] + (-3.0_f64)).abs();
assert!(
resid_zero > 0.1,
"bound_contrib=0 must give wrong y for boundary col (resid={})",
resid_zero
);
let mut sol_corr = SolverResult {
status: SolveStatus::Optimal,
solution: vec![1.0],
dual_solution: vec![0.0],
bound_duals: vec![],
..SolverResult::default()
};
recover_y_for_singleton_row_with_bound(0, 0, &prob, &mut sol_corr, -3.0);
let resid_corr = (0.0 + 2.0 + sol_corr.dual_solution[0] + (-3.0_f64)).abs();
assert!(
resid_corr < 1e-12,
"correct bound_contrib must zero stationarity (resid={})",
resid_corr
);
}
#[test]
fn test_sentinel_c3_near_singular_pivot_skipped() {
use crate::problem::ConstraintType;
let n = 1usize;
let m = 1usize;
let q = CscMatrix::new(n, n);
let c = vec![1.0_f64];
let a = CscMatrix::from_triplets(&[0], &[0], &[1e-20_f64], m, n).unwrap();
let b = vec![1.0];
let bounds = vec![(f64::NEG_INFINITY, f64::INFINITY)];
let cts = vec![ConstraintType::Eq];
let prob = QpProblem::new(q, c, a, b, bounds, cts).unwrap();
let mut sol = SolverResult {
status: SolveStatus::Optimal,
solution: vec![1e20_f64],
dual_solution: vec![0.0],
bound_duals: vec![],
..SolverResult::default()
};
recover_y_for_singleton_row_with_bound(0, 0, &prob, &mut sol, 0.0);
assert!(
sol.dual_solution[0] == 0.0,
"near-singular pivot must be skipped: y stays at initial 0.0, got {}",
sol.dual_solution[0]
);
}
#[test]
fn test_compute_qx_at_symmetric_q() {
let q = CscMatrix::from_triplets(
&[0, 1, 0, 1],
&[0, 0, 1, 1],
&[2.0_f64, 3.0, 3.0, 4.0],
2,
2,
)
.unwrap();
let x = vec![1.0_f64, 1.0];
assert!((compute_qx_at(&q, &x, 0) - 5.0).abs() < 1e-12);
assert!((compute_qx_at(&q, &x, 1) - 7.0).abs() < 1e-12);
}
}