diff --git a/docs/src/examples/Thermal_Generation_Dispatch_Example.jl b/docs/src/examples/Thermal_Generation_Dispatch_Example.jl index 1fe273aa3..b5846c0d5 100644 --- a/docs/src/examples/Thermal_Generation_Dispatch_Example.jl +++ b/docs/src/examples/Thermal_Generation_Dispatch_Example.jl @@ -158,7 +158,9 @@ Plots.plot( d, data_results[1, :, 1:(I+1)]; title = "Generation by Demand", - label = ["Thermal Generation 1" "Thermal Generation 2" "Thermal Generation 3" "Generation Deficit"], + label = [ + "Thermal Generation 1" "Thermal Generation 2" "Thermal Generation 3" "Generation Deficit" + ], xlabel = "Demand [unit]", ylabel = "Generation [unit]", ) @@ -168,7 +170,9 @@ Plots.plot( d, data_results[1, :, (I+2):(2*(I+1))]; title = "Sensitivity of Generation by Demand", - label = ["T. Gen. 1 Sensitivity" "T. Gen. 2 Sensitivity" "T. Gen. 3 Sensitivity" "Gen. Deficit Sensitivity"], + label = [ + "T. Gen. 1 Sensitivity" "T. Gen. 2 Sensitivity" "T. Gen. 3 Sensitivity" "Gen. Deficit Sensitivity" + ], xlabel = "Demand [unit]", ylabel = "Sensitivity [-]", ) @@ -179,7 +183,9 @@ Plots.plot( d, data_results[2, :, 1:(I+1)]; title = "Generation by Demand", - label = ["Thermal Generation 1" "Thermal Generation 2" "Thermal Generation 3" "Generation Deficit"], + label = [ + "Thermal Generation 1" "Thermal Generation 2" "Thermal Generation 3" "Generation Deficit" + ], xlabel = "Demand [unit]", ylabel = "Generation [unit]", ) @@ -189,7 +195,9 @@ Plots.plot( d, data_results[2, :, (I+2):(2*(I+1))]; title = "Sensitivity of Generation by Demand", - label = ["T. Gen. 1 Sensitivity" "T. Gen. 2 Sensitivity" "T. Gen. 3 Sensitivity" "Gen. Deficit Sensitivity"], + label = [ + "T. Gen. 1 Sensitivity" "T. Gen. 2 Sensitivity" "T. Gen. 3 Sensitivity" "Gen. Deficit Sensitivity" + ], xlabel = "Demand [unit]", ylabel = "Sensitivity [-]", ) diff --git a/docs/src/examples/Thermal_Generation_Dispatch_Example_new.jl b/docs/src/examples/Thermal_Generation_Dispatch_Example_new.jl index 391f3f2f3..0872c2bd9 100644 --- a/docs/src/examples/Thermal_Generation_Dispatch_Example_new.jl +++ b/docs/src/examples/Thermal_Generation_Dispatch_Example_new.jl @@ -154,7 +154,9 @@ Plots.plot( d, data_results[1, :, 1:(I+1)]; title = "Generation by Demand", - label = ["Thermal Generation 1" "Thermal Generation 2" "Thermal Generation 3" "Generation Deficit"], + label = [ + "Thermal Generation 1" "Thermal Generation 2" "Thermal Generation 3" "Generation Deficit" + ], xlabel = "Demand [unit]", ylabel = "Generation [unit]", ) @@ -164,7 +166,9 @@ Plots.plot( d, data_results[1, :, (I+2):(2*(I+1))]; title = "Sensitivity of Generation by Demand", - label = ["T. Gen. 1 Sensitivity" "T. Gen. 2 Sensitivity" "T. Gen. 3 Sensitivity" "Gen. Deficit Sensitivity"], + label = [ + "T. Gen. 1 Sensitivity" "T. Gen. 2 Sensitivity" "T. Gen. 3 Sensitivity" "Gen. Deficit Sensitivity" + ], xlabel = "Demand [unit]", ylabel = "Sensitivity [-]", ) @@ -175,7 +179,9 @@ Plots.plot( d, data_results[2, :, 1:(I+1)]; title = "Generation by Demand", - label = ["Thermal Generation 1" "Thermal Generation 2" "Thermal Generation 3" "Generation Deficit"], + label = [ + "Thermal Generation 1" "Thermal Generation 2" "Thermal Generation 3" "Generation Deficit" + ], xlabel = "Demand [unit]", ylabel = "Generation [unit]", ) @@ -185,7 +191,9 @@ Plots.plot( d, data_results[2, :, (I+2):(2*(I+1))]; title = "Sensitivity of Generation by Demand", - label = ["T. Gen. 1 Sensitivity" "T. Gen. 2 Sensitivity" "T. Gen. 3 Sensitivity" "Gen. Deficit Sensitivity"], + label = [ + "T. Gen. 1 Sensitivity" "T. Gen. 2 Sensitivity" "T. Gen. 3 Sensitivity" "Gen. Deficit Sensitivity" + ], xlabel = "Demand [unit]", ylabel = "Sensitivity [-]", ) diff --git a/docs/src/examples/autotuning-ridge.jl b/docs/src/examples/autotuning-ridge.jl index 17619aef1..28cb6661f 100644 --- a/docs/src/examples/autotuning-ridge.jl +++ b/docs/src/examples/autotuning-ridge.jl @@ -86,10 +86,7 @@ for α in αs ŷ_test = X_test * ŵ ŷ_train = X_train * ŵ push!(mse_test, LinearAlgebra.norm(ŷ_test - y_test)^2 / (2 * Ntest * D)) - push!( - mse_train, - LinearAlgebra.norm(ŷ_train - y_train)^2 / (2 * Ntrain * D), - ) + push!(mse_train, LinearAlgebra.norm(ŷ_train - y_train)^2 / (2 * Ntrain * D)) end # Visualize the Mean Score Error metric diff --git a/docs/src/examples/autotuning-ridge_new.jl b/docs/src/examples/autotuning-ridge_new.jl index b7fa24a76..87f05ca95 100644 --- a/docs/src/examples/autotuning-ridge_new.jl +++ b/docs/src/examples/autotuning-ridge_new.jl @@ -93,10 +93,7 @@ for α in αs ŷ_test = X_test * ŵ ŷ_train = X_train * ŵ push!(mse_test, LinearAlgebra.norm(ŷ_test - y_test)^2 / (2 * Ntest * D)) - push!( - mse_train, - LinearAlgebra.norm(ŷ_train - y_train)^2 / (2 * Ntrain * D), - ) + push!(mse_train, LinearAlgebra.norm(ŷ_train - y_train)^2 / (2 * Ntrain * D)) end # Visualize the Mean Score Error metric diff --git a/docs/src/examples/sensitivity-analysis-ridge.jl b/docs/src/examples/sensitivity-analysis-ridge.jl index a4d3c0465..5604d0b69 100644 --- a/docs/src/examples/sensitivity-analysis-ridge.jl +++ b/docs/src/examples/sensitivity-analysis-ridge.jl @@ -140,13 +140,7 @@ p = Plots.scatter( label = "", ) mi, ma = minimum(X), maximum(X) -Plots.plot!( - p, - [mi, ma], - [mi * ŵ + b̂, ma * ŵ + b̂]; - color = :blue, - label = "", -) +Plots.plot!(p, [mi, ma], [mi * ŵ + b̂, ma * ŵ + b̂]; color = :blue, label = "") Plots.title!("Regression slope sensitivity with respect to x") # @@ -159,13 +153,7 @@ p = Plots.scatter( label = "", ) mi, ma = minimum(X), maximum(X) -Plots.plot!( - p, - [mi, ma], - [mi * ŵ + b̂, ma * ŵ + b̂]; - color = :blue, - label = "", -) +Plots.plot!(p, [mi, ma], [mi * ŵ + b̂, ma * ŵ + b̂]; color = :blue, label = "") Plots.title!("Regression slope sensitivity with respect to y") # Note the points with less central `x` values induce a greater y sensitivity of the slope. diff --git a/docs/src/examples/sensitivity-analysis-ridge_new.jl b/docs/src/examples/sensitivity-analysis-ridge_new.jl index aa41f80a2..59d98fecb 100644 --- a/docs/src/examples/sensitivity-analysis-ridge_new.jl +++ b/docs/src/examples/sensitivity-analysis-ridge_new.jl @@ -140,13 +140,7 @@ p = Plots.scatter( label = "", ) mi, ma = minimum(X), maximum(X) -Plots.plot!( - p, - [mi, ma], - [mi * ŵ + b̂, ma * ŵ + b̂]; - color = :blue, - label = "", -) +Plots.plot!(p, [mi, ma], [mi * ŵ + b̂, ma * ŵ + b̂]; color = :blue, label = "") Plots.title!("Regression slope sensitivity with respect to x") # @@ -159,13 +153,7 @@ p = Plots.scatter( label = "", ) mi, ma = minimum(X), maximum(X) -Plots.plot!( - p, - [mi, ma], - [mi * ŵ + b̂, ma * ŵ + b̂]; - color = :blue, - label = "", -) +Plots.plot!(p, [mi, ma], [mi * ŵ + b̂, ma * ŵ + b̂]; color = :blue, label = "") Plots.title!("Regression slope sensitivity with respect to y") # Note the points with less central `x` values induce a greater y sensitivity of the slope. diff --git a/src/NonLinearProgram/NonLinearProgram.jl b/src/NonLinearProgram/NonLinearProgram.jl index 3df0ecaec..e315b0ac0 100644 --- a/src/NonLinearProgram/NonLinearProgram.jl +++ b/src/NonLinearProgram/NonLinearProgram.jl @@ -340,6 +340,33 @@ function MOI.set( return DiffOpt._enlarge_set(model.x, vi.value, value) end +# This model can be preserved across `MOI.optimize!` calls under +# `DiffOpt.PreserveDiffModel`: parameters are stored natively (no +# ParametricOptInterface substitution), so updating a value below is an exact +# assignment read live by the evaluator, and everything else in the model is either +# structural or refreshed by `DiffOpt._copy_dual`. +DiffOpt._diff_model_supports_preserve(::Model) = true + +function MOI.set( + model::Model, + ::MOI.ConstraintSet, + ci::MOI.ConstraintIndex{MOI.VariableIndex,MOI.Parameter{T}}, + set::MOI.Parameter{T}, +) where {T} + p = model.model.var2param[MOI.VariableIndex(ci.value)] + model.model.model[p] = set.value + return +end + +function MOI.get( + model::Model, + ::MOI.ConstraintSet, + ci::MOI.ConstraintIndex{MOI.VariableIndex,MOI.Parameter{T}}, +) where {T} + p = model.model.var2param[MOI.VariableIndex(ci.value)] + return MOI.Parameter{T}(model.model.model[p]) +end + function MOI.is_empty(model::Model) return model.cache === nothing end diff --git a/src/moi_wrapper.jl b/src/moi_wrapper.jl index 8d14a5f20..7288d36d3 100644 --- a/src/moi_wrapper.jl +++ b/src/moi_wrapper.jl @@ -97,9 +97,20 @@ mutable struct Optimizer{OT<:MOI.ModelLike} <: MOI.AbstractOptimizer # sensitivity input cache using MOI-like sparse format input_cache::InputCache + # opt-in [`PreserveDiffModel`](@ref): keep `diff` across `optimize!` when only + # `MOI.Parameter` values changed since it was instantiated + preserve_diff_model::Bool + function Optimizer(optimizer::OT) where {OT<:MOI.ModelLike} - output = - new{OT}(optimizer, Any[], nothing, nothing, nothing, InputCache()) + output = new{OT}( + optimizer, + Any[], + nothing, + nothing, + nothing, + InputCache(), + false, + ) add_all_model_constructors(output) add_default_factorization(output) return output @@ -195,6 +206,21 @@ function MOI.set( return MOI.set(model.optimizer, attr, ci, s) end +function MOI.set( + model::Optimizer, + attr::MOI.ConstraintSet, + ci::MOI.ConstraintIndex{MOI.VariableIndex,MOI.Parameter{T}}, + s::MOI.Parameter{T}, +) where {T} + # A parameter-value update does not invalidate the structure of `model.diff`, so + # under `PreserveDiffModel` the differentiation model is kept; the new value is + # written into it by `_refresh_parameters` at the next differentiation call. + if !_preserve_diff_active(model) + model.diff = nothing + end + return MOI.set(model.optimizer, attr, ci, s) +end + function MOI.get( model::Optimizer, attr::MOI.ConstraintFunction, @@ -513,9 +539,72 @@ function MOI.get( return MOI.get(model.optimizer, attr) end +# `MOI.optimize!` discards `model.diff` because a re-solve invalidates its +# point-dependent state: the primal/dual solution copied by `_copy_dual` and the +# `MOI.Parameter` values. When only parameter values changed, the structure inside +# `model.diff` is still valid — every structural edit on this `Optimizer` resets +# `model.diff` to `nothing`, so a non-`nothing` `diff` is structurally in sync with +# `model.optimizer` by construction. Under [`PreserveDiffModel`](@ref) we keep `diff` +# and refresh the point-dependent state instead: the solution is re-copied after each +# solve, and parameter values are re-written at the next differentiation call +# (`_refresh_parameters`). This only applies to differentiation models that natively +# support `MOI.Parameter` (no ParametricOptInterface layer inside `model.diff`), where +# a parameter update is an exact value assignment; models opt in through +# `_diff_model_supports_preserve`. + +_diff_model_supports_preserve(::Any) = false + +_unwrap_diff_model(diff) = diff + +function _unwrap_diff_model(diff::MOI.Bridges.LazyBridgeOptimizer) + return _unwrap_diff_model(diff.model) +end + +function _preserve_diff_active(model::Optimizer) + return model.preserve_diff_model && + model.diff !== nothing && + model.diff !== model.optimizer && + _diff_model_supports_preserve(_unwrap_diff_model(model.diff)) +end + +function _refresh_parameters(model::Optimizer) + for ci in MOI.get( + model.optimizer, + MOI.ListOfConstraintIndices{MOI.VariableIndex,MOI.Parameter{Float64}}(), + ) + set = MOI.get(model.optimizer, MOI.ConstraintSet(), ci) + MOI.set(model.diff, MOI.ConstraintSet(), model.index_map[ci], set) + # The parameter also occupies a slot of the primal point (`_copy_dual` copies + # `MOI.VariablePrimal` of every variable, and for a parameter that is its + # value), so refresh it there as well — this is what differentiation + # evaluates at. + MOI.set( + model.diff, + MOI.VariablePrimalStart(), + model.index_map[MOI.VariableIndex(ci.value)], + set.value, + ) + end + return +end + function MOI.optimize!(model::Optimizer) - model.diff = nothing + if !_preserve_diff_active(model) + model.diff = nothing + MOI.optimize!(model.optimizer) + return + end MOI.optimize!(model.optimizer) + if MOI.get(model.optimizer, MOI.TerminationStatus()) in + (MOI.LOCALLY_SOLVED, MOI.OPTIMAL) + # Reset the differentiation model to the state a fresh instantiation would be + # in: no input sensitivities, and the new solution copied over. Stale results + # cannot survive — the output caches are overwritten by every differentiation. + empty_input_sensitivities!(model.diff) + _copy_dual(model.diff, model.optimizer, model.index_map) + else + model.diff = nothing + end return end @@ -548,6 +637,45 @@ end MOI.get(model::Optimizer, ::ModelConstructor) = model.model_constructor +""" + PreserveDiffModel <: MOI.AbstractOptimizerAttribute + +An opt-in attribute (default `false`) to keep the differentiation model across +`MOI.optimize!` calls when only `MOI.Parameter` values changed since it was +instantiated. + +By default, `MOI.optimize!` discards the differentiation model, and the next +differentiation call re-instantiates it and re-copies the whole problem. When this +attribute is `true` and the only changes since the last instantiation are +`MOI.Parameter` values, the differentiation model is preserved instead: the new +parameter values are written into it at the next differentiation call and the new +primal/dual solution is copied after each solve, which produces the same results as the +full rebuild. Any structural change (adding or deleting variables or constraints, +modifying functions or non-parameter sets, changing the objective) still discards the +differentiation model. + +Only differentiation models that natively support parameters can be preserved, +currently [`DiffOpt.NonLinearProgram.Model`](@ref); for the others this attribute has +no effect. + +```julia +MOI.set(model, DiffOpt.PreserveDiffModel(), true) +``` +""" +struct PreserveDiffModel <: MOI.AbstractOptimizerAttribute end + +MOI.supports(::Optimizer, ::PreserveDiffModel) = true + +function MOI.set(model::Optimizer, ::PreserveDiffModel, value::Bool) + # Changing the policy conservatively resets the differentiation model, like + # `ModelConstructor` above. + model.diff = nothing + model.preserve_diff_model = value + return +end + +MOI.get(model::Optimizer, ::PreserveDiffModel) = model.preserve_diff_model + MOI.supports(::Optimizer, ::ReverseDifferentiate) = true function MOI.set(model::Optimizer, attr::ReverseDifferentiate, value) @@ -842,6 +970,12 @@ function _diff( model.index_map = MOI.copy_to(model.diff, model.optimizer) end _copy_dual(model.diff, model.optimizer, model.index_map) + elseif _preserve_diff_active(model) + # The preserved differentiation model may hold outdated parameter values (the + # only data a full re-instantiation would change: the solution was re-copied by + # `MOI.optimize!`). Re-write them from the source so differentiation sees + # exactly what a rebuild would have copied. + _refresh_parameters(model) end return model.diff end diff --git a/test/nlp_program.jl b/test/nlp_program.jl index a9ec9e1fa..7c4649b3d 100644 --- a/test/nlp_program.jl +++ b/test/nlp_program.jl @@ -1126,6 +1126,205 @@ function test_reverse_bounds_upper() @test isapprox(dp, 2.88888; atol = 1e-4) end +function _build_preserve_model(P) + model = DiffOpt.nonlinear_diff_model(Ipopt.Optimizer) + set_silent(model) + @variable(model, p[i=1:P] in MOI.Parameter(1.0 + 0.2 * i)) + @variable(model, x[1:P] >= 0) + @constraint(model, [i = 1:P], x[i] * (1 + 0.1 * sin(p[i])) >= p[i]) + @constraint(model, sum(x) >= 0.4 * P) + @objective( + model, + Min, + sum((x[i] - p[i])^2 for i in 1:P) + 0.01 * sum(x[i]^4 for i in 1:P) + ) + return model, p, x +end + +function _preserve_reverse_dp(model, p, x, seed) + P = length(p) + DiffOpt.empty_input_sensitivities!(model) + for i in 1:P + DiffOpt.set_reverse_variable(model, x[i], seed[i]) + end + DiffOpt.reverse_differentiate!(model) + return [ + MOI.get(model, DiffOpt.ReverseConstraintSet(), ParameterRef(p[i])).value + for i in 1:P + ] +end + +function test_preserve_diff_model_parameter_resolves() + # With PreserveDiffModel, a Parameter-only re-solve keeps the differentiation model + # (no re-instantiate + copy_to) and must produce results identical to the default + # full-rebuild path. + P = 4 + preserved, p1, x1 = _build_preserve_model(P) + MOI.set(preserved, DiffOpt.PreserveDiffModel(), true) + @test MOI.get(preserved, DiffOpt.PreserveDiffModel()) == true + stock, p2, x2 = _build_preserve_model(P) + @test MOI.get(stock, DiffOpt.PreserveDiffModel()) == false + diffopt = JuMP.unsafe_backend(preserved) + diff_handle = nothing + for (step, shift) in enumerate((0.0, 0.3, -0.2, 0.7)) + for i in 1:P + set_parameter_value(p1[i], 1.0 + 0.2 * i + shift) + set_parameter_value(p2[i], 1.0 + 0.2 * i + shift) + end + optimize!(preserved) + optimize!(stock) + @assert is_solved_and_feasible(preserved) + seed = [sin(0.7 * i + step) for i in 1:P] + dp_preserved = _preserve_reverse_dp(preserved, p1, x1, seed) + dp_stock = _preserve_reverse_dp(stock, p2, x2, seed) + @test dp_preserved == dp_stock + if step == 1 + diff_handle = diffopt.diff + @test diff_handle !== nothing + else + # the differentiation model must actually be the preserved one + @test diffopt.diff === diff_handle + end + end + # Forward mode is preserved too. + DiffOpt.empty_input_sensitivities!(preserved) + DiffOpt.set_forward_parameter(preserved, p1[1], 1.0) + DiffOpt.forward_differentiate!(preserved) + @test diffopt.diff === diff_handle + DiffOpt.empty_input_sensitivities!(stock) + DiffOpt.set_forward_parameter(stock, p2[1], 1.0) + DiffOpt.forward_differentiate!(stock) + dx_preserved = [ + MOI.get(preserved, DiffOpt.ForwardVariablePrimal(), x1[i]) for i in 1:P + ] + dx_stock = + [MOI.get(stock, DiffOpt.ForwardVariablePrimal(), x2[i]) for i in 1:P] + @test dx_preserved == dx_stock + return +end + +function test_preserve_diff_model_parameter_change_without_resolve() + # Changing a parameter and differentiating again without re-solving must match the + # default path (which rebuilds the model with the new parameter values and the old + # solution). + P = 3 + preserved, p1, x1 = _build_preserve_model(P) + MOI.set(preserved, DiffOpt.PreserveDiffModel(), true) + stock, p2, x2 = _build_preserve_model(P) + optimize!(preserved) + optimize!(stock) + seed = [cos(1.1 * i) for i in 1:P] + dp_preserved = _preserve_reverse_dp(preserved, p1, x1, seed) + dp_stock = _preserve_reverse_dp(stock, p2, x2, seed) + @test dp_preserved == dp_stock + for i in 1:P + set_parameter_value(p1[i], 1.1 + 0.2 * i) + set_parameter_value(p2[i], 1.1 + 0.2 * i) + end + dp_preserved = _preserve_reverse_dp(preserved, p1, x1, seed) + dp_stock = _preserve_reverse_dp(stock, p2, x2, seed) + @test dp_preserved == dp_stock + return +end + +function test_preserve_diff_model_structural_change_falls_back() + # A structural edit must discard the preserved differentiation model and fall back + # to the stock rebuild. + P = 3 + model, p, x = _build_preserve_model(P) + MOI.set(model, DiffOpt.PreserveDiffModel(), true) + optimize!(model) + seed = [1.0 for i in 1:P] + _preserve_reverse_dp(model, p, x, seed) + diffopt = JuMP.unsafe_backend(model) + diff_handle = diffopt.diff + @test diff_handle !== nothing + @constraint(model, sum(x) <= 10.0 * P) + optimize!(model) + dp = _preserve_reverse_dp(model, p, x, seed) + @test diffopt.diff !== diff_handle + # and the rebuilt model matches a fresh one built directly in the final state + fresh, pf, xf = _build_preserve_model(P) + @constraint(fresh, sum(xf) <= 10.0 * P) + optimize!(fresh) + dp_fresh = _preserve_reverse_dp(fresh, pf, xf, seed) + @test dp ≈ dp_fresh rtol = 1e-10 + return +end + +# A model feasible for small p and INFEASIBLE for large p via a Parameter-only change +# (x ∈ [0,1], x ≥ p is infeasible once p > 1): lets a preserved re-solve reach a +# non-differentiable status without any structural edit. +function _build_boundable_preserve_model() + model = DiffOpt.nonlinear_diff_model(Ipopt.Optimizer) + set_silent(model) + @variable(model, p in MOI.Parameter(0.5)) + @variable(model, 0.0 <= x <= 1.0) + @constraint(model, x >= p) + @objective(model, Min, (x - 0.3)^2) + return model, p, x +end + +function _reverse_dp_scalar(model, p, x, seed) + DiffOpt.empty_input_sensitivities!(model) + DiffOpt.set_reverse_variable(model, x, seed) + DiffOpt.reverse_differentiate!(model) + return MOI.get(model, DiffOpt.ReverseConstraintSet(), ParameterRef(p)).value +end + +function test_preserve_diff_model_nondifferentiable_solve_discards() + # Covers the MOI.optimize! fallback (moi_wrapper.jl): a preserved Parameter-only + # re-solve that ends non-differentiable must DISCARD the diff model, and the next + # feasible solve must rebuild gradients identical to a fresh model. + model, p, x = _build_boundable_preserve_model() + MOI.set(model, DiffOpt.PreserveDiffModel(), true) + optimize!(model) + @assert is_solved_and_feasible(model) + _reverse_dp_scalar(model, p, x, 1.0) # instantiate the preserved diff model + diffopt = JuMP.unsafe_backend(model) + @test diffopt.diff !== nothing + set_parameter_value(p, 2.0) # Parameter-only push into the infeasible region + optimize!(model) + @test !is_solved_and_feasible(model) + @test diffopt.diff === nothing # the non-differentiable fallback fired + set_parameter_value(p, 0.5) + optimize!(model) + @assert is_solved_and_feasible(model) + dp = _reverse_dp_scalar(model, p, x, 1.0) + fresh, pf, xf = _build_boundable_preserve_model() + optimize!(fresh) + dp_fresh = _reverse_dp_scalar(fresh, pf, xf, 1.0) + @test isapprox(dp, dp_fresh; rtol = 1e-10) + return +end + +function test_preserve_diff_model_parameter_set_getter_roundtrips() + # Covers MOI.get(::NonLinearProgram.Model, ::ConstraintSet, ::Parameter): after a + # preserved re-solve refreshes the value into the diff model, reading it back through + # the diff model's own getter must return that value. + model, p, x = _build_boundable_preserve_model() + MOI.set(model, DiffOpt.PreserveDiffModel(), true) + optimize!(model) + _reverse_dp_scalar(model, p, x, 1.0) # instantiate the preserved diff model + newval = 0.8 + set_parameter_value(p, newval) + optimize!(model) + _reverse_dp_scalar(model, p, x, 1.0) # _refresh_parameters writes newval into diff + diffopt = JuMP.unsafe_backend(model) + ci_src = only( + MOI.get( + diffopt.optimizer, + MOI.ListOfConstraintIndices{ + MOI.VariableIndex, + MOI.Parameter{Float64}, + }(), + ), + ) + got = MOI.get(diffopt.diff, MOI.ConstraintSet(), diffopt.index_map[ci_src]) + @test got.value == newval + return +end + end # module TestNLPProgram.runtests()