diff --git a/README.md b/README.md index be5b7b8..eb07ba9 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,18 @@ If your work uses Gencan, the suggested references are: with negative curvature directions and spectral projected gradients", Computing [Suppl] 15, pp. 49-60, 2001. +## Affiliation + +NLPModelsAlgencan.jl is developed and maintained by Paulo J. S. Silva (@pjssilva). It is not a product of the Tango Project, which develops +Algencan itself. For the Algencan developers, see [How to cite](#how-to-cite) +above. + +## Getting help + +Open an issue on the [issue +tracker](https://github.com/pjssilva/NLPModelsAlgencan.jl/issues) for bugs, +feature requests and other questions. + ## Status At this point this is beta software. It requires Julia 1.10 or later. @@ -115,6 +127,49 @@ Prefer `set_algencan_library!`: environment variables are invisible to precompilation, so changing one does not invalidate the cached module and can be silently ignored. +## Use with JuMP + +Algencan is an NLPModels solver, so JuMP reaches it through +[NLPModelsJuMP.jl](https://github.com/JuliaSmoothOptimizers/NLPModelsJuMP.jl), +the generic MathOptInterface wrapper for NLPModels solvers. Install it alongside +this package and pass `AlgencanSolver` as the `solver` attribute: + +```julia +using JuMP, NLPModelsJuMP, NLPModelsAlgencan + +model = Model(NLPModelsJuMP.Optimizer) +set_attribute(model, "solver", NLPModelsAlgencan.AlgencanSolver) + +@variable(model, 0 <= x[1:2] <= 5) +set_start_value.(x, 1.0) +@objective(model, Min, x[1] * x[2] + 5) +@constraint(model, x[1] + x[2] <= 5) +@constraint(model, x[1]^2 + x[2]^2 == 10) + +optimize!(model) +@show termination_status(model), objective_value(model), value.(x) +``` + +Solver options are set the same way, using the names from +[`docs/src/parameters.md`](docs/src/parameters.md): + +```julia +set_attribute(model, "epsfeas", 1.0e-10) +set_attribute(model, "epsopt", 1.0e-10) +``` + +Two things to know about this path: + +* `set_silent(model)` suppresses the iteration table but not Algencan's banner + and parameter listing, which Algencan 3.1.1 always writes to standard output. +* Constraint duals are not yet mapped onto MathOptInterface. Algencan does + compute the multipliers; they are available from the NLPModels interface via + `stats.multipliers`. + +To use Algencan directly on an `AbstractNLPModel`, without JuMP, see [First +steps](https://pjssilva.github.io/NLPModelsAlgencan.jl/dev/first_steps/) in the +documentation. + ## Contributing See the [developer diff --git a/src/NLPModelsAlgencan.jl b/src/NLPModelsAlgencan.jl index b0ed410..290569e 100644 --- a/src/NLPModelsAlgencan.jl +++ b/src/NLPModelsAlgencan.jl @@ -156,10 +156,10 @@ mutable struct AlgencanSolver <: AbstractOptimizationSolver :eostain => (atol)^1.5, :efacc => sqrt(atol), :eoacc => sqrt(atol), :outputfnm => "", :specfnm => specfnm) if verbose != 10 - solver.options["iterations_output_detail"] = verbose + solver.options[:iterations_output_detail] = verbose end if max_iter != Int(typemax(Int32)) - solver.options["outer_iterations_limit"] = max_iter + solver.options[:outer_iterations_limit] = max_iter end for k in keys(kwargs) solver.options[k] = kwargs[k] diff --git a/test/MOI_wrapper.jl b/test/MOI_wrapper.jl new file mode 100644 index 0000000..96f5cc8 --- /dev/null +++ b/test/MOI_wrapper.jl @@ -0,0 +1,116 @@ +# MathOptInterface conformance suite, run against Algencan. +# +# Algencan has no MOI wrapper of its own. It is an NLPModels solver, and JuMP +# reaches it through `NLPModelsJuMP.Optimizer`, the generic MOI wrapper for any +# `SolverCore.AbstractOptimizationSolver`: +# +# model = Model(NLPModelsJuMP.Optimizer) +# set_attribute(model, "solver", NLPModelsAlgencan.AlgencanSolver) +# +# That is the path exercised here. 541 of the 561 `MOI.Test` tests pass; the 20 +# exclusions below are grouped by cause and each group is explained. +module TestMOI + +using Test +import MathOptInterface as MOI +import NLPModelsJuMP +import NLPModelsAlgencan + +# Attributes that `NLPModelsJuMP.Optimizer` does not implement. Duals are the +# notable one: Algencan returns multipliers, but the wrapper does not yet map +# them onto MOI's constraint duals. +const EXCLUDED_ATTRIBUTES = Any[ + MOI.ConstraintBasisStatus, + MOI.VariableBasisStatus, + MOI.ConstraintName, + MOI.VariableName, + MOI.ObjectiveBound, + MOI.SolverVersion, + MOI.DualObjectiveValue, + MOI.ConstraintDual, +] + +const EXCLUDED_TESTS = [ + # --------------------------------------------------------------------- + # Second-derivative oracles are not negotiated with the user's evaluator. + # + # Algencan is a second-order method: it evaluates the Hessian of the + # Lagrangian. When the model arrives as a raw `MOI.NLPBlock`, + # `NLPModelsJuMP` requests `:Hess`/`:HessVec`/`:JacVec` from the + # user-supplied evaluator unconditionally, and the hand-written evaluators + # in `MOI.Test` deliberately advertise only a subset, so `MOI.initialize` + # raises `Unsupported feature ...`. + # + # This is a limitation of the shared wrapper, not of Algencan: models built + # through JuMP get a full `MOI.Nonlinear` evaluator and work fine (see the + # "JuMP interface test" testset in runtests.jl). Fixing it means having + # `NLPModelsJuMP` request only the features the evaluator reports and fall + # back to finite differences or a quasi-Newton Hessian otherwise. + r"^test_nonlinear_expression_multivariate_function$", # Unsupported feature Hess + r"^test_nonlinear_hs071$", # Unsupported feature HessVec + r"^test_nonlinear_hs071_NLPBlockDual$", # Unsupported feature HessVec + r"^test_nonlinear_hs071_hessian_vector_product$", # Unsupported feature Hess + r"^test_nonlinear_hs071_no_hessian$", # Unsupported feature Hess + r"^test_nonlinear_invalid$", # feat in features_available(d) + r"^test_nonlinear_objective$", # Unsupported feature JacVec + r"^test_nonlinear_objective_and_moi_objective_test$", # Unsupported feature JacVec + r"^test_nonlinear_without_objective$", # Unsupported feature JacVec + + # --------------------------------------------------------------------- + # No global infeasibility or unboundedness certificate. + # + # Algencan is a local augmented-Lagrangian method. When it stops at a + # stationary point of the infeasibility measure it reports + # `LOCALLY_INFEASIBLE`, which is the honest answer; these tests require the + # global `INFEASIBLE` / `DUAL_INFEASIBLE` / `INFEASIBLE_OR_UNBOUNDED`. + # Other local NLP solvers exclude the same tests. + r"^test_conic_NormInfinityCone_INFEASIBLE$", + r"^test_conic_NormOneCone_INFEASIBLE$", + r"^test_conic_linear_INFEASIBLE$", + r"^test_conic_linear_INFEASIBLE_2$", + r"^test_linear_DUAL_INFEASIBLE$", + r"^test_linear_INFEASIBLE$", + r"^test_linear_INFEASIBLE_2$", + + # --------------------------------------------------------------------- + # Converges to an infeasible stationary point from the starting point the + # test supplies: `LOCALLY_INFEASIBLE` where `LOCALLY_SOLVED` is expected. + # Again a property of local optimization, not a wrong answer. + r"^test_nonlinear_expression_hs109$", + r"^test_quadratic_SecondOrderCone_basic$", + r"^test_quadratic_nonconvex_constraint_basic$", + + # --------------------------------------------------------------------- + # `NLPModelsJuMP.Optimizer` reads the model attributes it knows and ignores + # the rest, so it does not raise `MOI.UnsupportedAttribute` for an unknown + # one. Wrapper-side; there is a FIXME to this effect in NLPModelsJuMP. + r"^test_model_copy_to_UnsupportedAttribute$", +] + +function test_runtests() + model = MOI.instantiate(NLPModelsJuMP.Optimizer, with_bridge_type=Float64) + MOI.set(model, MOI.RawOptimizerAttribute("solver"), NLPModelsAlgencan.AlgencanSolver) + MOI.set(model, MOI.Silent(), true) # comment out to see Algencan's own output + config = MOI.Test.Config( + atol=1.0e-2, + optimal_status=MOI.LOCALLY_SOLVED, + exclude=EXCLUDED_ATTRIBUTES, + ) + MOI.Test.runtests(model, config; exclude=EXCLUDED_TESTS) + return +end + +function runtests() + for name in names(@__MODULE__; all=true) + if startswith("$(name)", "test_") + @testset "$(name)" begin + getfield(@__MODULE__, name)() + end + end + end + return +end + +end # module + +TestMOI.runtests() diff --git a/test/Project.toml b/test/Project.toml index 5f1e672..a6de3b8 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -2,5 +2,6 @@ ADNLPModels = "54578032-b7ea-4c30-94aa-7cbd1cce6c9a" CUTEst = "1b53aba6-35b6-5f92-a507-53c67d53f819" JuMP = "4076af6c-e467-56ae-b986-b466b2749572" +MathOptInterface = "b8f27783-ece8-5eb3-8dc8-9495eed66fee" NLPModelsJuMP = "792afdf1-32c1-5681-94e0-d7bf7a5df49e" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" diff --git a/test/runtests.jl b/test/runtests.jl index 7187577..b5cda77 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -36,6 +36,24 @@ end @test stats.multipliers ≈ [3.277936962780388, 2.905444126051696, -7.747851002665478] rtol = 1.0e-4 end +# Regression test for the `verbose` and `max_iter` keyword arguments. +# +# `solver.options` is a `Dict{Symbol,Any}`, but both were stored under `String` +# keys, so any non-default value threw +# MethodError: Cannot `convert` an object of type String to an object of type Symbol +# Nothing exercised them, which is how it went unnoticed. +@testset "verbose and max_iter keyword arguments" begin + stats = algencan(hs12(); verbose=0) + @test stats.status == :first_order + @test stats.objective ≈ -30.0 rtol = 1.0e-6 + + solver = NLPModelsAlgencan.AlgencanSolver(hs12(); verbose=0) + @test solver.options[:iterations_output_detail] == 0 + + solver = (@test_logs (:warn,) (:warn,) NLPModelsAlgencan.AlgencanSolver(hs12(); max_iter=5)) + @test solver.options[:outer_iterations_limit] == 5 +end + # Regression test for Apple Silicon (aarch64). # # The Algencan callbacks used to be built with the closure form of `@cfunction`, @@ -220,3 +238,7 @@ end optimize!(model) @test value.(x) ≈ center end + +@testset "MathOptInterface conformance tests" begin + include("MOI_wrapper.jl") +end