use crate::lp::solve_lp_with;
use crate::mip::branch::{fractionality, is_integer_feasible};
use crate::mip::integer_mask;
use crate::options::SolverOptions;
use crate::problem::{ConstraintType, LpProblem, SolveStatus, SolverResult};
use crate::sparse::CscMatrix;
use crate::tolerances::PIVOT_TOL;
const MAX_FP_ITER: usize = 30;
const STALL_THRESHOLD: usize = 5;
const PERTURB_FLIP_COUNT: usize = 3;
const HALF_INTEGER_THRESHOLD: f64 = 0.5;
pub(crate) fn run_feasibility_pump(
lp: &LpProblem,
integer_vars: &[usize],
integer_feas_tol: f64,
opts: &SolverOptions,
) -> Option<SolverResult> {
if integer_vars.is_empty() {
return None;
}
let n = lp.num_vars;
let mask = integer_mask(n, integer_vars);
let root = solve_lp_with(lp, opts);
if !matches!(root.status, SolveStatus::Optimal) || root.solution.is_empty() {
return None;
}
if is_integer_feasible(&root.solution, &mask, integer_feas_tol) {
let x_rounded = round_integer_vars(&root.solution, &mask);
if validate_against_bounds(&x_rounded, &lp.bounds)
&& validate_against_constraints(&x_rounded, &lp.a, &lp.b, &lp.constraint_types)
{
return Some(make_result(&lp.c, lp.obj_offset, x_rounded));
}
return None;
}
let mut x_lp = root.solution;
let mut x_int = round_integer_vars(&x_lp, &mask);
let mut prev_x_int: Option<Vec<f64>> = None;
let mut stall_count = 0usize;
for _ in 0..MAX_FP_ITER {
let fp_cost = signed_fp_cost(&x_lp, &x_int, &mask, n);
let mut fp_lp = lp.clone();
fp_lp.c = fp_cost;
let fp_res = solve_lp_with(&fp_lp, opts);
if !matches!(fp_res.status, SolveStatus::Optimal) || fp_res.solution.is_empty() {
break;
}
x_lp = fp_res.solution;
if is_integer_feasible(&x_lp, &mask, integer_feas_tol) {
let x_rounded = round_integer_vars(&x_lp, &mask);
if validate_against_bounds(&x_rounded, &lp.bounds)
&& validate_against_constraints(&x_rounded, &lp.a, &lp.b, &lp.constraint_types)
{
return Some(make_result(&lp.c, lp.obj_offset, x_rounded));
}
break;
}
let new_x_int = round_integer_vars(&x_lp, &mask);
let stalled = prev_x_int
.as_ref()
.is_some_and(|p| integers_same(p, &new_x_int, &mask));
stall_count = if stalled { stall_count + 1 } else { 0 };
x_int = if stall_count >= STALL_THRESHOLD {
stall_count = 0;
perturb(&new_x_int, &x_lp, &mask, PERTURB_FLIP_COUNT)
} else {
new_x_int.clone()
};
prev_x_int = Some(new_x_int);
}
None
}
fn round_integer_vars(x: &[f64], mask: &[bool]) -> Vec<f64> {
x.iter()
.zip(mask.iter())
.map(|(&xi, &is_int)| if is_int { xi.round() } else { xi })
.collect()
}
fn signed_fp_cost(x_lp: &[f64], x_int: &[f64], mask: &[bool], n: usize) -> Vec<f64> {
let mut cost = vec![0.0; n];
for j in 0..n {
if !mask[j] {
continue;
}
let diff = x_lp[j] - x_int[j];
cost[j] = if diff > 0.0 {
1.0
} else if diff < 0.0 {
-1.0
} else {
0.0
};
}
cost
}
fn integers_same(a: &[f64], b: &[f64], mask: &[bool]) -> bool {
a.iter()
.zip(b.iter())
.zip(mask.iter())
.all(|((&ai, &bi), &is_int)| !is_int || (ai - bi).abs() < HALF_INTEGER_THRESHOLD)
}
fn perturb(x_int: &[f64], x_lp: &[f64], mask: &[bool], flip_count: usize) -> Vec<f64> {
let mut frac_vars: Vec<(usize, f64)> = mask
.iter()
.enumerate()
.filter(|(_, &is_int)| is_int)
.map(|(j, _)| (j, fractionality(x_lp[j])))
.collect();
frac_vars.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let mut result = x_int.to_vec();
for &(j, _) in frac_vars.iter().take(flip_count) {
let floor_val = x_lp[j].floor();
let ceil_val = x_lp[j].ceil();
result[j] = if (result[j] - floor_val).abs() < HALF_INTEGER_THRESHOLD {
ceil_val
} else {
floor_val
};
}
result
}
fn validate_against_bounds(x: &[f64], bounds: &[(f64, f64)]) -> bool {
x.iter()
.zip(bounds.iter())
.all(|(&xi, &(lb, ub))| lb <= xi && xi <= ub)
}
fn validate_against_constraints(
x: &[f64],
a: &CscMatrix,
b: &[f64],
constraint_types: &[ConstraintType],
) -> bool {
let ax = match a.mat_vec_mul(x) {
Ok(v) => v,
Err(_) => return false,
};
ax.iter()
.zip(b.iter())
.zip(constraint_types.iter())
.all(|((&ax_i, &b_i), &ct)| match ct {
ConstraintType::Le => ax_i <= b_i + PIVOT_TOL,
ConstraintType::Ge => ax_i >= b_i - PIVOT_TOL,
ConstraintType::Eq => (ax_i - b_i).abs() <= PIVOT_TOL,
})
}
fn make_result(c: &[f64], obj_offset: f64, x: Vec<f64>) -> SolverResult {
let obj: f64 = c.iter().zip(x.iter()).map(|(ci, xi)| ci * xi).sum::<f64>() + obj_offset;
SolverResult {
status: SolveStatus::Optimal,
objective: obj,
solution: x,
..SolverResult::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::options::SolverOptions;
use crate::problem::{ConstraintType, LpProblem};
use crate::sparse::CscMatrix;
fn opts() -> SolverOptions {
SolverOptions {
timeout_secs: Some(10.0),
..Default::default()
}
}
fn single_constraint_lp(
c: Vec<f64>,
a_vals: &[f64],
b: f64,
bounds: Vec<(f64, f64)>,
) -> LpProblem {
let n = c.len();
let rows: Vec<usize> = vec![0; n];
let cols: Vec<usize> = (0..n).collect();
let a = CscMatrix::from_triplets(&rows, &cols, a_vals, 1, n).unwrap();
LpProblem::new_general(c, a, vec![b], vec![ConstraintType::Le], bounds, None).unwrap()
}
#[test]
fn fp_skips_empty_integer_vars() {
let lp = single_constraint_lp(vec![1.0, 1.0], &[1.0, 1.0], 5.0, vec![(0.0, 3.0); 2]);
assert!(run_feasibility_pump(&lp, &[], 1e-6, &opts()).is_none());
}
#[test]
fn fp_returns_integral_lp_root_immediately() {
let lp = single_constraint_lp(vec![1.0], &[1.0], 3.0, vec![(0.0, 5.0)]);
let r = run_feasibility_pump(&lp, &[0], 1e-6, &opts()).expect("integer root → Some");
assert!((r.solution[0] - 0.0).abs() < 1e-6, "sol={}", r.solution[0]);
}
#[test]
fn fp_converges_binary_knapsack_one_iter() {
let lp = single_constraint_lp(
vec![-3.0, -5.0, -2.0, -4.0],
&[3.0, 5.0, 2.0, 4.0],
7.0,
vec![(0.0, 1.0); 4],
);
let r = run_feasibility_pump(&lp, &[0, 1, 2, 3], 1e-6, &opts()).expect("FP must converge");
let frac: f64 = r.solution.iter().map(|&v| (v - v.round()).abs()).sum();
assert!(frac < 1e-6, "solution not integer: {:?}", r.solution);
let obj_recheck: f64 = [-3.0f64, -5.0, -2.0, -4.0]
.iter()
.zip(r.solution.iter())
.map(|(c, x)| c * x)
.sum();
assert!((r.objective - obj_recheck).abs() < 1e-6);
}
#[test]
fn fp_stall_perturbation_does_not_panic() {
let a = CscMatrix::from_triplets(&[0, 1], &[0, 0], &[1.0, -1.0], 2, 1).unwrap();
let lp = LpProblem::new_general(
vec![1.0],
a,
vec![0.6, -0.4],
vec![ConstraintType::Le, ConstraintType::Le],
vec![(0.0, 1.0)],
None,
)
.unwrap();
let result = run_feasibility_pump(&lp, &[0], 1e-6, &opts());
assert!(
result.is_none(),
"expected None for integer-infeasible problem, got Some"
);
}
#[test]
fn fp_incumbent_uses_rounded_objective_valid_bounds() {
let near_one = 1.0 - 1e-7;
let lp = single_constraint_lp(vec![1.0], &[1.0], 1.5, vec![(near_one, 1.5)]);
let r = run_feasibility_pump(&lp, &[0], 1e-6, &opts())
.expect("LP root integer-feasible within tol, rounded in bounds → Some");
assert!(
(r.solution[0] - 1.0).abs() < 1e-9,
"solution should be rounded to 1.0, got {}",
r.solution[0]
);
assert!(
(r.objective - 1.0).abs() < 1e-9,
"objective should use rounded value 1.0, got {}",
r.objective
);
}
#[test]
fn fp_rejects_rounded_incumbent_violating_bounds() {
let near_ub = 1.0 - 1e-7;
let lp = single_constraint_lp(vec![-1.0], &[1.0], near_ub, vec![(0.0, near_ub)]);
let result = run_feasibility_pump(&lp, &[0], 1e-6, &opts());
assert!(
result.is_none(),
"FP must reject rounded incumbent that violates UB={}; \
no-op (remove validate_against_bounds) returns Some(solution[0]=1.0) → FAIL",
near_ub
);
}
#[test]
fn test_milp_feasibility_pump_includes_obj_offset() {
let mut lp = single_constraint_lp(vec![1.0], &[1.0], 3.0, vec![(0.0, 5.0)]);
lp.obj_offset = 7.0;
let r = run_feasibility_pump(&lp, &[0], 1e-6, &opts())
.expect("LP root is integer-feasible → FP must return Some");
assert!(
(r.objective - 7.0).abs() < 1e-9,
"FP incumbent must include obj_offset 7.0; removing makes obj=0.0 → FAIL. Got {}",
r.objective
);
}
#[test]
fn perturb_flips_most_fractional_variable() {
let x_int = vec![1.0, 0.0];
let x_lp = vec![0.4, 0.7];
let mask = vec![true, true];
let result = perturb(&x_int, &x_lp, &mask, 1);
assert!(
(result[0] - 0.0).abs() < 1e-9,
"most-fractional var should flip 1→0; got {}",
result[0],
);
assert!(
(result[1] - 0.0).abs() < 1e-9,
"unflipped var should remain 0; got {}",
result[1],
);
}
}