diff --git a/Project.toml b/Project.toml index 2a3693c27..f5a24d00a 100644 --- a/Project.toml +++ b/Project.toml @@ -1,13 +1,12 @@ name = "Boscia" uuid = "36b166db-dac5-4d05-b36a-e6c4cef071c9" authors = ["ZIB IOL"] -version = "0.1.9" +version = "0.1.12" [deps] Bonobo = "f7b14807-3d4d-461a-888a-05dd4bca8bc3" DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" -Distributions = "31c24e10-a181-5473-b8eb-7969acd0382f" FrankWolfe = "f55ce6ea-fdc5-4628-88c5-0087fe54bd30" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" MathOptInterface = "b8f27783-ece8-5eb3-8dc8-9495eed66fee" @@ -16,7 +15,6 @@ Printf = "de0858da-6303-5e67-8744-51eddeeeb8d7" Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" SCIP = "82193955-e24f-5292-bf16-6f2c5261a85f" Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" -Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [compat] Bonobo = "0.1.3" @@ -30,8 +28,9 @@ SCIP = "0.11" julia = "1.6" [extras] +Distributions = "31c24e10-a181-5473-b8eb-7969acd0382f" HiGHS = "87dc4568-4c63-4d18-b0c0-bb2238e4078b" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] -test = ["Test", "HiGHS"] +test = ["Test", "HiGHS", "Distributions"] diff --git a/examples/mps-examples/mip-examples.jl b/examples/mps-examples/mip-examples.jl index 7c7ac4fab..df2033fb6 100644 --- a/examples/mps-examples/mip-examples.jl +++ b/examples/mps-examples/mip-examples.jl @@ -25,7 +25,10 @@ seed = rand(UInt64) @show seed Random.seed!(seed) -example = "neos5" +# To see debug statements +#ENV["JULIA_DEBUG"] = "Boscia" + +example = "n5-3" function build_example(example, num_v) file_name = string(example, ".mps") @@ -38,6 +41,9 @@ function build_example(example, num_v) n = MOI.get(o, MOI.NumberOfVariables()) lmo = FrankWolfe.MathOptLMO(o) + # Disable Presolving + MOI.set(o, MOI.RawOptimizerAttribute("presolving/maxrounds"), 0) + #trick to push the optimum towards the interior vs = [FrankWolfe.compute_extreme_point(lmo, randn(n)) for _ in 1:num_v] # done to avoid one vertex being systematically selected @@ -88,7 +94,7 @@ test_instance = string("MPS ", example, " instance") @testset "$test_instance" begin println("Example $(example)") lmo, f, grad! = build_example(example, num_v) - x, _, result = Boscia.solve(f, grad!, lmo, verbose=true, print_iter = 10, fw_epsilon = 1e-1, min_node_fw_epsilon = 1e-3, time_limit=2000) + x, _, result = Boscia.solve(f, grad!, lmo, verbose=true, print_iter = 10, fw_epsilon = 1e-1, min_node_fw_epsilon = 1e-3, time_limit=600) @test f(x) <= f(result[:raw_solution]) end diff --git a/src/callbacks.jl b/src/callbacks.jl index 7b29a90be..a838d5c03 100644 --- a/src/callbacks.jl +++ b/src/callbacks.jl @@ -12,7 +12,6 @@ function build_FW_callback(tree, min_number_lower, check_rounding_value::Bool, f check_infeasible_vertex(tree.root.problem.tlmo.blmo, tree) @assert is_linear_feasible(tree.root.problem.tlmo, state.v) end - # @assert is_linear_feasible(tree.root.problem.lmo, state.x) push!(fw_iterations, state.t) if state.lmo !== nothing # can happen with using Blended Conditional Gradient diff --git a/src/frank_wolfe_variants.jl b/src/frank_wolfe_variants.jl index 0c18a6318..2dbb50ae7 100644 --- a/src/frank_wolfe_variants.jl +++ b/src/frank_wolfe_variants.jl @@ -49,6 +49,7 @@ function solve_frank_wolfe( extra_vertex_storage=nothing, callback=nothing, lazy=false, + lazy_tolerance=2.0, timeout=Inf, verbose=false, workspace=nothing @@ -62,7 +63,8 @@ function solve_frank_wolfe( max_iteration=max_iteration, line_search=line_search, callback=callback, - lazy=lazy, + lazy=lazy, + lazy_tolerance=lazy_tolerance, timeout=timeout, add_dropped_vertices=add_dropped_vertices, use_extra_vertex_storage=use_extra_vertex_storage, @@ -95,6 +97,7 @@ function solve_frank_wolfe( extra_vertex_storage=nothing, callback=nothing, lazy=false, + lazy_tolerance=2.0, timeout=Inf, verbose=false, workspace=nothing @@ -113,6 +116,7 @@ function solve_frank_wolfe( callback=callback, timeout=timeout, verbose=verbose, + lazy_tolerance=lazy_tolerance, ) return x, primal, dual_gap, active_set @@ -139,6 +143,7 @@ function solve_frank_wolfe( extra_vertex_storage=nothing, callback=nothing, lazy=false, + lazy_tolerance=2.0, timeout=Inf, verbose=false, workspace=nothing @@ -156,6 +161,7 @@ function solve_frank_wolfe( extra_vertex_storage=extra_vertex_storage, callback=callback, lazy=lazy, + lazy_tolerance=lazy_tolerance, timeout=timeout, verbose=verbose ) @@ -188,6 +194,7 @@ function solve_frank_wolfe( extra_vertex_storage=nothing, callback=nothing, lazy=false, + lazy_tolerance=2.0, timeout=Inf, verbose=false, workspace=nothing diff --git a/src/interface.jl b/src/interface.jl index 0005e175d..bfa861805 100644 --- a/src/interface.jl +++ b/src/interface.jl @@ -79,7 +79,14 @@ variant - variant of FrankWolfe to be used to solve the node prob AFW -- Away FrankWolfe BPCG -- Blended Pairwise Conditional Gradient line_search - specifies the Line Search method used in the FrankWolfe variant. - Default is the Adaptive Line Search. For other types, check the FrankWolfe.jl package. + Default is the Adaptive Line Search. For other types, check the FrankWolfe.jl package. +active_set - can be used to specify a starting point, e.g. if the feasible region is not completely + contained in the domain of the objective. By default, the direction (1,..,n) where n is + the size of the problem is used to find a start vertex. Beware that the active set may + only contain actual vertices of the feasible region. +lazy - specifies whether the lazification shoud be used. Per default true. + Beware that it has no effect with Vanilla Frank-Wolfe. +lazy_tolerance - decides how much progress is deemed enough to not have to call the LMO. fw_epsilon - the tolerance for FrankWolfe in the root node. verbose - if true, a log and solution statistics are printed. dual_gap - if this absolute dual gap is reached, the algorithm stops. @@ -115,6 +122,8 @@ function solve( variant::FrankWolfeVariant=BPCG(), line_search::FrankWolfe.LineSearchMethod=FrankWolfe.Adaptive(), active_set::Union{Nothing, FrankWolfe.ActiveSet} = nothing, + lazy=true, + lazy_tolerance = 2.0, fw_epsilon=1e-2, verbose=false, dual_gap=1e-6, @@ -144,6 +153,8 @@ function solve( println("\t Branching strategy: ", _value_to_print(branching_strategy)) println("\t FrankWolfe variant: $(variant)") println("\t Line Search Method: $(line_search)") + println("\t Lazification: $(lazy)") + lazy ? println("\t Lazification Tolerance: $(lazy_tolerance)") : nothing @printf("\t Absolute dual gap tolerance: %e\n", dual_gap) @printf("\t Relative dual gap tolerance: %e\n", rel_dual_gap) @printf("\t Frank-Wolfe subproblem tolerance: %e\n", fw_epsilon) @@ -231,20 +242,22 @@ function solve( ), global_tightenings = IntegerBounds(), options=Dict{Symbol,Any}( - :dual_gap_decay_factor => dual_gap_decay_factor, + :domain_oracle => domain_oracle, :dual_gap => dual_gap, - :print_iter => print_iter, - :max_fw_iter => max_fw_iter, - :min_node_fw_epsilon => min_node_fw_epsilon, - :time_limit => time_limit, + :dual_gap_decay_factor => dual_gap_decay_factor, :dual_tightening => dual_tightening, + :fwVerbose => fw_verbose, :global_dual_tightening => global_dual_tightening, - :strong_convexity => strong_convexity, - :variant => variant, + :lazy => lazy, + :lazy_tolerance => lazy_tolerance, :lineSearch => line_search, - :domain_oracle => domain_oracle, + :min_node_fw_epsilon => min_node_fw_epsilon, + :max_fw_iter => max_fw_iter, + :print_iter => print_iter, + :strong_convexity => strong_convexity, + :time_limit => time_limit, :usePostsolve => use_postsolve, - :fwVerbose => fw_verbose, + :variant => variant, ), ), branch_strategy=branching_strategy, @@ -271,6 +284,8 @@ function solve( if size(start_solution) != size(v) error("size of starting solution differs from vertices: $(size(start_solution)), $(size(v))") end + # Sanity check that the provided solution is in fact feasible. + @assert is_linear_feasible(lmo, start_solution) && is_integer_feasible(tree, start_solution) node = tree.nodes[1] sol = FrankWolfeSolution(f(start_solution), start_solution, node, :start) push!(tree.solutions, sol) diff --git a/src/node.jl b/src/node.jl index dd0f9cafb..2ebb14c25 100644 --- a/src/node.jl +++ b/src/node.jl @@ -49,46 +49,22 @@ function Bonobo.get_branching_nodes_info(tree::Bonobo.BnBTree, node::FrankWolfeN if !is_valid_split(tree, vidx) error("Splitting on the same index as parent! Abort!") end - # update splitting index + + # get iterate, primal and lower bound x = Bonobo.get_relaxed_values(tree, node) primal = tree.root.problem.f(x) lower_bound_base = primal - node.dual_gap @assert isfinite(lower_bound_base) - prune_left = false - prune_right = false - # if strong convexity, potentially remove one of two children - μ = tree.root.options[:strong_convexity] - if μ > 0 - @debug "Using strong convexity $μ" - for j in tree.root.problem.integer_variables - if vidx == j - continue - end - lower_bound_base += μ/2 * min( - (x[j] - floor(x[j]))^2, - (ceil(x[j]) - x[j])^2, - ) - end - new_bound_left = lower_bound_base + μ/2 * (x[vidx] - floor(x[vidx]))^2 - new_bound_right = lower_bound_base + μ/2 * (ceil(x[vidx]) - x[vidx])^2 - if new_bound_left > tree.incumbent - @debug "prune left, from $(node.lb) -> $new_bound_left, ub $(tree.incumbent), lb $(node.lb)" - prune_left = true - end - if new_bound_right > tree.incumbent - @debug "prune right, from $(node.lb) -> $new_bound_right, ub $(tree.incumbent), lb $(node.lb)" - prune_right = true - end - end - - @assert !(prune_left && prune_right) "both sides should not be pruned" - - # split active set + # In case of strong convexity, check if a child can be pruned + prune_left, prune_right = prune_children(tree, node, lower_bound_base, x, vidx) + + # Split active set active_set_left, active_set_right = split_vertices_set!(node.active_set, tree, vidx, node.local_bounds) discarded_set_left, discarded_set_right = split_vertices_set!(node.discarded_vertices, tree, vidx, x, node.local_bounds) + # Sanity check @assert isapprox(sum(active_set_left.weights), 1.0) @assert sum(active_set_left.weights .< 0) == 0 @assert isapprox(sum(active_set_right.weights), 1.0) @@ -108,10 +84,11 @@ function Bonobo.get_branching_nodes_info(tree::Bonobo.BnBTree, node::FrankWolfeN push!(varbounds_left.upper_bounds, (vidx => MOI.LessThan(floor(x[vidx])))) push!(varbounds_right.lower_bounds, (vidx => MOI.GreaterThan(ceil(x[vidx])))) - # compute new dual gap + # compute new dual gap limit fw_dual_gap_limit = tree.root.options[:dual_gap_decay_factor] * node.fw_dual_gap_limit fw_dual_gap_limit = max(fw_dual_gap_limit, tree.root.options[:min_node_fw_epsilon]) + # Sanity check for v in active_set_left.atoms if !(v[vidx] <= floor(x[vidx]) + tree.options.atol) error("active_set_left\n$(v)\n$vidx, $(x[vidx]), $(v[vidx])") @@ -132,6 +109,7 @@ function Bonobo.get_branching_nodes_info(tree::Bonobo.BnBTree, node::FrankWolfeN error("storage right\n$(v)\n$vidx, $(x[vidx]), $(v[vidx])") end end + # update the LMO node_info_left = ( active_set=active_set_left, @@ -163,7 +141,7 @@ function Bonobo.get_branching_nodes_info(tree::Bonobo.BnBTree, node::FrankWolfeN x_right = FrankWolfe.compute_active_set_iterate!(active_set_right) domain_oracle = tree.root.options[:domain_oracle] - nodes = if !prune_left && !prune_right #&& domain_oracle(x_left) && domain_oracle(x_right) + nodes = if !prune_left && !prune_right [node_info_left, node_info_right] elseif prune_left [node_info_right] @@ -177,11 +155,47 @@ function Bonobo.get_branching_nodes_info(tree::Bonobo.BnBTree, node::FrankWolfeN return nodes end +""" +Use strong convexity to potentially remove one of the children nodes +""" +function prune_children(tree, node, lower_bound_base, x, vidx) + prune_left = false + prune_right = false + + μ = tree.root.options[:strong_convexity] + if μ > 0 + @debug "Using strong convexity $μ" + for j in tree.root.problem.integer_variables + if vidx == j + continue + end + lower_bound_base += μ/2 * min( + (x[j] - floor(x[j]))^2, + (ceil(x[j]) - x[j])^2, + ) + end + new_bound_left = lower_bound_base + μ/2 * (x[vidx] - floor(x[vidx]))^2 + new_bound_right = lower_bound_base + μ/2 * (ceil(x[vidx]) - x[vidx])^2 + if new_bound_left > tree.incumbent + @debug "prune left, from $(node.lb) -> $new_bound_left, ub $(tree.incumbent), lb $(node.lb)" + prune_left = true + end + if new_bound_right > tree.incumbent + @debug "prune right, from $(node.lb) -> $new_bound_right, ub $(tree.incumbent), lb $(node.lb)" + prune_right = true + end + end + + @assert !(prune_left && prune_right) "both sides should not be pruned" + + return prune_left, prune_right +end + """ Computes the relaxation at that node """ function Bonobo.evaluate_node!(tree::Bonobo.BnBTree, node::FrankWolfeNode) - # check if conflict between local bounds and global tightening + # check that local bounds and global tightening don't conflict for (j, ub) in tree.root.global_tightenings.upper_bounds if !haskey(node.local_bounds.lower_bounds, j) continue @@ -235,41 +249,16 @@ function Bonobo.evaluate_node!(tree::Bonobo.BnBTree, node::FrankWolfeNode) restart_active_set(node, tree.root.problem.tlmo.blmo, tree.root.problem.nvars) end =# - # time tracking FW + # Check feasibility of the iterate active_set = node.active_set - time_ref = Dates.now() - FrankWolfe.compute_active_set_iterate!(node.active_set) - x = node.active_set.x - for list in (node.local_bounds.lower_bounds, node.local_bounds.upper_bounds) - for (idx, set) in list - dist = MOD.distance_to_set(MOD.DefaultDistance(), x[idx], set) - if dist > 0.01 - @warn "infeas x $dist" - end - for v_idx in eachindex(node.active_set) - dist_v = MOD.distance_to_set(MOD.DefaultDistance(), node.active_set.atoms[v_idx][idx], set) - if dist_v > 0.01 - error("vertex beginning") - end - end - if dist > 0.01 - @warn "infeas x $dist" - @error("infeasible but vertex okay") - FrankWolfe.compute_active_set_iterate!(active_set) - dist2 = MOD.distance_to_set(MOD.DefaultDistance(), x[idx], set) - if dist2 > 0.01 - error("$dist, $idx, $set") - else - error("recovered") - end - end - end + x = FrankWolfe.compute_active_set_iterate!(node.active_set) + @assert is_linear_feasible(tree.root.problem.lmo, x) + for (_,v) in node.active_set + @assert is_linear_feasible(tree.root.problem.lmo, v) end - # x = zeros(tree.root.problem.nvars) - #dual_gap = primal = 0 - #active_set = FrankWolfe.ActiveSet([(1.0, x)]) - x=FrankWolfe.compute_active_set_iterate!(node.active_set) + # time tracking FW + time_ref = Dates.now() domain_oracle = tree.root.options[:domain_oracle] x, primal, dual_gap, active_set = solve_frank_wolfe( @@ -281,7 +270,8 @@ function Bonobo.evaluate_node!(tree::Bonobo.BnBTree, node::FrankWolfeNode) epsilon=node.fw_dual_gap_limit, max_iteration=tree.root.options[:max_fw_iter], line_search=tree.root.options[:lineSearch], - lazy=true, + lazy=tree.root.options[:lazy], + lazy_tolerance=tree.root.options[:lazy_tolerance], timeout=tree.root.options[:time_limit], add_dropped_vertices=true, use_extra_vertex_storage=true, @@ -296,6 +286,36 @@ function Bonobo.evaluate_node!(tree::Bonobo.BnBTree, node::FrankWolfeNode) # update active set of the node node.active_set = active_set + # tightening bounds at node level + dual_tightening(tree, node, x, dual_gap) + + # tightening the global bounds + store_data_global_tightening(tree, node, x, dual_gap) + global_tightening(tree, node) + + lower_bound = primal - dual_gap + # improvement of the lower bound using strong convexity + lower_bound = tightening_strong_convexity(tree, x, lower_bound) + + # Found an upper bound + if is_integer_feasible(tree, x) + node.ub = primal + return lower_bound, primal + # Sanity check: If the incumbent is better than the lower bound of the root node + # and the root node is not integer feasible, something is off! + elseif node.id == 1 + @debug "Lower bound of root node: $(lower_bound)" + @debug "Current incumbent: $(tree.incumbent)" + @assert lower_bound <= tree.incumbent + 1e-5 + end + + return lower_bound, NaN +end + +""" +Tightening of the bounds at node level. Children node inherit the updated bounds. +""" +function dual_tightening(tree, node, x, dual_gap) if tree.root.options[:dual_tightening] && isfinite(tree.incumbent) grad = similar(x) tree.root.problem.g(grad, x) @@ -372,8 +392,13 @@ function Bonobo.evaluate_node!(tree::Bonobo.BnBTree, node::FrankWolfeNode) node.local_tightenings = num_tightenings node.local_potential_tightenings = num_potential_tightenings end +end - # store gradient, dual gap and relaxation +""" +Save the gradient of the root solution (i.e. the relaxed solution) and the +corresponding lower and upper bounds. +""" +function store_data_global_tightening(tree, node, x, dual_gap) if tree.root.options[:global_dual_tightening] && node.std.id == 1 @debug "storing root node info for tightening" grad = similar(x) @@ -395,7 +420,12 @@ function Bonobo.evaluate_node!(tree::Bonobo.BnBTree, node::FrankWolfeNode) end end end +end +""" +Use the gradient of the root node to tighten the global bounds. +""" +function global_tightening(tree, node) # new incumbent: check global fixings if tree.root.options[:global_dual_tightening] && tree.root.updated_incumbent[] num_tightenings = 0 @@ -457,9 +487,12 @@ function Bonobo.evaluate_node!(tree::Bonobo.BnBTree, node::FrankWolfeNode) end node.global_tightenings = num_tightenings end +end - lower_bound = primal - dual_gap - +""" +Tighten the lower bound using strong convexity of the objective. +""" +function tightening_strong_convexity(tree, x, lower_bound) μ = tree.root.options[:strong_convexity] if μ > 0 @debug "Using strong convexity $μ" @@ -478,14 +511,7 @@ function Bonobo.evaluate_node!(tree::Bonobo.BnBTree, node::FrankWolfeNode) @assert num_fractional == 0 || strong_convexity_bound > lower_bound lower_bound = strong_convexity_bound end - - # Found an upper bound? - if is_integer_feasible(tree, x) - node.ub = primal - return lower_bound, primal - end - - return lower_bound, NaN + return lower_bound end diff --git a/src/problem.jl b/src/problem.jl index e2447c445..2a706ce7c 100644 --- a/src/problem.jl +++ b/src/problem.jl @@ -75,14 +75,6 @@ function is_integer_feasible(tree::Bonobo.BnBTree, x::AbstractVector) ) && indicator_feasible end -""" -Return the underlying optimizer -For better access and readability -""" -#function get_optimizer(tree::Bonobo.BnBTree) -# return tree.root.problem.lmo.lmo.o -#end - """ Checks if x is valid for all linear and variable bound constraints """ diff --git a/test/interface_test.jl b/test/interface_test.jl index bf51f9178..1375f1e8f 100644 --- a/test/interface_test.jl +++ b/test/interface_test.jl @@ -199,22 +199,16 @@ diffi = Random.rand(Bool, n) * 0.6 .+ 0.3 @. storage = x - diffi end - direction = rand(n) - lmo = build_model() - v = FrankWolfe.compute_extreme_point(lmo, direction) x_afw, _, result_afw = Boscia.solve(f, grad!, lmo, verbose=false, variant=Boscia.AwayFrankWolfe()) lmo = build_model() - v = FrankWolfe.compute_extreme_point(lmo, direction) x_blended, _, result_blended = Boscia.solve(f, grad!, lmo, verbose=false, variant=Boscia.Blended()) lmo = build_model() - v = FrankWolfe.compute_extreme_point(lmo, direction) x_bpcg, _, result_bpcg = Boscia.solve(f, grad!, lmo, verbose=false, variant=Boscia.BPCG()) lmo = build_model() - v = FrankWolfe.compute_extreme_point(lmo, direction) x_vfw, _, result_vfw = Boscia.solve(f, grad!, lmo, verbose=false, variant=Boscia.VanillaFrankWolfe()) @test isapprox(f(x_afw), f(result_afw[:raw_solution]), atol=1e-6, rtol=1e-3) @@ -251,20 +245,16 @@ end @. storage = x - diffi end - direction = rand(n) lmo = build_model() - v = FrankWolfe.compute_extreme_point(lmo, direction) line_search = FrankWolfe.Adaptive() x_adaptive, _, result_adaptive = Boscia.solve(f, grad!, lmo, verbose=false, line_search=line_search) lmo = build_model() - v = FrankWolfe.compute_extreme_point(lmo, direction) line_search = FrankWolfe.MonotonicStepSize() x_monotonic, _, result_monotonic = Boscia.solve(f, grad!, lmo, verbose=false, line_search=line_search, time_limit=120) lmo = build_model() - v = FrankWolfe.compute_extreme_point(lmo, direction) line_search = FrankWolfe.Agnostic() x_agnostic, _, result_agnostic = Boscia.solve(f, grad!, lmo, verbose=false, line_search=line_search, time_limit=120) @@ -275,4 +265,47 @@ end @test sum(isapprox.(x_adaptive, x_monotonic, atol=1e-6, rtol=1e-3)) == n @test sum(isapprox.(x_agnostic, x_monotonic, atol=1e-6, rtol=1e-3)) == n @test sum(isapprox.(x_adaptive, x_agnostic, atol=1e-6, rtol=1e-3)) == n -end \ No newline at end of file +end + +n = 20 +diffi = Random.rand(Bool, n) * 0.6 .+ 0.3 + +@testset "Lazification" begin + + function build_model() + o = SCIP.Optimizer() + MOI.set(o, MOI.Silent(), true) + MOI.empty!(o) + x = MOI.add_variables(o, n) + for xi in x + MOI.add_constraint(o, xi, MOI.GreaterThan(0.0)) + MOI.add_constraint(o, xi, MOI.LessThan(1.0)) + MOI.add_constraint(o, xi, MOI.ZeroOne()) # or MOI.Integer() + end + lmo = FrankWolfe.MathOptLMO(o) + return lmo + end + + function f(x) + return 0.5 * sum((x[i] - diffi[i])^2 for i in eachindex(x)) + end + function grad!(storage, x) + @. storage = x - diffi + end + + lmo = build_model() + x_lazy, _, result_lazy = Boscia.solve(f, grad!, lmo, verbose=false) + + lmo = build_model() + x_no, _, result_no = Boscia.solve(f, grad!, lmo, verbose=false, lazy=false) + + lmo = build_model() + x_mid, _, result_mid = Boscia.solve(f, grad!, lmo, verbose=false, lazy=true, lazy_tolerance=1.5) + + @test isapprox(f(x_lazy), f(result_lazy[:raw_solution]), atol=1e-6, rtol=1e-2) + @test isapprox(f(x_no), f(result_no[:raw_solution]), atol=1e-6, rtol=1e-2) + @test isapprox(f(x_mid), f(result_mid[:raw_solution]), atol=1e-6, rtol=1e-2) + @test sum(isapprox.(x_lazy, x_no, atol=1e-6, rtol=1e-2)) == n + @test sum(isapprox.(x_lazy, x_mid, atol=1e-6, rtol=1e-2)) == n +end +