From 8c245fc236f3e890490d531f82445a0419ba8029 Mon Sep 17 00:00:00 2001 From: znittzel Date: Tue, 17 Mar 2026 10:34:50 +0100 Subject: [PATCH 1/3] Using i64 as obj vals --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- src/domain/solvers/highs_solver.rs | 2 +- src/models.rs | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c2ecf24..acf8bd1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -916,9 +916,9 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "glpk-rust" -version = "0.1.13" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd145f2020224b83aabe238b76bbc461e516fb38885a4686f171305a8e35f2fd" +checksum = "853a9fa49999fac31ccc240b5461b6fabe60b71c65fb0986a38d82aec1e3431e" dependencies = [ "cc", "cmake", diff --git a/Cargo.toml b/Cargo.toml index 83db433..00221ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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.14" 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 } diff --git a/src/domain/solvers/highs_solver.rs b/src/domain/solvers/highs_solver.rs index 7cb52d9..3bd8d9b 100644 --- a/src/domain/solvers/highs_solver.rs +++ b/src/domain/solvers/highs_solver.rs @@ -305,7 +305,7 @@ impl Solver for HighsSolver { solutions.push(ApiSolution { status: api_status, - objective: objective_value.round() as i32, + objective: objective_value.round() as i64, solution: solution_map, error: None, }); diff --git a/src/models.rs b/src/models.rs index cd287c0..1b328fa 100644 --- a/src/models.rs +++ b/src/models.rs @@ -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: i64, // matches glpk_rust’s current output pub solution: HashMap, pub error: Option, } From 99d9e88c1616c01db741096af37300137b5d50f9 Mon Sep 17 00:00:00 2001 From: znittzel Date: Thu, 19 Mar 2026 10:23:26 +0100 Subject: [PATCH 2/3] f64 objective value to match solvers data type --- Cargo.lock | 4 +- Cargo.toml | 2 +- src/domain/solvers/glpk_solver.rs | 225 ++++++++++++++++++++++++++++ src/domain/solvers/gurobi_solver.rs | 2 +- src/domain/solvers/highs_solver.rs | 68 ++++++++- src/models.rs | 2 +- 6 files changed, 296 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index acf8bd1..b3dd409 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -916,9 +916,9 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "glpk-rust" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "853a9fa49999fac31ccc240b5461b6fabe60b71c65fb0986a38d82aec1e3431e" +checksum = "29a396751c90c42f0da9df8b988522deb441ddb911c7f08b8dea837ff378f590" dependencies = [ "cc", "cmake", diff --git a/Cargo.toml b/Cargo.toml index 00221ef..bceeec0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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.14" +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 } diff --git a/src/domain/solvers/glpk_solver.rs b/src/domain/solvers/glpk_solver.rs index cb4e9c6..bd8fbbb 100644 --- a/src/domain/solvers/glpk_solver.rs +++ b/src/domain/solvers/glpk_solver.rs @@ -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 + ); + // 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)); + 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::>(); + 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::>(); + 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)); + } + } + } +} diff --git a/src/domain/solvers/gurobi_solver.rs b/src/domain/solvers/gurobi_solver.rs index 3fee46f..3a735c8 100644 --- a/src/domain/solvers/gurobi_solver.rs +++ b/src/domain/solvers/gurobi_solver.rs @@ -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, }); diff --git a/src/domain/solvers/highs_solver.rs b/src/domain/solvers/highs_solver.rs index 3bd8d9b..562cbf6 100644 --- a/src/domain/solvers/highs_solver.rs +++ b/src/domain/solvers/highs_solver.rs @@ -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)), }); @@ -305,7 +305,7 @@ impl Solver for HighsSolver { solutions.push(ApiSolution { status: api_status, - objective: objective_value.round() as i64, + objective: objective_value, solution: solution_map, error: None, }); @@ -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)); + } } diff --git a/src/models.rs b/src/models.rs index 1b328fa..9c5959d 100644 --- a/src/models.rs +++ b/src/models.rs @@ -21,7 +21,7 @@ pub enum Status { #[derive(Serialize, Deserialize)] pub struct ApiSolution { pub status: Status, - pub objective: i64, // matches glpk_rust’s current output + pub objective: f64, pub solution: HashMap, pub error: Option, } From 8a99a5358585ac6114110cbf29522d32508e6d53 Mon Sep 17 00:00:00 2001 From: znittzel Date: Thu, 19 Mar 2026 10:29:26 +0100 Subject: [PATCH 3/3] Version bump since breaking change on output data type Matching clients version to servers version --- Cargo.lock | 2 +- Cargo.toml | 2 +- clients/Rust/Cargo.toml | 2 +- clients/Rust/src/types.rs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b3dd409..5e4f7c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2102,7 +2102,7 @@ dependencies = [ [[package]] name = "rust-solver-api" -version = "0.1.9" +version = "0.2.0" dependencies = [ "actix-web", "dotenv", diff --git a/Cargo.toml b/Cargo.toml index bceeec0..2528c94 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rust-solver-api" -version = "0.1.9" +version = "0.2.0" edition = "2021" [features] diff --git a/clients/Rust/Cargo.toml b/clients/Rust/Cargo.toml index 28307bc..b267f45 100644 --- a/clients/Rust/Cargo.toml +++ b/clients/Rust/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "glpk-api-sdk" -version = "0.1.1" +version = "0.2.0" edition = "2021" authors = ["Rikard Olsson "] description = "Rust client SDK for GLPK REST API" diff --git a/clients/Rust/src/types.rs b/clients/Rust/src/types.rs index 6235d80..4003a04 100644 --- a/clients/Rust/src/types.rs +++ b/clients/Rust/src/types.rs @@ -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, /// Error message, if any