From 3e4957e01724ac2db6088b651ce50dd08e3f39c4 Mon Sep 17 00:00:00 2001 From: Arnav Kapoor Date: Sun, 12 Oct 2025 06:23:20 +0530 Subject: [PATCH 1/5] Add JSO-style callback support, tests, and docs --- docs/src/tutorial.md | 18 ++++++++++++++++++ src/NLPModelsIpopt.jl | 37 +++++++++++++++++++++++-------------- test/runtests.jl | 12 ++++++++++++ 3 files changed, 53 insertions(+), 14 deletions(-) diff --git a/docs/src/tutorial.md b/docs/src/tutorial.md index b10ec4c..ffe8c30 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 ex5 +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..9e6b7eb 100644 --- a/src/NLPModelsIpopt.jl +++ b/src/NLPModelsIpopt.jl @@ -307,20 +307,29 @@ 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..., - ) + try + return callback(nlp, problem, stats) + catch err + # If the signature doesn't match, fall back to the Ipopt-style callback call. + if isa(err, MethodError) || isa(err, ArgumentError) + return callback( + alg_mod, + iter_count, + obj_value, + inf_pr, + inf_du, + mu, + d_norm, + regularization_size, + alpha_du, + alpha_pr, + ls_trials, + args..., + ) + else + rethrow(err) + end + end end SetIntermediateCallback(problem, solver_callback) diff --git a/test/runtests.jl b/test/runtests.jl index 89b960e..6c172e4 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -65,6 +65,18 @@ end @test stats.primal_feas ≈ 0.0 # @test stats.dual_feas ≈ 4.63 + # Test JSO-style callback signature: (nlp, solver, stats) -> Bool + function jso_callback(nlp_in, solver_in, stats_in) + @test typeof(nlp_in) <: AbstractNLPModel + @test hasproperty(stats_in, :iter) + return false + 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 >= 0 + 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) From 87a1c7cb7ed46b928575f8468942c69a95502c35 Mon Sep 17 00:00:00 2001 From: Arnav Kapoor Date: Thu, 16 Oct 2025 00:25:49 +0530 Subject: [PATCH 2/5] Restructuring runtests --- test/runtests.jl | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/runtests.jl b/test/runtests.jl index 6c172e4..b406740 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -69,13 +69,15 @@ end function jso_callback(nlp_in, solver_in, stats_in) @test typeof(nlp_in) <: AbstractNLPModel @test hasproperty(stats_in, :iter) - return false + # stop after 5 iterations + println("iter=", stats_in.iter, " x=", solver_in.x) + 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 >= 0 + @test stats.iter == 5 nlp = ADNLPModel(x -> (x[1] - 1)^2 + 4 * (x[2] - 3)^2, zeros(2), x -> [sum(x) - 1.0], [0.0], [0.0]) From d4c56b8eb4c70b58caf107857f84743479448b07 Mon Sep 17 00:00:00 2001 From: Arnav Kapoor Date: Sat, 18 Oct 2025 17:33:53 +0530 Subject: [PATCH 3/5] Update docs/src/tutorial.md Co-authored-by: Tangi Migot --- docs/src/tutorial.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/tutorial.md b/docs/src/tutorial.md index ffe8c30..60ed930 100644 --- a/docs/src/tutorial.md +++ b/docs/src/tutorial.md @@ -136,7 +136,7 @@ Where `nlp` is the `AbstractNLPModel` being solved, `solver` is the internal Ipo Example: -```@example ex5 +```@example ex4 function jso_cb(nlp, solver, stats) println("iter=", stats.iter, " x=", solver.x) return stats.iter < 5 From bc1733b8203426cb84ca4207a5fef4272c25b077 Mon Sep 17 00:00:00 2001 From: Arnav Kapoor Date: Sun, 19 Oct 2025 00:42:07 +0530 Subject: [PATCH 4/5] Support JSO callback signature and Ipopt short/full signatures with explicit callback_style - Introduce `callback_style` keyword with :auto (default), :jso, :ipopt_short, :ipopt_full - In :auto, prefer JSO-style, then full Ipopt, then short Ipopt 3-arg - Normalize callback return to Bool; `nothing` => true - Update tests: * Add JSO callback tests (stop at 5; problem/nlp access; use solver/nlp) * Add short Ipopt 3-arg tests and force with callback_style=:ipopt_short * Fix obj_value type assertion - All tests now pass locally --- src/NLPModelsIpopt.jl | 72 +++++++++++++++++++++++++++---------- test/runtests.jl | 84 ++++++++++++++++++++++++++++++++++++------- 2 files changed, 126 insertions(+), 30 deletions(-) diff --git a/src/NLPModelsIpopt.jl b/src/NLPModelsIpopt.jl index 9e6b7eb..fe7820d 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,29 +308,64 @@ function SolverCore.solve!( ) set_residuals!(stats, inf_pr, inf_du) set_iter!(stats, Int(iter_count)) + + # Helper to normalize callback return value + _to_bool(rv) = (rv === nothing) ? true : Bool(rv) + + # Explicit styles take precedence when set + 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 + + # Auto-detect mode: prefer JSO-style first to align with JSO ecosystem expectations. + # 1) JSO-style (nlp, solver, stats) try - return callback(nlp, problem, stats) + return _to_bool(callback(nlp, problem, stats)) catch err - # If the signature doesn't match, fall back to the Ipopt-style callback call. - if isa(err, MethodError) || isa(err, ArgumentError) - return callback( - alg_mod, - iter_count, - obj_value, - inf_pr, - inf_du, - mu, - d_norm, - regularization_size, - alpha_du, - alpha_pr, - ls_trials, - args..., - ) - else + if !(isa(err, MethodError) || isa(err, ArgumentError)) + rethrow(err) + end + end + # 2) Full Ipopt-style (least likely to match accidentally) + 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 + # 3) Short Ipopt-style (3-arg) + 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 b406740..5c86d87 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -65,19 +65,79 @@ end @test stats.primal_feas ≈ 0.0 # @test stats.dual_feas ≈ 4.63 - # Test JSO-style callback signature: (nlp, solver, stats) -> Bool - function jso_callback(nlp_in, solver_in, stats_in) - @test typeof(nlp_in) <: AbstractNLPModel - @test hasproperty(stats_in, :iter) - # stop after 5 iterations - println("iter=", stats_in.iter, " x=", solver_in.x) - return stats_in.iter < 5 + @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 + 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 nlp = ADNLPModel(x -> (x[1] - 1)^2 + 4 * (x[2] - 3)^2, zeros(2), x -> [sum(x) - 1.0], [0.0], [0.0]) From 501498f9855d2bce3ffd57931ac0918c31bc6f3e Mon Sep 17 00:00:00 2001 From: Arnav Kapoor Date: Sun, 19 Oct 2025 01:09:00 +0530 Subject: [PATCH 5/5] comment changes --- src/NLPModelsIpopt.jl | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/NLPModelsIpopt.jl b/src/NLPModelsIpopt.jl index fe7820d..b03b64f 100644 --- a/src/NLPModelsIpopt.jl +++ b/src/NLPModelsIpopt.jl @@ -312,7 +312,6 @@ function SolverCore.solve!( # Helper to normalize callback return value _to_bool(rv) = (rv === nothing) ? true : Bool(rv) - # Explicit styles take precedence when set if callback_style === :ipopt_full return _to_bool(callback( alg_mod, @@ -334,8 +333,6 @@ function SolverCore.solve!( return _to_bool(callback(nlp, problem, stats)) end - # Auto-detect mode: prefer JSO-style first to align with JSO ecosystem expectations. - # 1) JSO-style (nlp, solver, stats) try return _to_bool(callback(nlp, problem, stats)) catch err @@ -343,7 +340,7 @@ function SolverCore.solve!( rethrow(err) end end - # 2) Full Ipopt-style (least likely to match accidentally) + try return _to_bool(callback( alg_mod, @@ -364,7 +361,6 @@ function SolverCore.solve!( rethrow(err) end end - # 3) Short Ipopt-style (3-arg) return _to_bool(callback(alg_mod, iter_count, obj_value)) end SetIntermediateCallback(problem, solver_callback)