Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "rust-solver-api"
version = "0.1.9"
version = "0.2.0"
edition = "2021"

[features]
Expand All @@ -14,7 +14,7 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
dotenv = "0.15.0"
env_logger = "0.11.8"
glpk-rust = "0.1.13"
glpk-rust = "0.1.15"
sentry = { version = "0.34", default-features = false, features = ["backtrace","contexts","panic","rustls","reqwest"] }
sentry-actix = "0.34"
highs-sys = { version = "1.8.1", optional = true }
Expand Down
2 changes: 1 addition & 1 deletion clients/Rust/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "glpk-api-sdk"
version = "0.1.1"
version = "0.2.0"
edition = "2021"
authors = ["Rikard Olsson <rikard@ourstudio.com>"]
description = "Rust client SDK for GLPK REST API"
Expand Down
2 changes: 1 addition & 1 deletion clients/Rust/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ pub struct Solution {
/// Solution status
pub status: Status,
/// Objective value achieved
pub objective: i32,
pub objective: f64,
/// Variable assignments
pub solution: HashMap<String, i64>,
/// Error message, if any
Expand Down
225 changes: 225 additions & 0 deletions src/domain/solvers/glpk_solver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,228 @@ impl Solver for GlpkSolver {
"GLPK"
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_glpk_solver_simple_solve() {
let solver = GlpkSolver::without_cache();
let polyhedron = SparseLEIntegerPolyhedron {
a: crate::models::ApiIntegerSparseMatrix {
rows: vec![0, 0],
cols: vec![0, 1],
vals: vec![-1, -1],
shape: crate::models::ApiShape { nrows: 1, ncols: 2 },
},
b: vec![-1],
variables: vec![
crate::models::ApiVariable {
id: "x".to_string(),
bound: (0, 1),
},
crate::models::ApiVariable {
id: "y".to_string(),
bound: (0, 1),
},
],
};
let objectives = vec![HashMap::from([
("x".to_string(), 1.0),
("y".to_string(), 1.0),
])];
let direction = SolverDirection::Maximize;
let solutions = solver.solve(&polyhedron, &objectives, direction, false);
match solutions {
Ok(solutions) => {
assert_eq!(solutions.len(), 1);
let solution = &solutions[0];
assert_eq!(solution.error, None);
assert_eq!(solution.objective, 2.0);
assert_eq!(solution.solution.get("x"), Some(&1));
assert_eq!(solution.solution.get("y"), Some(&1));
}
Err(e) => panic!("Solver failed with error: {:?}", e.details),
}
}

#[test]
fn test_glpk_solver_f64_max_obj_vals_returns_correct_solution() {
/*
Testing that our assumptions of the handling of too large objective coefficients are correct.
- We expect that GLPK will return an infinite objective value but still provide a valid solution for x and y.
*/
let solver = GlpkSolver::without_cache();
let polyhedron = SparseLEIntegerPolyhedron {
a: crate::models::ApiIntegerSparseMatrix {
rows: vec![0, 0, 0],
cols: vec![0, 1, 2],
vals: vec![-1, -1, 1],
shape: crate::models::ApiShape { nrows: 1, ncols: 3 },
},
b: vec![-1],
variables: vec![
crate::models::ApiVariable {
id: "x".to_string(),
bound: (0, 1),
},
crate::models::ApiVariable {
id: "y".to_string(),
bound: (0, 1),
},
crate::models::ApiVariable {
id: "z".to_string(),
bound: (0, 1),
},
],
};
let objectives = vec![HashMap::from([
("x".to_string(), f64::MAX),
("y".to_string(), f64::MAX),
("z".to_string(), f64::MAX),
])];
let direction = SolverDirection::Maximize;
let solutions = solver.solve(&polyhedron, &objectives, direction, false);
match solutions {
Ok(solutions) => {
assert_eq!(solutions.len(), 1);
let solution = &solutions[0];
assert_eq!(solution.error, None);
println!(
"Objective value with max coefficients: {}",
solution.objective
);
Comment on lines +161 to +164
// We expect the objective to be inf but still x and y to be 1
assert_eq!(solution.objective, f64::INFINITY);
assert_eq!(solution.solution.get("x"), Some(&1));
Comment on lines +149 to +167
assert_eq!(solution.solution.get("y"), Some(&1));
assert_eq!(solution.solution.get("z"), Some(&1));
}
Err(e) => panic!("Solver failed with error: {:?}", e.details),
}
}

#[test]
fn test_glpk_solver_tolerance() {
/*
According to wiki and double precision floating points:
"
Between 2^52=4,503,599,627,370,496 and 2^53=9,007,199,254,740,992 the representable numbers are exactly the integers.
For the next range, from 2^53 to 2^54, everything is multiplied by 2, so the representable numbers are the even ones, etc.
Conversely, for the previous range from 2^51 to 2^52, the spacing is 0.5, etc.
"
This means that 2^52 ≈ 2^52+1 and so glpk won't make any difference
*/
let solver = GlpkSolver::without_cache();
let polyhedron = SparseLEIntegerPolyhedron {
a: crate::models::ApiIntegerSparseMatrix {
rows: vec![0, 0, 0],
cols: vec![0, 1, 2],
vals: vec![-1, -1, 1],
shape: crate::models::ApiShape { nrows: 1, ncols: 3 },
},
b: vec![-1],
variables: vec![
crate::models::ApiVariable {
id: "x".to_string(),
bound: (0, 1),
},
crate::models::ApiVariable {
id: "y".to_string(),
bound: (0, 1),
},
crate::models::ApiVariable {
id: "z".to_string(),
bound: (0, 1),
},
],
};
// The max exp is one thing in GLPK whereas the tolerance is another.
// The tolerance seams to be 33 (or 2^(-33)), e.g. the diff between the smallest and largest number used.
const EXP_TOL_MAX: i32 = 33;
let direction = SolverDirection::Maximize;
for i in 0..4 {
let objective = HashMap::from([
("x".to_string(), 2f64.powi(EXP_TOL_MAX + i)),
("y".to_string(), 1.0),
("z".to_string(), 1.0),
]);
let api_solutions = solver
.solve(&polyhedron, &[objective], direction, false)
.ok()
.unwrap();
let api_solution = &api_solutions[0];
// Only first solution should be correct
if i == 0 {
assert_eq!(api_solution.objective, 2f64.powi(EXP_TOL_MAX) + 2.0);
assert_eq!(api_solution.solution.get("x"), Some(&1));
assert_eq!(api_solution.solution.get("y"), Some(&1));
assert_eq!(api_solution.solution.get("z"), Some(&1));
} else {
assert_eq!(api_solution.objective, 2f64.powi(EXP_TOL_MAX + i));
assert_eq!(api_solution.solution.get("x"), Some(&1));
assert_eq!(api_solution.solution.get("y"), Some(&0));
assert_eq!(api_solution.solution.get("z"), Some(&0));
}
}

// However, sending many objectives at the time will set glpk to use the same tolerance for all of them, which means that some of them will be affected by the tolerance.
let objectives = (0..4)
.map(|i| {
HashMap::from([
("x".to_string(), 2f64.powi(EXP_TOL_MAX + i)),
("y".to_string(), 1.0),
("z".to_string(), 1.0),
])
})
.collect::<Vec<_>>();
let api_solutions = solver
.solve(&polyhedron, &objectives, direction, false)
.ok()
.unwrap();
for (i, api_solution) in api_solutions.iter().enumerate() {
// Due to the tolerance, all objectives will be treated as if they were the same, which means that all of them will have the same solution and objective value as the first one.
assert_eq!(
api_solution.objective,
2f64.powi(EXP_TOL_MAX + (i as i32)) + 2.0
);
assert_eq!(api_solution.solution.get("x"), Some(&1));
assert_eq!(api_solution.solution.get("y"), Some(&1));
assert_eq!(api_solution.solution.get("z"), Some(&1));
}

// But if we change the order of them, the last one will be the one that is correct and the rest will be affected by the tolerance.
let objectives = (0..4)
.rev()
.map(|i| {
HashMap::from([
("x".to_string(), 2f64.powi(EXP_TOL_MAX + i)),
("y".to_string(), 1.0),
("z".to_string(), 1.0),
])
})
.collect::<Vec<_>>();
let api_solutions = solver
.solve(&polyhedron, &objectives, direction, false)
.ok()
.unwrap();
for (i, api_solution) in api_solutions.iter().enumerate() {
// Due to the tolerance, all objectives will be treated as if they were the same, which means that all of them will have the same solution and objective value as the first one.
if i == 3 {
assert_eq!(api_solution.objective, 2f64.powi(EXP_TOL_MAX) + 2.0);
assert_eq!(api_solution.solution.get("x"), Some(&1));
assert_eq!(api_solution.solution.get("y"), Some(&1));
assert_eq!(api_solution.solution.get("z"), Some(&1));
} else {
assert_eq!(
api_solution.objective,
2f64.powi(EXP_TOL_MAX + ((3 - i) as i32))
);
assert_eq!(api_solution.solution.get("x"), Some(&1));
assert_eq!(api_solution.solution.get("y"), Some(&0));
assert_eq!(api_solution.solution.get("z"), Some(&0));
}
}
}
}
2 changes: 1 addition & 1 deletion src/domain/solvers/gurobi_solver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ impl Solver for GurobiSolver {

solutions.push(ApiSolution {
status,
objective: objective_value.round() as i32,
objective: objective_value,
solution: solution_map,
error: None,
});
Expand Down
68 changes: 66 additions & 2 deletions src/domain/solvers/highs_solver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ impl Solver for HighsSolver {
if status != 0 {
solutions.push(ApiSolution {
status: Status::Undefined,
objective: 0,
objective: 0.0,
solution: HashMap::new(),
error: Some(format!("HiGHS solve failed with status {}", status)),
});
Expand Down Expand Up @@ -305,7 +305,7 @@ impl Solver for HighsSolver {

solutions.push(ApiSolution {
status: api_status,
objective: objective_value.round() as i32,
objective: objective_value,
solution: solution_map,
error: None,
});
Expand Down Expand Up @@ -390,4 +390,68 @@ mod tests {
let result = solver.solve(&polyhedron, &[obj], SolverDirection::Maximize, true);
assert!(result.is_ok());
}

#[test]
fn test_highs_tolerance() {
let solver = HighsSolver::without_cache();
let polyhedron = SparseLEIntegerPolyhedron {
a: crate::models::ApiIntegerSparseMatrix {
rows: vec![0, 0, 0],
cols: vec![0, 1, 2],
vals: vec![-1, -1, 1],
shape: crate::models::ApiShape { nrows: 1, ncols: 3 },
},
b: vec![-1],
variables: vec![
crate::models::ApiVariable {
id: "x".to_string(),
bound: (0, 1),
},
crate::models::ApiVariable {
id: "y".to_string(),
bound: (0, 1),
},
crate::models::ApiVariable {
id: "z".to_string(),
bound: (0, 1),
},
],
};
// Highs has a strong limit of 52 bits of precision for integer problems,
// so we test with a large coefficient that is still within that limit to ensure it can handle it without numerical issues.
const EXP_TOL_MAX: i32 = 52;
let direction = SolverDirection::Maximize;
let objective = HashMap::from([
("x".to_string(), 2f64.powi(EXP_TOL_MAX)),
("y".to_string(), 1.0),
("z".to_string(), 1.0),
]);
let api_solutions = solver
.solve(&polyhedron, &[objective], direction, false)
.ok()
.unwrap();
let api_solution = &api_solutions[0];
assert_eq!(api_solution.objective, 2f64.powi(EXP_TOL_MAX) + 2.0);
assert_eq!(api_solution.solution.get("x"), Some(&1));
assert_eq!(api_solution.solution.get("y"), Some(&1));
assert_eq!(api_solution.solution.get("z"), Some(&1));

// So this test should return a not correct objective value for the solution
// However, the solution is correct!
let direction = SolverDirection::Maximize;
let objective = HashMap::from([
("x".to_string(), 2f64.powi(EXP_TOL_MAX + 1)),
("y".to_string(), 1.0),
("z".to_string(), 1.0),
]);
let api_solutions = solver
.solve(&polyhedron, &[objective], direction, false)
.ok()
.unwrap();
let api_solution = &api_solutions[0];
assert_eq!(api_solution.objective, 2f64.powi(EXP_TOL_MAX + 1));
assert_eq!(api_solution.solution.get("x"), Some(&1));
assert_eq!(api_solution.solution.get("y"), Some(&1));
assert_eq!(api_solution.solution.get("z"), Some(&1));
}
}
2 changes: 1 addition & 1 deletion src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ pub enum Status {
#[derive(Serialize, Deserialize)]
pub struct ApiSolution {
pub status: Status,
pub objective: i32, // matches glpk_rust’s current output
pub objective: f64,
pub solution: HashMap<String, i32>,
Comment on lines 23 to 25
pub error: Option<String>,
}
Expand Down
Loading