Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
ADNLPModels = "54578032-b7ea-4c30-94aa-7cbd1cce6c9a"
Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
GR = "28b8d3ca-fb5f-59d9-8090-bfdbd6d07a71"
JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6"
JSOSolvers = "10dff2fc-5484-5881-a0e0-c90441020f8a"
NLPModels = "a4795742-8479-5a88-8948-cc11e1c8c1a6"
NLPModelsJuMP = "792afdf1-32c1-5681-94e0-d7bf7a5df49e"
Expand Down
32 changes: 32 additions & 0 deletions docs/RULES_hs85_hs89.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# OptimizationProblems.jl Rules — HS85 & HS89 Example

This table doubles as the specification for `docs/check_rules.jl`: every row with a
`✓` in "Automated" is checked by that script (`julia --project=docs docs/check_rules.jl
<problem>...`); everything else needs a human reviewer.

| Rule | Automated | HS85 | HS89 | How to Check |
|------|:---:|------|------|--------------|
| File Structure | ✓ | src/ADNLPProblems/hs85.jl, src/PureJuMP/hs85.jl, src/Meta/hs85.jl | src/ADNLPProblems/hs89.jl, src/PureJuMP/hs89.jl, src/Meta/hs89.jl | Check for all three files per problem |
| Header | ✓ | Source reference in the PureJuMP file header | Same | Parse header comments for a "Source" line |
| Mathematical Expressions | | All intermediates, constraints, objective explicit, match paper | Same | Compare expressions to paper and extracted JSON |
| Variable Bounds | ✓ | Explicit bounds and x0 in ADNLP (unless `:has_bounds => false`) | Same | Check for `lvar`/`uvar`/`x0` in the ADNLP file |
| Metadata | ✓ | `:name`, `:best_known_upper_bound`, and all of `get_hs85_{nvar,ncon,nlin,nnln,nequ,nineq}` in Meta file | Same | Parse Meta file for required fields and getters |
| Naming | ✓ (via File Structure) | hs85.jl in ADNLPProblems, hs85.jl in PureJuMP, hs85.jl in Meta | hs89.jl in ADNLPProblems, hs89.jl in PureJuMP, hs89.jl in Meta | Check file/function names against repository conventions |
| JuMP Interface Consistency | ✓ | PureJuMP file uses only the modern interface (`@objective`/`@constraint`/`@expression`), never mixed with `@NLobjective`/`@NLconstraint`/`@NLexpression` | Same | Grep for `@NL*` macros in the PureJuMP file — JuMP refuses to build a model mixing both, so this crashes `MathOptNLPModel` |
| AD-Generic Signatures | ✓ | ADNLPProblems objective/constraint closures are not typed to a concrete `x::AbstractVector{T}` | Same | Grep the ADNLP file for `::AbstractVector{T}` argument annotations — these silently block ForwardDiff |
| ADNLP/PureJuMP Compatibility | ✓ | `nvar`, `x0`, `ncon`, objective, `cons()`, `lcon`/`ucon`, and `lin` agree between the two formulations at `x0` | Same | Instantiate both models and compare (this is `test/test-utils.jl`'s `test_compatibility`, run early) |
| Allocation | ✓ (informational) | `cons_nln!` allocation count reported | Same | Run `cons_nln!` twice and check `@allocated` is 0 |
| Ipopt Solve | manual | Problem solves with Ipopt (PureJuMP) | Same | Run Ipopt solver and check for solution |
| Reviewer Markdown | ✓ (presence only) | Summary, PDF screenshot, extraction uncertainties, test results | Same | Generate reviewer markdown file |
| Duplication | | No duplicate (by name, structure, metadata) | Same | Check for similar names/metadata |
| Traceability | ✓ (via Header) | Origin clear and referenced in header/Meta | Same | Check Meta/header |
| Scalability | | Marked if scalable (not for hs85/hs89) | Same | Check Meta/implementation |
| Multiple Problems | | One PDF = one problem (for hs85/hs89) | Same | Allow multiple if needed |
| Uncertainty | | Warn if extraction unclear (not for hs85/hs89) | Same | Parse extraction JSON for uncertainties |

---

This table is tailored to HS85 and HS89, but the automated rows and
`docs/check_rules.jl` are written to run on any problem name.
They were used as-is to catch a JuMP-model-construction crash, a ForwardDiff-breaking type annotation, and a full constraint-by-constraint ADNLP/PureJuMP mismatch in this
PR before merge. Use this table as a checklist for similar problems and PRs.
171 changes: 171 additions & 0 deletions docs/check_rules.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
# Script to check OptimizationProblems.jl rules for HS85/HS89
using JSON

# All paths are resolved relative to the repository root (the parent of this file's
# directory), not the process's current working directory: this script is meant to
# be run both as `julia docs/check_rules.jl` from the repo root and `include()`d
# from docs/hs85_workflow.ipynb, whose cwd is docs/.
const REPO_ROOT = normpath(joinpath(@__DIR__, ".."))

# Static (text-based) checks: do not require loading the package, so they run even
# if the package fails to precompile.
function check_problem_static(problem::String)
result = Dict{String, Any}()
adnlp_path = joinpath(REPO_ROOT, "src", "ADNLPProblems", "$problem.jl")
jump_path = joinpath(REPO_ROOT, "src", "PureJuMP", "$problem.jl")
meta_path = joinpath(REPO_ROOT, "src", "Meta", "$problem.jl")

# Check that the required files exist for the problem: ADNLPProblems, PureJuMP, and Meta.
result["ADNLP"] = isfile(adnlp_path)
result["PureJuMP"] = isfile(jump_path)
result["Meta"] = isfile(meta_path)

adnlp_text = result["ADNLP"] ? read(adnlp_path, String) : ""
jump_text = result["PureJuMP"] ? read(jump_path, String) : ""
meta_text = result["Meta"] ? read(meta_path, String) : ""

# Check that the header comment of the PureJuMP file (the file that consistently
# carries one across the repository) has a "Source" line in the first 20 lines,
# so the origin of the problem is traceable.
jump_lines = result["PureJuMP"] ? readlines(jump_path) : String[]
result["Header"] =
any(occursin("Source", jump_lines[i]) for i = 1:min(length(jump_lines), 20))

# Check that the ADNLPProblems file defines an initial point (x0), and lower/upper
# bounds (lvar/uvar) unless the Meta file explicitly says the problem has none.
has_bounds_required = !occursin(":has_bounds => false", meta_text)
result["Bounds"] =
result["ADNLP"] &&
occursin("x0", adnlp_text) &&
(!has_bounds_required || (occursin("lvar", adnlp_text) && occursin("uvar", adnlp_text)))

# Check that the Meta file names the problem, records a best known upper bound, and
# defines every getter required by docs/src/contributing.md
# (get_<name>_nvar/ncon/nlin/nnln/nequ/nineq).
required_getters = ["nvar", "ncon", "nlin", "nnln", "nequ", "nineq"]
missing_getters = [g for g in required_getters if !occursin("get_$(problem)_$(g)(", meta_text)]
result["Metadata"] =
result["Meta"] &&
occursin(":name => \"$problem\"", meta_text) &&
occursin(":best_known_upper_bound", meta_text) &&
isempty(missing_getters)
if !isempty(missing_getters)
result["MissingGetters"] = missing_getters
end

# Check that the PureJuMP file does not mix the legacy nonlinear interface
# (@NLobjective/@NLconstraint/@NLexpression) with the modern one
# (@objective/@constraint/@expression): JuMP refuses to build a model that uses
# both, so MathOptNLPModel(model) throws instead of failing a specific test.
uses_legacy_macro =
occursin("@NLobjective", jump_text) ||
occursin("@NLconstraint", jump_text) ||
occursin("@NLexpression", jump_text)
result["JuMPInterfaceConsistent"] = result["PureJuMP"] && !uses_legacy_macro

# Check that the objective/constraint closures in ADNLPProblems are not typed to a
# concrete vector element type (e.g. `x::AbstractVector{T}`), which would prevent
# ForwardDiff from calling them with Dual-typed vectors and break grad()/jacobian().
result["ADNLPGenericSignatures"] =
result["ADNLP"] && !occursin(r"\([^)]*::AbstractVector\{T\}", adnlp_text)

# Reviewer markdown (optional, but useful for PR traceability).
result["ReviewerMarkdown"] =
isfile(joinpath(REPO_ROOT, "docs", "review_$problem.md")) ||
isfile(joinpath(REPO_ROOT, "docs", "review", "$problem.md"))

return result
end

# Whether OptimizationProblems and its test dependencies can be loaded, computed once.
const HAVE_DYNAMIC = try
@eval using OptimizationProblems, ADNLPModels, NLPModelsJuMP, NLPModels
true
catch err
@warn "Dynamic checks unavailable: could not load OptimizationProblems/ADNLPModels/NLPModelsJuMP/NLPModels ($err)"
false
end

# Dynamic checks: instantiate the actual models. These catch what static text checks
# cannot, e.g. a JuMP model that throws on construction, or an ADNLPProblems model
# whose objective/constraints numerically disagree with its PureJuMP sibling.
function check_problem_dynamic(problem::String)
out = Dict{String, Any}()
if !HAVE_DYNAMIC
out["Allocation"] = "skipped (package unavailable)"
out["Compatibility"] = "skipped (package unavailable)"
return out
end
prob = Symbol(problem)
ad_mod = OptimizationProblems.ADNLPProblems
jump_mod = OptimizationProblems.PureJuMP

if !isdefined(ad_mod, prob)
out["Allocation"] = "skipped (no ADNLPProblems.$problem)"
else
try
nlp = getfield(ad_mod, prob)(matrix_free = true)
if nlp.meta.nnln > 0
x = nlp.meta.x0
cx = similar(x, nlp.meta.nnln)
NLPModels.cons_nln!(nlp, x, cx)
nbytes = @allocated NLPModels.cons_nln!(nlp, x, cx)
out["Allocation"] = nbytes == 0 ? "pass (0 bytes)" : "warn ($(nbytes) bytes)"
else
out["Allocation"] = "n/a (no nonlinear constraints)"
end
catch err
out["Allocation"] = "error: $err"
end
end

if !isdefined(ad_mod, prob) || !isdefined(jump_mod, prob)
out["Compatibility"] = "skipped (missing ADNLPProblems or PureJuMP implementation)"
else
try
nlp_ad = getfield(ad_mod, prob)(matrix_free = true)
model = getfield(jump_mod, prob)()
nlp_jump = NLPModelsJuMP.MathOptNLPModel(model; name = problem)
x0 = nlp_ad.meta.x0

failures = String[]
nlp_jump.meta.nvar == nlp_ad.meta.nvar || push!(failures, "nvar mismatch")
nlp_jump.meta.x0 == nlp_ad.meta.x0 || push!(failures, "x0 mismatch")
nlp_jump.meta.ncon == nlp_ad.meta.ncon || push!(failures, "ncon mismatch")
isapprox(NLPModels.obj(nlp_ad, x0), NLPModels.obj(nlp_jump, x0), rtol = 1e-6) ||
push!(failures, "objective mismatch at x0")
if nlp_ad.meta.ncon > 0 && nlp_jump.meta.ncon == nlp_ad.meta.ncon
nlp_ad.meta.lcon ≈ nlp_jump.meta.lcon || push!(failures, "lcon mismatch")
nlp_ad.meta.ucon ≈ nlp_jump.meta.ucon || push!(failures, "ucon mismatch")
all(isapprox.(NLPModels.cons(nlp_ad, x0), NLPModels.cons(nlp_jump, x0), atol = 1e-6)) ||
push!(failures, "cons() mismatch at x0")
nlp_ad.meta.lin == nlp_jump.meta.lin || push!(failures, "lin (linear constraint indices) mismatch")
end
out["Compatibility"] = isempty(failures) ? "pass" : "fail: " * join(failures, "; ")
catch err
out["Compatibility"] = "error: $err"
end
end
return out
end

function check_problem(problem::String)
result = check_problem_static(problem)
merge!(result, check_problem_dynamic(problem))
# Solving with Ipopt is not run automatically; left for manual reviewer attention.
result["IpoptSolve"] = "manual"
return result
end

function main()
problems = isempty(ARGS) ? ["hs85", "hs89"] : ARGS
results = Dict()
for p in problems
results[p] = check_problem(p)
end
println(JSON.json(results, 2))
end

if abspath(PROGRAM_FILE) == @__FILE__
main()
end
66 changes: 66 additions & 0 deletions docs/extract_hs85.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
{
"problem": "hs85",
"source": "Hock and Schittkowski, Problem 85",
"variables": ["x1", "x2", "x3", "x4", "x5"],
"bounds": {
"lvar": [704.4148, 68.6, 0.0, 193.0, 25.0],
"uvar": [906.3855, 288.88, 134.75, 287.0966, 84.1988],
"x0": [900.0, 80.0, 115.0, 267.0, 27.0]
},
"objective": "-5.843e-7 * y17 + 1.17e-4 * y14 + 2.358e-5 * y13 + 1.502e-6 * y16 + 0.0321 * y12 + 0.004324 * y5 + 1e-4 * c15 / c16 + 37.48 * y2 / c12 + 0.1365",
"constraints": [
"1.5 * x2 - x3 >= 0",
"y1 - 213.1 >= 0",
"405.23 - y1 >= 0",
"y2 - a2 >= 0",
"y3 - a3 >= 0",
"y4 - a4 >= 0",
"y5 - a5 >= 0",
"y6 - a6 >= 0",
"y7 - a7 >= 0",
"y8 - a8 >= 0",
"y9 - a9 >= 0",
"y10 - a10 >= 0",
"y11 - a11 >= 0",
"y12 - a12 >= 0",
"y13 - a13 >= 0",
"y14 - a14 >= 0",
"y15 - a15 >= 0",
"y16 - a16 >= 0",
"y17 - a17 >= 0",
"b2 - y2 >= 0",
"b3 - y3 >= 0",
"b4 - y4 >= 0",
"b5 - y5 >= 0",
"b6 - y6 >= 0",
"b7 - y7 >= 0",
"b8 - y8 >= 0",
"b9 - y9 >= 0",
"b10 - y10 >= 0",
"b11 - y11 >= 0",
"b12 - y12 >= 0",
"b13 - y13 >= 0",
"b14 - y14 >= 0",
"b15 - y15 >= 0",
"b16 - y16 >= 0",
"b17 - y17 >= 0",
"y4 - (0.28 / 0.72) * y5 >= 0",
"21 - 3496 * y2 / c12 >= 0",
"62212 / c17 - 110.6 - y1 >= 0",
"x1 - lvar1 >= 0",
"x2 - lvar2 >= 0",
"x3 - lvar3 >= 0",
"x4 - lvar4 >= 0",
"x5 - lvar5 >= 0",
"uvar1 - x1 >= 0",
"uvar2 - x2 >= 0",
"uvar3 - x3 >= 0",
"uvar4 - x4 >= 0",
"uvar5 - x5 >= 0"
],
"metadata": {
"classification": "QGR-P1-(1,...,6)",
"implementation": "AI/JSO, 03/2026"
},
"uncertainties": []
}
21 changes: 21 additions & 0 deletions docs/extract_hs89.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"problem": "hs89",
"source": "Hock and Schittkowski, Problem 89",
"variables": ["x1", "x2", "x3"],
"bounds": {
"lvar": "none",
"uvar": "none",
"x0": [0.5, -0.5, 0.5]
},
"objective": "sum_{j=1}^{30} A_j * rho_j(x)\nwhere rho_j(x) = - (exp(-mu_j^2 * r) + 2*exp(-mu_j^2*(x2^2 + x3^2)) + 2*exp(-mu_j^2*x3^2) + 1) / mu_j^2\nr = x1^2 + x2^2 + x3^2\nA_j = 2*sin(mu_j)/(mu_j + sin(mu_j)*cos(mu_j))\nmu_j: first 30 positive roots of tan(mu) = mu",
"constraints": [
"c(x) = termA + termB - 2/15 = 0",
"termA = sum_{j=1}^{30} A_j^2 * rho_j(x)^2 * (sin(2*mu_j)/(2*mu_j) + 1)/2",
"termB = sum_{i=1}^{29} sum_{j=i+1}^{30} A_i * A_j * rho_i(x) * rho_j(x) * (sin(mu_i+mu_j)/(mu_i+mu_j) + sin(mu_i-mu_j)/(mu_i-mu_j))"
],
"metadata": {
"classification": "QGR-P1-(1,...,6)",
"implementation": "AI/JSO, 03/2026"
},
"uncertainties": []
}
Loading