diff --git a/docs/src/tutorial.md b/docs/src/tutorial.md index b10ec4c..60ed930 100644 --- a/docs/src/tutorial.md +++ b/docs/src/tutorial.md @@ -126,6 +126,24 @@ solver = IpoptSolver(nlp) stats = solve!(solver, nlp, callback = my_callback, print_level = 0) ``` +### JSO-style callback signature + +In addition to the Ipopt-style callback parameters, this package also accepts a simpler JSO-style callback used across JuliaSmoothOptimizers packages: + +- `cb(nlp, solver, stats) -> Bool` + +Where `nlp` is the `AbstractNLPModel` being solved, `solver` is the internal Ipopt problem/solver handle, and `stats` is the `GenericExecutionStats` object that will be updated during the solve. The callback should return `true` to continue the optimization or `false` to stop (this maps to Ipopt's user-requested stop). + +Example: + +```@example ex4 +function jso_cb(nlp, solver, stats) + println("iter=", stats.iter, " x=", solver.x) + return stats.iter < 5 +end +stats = ipopt(nlp, callback = jso_cb, print_level = 0) +``` + ### Custom stopping criteria Callbacks are particularly useful for implementing custom stopping criteria: diff --git a/src/NLPModelsIpopt.jl b/src/NLPModelsIpopt.jl index d7048a2..b03b64f 100644 --- a/src/NLPModelsIpopt.jl +++ b/src/NLPModelsIpopt.jl @@ -242,6 +242,7 @@ function SolverCore.solve!( nlp::AbstractNLPModel, stats::GenericExecutionStats; callback = (args...) -> true, + callback_style::Symbol = :auto, kwargs..., ) problem = solver.problem @@ -307,20 +308,60 @@ function SolverCore.solve!( ) set_residuals!(stats, inf_pr, inf_du) set_iter!(stats, Int(iter_count)) - return callback( - alg_mod, - iter_count, - obj_value, - inf_pr, - inf_du, - mu, - d_norm, - regularization_size, - alpha_du, - alpha_pr, - ls_trials, - args..., - ) + + # Helper to normalize callback return value + _to_bool(rv) = (rv === nothing) ? true : Bool(rv) + + if callback_style === :ipopt_full + return _to_bool(callback( + alg_mod, + iter_count, + obj_value, + inf_pr, + inf_du, + mu, + d_norm, + regularization_size, + alpha_du, + alpha_pr, + ls_trials, + args..., + )) + elseif callback_style === :ipopt_short + return _to_bool(callback(alg_mod, iter_count, obj_value)) + elseif callback_style === :jso + return _to_bool(callback(nlp, problem, stats)) + end + + try + return _to_bool(callback(nlp, problem, stats)) + catch err + if !(isa(err, MethodError) || isa(err, ArgumentError)) + rethrow(err) + end + end + + try + return _to_bool(callback( + alg_mod, + iter_count, + obj_value, + inf_pr, + inf_du, + mu, + d_norm, + regularization_size, + alpha_du, + alpha_pr, + ls_trials, + args..., + )) + catch err + if !(isa(err, MethodError) || isa(err, ArgumentError)) + rethrow(err) + end + end + return _to_bool(callback(alg_mod, iter_count, obj_value)) end SetIntermediateCallback(problem, solver_callback) diff --git a/test/runtests.jl b/test/runtests.jl index 89b960e..5c86d87 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -65,6 +65,80 @@ end @test stats.primal_feas ≈ 0.0 # @test stats.dual_feas ≈ 4.63 + @testset "JSO callback stops after 5 iterations" begin + function jso_callback(nlp_in, solver_in, stats_in) + @test typeof(nlp_in) <: AbstractNLPModel + @test hasproperty(stats_in, :iter) + return stats_in.iter < 5 + end + nlp = ADNLPModel(x -> (x[1] - 1)^2 + 100 * (x[2] - x[1]^2)^2, [-1.2; 1.0]) + stats = ipopt(nlp, tol = 1e-12, callback = jso_callback, print_level = 0) + @test stats.status == :user + @test stats.solver_specific[:internal_msg] == :User_Requested_Stop + @test stats.iter == 5 + end + + @testset "JSO callback can read problem and nlp" begin + function jso_cb_problem_nlp(nlp_in, solver_in, stats_in) + @test typeof(nlp_in) <: AbstractNLPModel + @test length(solver_in.x) == nlp_in.meta.nvar + if nlp_in.meta.ncon > 0 + @test length(solver_in.mult_g) == nlp_in.meta.ncon + end + return stats_in.iter < 3 + end + nlp = ADNLPModel(x -> (x[1] - 1)^2 + 100 * (x[2] - x[1]^2)^2, [-1.2; 1.0]) + stats = ipopt(nlp, callback = jso_cb_problem_nlp, print_level = 0) + @test stats.status == :user + @test stats.iter == 3 + end + + @testset "Short Ipopt-style 3-arg callback" begin + function short_cb(alg_mod, iter_count, obj_value) + @test isa(alg_mod, Integer) + @test iter_count >= 0 + @test isa(obj_value, Real) + return iter_count < 4 + end + nlp = ADNLPModel(x -> (x[1] - 1)^2 + 100 * (x[2] - x[1]^2)^2, [-1.2; 1.0]) + stats = ipopt(nlp, callback = short_cb, callback_style = :ipopt_short, print_level = 0) + @test stats.status == :user + @test stats.iter == 4 + end + + @testset "JSO callback can use solver and nlp" begin + used_solver = Ref(false) + used_nlp = Ref(false) + function jso_cb(nlp_in, solver_in, stats_in) + # Use solver.x (problem current iterate) + @test length(solver_in.x) == nlp_in.meta.nvar + used_solver[] = true + # Use nlp to compute objective at current x + _ = obj(nlp_in, solver_in.x) + used_nlp[] = true + return stats_in.iter < 3 + end + nlp = ADNLPModel(x -> (x[1] - 1)^2 + 100 * (x[2] - x[1]^2)^2, [-1.2; 1.0]) + stats = ipopt(nlp, callback = jso_cb, print_level = 0) + @test stats.status == :user + @test used_solver[] + @test used_nlp[] + @test stats.iter == 3 + end + + @testset "Ipopt-style short callback (3 args)" begin + function short_cb(alg_mod, iter_count, obj_value) + @test isa(alg_mod, Integer) + @test isa(iter_count, Integer) + @test isa(obj_value, Real) + return iter_count < 2 + end + nlp = ADNLPModel(x -> (x[1] - 1)^2 + 100 * (x[2] - x[1]^2)^2, [-1.2; 1.0]) + stats = ipopt(nlp, callback = short_cb, callback_style = :ipopt_short, print_level = 0) + @test stats.status == :user + @test stats.iter == 2 + end + nlp = ADNLPModel(x -> (x[1] - 1)^2 + 4 * (x[2] - 3)^2, zeros(2), x -> [sum(x) - 1.0], [0.0], [0.0]) stats = ipopt(nlp, print_level = 0)