From 2580e6a94aa655b450a1d4851a50ff66b20dc551 Mon Sep 17 00:00:00 2001 From: joaquimg Date: Sun, 15 Feb 2026 15:49:52 -0300 Subject: [PATCH 01/14] Add proper management of parameter times quadratic terms --- docs/make.jl | 1 + docs/src/Examples/progressive_hedging.md | 153 ++ docs/src/manual.md | 54 + plan.md | 2216 ++++++++++++++++++++++ src/MOI_wrapper.jl | 3 +- src/ParametricOptInterface.jl | 12 +- src/cubic_objective.jl | 193 ++ src/cubic_parser.jl | 514 +++++ src/cubic_types.jl | 97 + src/parametric_cubic_function.jl | 475 +++++ src/update_parameters.jl | 1 + test/cubic_tests.jl | 489 +++++ test/moi_tests.jl | 48 +- test/runtests.jl | 1 + 14 files changed, 4242 insertions(+), 15 deletions(-) create mode 100644 docs/src/Examples/progressive_hedging.md create mode 100644 plan.md create mode 100644 src/cubic_objective.jl create mode 100644 src/cubic_parser.jl create mode 100644 src/cubic_types.jl create mode 100644 src/parametric_cubic_function.jl create mode 100644 test/cubic_tests.jl diff --git a/docs/make.jl b/docs/make.jl index 894f8f19..667e90f9 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -25,6 +25,7 @@ makedocs( "Examples/example.md", "Examples/benders.md", "Examples/markowitz.md", + "Examples/progressive_hedging.md", ], "reference.md", ], diff --git a/docs/src/Examples/progressive_hedging.md b/docs/src/Examples/progressive_hedging.md new file mode 100644 index 00000000..ab87e57c --- /dev/null +++ b/docs/src/Examples/progressive_hedging.md @@ -0,0 +1,153 @@ +# Progressive Hedging + +Progressive Hedging (PH) is a popular decomposition algorithm for stochastic programming. It decomposes a stochastic problem into scenario subproblems that are solved iteratively, with penalty terms driving solutions toward consensus. POI is well-suited for PH because the penalty parameters and target values can be updated efficiently without rebuilding the model. + +## Background + +In progressive hedging, each scenario subproblem includes a quadratic penalty term: + +``` +minimize: f_s(x) + (ρ/2) * ||x - x̄||² + w' * x +``` + +where: +- `f_s(x)` is the original scenario objective +- `ρ` is the penalty parameter +- `x̄` is the current consensus (average) solution +- `w` is the dual price (Lagrangian multiplier) + +The penalty term `(ρ/2) * (x - x̄)²` expands to `(ρ/2) * x² - ρ * x̄ * x + (ρ/2) * x̄²`. Using POI parameters for `ρ` and `x̄` allows efficient updates between PH iterations. + +## Simple Example: Two-Stage Stochastic Program + +Consider a simple production planning problem with two scenarios: + +```julia +using JuMP, HiGHS +import ParametricOptInterface as POI + +# Problem data +scenarios = [ + (demand = 100, probability = 0.4), + (demand = 150, probability = 0.6), +] +production_cost = 10 +penalty_cost = 25 # unmet demand penalty + +# PH parameters +ρ = 1.0 # penalty parameter +max_iterations = 20 +tolerance = 1e-4 + +# Build scenario subproblems with POI +function build_subproblem(scenario, ρ_init, x_bar_init, w_init) + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + # First-stage variable (production quantity to decide before demand is known) + @variable(model, x >= 0) + + # Second-stage variable (unmet demand) + @variable(model, y >= 0) + + # Parameters for PH updates + @variable(model, ρ_param in MOI.Parameter(ρ_init)) + @variable(model, x_bar in MOI.Parameter(x_bar_init)) + @variable(model, w in MOI.Parameter(w_init)) + + # Demand satisfaction constraint + @constraint(model, x + y >= scenario.demand) + + # Objective: original cost + PH penalty terms + @objective(model, Min, + production_cost * x + penalty_cost * y + # original objective + w * x + # dual price term + 0.5 * ρ_param * (x - x_bar)^2 # quadratic penalty + ) + + return model, x, ρ_param, x_bar, w +end + +# Initialize subproblems +subproblems = [] +x_vars = [] +ρ_params = [] +x_bar_params = [] +w_params = [] + +for s in scenarios + model, x, ρ_p, x_bar_p, w_p = build_subproblem(s, ρ, 0.0, 0.0) + push!(subproblems, model) + push!(x_vars, x) + push!(ρ_params, ρ_p) + push!(x_bar_params, x_bar_p) + push!(w_params, w_p) +end + +# Progressive Hedging iterations +x_bar = 0.0 +w_values = zeros(length(scenarios)) + +for iter in 1:max_iterations + # Solve all subproblems + x_values = Float64[] + for (i, model) in enumerate(subproblems) + optimize!(model) + push!(x_values, value(x_vars[i])) + end + + # Compute new consensus (probability-weighted average) + x_bar_new = sum(scenarios[i].probability * x_values[i] for i in eachindex(scenarios)) + + # Check convergence + max_deviation = maximum(abs.(x_values .- x_bar_new)) + println("Iteration $iter: x̄ = $(round(x_bar_new, digits=2)), max deviation = $(round(max_deviation, digits=4))") + + if max_deviation < tolerance + println("Converged!") + break + end + + # Update dual prices + for i in eachindex(scenarios) + w_values[i] += ρ * (x_values[i] - x_bar_new) + end + + # Update parameters for next iteration (this is where POI shines!) + # Parameters are automatically updated when optimize! is called + for i in eachindex(scenarios) + set_parameter_value(x_bar_params[i], x_bar_new) + set_parameter_value(w_params[i], w_values[i]) + end + + x_bar = x_bar_new +end + +println("\nFinal consensus solution: x̄ = $(round(x_bar, digits=2))") +``` + +## Why POI for Progressive Hedging? + +1. **Efficient updates**: Parameters `x̄` and `w` change every iteration. Without POI, you would need to rebuild the model or modify constraints manually. + +2. **Quadratic penalties**: The term `ρ * x̄ * x` is a parameter times a variable, which POI handles natively. This creates the cross-term needed for the quadratic penalty. + +3. **Warm starting**: Since the model structure is preserved, solvers can warm-start from the previous solution, significantly speeding up convergence. + +4. **Clean separation**: The scenario-specific data stays fixed while PH-specific parameters are clearly identified and updated. + +## Advanced: Adaptive Penalty Parameter + +You can also make the penalty parameter `ρ` adaptive: + +```julia +# Increase penalty if not converging fast enough +if iter > 5 && max_deviation > prev_deviation * 0.95 + ρ *= 1.5 + for i in eachindex(scenarios) + set_parameter_value(ρ_params[i], ρ) + end +end +``` + +This showcases POI's flexibility: both the consensus target and the penalty strength can be parameters that evolve during the algorithm. diff --git a/docs/src/manual.md b/docs/src/manual.md index 41f8ce6f..8e3fe89b 100644 --- a/docs/src/manual.md +++ b/docs/src/manual.md @@ -40,6 +40,7 @@ a function that is not listed here, it will return an unsupported error. |:-------| | `ScalarAffineFunction` | | `ScalarQuadraticFunction` | +| `ScalarNonlinearFunction` (cubic polynomials only) | ### Declare a Optimizer @@ -86,3 +87,56 @@ One can do so by getting the `MOI.ConstraintDual` attribute of the parameter's ` ```julia MOI.get(optimizer, POI.ParameterDual(), y) ``` + +### Parameters multiplying quadratic terms + +POI supports parameters that multiply quadratic variable terms in objectives **only**. This creates cubic polynomial expressions of the form `c * p * x * y` where `c` is a number, `p` is a parameter and `x`, `y` are variables. After parameter substitution, these become standard quadratic terms that solvers can handle. + + +#### Attention + +- Maximum degree is 3 (cubic) +- At least one factor in each cubic term must be a parameter +- Pure cubic variable terms (e.g., `x * y * z` with no parameters) are **not supported** + +#### Example using MOI + +# TODO + +#### Example using JuMP + +```julia +using JuMP, Ipopt +import ParametricOptInterface as POI + +# Create model with POI optimizer +model = Model(() -> POI.Optimizer(Ipopt.Optimizer())) +set_silent(model) + +# Define variables and parameter +@variable(model, 0 <= x <= 10) +@variable(model, p in MOI.Parameter(2.0)) + +# Set cubic objective: p * x * y +@objective(model, Min, p * x ^ 2 - 3x) + +# p (x - 3) * (x - 2) + +p x^2 - c x + +2px - c + +x = c / 2p + +# Solve +optimize!(model) + +@show value(x) # == 3 / (2 * p) + +# Update parameter and re-solve +# Parameters are automatically updated when optimize! is called +set_parameter_value(p, 3.0) +optimize!(model) + +@show value(x) # == 3 / (2 * p) +``` diff --git a/plan.md b/plan.md new file mode 100644 index 00000000..5c53a08c --- /dev/null +++ b/plan.md @@ -0,0 +1,2216 @@ +# Implementation Plan: Parametric Cubic Functions in POI + +Note: do not change this plan during implementation. If changes are needed, create a new version of the plan called plan_v2.md to track changes to the plan. + +## Overview + +This document details the implementation plan for supporting parameters multiplying quadratic terms in ParametricOptInterface (POI). + +### The Problem + +We want to support expressions of the form: + +``` +c * x * y * p +``` + +Where: +- `c` = coefficient (constant) +- `x`, `y` = decision variables +- `p` = parameter + +This creates a **cubic function** since a parameter behaves like a decision variable in the expression structure. However, MOI does not have a native cubic function type. + +### Current State + +POI currently supports: +- `ParametricAffineFunction`: handles `c + c*x + c*p + c*x*p` +- `ParametricQuadraticFunction`: handles `c + c*x + c*p + c*x*y + c*p*x + c*p*q` + +The missing pieces are: +- `c*x*y*p` - one parameter multiplying a quadratic term (becomes quadratic after substitution) +- `c*p1*p2*x` - two parameters multiplying a variable (becomes affine after substitution) + +### Approach + +Since MOI lacks cubic functions, users must express `c*x*y*p` as a `ScalarNonlinearFunction`. We will: +1. Parse `ScalarNonlinearFunction` to detect if it represents exactly a cubic polynomial +2. Store the parsed data in a new `ParametricCubicFunction` structure +3. Support this **only in objectives** (not constraints) + +--- + +## Part 1: Understanding MOI.ScalarNonlinearFunction + +### Structure + +```julia +MOI.ScalarNonlinearFunction( + head::Symbol, # Operator (:+, :*, :^, etc.) + args::Vector{Any} # Operands (constants, variables, nested expressions) +) +``` + +### Expression Tree Examples + +**Example 1**: `2 * x * y * p` could be represented as: +```julia +ScalarNonlinearFunction(:*, Any[2.0, x, y, p]) +``` + +**Example 2**: `x * y * p + 3 * x * z * q` (sum of cubic terms): +```julia +ScalarNonlinearFunction(:+, Any[ + ScalarNonlinearFunction(:*, Any[x, y, p]), + ScalarNonlinearFunction(:*, Any[3.0, x, z, q]) +]) +``` + +### Parsing Strategy + +We need to recursively traverse the expression tree and: +1. Identify if all operations are `+`, `*`, `-`, or `^` with integer exponents +2. Expand products into monomials +3. Classify each monomial by degree in variables and parameters +4. Reject if any monomial exceeds cubic degree + +--- + +## Part 1.5: Parsing Corner Cases (Critical) + +The parser must handle various equivalent representations of the same mathematical expression. This section documents the corner cases that **must** be tested and handled correctly. + +### 1.5.1 Mixed Parenthesis Orderings + +The same expression `c * x * y * p` can have many different tree structures: + +**Flat multiplication:** +```julia +# 2 * x * y * p as a single :* node with 4 children +ScalarNonlinearFunction(:*, Any[2.0, x, y, p]) +``` + +**Left-associative (typical from parsing `((2*x)*y)*p`):** +```julia +ScalarNonlinearFunction(:*, Any[ + ScalarNonlinearFunction(:*, Any[ + ScalarNonlinearFunction(:*, Any[2.0, x]), + y + ]), + p +]) +``` + +**Right-associative (`2*(x*(y*p))`):** +```julia +ScalarNonlinearFunction(:*, Any[ + 2.0, + ScalarNonlinearFunction(:*, Any[ + x, + ScalarNonlinearFunction(:*, Any[y, p]) + ]) +]) +``` + +**Mixed groupings (`(2*x) * (y*p)`):** +```julia +ScalarNonlinearFunction(:*, Any[ + ScalarNonlinearFunction(:*, Any[2.0, x]), + ScalarNonlinearFunction(:*, Any[y, p]) +]) +``` + +**Coefficient grouped with parameter (`(2*p) * (x*y)`):** +```julia +ScalarNonlinearFunction(:*, Any[ + ScalarNonlinearFunction(:*, Any[2.0, p]), + ScalarNonlinearFunction(:*, Any[x, y]) +]) +``` + +**Parser requirement**: All of the above must produce the same result: `_ScalarCubicTerm(2.0, p, x, y)` with type `:pvv`. + +### 1.5.2 Squared Variables: `p * c * x^2` + +This is a valid cubic term where `variable_1 == variable_2`. Representations: + +**Using power operator:** +```julia +# 3 * p * x^2 +ScalarNonlinearFunction(:*, Any[ + 3.0, + p, + ScalarNonlinearFunction(:^, Any[x, 2]) +]) +``` + +**Using explicit multiplication:** +```julia +# 3 * p * x * x +ScalarNonlinearFunction(:*, Any[3.0, p, x, x]) +``` + +**Nested with power:** +```julia +# (3*p) * x^2 +ScalarNonlinearFunction(:*, Any[ + ScalarNonlinearFunction(:*, Any[3.0, p]), + ScalarNonlinearFunction(:^, Any[x, 2]) +]) +``` + +**Parser requirement**: All must produce `_ScalarCubicTerm(3.0, p, x, x)` with type `:pvv`. + +### 1.5.3 Power Operator Variations + +**x^2 (valid quadratic):** +```julia +ScalarNonlinearFunction(:^, Any[x, 2]) +# Should expand to: x * x (two variables) +``` + +**p^2 (valid quadratic in parameters):** +```julia +ScalarNonlinearFunction(:^, Any[p, 2]) +# Should expand to: p * p (two parameters) +``` + +**(x*y)^2 = x^2 * y^2 (degree 4 - INVALID):** +```julia +ScalarNonlinearFunction(:^, Any[ + ScalarNonlinearFunction(:*, Any[x, y]), + 2 +]) +# Should be rejected - exceeds cubic degree +``` + +**x^3 (degree 3 in one variable - special case):** +```julia +ScalarNonlinearFunction(:^, Any[x, 3]) +# This is x*x*x - three variables, no parameters +# Should be REJECTED for our use case (no parameter involved) +# OR could be stored as a degenerate cubic term if we allow it +``` + +**Parser requirement**: Must correctly expand `^` operator and track resulting degree. + +### 1.5.4 Addition/Subtraction Variations + +**Sum of terms:** +```julia +# x*y*p + x*z*q +ScalarNonlinearFunction(:+, Any[ + ScalarNonlinearFunction(:*, Any[x, y, p]), + ScalarNonlinearFunction(:*, Any[x, z, q]) +]) +``` + +**Subtraction (which is addition with negation):** +```julia +# x*y*p - 2*x +ScalarNonlinearFunction(:-, Any[ + ScalarNonlinearFunction(:*, Any[x, y, p]), + ScalarNonlinearFunction(:*, Any[2.0, x]) +]) +# The second term should have coefficient -2.0 +``` + +**Nested sums:** +```julia +# (a + b) + (c + d) with various term types +ScalarNonlinearFunction(:+, Any[ + ScalarNonlinearFunction(:+, Any[term_a, term_b]), + ScalarNonlinearFunction(:+, Any[term_c, term_d]) +]) +``` + +**Unary minus:** +```julia +# -x*y*p (negation of a term) +ScalarNonlinearFunction(:-, Any[ + MOI.ScalarNonlinearFunction(:*, Any[x, y, p]) +]) +# Should produce coefficient = -1.0 +``` + +**Parser requirement**: Must correctly propagate signs through subtraction, unary minus, and flatten additions. + +### 1.5.5 Coefficient Positions + +The numeric coefficient can appear anywhere in a product: + +```julia +# All equivalent to 5*x*y*p: +ScalarNonlinearFunction(:*, Any[5.0, x, y, p]) # coefficient first +ScalarNonlinearFunction(:*, Any[x, 5.0, y, p]) # coefficient second +ScalarNonlinearFunction(:*, Any[x, y, 5.0, p]) # coefficient third +ScalarNonlinearFunction(:*, Any[x, y, p, 5.0]) # coefficient last +``` + +**Multiple coefficients (must multiply):** +```julia +# 2 * 3 * x * y * p = 6*x*y*p +ScalarNonlinearFunction(:*, Any[2.0, 3.0, x, y, p]) +``` + +**Parser requirement**: Accumulate all numeric values by multiplication. + +### 1.5.6 Term Combination + +Like terms must be combined: + +```julia +# x*y*p + 2*x*y*p should become 3*x*y*p +ScalarNonlinearFunction(:+, Any[ + ScalarNonlinearFunction(:*, Any[x, y, p]), + ScalarNonlinearFunction(:*, Any[2.0, x, y, p]) +]) +# Result: single _ScalarCubicTerm with coefficient = 3.0 (type=:pvv) +``` + +**Parser requirement**: After expanding to monomials, combine terms with identical variable/parameter sets. + +### 1.5.7 Nested MOI Functions + +`ScalarNonlinearFunction` args can contain other MOI function types: + +```julia +# ScalarAffineFunction nested inside +ScalarNonlinearFunction(:*, Any[ + MOI.ScalarAffineFunction([MOI.ScalarAffineTerm(2.0, x)], 1.0), # 2x + 1 + p +]) +# Should expand to: 2*x*p + p (one pv term + one p term) +``` + +```julia +# ScalarQuadraticFunction nested inside +ScalarNonlinearFunction(:*, Any[ + MOI.ScalarQuadraticFunction( + [MOI.ScalarQuadraticTerm(1.0, x, y)], # x*y + MOI.ScalarAffineTerm{Float64}[], + 0.0 + ), + p +]) +# Should expand to: x*y*p (one pvv term) +``` + +**Parser requirement**: Recursively handle `ScalarAffineFunction` and `ScalarQuadraticFunction` as leaf nodes that expand to their constituent terms. + +### 1.5.8 Edge Cases Summary Table + +| Expression | Tree Variations | Expected Result | +|------------|-----------------|-----------------| +| `2*x*y*p` | 5+ structures | `_ScalarCubicTerm(2.0, p, x, y)` (type=:pvv) | +| `3*p*x^2` | 3+ structures | `_ScalarCubicTerm(3.0, p, x, x)` (type=:pvv) | +| `x*y*p - 2*x` | subtraction | 1 pvv term + 1 affine (coef=-2) | +| `-x*y*p` | unary minus | `_ScalarCubicTerm(-1.0, p, x, y)` (type=:pvv) | +| `(2*3)*x*y*p` | nested coefficients | `_ScalarCubicTerm(6.0, p, x, y)` (type=:pvv) | +| `x*y*p + 2*x*y*p` | like terms | `_ScalarCubicTerm(3.0, p, x, y)` (type=:pvv) | +| `(2x+1)*p` | nested affine | 1 pv + 1 p term | +| `x^2*y^2` | power of product | REJECT (degree 4) | +| `x*y*z` | 3 vars, 0 params | REJECT (no parameter) | +| `sin(x)*p` | non-poly operator | REJECT | +| `x/y*p` | division | REJECT | +| `p*x*y` with p=0 | zero parameter value | MUST parse as cubic (see 1.5.10) | + +### 1.5.9 Parser Implementation Strategy + +Given these corner cases, the parser should: + +1. **Normalize to monomials**: Recursively expand the tree into a sum of monomials +2. **Track factors per monomial**: Each monomial has: + - `coefficient::Float64` (product of all numeric values) + - `variables::Vector{MOI.VariableIndex}` (including repeats for x^2) + - `parameters::Vector{ParameterIndex}` (including repeats for p^2) +3. **Handle operators**: + - `:+` → collect monomials from each arg + - `:-` → negate coefficients of args after the first (unary: negate single arg) + - `:*` → multiply monomials (combine factors) + - `:^` → expand to repeated factors (only for small integer exponents) +4. **Handle nested MOI functions**: + - `ScalarAffineFunction` → expand to affine monomials + - `ScalarQuadraticFunction` → expand to quadratic + affine monomials +5. **Combine like terms**: Group monomials by (sorted variables, sorted parameters), sum coefficients +6. **Classify after expansion**: Once we have flat, combined monomials, classification is straightforward + +```julia +struct Monomial{T} + coefficient::T + variables::Vector{MOI.VariableIndex} # length = variable degree + parameters::Vector{ParameterIndex} # length = parameter degree +end + +# Total degree = length(variables) + length(parameters) +# For valid cubic: total degree <= 3 AND length(variables) <= 2 +``` + +### 1.5.10 Parameter Values at Parse Time (CRITICAL) + +**The parser must NOT consider parameter values when classifying terms.** + +When parsing `p * x * y`, the parser must always create a PVV cubic term, regardless of whether `p`'s current value is 0, 1, or any other number. The parameter value only affects the *evaluation* of the term, not its *classification*. + +**Why this matters:** + +```julia +# User creates model with p=0 +@variable(model, p in MOI.Parameter(0.0)) +@objective(model, Min, p * x * y + x + y) + +# First solve: p=0, so effectively minimizing x + y +optimize!(model) # Works fine + +# Later, user updates p +set_parameter_value(p, 2.0) +optimize!(model) # Must now minimize 2*x*y + x + y +``` + +If the parser had ignored the `p * x * y` term because `p=0`, the second optimization would be wrong. + +**Implementation requirement:** + +- Parser classifies terms based on **structure** (which indices are parameters vs variables) +- Parser does NOT read parameter values from the model +- All cubic terms are stored, even if their parameters are currently zero +- During `_update_cubic_objective!`, zero-valued parameters correctly result in zero contributions + +**Test cases:** See D1b and D1c in the test plan. + +### 1.5.11 MOI Utilities That May Simplify Implementation + +MOI provides several utilities in `MOI.Utilities` and `MOI.Nonlinear` that could simplify our work: + +#### Potentially Useful Utilities + +| Utility | Location | Purpose | Use Case | +|---------|----------|---------|----------| +| `substitute_variables(fn, f)` | `MOI.Utilities` | Replace variables using a mapping function | Could substitute parameter values | +| `filter_variables(keep, f)` | `MOI.Utilities` | Remove variables not satisfying predicate | Separate params from vars | +| `canonical(f)` | `MOI.Utilities` | Normalize function (combine terms, sort) | Simplify parsed result | +| `eval_variables(value_fn, model, f)` | `MOI.Nonlinear` | Evaluate expression with variable values | Evaluate with param values | +| `operate(op, T, args...)` | `MOI.Utilities` | Compose functions with operators | Build result functions | +| `map_indices(fn, f)` | `MOI.Utilities` | Remap variable indices | Index transformations | + +#### Key Insight: Alternative Approach Using `substitute_variables` + +Instead of building a full custom parser, we could potentially: + +```julia +function _current_function_alternative(f::MOI.ScalarNonlinearFunction, model) + # Substitute parameters with their values + result = MOI.Utilities.substitute_variables(f) do vi + if _is_parameter(vi) + # Return the parameter value as a constant + return model.parameters[p_idx(vi)] + else + # Keep variables as-is + return vi + end + end + # Result should now be a polynomial in variables only + # Convert to ScalarQuadraticFunction or ScalarAffineFunction + return result +end +``` + +**Limitation**: `substitute_variables` returns `ScalarNonlinearFunction` even after substitution. We'd still need to detect that the result is polynomial and convert it. + +#### Recommended Approach + +Use MOI utilities for: +1. **`canonical()`** - After parsing, to combine like terms and normalize +2. **`map_indices()`** - If we need to remap variable indices +3. **`operate()`** - To build the final `ScalarQuadraticFunction` or `ScalarAffineFunction` + +Build custom logic for: +1. **Expression tree traversal** - To expand into monomials +2. **Polynomial detection** - To verify the expression is valid cubic +3. **Term classification** - To categorize by parameter/variable composition + +This hybrid approach leverages MOI utilities where beneficial while maintaining control over the polynomial-specific logic. + +--- + +## Part 2: Data Structures + +### 2.1 Unified Cubic Term Type + +We use a single unified type for all cubic terms, similar to how MOI uses `ScalarQuadraticTerm`: + +```julia +""" + _ScalarCubicTerm{T} + +Represents a cubic term of the form `coefficient * index_1 * index_2 * index_3`. + +Each index can be either a variable (MOI.VariableIndex) or a parameter (encoded as +VariableIndex with value > PARAMETER_INDEX_THRESHOLD). + +The term type is determined by counting parameters vs variables: +- PVV (1 param, 2 vars): becomes quadratic after substitution +- PPV (2 params, 1 var): becomes affine after substitution +- PPP (3 params, 0 vars): becomes constant after substitution + +# Fields +- `coefficient::T`: The numeric coefficient +- `index_1::MOI.VariableIndex`: First factor (variable or parameter) +- `index_2::MOI.VariableIndex`: Second factor (variable or parameter) +- `index_3::MOI.VariableIndex`: Third factor (variable or parameter) + +# Convention +Indices are stored in canonical order: +- Parameters come before variables +- Within each group, sorted by index value +This ensures `2*p*x*y` and `2*x*p*y` produce the same term. + +# Examples +```julia +# p * x * y (PVV): 1 parameter, 2 variables +_ScalarCubicTerm(2.0, p_vi, x, y) # becomes 2*p_val*x*y + +# p * q * x (PPV): 2 parameters, 1 variable +_ScalarCubicTerm(3.0, p_vi, q_vi, x) # becomes 3*p_val*q_val*x + +# p * q * r (PPP): 3 parameters, 0 variables +_ScalarCubicTerm(4.0, p_vi, q_vi, r_vi) # becomes 4*p_val*q_val*r_val +``` +""" +struct _ScalarCubicTerm{T} + coefficient::T + index_1::MOI.VariableIndex + index_2::MOI.VariableIndex + index_3::MOI.VariableIndex +end + +# Helper to classify a cubic term +function _cubic_term_type(term::_ScalarCubicTerm) + num_params = _is_parameter(term.index_1) + _is_parameter(term.index_2) + _is_parameter(term.index_3) + if num_params == 1 + return :pvv # 1 param, 2 vars → quadratic + elseif num_params == 2 + return :ppv # 2 params, 1 var → affine + else # num_params == 3 + return :ppp # 3 params → constant + end +end + +# Helper to extract parameters and variables from a term +function _split_cubic_term(term::_ScalarCubicTerm) + params = MOI.VariableIndex[] + vars = MOI.VariableIndex[] + for idx in (term.index_1, term.index_2, term.index_3) + if _is_parameter(idx) + push!(params, idx) + else + push!(vars, idx) + end + end + return params, vars +end +``` + +#### 2.1.1 Summary of Cubic Term Classifications + +| Classification | # Params | # Vars | After Substitution | Example | +|----------------|----------|--------|-------------------|---------| +| PVV | 1 | 2 | Quadratic: `c*p_val*x*y` | `2*p*x*y` → `6*x*y` (p=3) | +| PPV | 2 | 1 | Affine: `c*p_val*q_val*x` | `2*p*q*x` → `12*x` (p=2,q=3) | +| PPP | 3 | 0 | Constant: `c*p_val*q_val*r_val` | `2*p*q*r` → `24` (p=2,q=3,r=4) | + +### 2.2 ParametricCubicFunction + +Storage for a full cubic function with parametric terms. + +```julia +""" + ParametricCubicFunction{T} <: ParametricFunction{T} + +Represents a cubic function where parameters multiply up to quadratic variable terms. + +Supports the general form: + constant + Σ(affine) + Σ(quadratic) + Σ(cubic) + +# Fields + +## Cubic terms (degree 3) - unified type +- `cubic::Vector{_ScalarCubicTerm{T}}`: All cubic terms (pvv, ppv, ppp combined) + - Classification done at runtime via `_cubic_term_type()` + - pvv terms → become quadratic after substitution + - ppv terms → become affine after substitution + - ppp terms → become constant after substitution + +## Quadratic terms (degree 2) - inherited pattern from ParametricQuadraticFunction +- `pv::Vector{...}`: Parameter-variable terms (c*p*x) → become affine +- `pp::Vector{...}`: Parameter-parameter terms (c*p*q) → become constant +- `vv::Vector{...}`: Variable-variable terms (c*x*y) → stay quadratic + +## Affine terms (degree 1) +- `p::Vector{...}`: Parameter affine terms (c*p) → become constant +- `v::Vector{...}`: Variable affine terms (c*x) → stay affine + +## Constants (degree 0) +- `c::T`: Constant term + +## Caches (for efficient updates) +- `current_quadratic_terms::Vector{MOI.ScalarQuadraticTerm{T}}`: From vv + pvv +- `current_affine_terms::Vector{MOI.ScalarAffineTerm{T}}`: From v + pv + ppv +- `current_constant::T`: From c + p + pp + +# Substitution Summary + +| Original Term | After Parameter Substitution | +|---------------|------------------------------| +| `pvv` (p*x*y) | quadratic term (c*p_val*x*y) | +| `ppv` (p*q*x) | affine term (c*p_val*q_val*x)| +| `ppp` (p*q*r) | constant (c*p_val*q_val*r_val)| +| `pv` (p*x) | affine term (c*p_val*x) | +| `pp` (p*q) | constant (c*p_val*q_val) | +| `vv` (x*y) | quadratic term (unchanged) | +| `v` (x) | affine term (unchanged) | +| `p` (p) | constant (c*p_val) | +| `c` | constant (unchanged) | +""" +mutable struct ParametricCubicFunction{T} <: ParametricFunction{T} + # === Cubic terms (degree 3) - unified storage === + cubic::Vector{_ScalarCubicTerm{T}} # All cubic terms (pvv, ppv, ppp) + # Classification done at runtime via _cubic_term_type() + + # === Quadratic terms (degree 2) - same as ParametricQuadraticFunction === + pv::Vector{MOI.ScalarQuadraticTerm{T}} # p*x → becomes affine + pp::Vector{MOI.ScalarQuadraticTerm{T}} # p*q → becomes constant + vv::Vector{MOI.ScalarQuadraticTerm{T}} # x*y → stays quadratic + + # === Affine terms (degree 1) === + p::Vector{MOI.ScalarAffineTerm{T}} # p → becomes constant + v::Vector{MOI.ScalarAffineTerm{T}} # x → stays affine + + # === Constant (degree 0) === + c::T + + # === Caches for efficient updates (following POI pattern) === + # Variable pairs in pvv terms (quadratic coefficients depend on parameters) + quadratic_data::Dict{Tuple{MOI.VariableIndex,MOI.VariableIndex}, T} + # Variables in ppv or pv terms (affine coefficients depend on parameters) + affine_data::Dict{MOI.VariableIndex, T} + # Variables NOT in parameter-dependent terms (fixed coefficients) + affine_data_np::Dict{MOI.VariableIndex, T} + # Current constant after parameter substitution + current_constant::T +end + +# Accessors for cubic terms by type (iterate and filter) +function _cubic_pvv_terms(f::ParametricCubicFunction) + return Iterators.filter(t -> _cubic_term_type(t) == :pvv, f.cubic) +end + +function _cubic_ppv_terms(f::ParametricCubicFunction) + return Iterators.filter(t -> _cubic_term_type(t) == :ppv, f.cubic) +end + +function _cubic_ppp_terms(f::ParametricCubicFunction) + return Iterators.filter(t -> _cubic_term_type(t) == :ppp, f.cubic) +end +``` + +### 2.3 Parser Result + +```julia +""" + ParsedCubicExpression{T} + +Result of parsing a ScalarNonlinearFunction into cubic polynomial form. + +Returns `nothing` from the parser if the expression is not a valid cubic polynomial. + +Note: Like-terms should be combined during parsing (e.g., `x*y*p + 2*x*y*p` → single term with coef=3). + +# Design + +The structure contains: +1. A `MOI.ScalarQuadraticFunction{T}` for all non-cubic terms (quadratic, affine, constant) +2. A vector of `_ScalarCubicTerm{T}` for cubic terms only + +This reuses MOI's existing structure and simplifies construction of the final function +after parameter substitution. + +# Term Classification + +Cubic terms are stored in a unified vector and classified at runtime: +- Use `_cubic_term_type(term)` to get `:pvv`, `:ppv`, or `:ppp` +- Use filter functions `_filter_pvv_terms()`, etc. if needed + +# Note on Parameter Encoding + +In the `quadratic_func`, parameters appear as `MOI.VariableIndex` with values above +`PARAMETER_INDEX_THRESHOLD`. The caller must use `_is_parameter()` to distinguish +parameter terms (pp, pv, p) from pure variable terms (vv, v, constant). +""" +struct ParsedCubicExpression{T} + # Cubic terms (degree 3) - unified storage + cubic_terms::Vector{_ScalarCubicTerm{T}} # All cubic: p*x*y, p*q*x, p*q*r + # Classification done via _cubic_term_type() at runtime + + # Non-cubic terms (degree ≤ 2) - reuse MOI's structure + # Contains: vv, pv, pp (quadratic), v, p (affine), constant + # Parameters encoded as VariableIndex > PARAMETER_INDEX_THRESHOLD + quadratic_func::MOI.ScalarQuadraticFunction{T} +end + +# Helper functions to filter cubic terms by type +function _filter_pvv_terms(parsed::ParsedCubicExpression) + return filter(t -> _cubic_term_type(t) == :pvv, parsed.cubic_terms) +end + +function _filter_ppv_terms(parsed::ParsedCubicExpression) + return filter(t -> _cubic_term_type(t) == :ppv, parsed.cubic_terms) +end + +function _filter_ppp_terms(parsed::ParsedCubicExpression) + return filter(t -> _cubic_term_type(t) == :ppp, parsed.cubic_terms) +end + +# Convenience accessors for non-cubic terms +function _quadratic_terms(parsed::ParsedCubicExpression) + return parsed.quadratic_func.quadratic_terms +end + +function _affine_terms(parsed::ParsedCubicExpression) + return parsed.quadratic_func.affine_terms +end + +function _constant(parsed::ParsedCubicExpression) + return parsed.quadratic_func.constant +end +``` + +--- + +## Part 3: Core Functions + +### 3.1 Parser Functions + +```julia +""" + _parse_cubic_expression(f::MOI.ScalarNonlinearFunction) -> Union{ParsedCubicExpression, Nothing} + +Parse a ScalarNonlinearFunction and return a ParsedCubicExpression if it represents +a valid cubic polynomial (with parameters multiplying at most quadratic variable terms). + +Returns `nothing` if the expression: +- Contains non-polynomial operations (sin, exp, etc.) +- Has degree > 3 in any monomial +- Has invalid structure + +# Example +```julia +x, y = MOI.VariableIndex(1), MOI.VariableIndex(2) +p = POI.ParameterIndex(1) + +# 2*x*y*p + 3*x +f = MOI.ScalarNonlinearFunction(:+, Any[ + MOI.ScalarNonlinearFunction(:*, Any[2.0, x, y, v_idx(p)]), + MOI.ScalarNonlinearFunction(:*, Any[3.0, x]) +]) + +result = _parse_cubic_expression(f) +# result.cubic_terms = [_ScalarCubicTerm(2.0, p, x, y)] +# result.quadratic_func.affine_terms = [ScalarAffineTerm(3.0, x)] +``` +""" +function _parse_cubic_expression(f::MOI.ScalarNonlinearFunction) + # Implementation +end +``` + +### 3.2 Helper Functions for Parsing + +```julia +""" + _expand_expression(f::MOI.ScalarNonlinearFunction) -> Vector{Monomial} + +Recursively expand the expression tree into a list of monomials. +Each monomial tracks: coefficient, list of variables, list of parameters. +""" +function _expand_expression(f) +end + +""" + _classify_monomial(m::Monomial) -> Symbol + +Classify a monomial by its structure (num_params, num_vars): + +Degree 0: +- :constant - (0, 0) no variables or parameters + +Degree 1: +- :affine_v - (0, 1) one variable, no parameters +- :affine_p - (1, 0) one parameter, no variables + +Degree 2: +- :quadratic_vv - (0, 2) two variables, no parameters +- :quadratic_pv - (1, 1) one parameter, one variable +- :quadratic_pp - (2, 0) two parameters, no variables + +Degree 3 (valid - at least one parameter): +- :cubic_pvv - (1, 2) one parameter, two variables +- :cubic_ppv - (2, 1) two parameters, one variable +- :cubic_ppp - (3, 0) three parameters, no variables + +Invalid: +- :cubic_vvv - (0, 3) three variables, no parameters → REJECT (no parameter) +- :invalid - degree > 3, or any other invalid combination +""" +function _classify_monomial(m) +end + +""" + _is_polynomial_operator(head::Symbol) -> Bool + +Check if the operator is valid for polynomial expressions. +Valid: :+, :-, :*, :^ +Invalid: :sin, :cos, :exp, :log, :/, etc. +""" +function _is_polynomial_operator(head::Symbol) +end +``` + +### 3.3 Conversion Functions + +```julia +""" + ParametricCubicFunction(f::MOI.ScalarNonlinearFunction) + +Construct a ParametricCubicFunction from a ScalarNonlinearFunction. + +Throws an error if the expression is not a valid cubic polynomial. +""" +function ParametricCubicFunction(f::MOI.ScalarNonlinearFunction) +end + +""" + _current_function(f::ParametricCubicFunction{T}, model) where {T} -> Union{MOI.ScalarQuadraticFunction{T}, MOI.ScalarAffineFunction{T}} + +Evaluate the cubic function with current parameter values and return +the appropriate MOI function type. Follows the same pattern as +ParametricQuadraticFunction._current_function. + +# Implementation +1. Build quadratic terms from: + - `vv` terms (unchanged) + - `pvv` terms with parameter substituted: coef * p_val * x * y + +2. Build affine terms from: + - `affine_data` (variables in parameter-dependent terms, with updated coefficients) + - `affine_data_np` (variables with fixed coefficients) + +3. Use `current_constant` for the constant term + +# Returns +- `ScalarQuadraticFunction{T}` if there are any quadratic terms +- `ScalarAffineFunction{T}` if all quadratic terms have zero coefficient + +# Example +```julia +# f = 2*p*x*y + 3*x + 5 with p=3 +# _current_function returns: 6*x*y + 3*x + 5 +``` +""" +function _current_function(f::ParametricCubicFunction{T}, model) where {T} + # Build quadratic terms + quadratic = MOI.ScalarQuadraticTerm{T}[] + # Add vv terms (unchanged) + append!(quadratic, f.vv) + # Add pvv terms with parameter values substituted + for (vars, coef) in f.quadratic_data + if !iszero(coef) + push!(quadratic, MOI.ScalarQuadraticTerm{T}(coef, vars[1], vars[2])) + end + end + + # Build affine terms + affine = MOI.ScalarAffineTerm{T}[] + for (v, coef) in f.affine_data + push!(affine, MOI.ScalarAffineTerm{T}(coef, v)) + end + for (v, coef) in f.affine_data_np + push!(affine, MOI.ScalarAffineTerm{T}(coef, v)) + end + + # Return appropriate type + if isempty(quadratic) + return MOI.ScalarAffineFunction{T}(affine, f.current_constant) + else + return MOI.ScalarQuadraticFunction{T}(quadratic, affine, f.current_constant) + end +end + +""" + _update_cache!(f::ParametricCubicFunction{T}, model) where {T} + +Update the cached current values based on new parameter values. +Follows the same pattern as ParametricQuadraticFunction._update_cache! +""" +function _update_cache!(f::ParametricCubicFunction{T}, model) where {T} + f.current_constant = _parametric_constant(model, f) + f.affine_data = _parametric_affine_terms(model, f) + f.quadratic_data = _parametric_quadratic_terms(model, f) + return nothing +end + +""" + _parametric_constant(model, f::ParametricCubicFunction{T}) where {T} + +Compute the constant term after parameter substitution. +Includes contributions from: c + p terms + pp terms + ppp cubic terms +""" +function _parametric_constant(model, f::ParametricCubicFunction{T}) where {T} + constant = f.c + # From affine parameter terms (p) + for term in f.p + constant += term.coefficient * model.parameters[p_idx(term.variable)] + end + # From quadratic parameter-parameter terms (pp) + for term in f.pp + constant += (term.coefficient / ifelse(term.variable_1 == term.variable_2, 2, 1)) * + model.parameters[p_idx(term.variable_1)] * + model.parameters[p_idx(term.variable_2)] + end + # From cubic ppp terms (all 3 indices are parameters) + for term in _cubic_ppp_terms(f) + params, _ = _split_cubic_term(term) + divisor = _cubic_divisor(params) # handles p^3, p^2*q, p*q*r cases + constant += (term.coefficient / divisor) * + model.parameters[p_idx(params[1])] * + model.parameters[p_idx(params[2])] * + model.parameters[p_idx(params[3])] + end + return constant +end + +# Helper to compute divisor for repeated indices (for symmetric terms) +function _cubic_divisor(indices::Vector{MOI.VariableIndex}) + if indices[1] == indices[2] == indices[3] + return 6 # p^3: divide by 3! + elseif indices[1] == indices[2] || indices[2] == indices[3] || indices[1] == indices[3] + return 2 # p^2*q: divide by 2! + else + return 1 # p*q*r: no division needed + end +end + +""" + _parametric_affine_terms(model, f::ParametricCubicFunction{T}) where {T} + +Compute affine coefficients after parameter substitution. +Includes contributions from: v terms + pv terms + ppv cubic terms +""" +function _parametric_affine_terms(model, f::ParametricCubicFunction{T}) where {T} + terms_dict = Dict{MOI.VariableIndex, T}() + # From pv terms (same as ParametricQuadraticFunction) + for term in f.pv + var = term.variable_2 + base = get(terms_dict, var, zero(T)) + terms_dict[var] = base + term.coefficient * model.parameters[p_idx(term.variable_1)] + end + # From ppv cubic terms (2 params, 1 var) - p * q * x becomes coef * p_val * q_val * x + for term in _cubic_ppv_terms(f) + params, vars = _split_cubic_term(term) + var = vars[1] # The single variable + p1_val = model.parameters[p_idx(params[1])] + p2_val = model.parameters[p_idx(params[2])] + divisor = ifelse(params[1] == params[2], 2, 1) + base = get(terms_dict, var, zero(T)) + terms_dict[var] = base + (term.coefficient / divisor) * p1_val * p2_val + end + # Add fixed affine terms from v (stored in affine_data) + for (var, coef) in f.affine_data + terms_dict[var] = get(terms_dict, var, zero(T)) + coef + end + return terms_dict +end + +""" + _parametric_quadratic_terms(model, f::ParametricCubicFunction{T}) where {T} + +Compute quadratic coefficients after parameter substitution. +Includes contributions from: pvv terms (p * x * y becomes coef * p_val * x * y) +""" +function _parametric_quadratic_terms(model, f::ParametricCubicFunction{T}) where {T} + terms_dict = Dict{Tuple{MOI.VariableIndex, MOI.VariableIndex}, T}() + for term in _cubic_pvv_terms(f) + params, vars = _split_cubic_term(term) + p = params[1] # The single parameter + var_pair = (vars[1], vars[2]) # The two variables + p_val = model.parameters[p_idx(p)] + base = get(terms_dict, var_pair, zero(T)) + terms_dict[var_pair] = base + term.coefficient * p_val + end + return terms_dict +end + +# === Delta functions for efficient updates (following POI pattern) === + +""" + _delta_parametric_constant(model, f::ParametricCubicFunction{T}) where {T} + +Compute the CHANGE in constant when parameters are updated. +Only computes delta for parameters that have been updated (not NaN). +""" +function _delta_parametric_constant(model, f::ParametricCubicFunction{T}) where {T} + # Similar to ParametricQuadraticFunction but also handles ppp terms + # ... (implementation follows existing pattern) +end + +""" + _delta_parametric_affine_terms(model, f::ParametricCubicFunction{T}) where {T} + +Compute the CHANGE in affine coefficients when parameters are updated. +Returns Dict{MOI.VariableIndex, T} of delta values. +""" +function _delta_parametric_affine_terms(model, f::ParametricCubicFunction{T}) where {T} + # Similar to ParametricQuadraticFunction but also handles ppv terms + # ... (implementation follows existing pattern) +end + +""" + _delta_parametric_quadratic_terms(model, f::ParametricCubicFunction{T}) where {T} + +Compute the CHANGE in quadratic coefficients when parameters are updated. +Returns Dict{Tuple{MOI.VariableIndex, MOI.VariableIndex}, T} of delta values. + +This is NEW for cubic functions - quadratic terms can change when pvv parameters change. +""" +function _delta_parametric_quadratic_terms(model, f::ParametricCubicFunction{T}) where {T} + delta_dict = Dict{Tuple{MOI.VariableIndex, MOI.VariableIndex}, T}() + for term in _cubic_pvv_terms(f) + params, vars = _split_cubic_term(term) + p = p_idx(params[1]) # The single parameter + if !isnan(model.updated_parameters[p]) + var_pair = (vars[1], vars[2]) # The two variables + old_val = model.parameters[p] + new_val = model.updated_parameters[p] + delta = term.coefficient * (new_val - old_val) + base = get(delta_dict, var_pair, zero(T)) + delta_dict[var_pair] = base + delta + end + end + return delta_dict +end +``` + +--- + +## Part 4: Integration with POI + +### 4.1 Optimizer Storage + +Add to the `Optimizer` struct: + +```julia +# In Optimizer struct definition +cubic_objective_cache::Union{Nothing, ParametricCubicFunction{T}} + +# Option to warn on quadratic coefficient sign changes (can affect convexity) +warn_on_quadratic_sign_change::Bool +``` + +**New constructor parameter:** + +```julia +function Optimizer{T}( + optimizer::OT; + evaluate_duals::Bool = true, + save_original_objective_and_constraints::Bool = true, + warn_on_quadratic_sign_change::Bool = false, # NEW +) where {T,OT} + # ... +end +``` + +**Purpose:** When `warn_on_quadratic_sign_change = true`, POI will check if any quadratic coefficient changes sign during parameter updates (e.g., from positive to negative or vice versa). A sign change in quadratic terms can change the problem's convexity, which may: +- Cause solvers to fail or produce suboptimal results +- Change the problem from having a unique solution to multiple local optima +- Affect convergence behavior + +**Note:** This check is disabled by default for performance. Enable it during development/debugging if you suspect convexity issues. + +### 4.2 Objective Setting + +Add method for setting ScalarNonlinearFunction as objective: + +```julia +function MOI.set( + model::Optimizer, + ::MOI.ObjectiveFunction{MOI.ScalarNonlinearFunction}, + f::MOI.ScalarNonlinearFunction, +) + # 1. Attempt to parse as cubic + parsed = _parse_cubic_expression(f) + if parsed === nothing + error("ScalarNonlinearFunction must be a cubic polynomial for POI") + end + + # 2. Create ParametricCubicFunction + cubic_func = ParametricCubicFunction(parsed) + + # 3. Clear old caches, store new cache + _empty_objective_function_caches!(model) + model.cubic_objective_cache = cubic_func + + # 4. Compute current function and set on inner optimizer + current = _current_function(cubic_func, model) + MOI.set(model.optimizer, MOI.ObjectiveFunction{typeof(current)}(), current) + + # 5. Store original for retrieval + MOI.Utilities.set_objective(model.original_objective_cache, f) +end +``` + +### 4.3 Parameter Updates + +Extend `_update_parameters!` to handle cubic objectives: + +```julia +function _update_cubic_objective!(model::Optimizer{T}) where {T} + if model.cubic_objective_cache === nothing + return + end + pf = model.cubic_objective_cache + + # 1. Update constant (from p, pp, ppp terms) + delta_constant = _delta_parametric_constant(model, pf) + if !iszero(delta_constant) + pf.current_constant += delta_constant + F = MOI.get(model.optimizer, MOI.ObjectiveFunctionType()) + MOI.modify( + model.optimizer, + MOI.ObjectiveFunction{F}(), + MOI.ScalarConstantChange(pf.current_constant), + ) + end + + # 2. Update affine terms (from pv, ppv terms) + delta_affine = _delta_parametric_affine_terms(model, pf) + if !isempty(delta_affine) + # Update cache and build changes + changes = _affine_build_change_and_up_param_func(pf, delta_affine) + F = MOI.get(model.optimizer, MOI.ObjectiveFunctionType()) + MOI.modify(model.optimizer, MOI.ObjectiveFunction{F}(), changes) + end + + # 3. Update quadratic terms (from pvv terms) - NEW for cubic + delta_quadratic = _delta_parametric_quadratic_terms(model, pf) + if !isempty(delta_quadratic) + F = MOI.get(model.optimizer, MOI.ObjectiveFunctionType()) + for ((var1, var2), delta) in delta_quadratic + # Update cache + old_coef = get(pf.quadratic_data, (var1, var2), zero(T)) + new_coef = old_coef + delta + pf.quadratic_data[(var1, var2)] = new_coef + + # Check for sign change if option is enabled + if model.warn_on_quadratic_sign_change + _check_quadratic_sign_change(old_coef, new_coef, var1, var2) + end + + # Apply change using MOI.ScalarQuadraticCoefficientChange + MOI.modify( + model.optimizer, + MOI.ObjectiveFunction{F}(), + MOI.ScalarQuadraticCoefficientChange(var1, var2, new_coef), + ) + end + end + + return +end + +""" + _check_quadratic_sign_change(old_coef, new_coef, var1, var2) + +Check if a quadratic coefficient changed sign and emit a warning if so. +Sign changes can affect problem convexity. +""" +function _check_quadratic_sign_change(old_coef::T, new_coef::T, var1, var2) where {T} + # Skip if either coefficient is zero (not a true sign change) + if iszero(old_coef) || iszero(new_coef) + return + end + # Check for sign change: positive → negative or negative → positive + if (old_coef > zero(T)) != (new_coef > zero(T)) + @warn "Quadratic coefficient sign change detected" var1 var2 old_coef new_coef + end +end +``` + +**Note**: MOI supports `ScalarQuadraticCoefficientChange` for modifying quadratic coefficients in-place. See [MOI modification documentation](https://jump.dev/MathOptInterface.jl/stable/manual/modification/). + +```julia +# In update_parameters.jl, add call to cubic update: +function update_parameters!(model::Optimizer) + _update_affine_constraints!(model) + _update_vector_affine_constraints!(model) + _update_quadratic_constraints!(model) + _update_vector_quadratic_constraints!(model) + _update_affine_objective!(model) + _update_quadratic_objective!(model) + _update_cubic_objective!(model) # NEW + + # Update parameters and put NaN to indicate updated + for (parameter_index, val) in model.updated_parameters + if !isnan(val) + model.parameters[parameter_index] = val + model.updated_parameters[parameter_index] = NaN + end + end + return +end +``` + +--- + +## Part 5: Test Plan + +### 5.1 Test Philosophy + +Tests should be: +- **Simple**: Easy to compute expected results by hand +- **Predictable**: Use integer/simple coefficients +- **Focused**: Each test validates one specific behavior +- **Complete**: Cover parameter changes and result verification + +### 5.1.1 JuMP vs MOI Tests + +**MOI-level tests** (`test/moi_tests.jl`): +- Parser unit tests (expression tree parsing) +- Data structure construction tests +- Low-level API verification + +**JuMP-level tests** (`test/jump_tests.jl`) - **PRIMARY**: +- Full model integration tests +- Parameter update and re-optimization tests +- User-facing API validation + +JuMP tests are preferred for full model validation because: +1. If MOI's ScalarNonlinearFunction is implemented correctly, JuMP will work automatically +2. JuMP syntax is closer to what users will write +3. Easier to read and verify expected behavior + +### 5.2 Test Categories + +#### Category A: Parser Tests + +```julia +# A1: Valid cubic expression - single PVV term +@testset "parse_cubic_single_term" begin + x, y = MOI.VariableIndex(1), MOI.VariableIndex(2) + p_vi = v_idx(ParameterIndex(1)) + + # 2 * x * y * p + f = MOI.ScalarNonlinearFunction(:*, Any[2.0, x, y, p_vi]) + result = _parse_cubic_expression(f) + + @test result !== nothing + pvv = _filter_pvv_terms(result) + @test length(pvv) == 1 + @test pvv[1].coefficient == 2.0 +end + +# A2: Valid cubic expression - mixed terms +@testset "parse_cubic_mixed_terms" begin + # 3*x*y*p + 2*x + 5 + # Should parse into: 1 cubic, 1 affine, 1 constant +end + +# A3: Invalid expression - degree too high (4 factors) +@testset "parse_cubic_invalid_degree_4" begin + x, y, z = MOI.VariableIndex(1), MOI.VariableIndex(2), MOI.VariableIndex(3) + p_vi = v_idx(ParameterIndex(1)) + + # x * y * z * p (degree 4) should return nothing + f = MOI.ScalarNonlinearFunction(:*, Any[x, y, z, p_vi]) + result = _parse_cubic_expression(f) + + @test result === nothing +end + +# A3b: Invalid expression - three variables, no parameter +@testset "parse_cubic_three_vars_no_param" begin + x, y, z = MOI.VariableIndex(1), MOI.VariableIndex(2), MOI.VariableIndex(3) + + # x * y * z (3 variables, 0 parameters) should be rejected + # This is cubic in variables but has no parameter - not useful for POI + f = MOI.ScalarNonlinearFunction(:*, Any[x, y, z]) + result = _parse_cubic_expression(f) + + @test result === nothing +end + +# A4: Invalid expression - non-polynomial operator +@testset "parse_cubic_invalid_operator" begin + # sin(x) * p should return nothing +end + +# A5: Squared variable - p * x^2 +@testset "parse_cubic_squared_variable" begin + x = MOI.VariableIndex(1) + p_vi = v_idx(ParameterIndex(1)) + + # 3 * p * x^2 using power operator + f = MOI.ScalarNonlinearFunction(:*, Any[ + 3.0, + p_vi, + MOI.ScalarNonlinearFunction(:^, Any[x, 2]) + ]) + result = _parse_cubic_expression(f) + + @test result !== nothing + pvv = _filter_pvv_terms(result) + @test length(pvv) == 1 + @test pvv[1].coefficient == 3.0 + # Check that both variables are x (squared variable) + _, vars = _split_cubic_term(pvv[1]) + @test vars[1] == x + @test vars[2] == x # same variable +end + +# A6: Mixed parenthesis orderings - all should give same result +@testset "parse_cubic_parenthesis_variations" begin + x, y = MOI.VariableIndex(1), MOI.VariableIndex(2) + p_vi = v_idx(ParameterIndex(1)) + + # Flat: 2 * x * y * p + f1 = MOI.ScalarNonlinearFunction(:*, Any[2.0, x, y, p_vi]) + + # Left-associative: ((2*x)*y)*p + f2 = MOI.ScalarNonlinearFunction(:*, Any[ + MOI.ScalarNonlinearFunction(:*, Any[ + MOI.ScalarNonlinearFunction(:*, Any[2.0, x]), + y + ]), + p_vi + ]) + + # Grouped: (2*p) * (x*y) + f3 = MOI.ScalarNonlinearFunction(:*, Any[ + MOI.ScalarNonlinearFunction(:*, Any[2.0, p_vi]), + MOI.ScalarNonlinearFunction(:*, Any[x, y]) + ]) + + r1 = _parse_cubic_expression(f1) + r2 = _parse_cubic_expression(f2) + r3 = _parse_cubic_expression(f3) + + # All should parse to equivalent results + for r in [r1, r2, r3] + @test r !== nothing + pvv = _filter_pvv_terms(r) + @test length(pvv) == 1 + @test pvv[1].coefficient == 2.0 + end +end + +# A7: Multiple numeric coefficients +@testset "parse_cubic_multiple_coefficients" begin + x, y = MOI.VariableIndex(1), MOI.VariableIndex(2) + p_vi = v_idx(ParameterIndex(1)) + + # 2 * 3 * x * y * p = 6*x*y*p + f = MOI.ScalarNonlinearFunction(:*, Any[2.0, 3.0, x, y, p_vi]) + result = _parse_cubic_expression(f) + + @test result !== nothing + pvv = _filter_pvv_terms(result) + @test pvv[1].coefficient == 6.0 +end + +# A8: Subtraction handling (binary minus) +@testset "parse_cubic_subtraction" begin + x, y = MOI.VariableIndex(1), MOI.VariableIndex(2) + p_vi = v_idx(ParameterIndex(1)) + + # x*y*p - 2*x (one cubic, one affine with negative coef) + f = MOI.ScalarNonlinearFunction(:-, Any[ + MOI.ScalarNonlinearFunction(:*, Any[x, y, p_vi]), + MOI.ScalarNonlinearFunction(:*, Any[2.0, x]) + ]) + result = _parse_cubic_expression(f) + + @test result !== nothing + pvv = _filter_pvv_terms(result) + @test length(pvv) == 1 + # Check affine term via quadratic_func + affine = _affine_terms(result) + v_affine = filter(t -> !_is_parameter(t.variable), affine) + @test length(v_affine) == 1 + @test v_affine[1].coefficient == -2.0 +end + +# A8b: Unary minus handling +@testset "parse_cubic_unary_minus" begin + x, y = MOI.VariableIndex(1), MOI.VariableIndex(2) + p_vi = v_idx(ParameterIndex(1)) + + # -x*y*p (negation of cubic term) + f = MOI.ScalarNonlinearFunction(:-, Any[ + MOI.ScalarNonlinearFunction(:*, Any[x, y, p_vi]) + ]) + result = _parse_cubic_expression(f) + + @test result !== nothing + pvv = _filter_pvv_terms(result) + @test length(pvv) == 1 + @test pvv[1].coefficient == -1.0 +end + +# A9: Explicit x*x vs x^2 should be equivalent +@testset "parse_cubic_explicit_square" begin + x = MOI.VariableIndex(1) + p_vi = v_idx(ParameterIndex(1)) + + # Using x^2 + f1 = MOI.ScalarNonlinearFunction(:*, Any[ + p_vi, + MOI.ScalarNonlinearFunction(:^, Any[x, 2]) + ]) + + # Using x*x explicitly + f2 = MOI.ScalarNonlinearFunction(:*, Any[p_vi, x, x]) + + r1 = _parse_cubic_expression(f1) + r2 = _parse_cubic_expression(f2) + + @test r1 !== nothing + @test r2 !== nothing + pvv1 = _filter_pvv_terms(r1) + pvv2 = _filter_pvv_terms(r2) + _, vars1 = _split_cubic_term(pvv1[1]) + _, vars2 = _split_cubic_term(pvv2[1]) + @test vars1[1] == vars2[1] + @test vars1[2] == vars2[2] +end + +# A10: Division should be rejected +@testset "parse_cubic_division_rejected" begin + x, y = MOI.VariableIndex(1), MOI.VariableIndex(2) + p_vi = v_idx(ParameterIndex(1)) + + # x/y * p - division is not a polynomial operation + f = MOI.ScalarNonlinearFunction(:*, Any[ + MOI.ScalarNonlinearFunction(:/, Any[x, y]), + p_vi + ]) + result = _parse_cubic_expression(f) + + @test result === nothing +end + +# A11: Two parameters times one variable (PPV term) +@testset "parse_cubic_ppv_term" begin + x = MOI.VariableIndex(1) + p_vi = v_idx(ParameterIndex(1)) + q_vi = v_idx(ParameterIndex(2)) + + # 2 * p * q * x + f = MOI.ScalarNonlinearFunction(:*, Any[2.0, p_vi, q_vi, x]) + result = _parse_cubic_expression(f) + + @test result !== nothing + ppv = _filter_ppv_terms(result) + @test length(ppv) == 1 + @test ppv[1].coefficient == 2.0 +end + +# A12: Three parameters (PPP term) +@testset "parse_cubic_ppp_term" begin + p_vi = v_idx(ParameterIndex(1)) + q_vi = v_idx(ParameterIndex(2)) + r_vi = v_idx(ParameterIndex(3)) + + # 3 * p * q * r + f = MOI.ScalarNonlinearFunction(:*, Any[3.0, p_vi, q_vi, r_vi]) + result = _parse_cubic_expression(f) + + @test result !== nothing + ppp = _filter_ppp_terms(result) + @test length(ppp) == 1 + @test ppp[1].coefficient == 3.0 +end + +# A13: Like terms should be combined +@testset "parse_cubic_term_combination" begin + x, y = MOI.VariableIndex(1), MOI.VariableIndex(2) + p_vi = v_idx(ParameterIndex(1)) + + # x*y*p + 2*x*y*p = 3*x*y*p (should combine into single term) + f = MOI.ScalarNonlinearFunction(:+, Any[ + MOI.ScalarNonlinearFunction(:*, Any[x, y, p_vi]), + MOI.ScalarNonlinearFunction(:*, Any[2.0, x, y, p_vi]) + ]) + result = _parse_cubic_expression(f) + + @test result !== nothing + pvv = _filter_pvv_terms(result) + @test length(pvv) == 1 # combined into single term + @test pvv[1].coefficient == 3.0 +end + +# A14: Nested ScalarAffineFunction inside ScalarNonlinearFunction +@testset "parse_cubic_nested_affine" begin + x = MOI.VariableIndex(1) + p_vi = v_idx(ParameterIndex(1)) + + # (2x + 1) * p = 2*x*p + p (one pv term + one p term) + affine_func = MOI.ScalarAffineFunction( + [MOI.ScalarAffineTerm(2.0, x)], + 1.0 + ) + f = MOI.ScalarNonlinearFunction(:*, Any[affine_func, p_vi]) + result = _parse_cubic_expression(f) + + @test result !== nothing + # Check quadratic terms: should have 1 pv term (2*x*p) + quad = _quadratic_terms(result) + pv_terms = filter(t -> _is_parameter(t.variable_1) != _is_parameter(t.variable_2), quad) + @test length(pv_terms) == 1 + # Check affine terms: should have 1 p term (1*p) + affine = _affine_terms(result) + p_affine = filter(t -> _is_parameter(t.variable), affine) + @test length(p_affine) == 1 +end + +# A15: Mixed cubic expression with all term types +@testset "parse_cubic_all_term_types" begin + x, y = MOI.VariableIndex(1), MOI.VariableIndex(2) + p_vi = v_idx(ParameterIndex(1)) + q_vi = v_idx(ParameterIndex(2)) + + # x*y*p + p*q*x + p*q*p + x*y + p*x + p*q + x + p + 5 + # pvv + ppv + ppp + vv + pv + pp + v + p + c + f = MOI.ScalarNonlinearFunction(:+, Any[ + MOI.ScalarNonlinearFunction(:*, Any[x, y, p_vi]), # pvv + MOI.ScalarNonlinearFunction(:*, Any[p_vi, q_vi, x]), # ppv + MOI.ScalarNonlinearFunction(:*, Any[p_vi, q_vi, p_vi]), # ppp (p²*q) + MOI.ScalarNonlinearFunction(:*, Any[x, y]), # vv + MOI.ScalarNonlinearFunction(:*, Any[p_vi, x]), # pv + MOI.ScalarNonlinearFunction(:*, Any[p_vi, q_vi]), # pp + x, # v + p_vi, # p + 5.0 # c + ]) + result = _parse_cubic_expression(f) + + @test result !== nothing + # Check cubic terms via filters + @test length(_filter_pvv_terms(result)) == 1 + @test length(_filter_ppv_terms(result)) == 1 + @test length(_filter_ppp_terms(result)) == 1 + + # Check quadratic terms via quadratic_func + quad = _quadratic_terms(result) + vv_terms = filter(t -> !_is_parameter(t.variable_1) && !_is_parameter(t.variable_2), quad) + pv_terms = filter(t -> _is_parameter(t.variable_1) != _is_parameter(t.variable_2), quad) + pp_terms = filter(t -> _is_parameter(t.variable_1) && _is_parameter(t.variable_2), quad) + @test length(vv_terms) == 1 + @test length(pv_terms) == 1 + @test length(pp_terms) == 1 + + # Check affine terms via quadratic_func + affine = _affine_terms(result) + v_affine = filter(t -> !_is_parameter(t.variable), affine) + p_affine = filter(t -> _is_parameter(t.variable), affine) + @test length(v_affine) == 1 + @test length(p_affine) == 1 + + # Check constant + @test _constant(result) == 5.0 +end +``` + +#### Category B: ParametricCubicFunction Construction + +```julia +# B1: Construct from parsed expression +@testset "cubic_function_construction" begin + # Verify all term categories are correctly stored +end + +# B2: Verify _current_function produces correct quadratic +@testset "cubic_function_current" begin + # With p=2: 3*x*y*p -> 6*x*y (quadratic term) +end +``` + +#### Category C: JuMP Integration Tests (Primary) + +These are the main validation tests using JuMP syntax. + +```julia +# C1: Basic PVV term - parameter times quadratic +function test_jump_cubic_pvv_basic() + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, 0 <= x <= 10) + @variable(model, 0 <= y <= 10) + @variable(model, p in MOI.Parameter(2.0)) + + # Minimize: x + y + p*x*y + # With p=2: minimize x + y + 2*x*y + # Subject to: x + y >= 2 + @constraint(model, x + y >= 2) + @objective(model, Min, x + y + p * x * y) + + optimize!(model) + @test termination_status(model) == OPTIMAL + # At p=2, optimal is x=y=1, obj = 1+1+2*1*1 = 4 + @test objective_value(model) ≈ 4.0 atol=1e-6 + + # Change p to 0 (removes cross term) + set_parameter_value(p, 0.0) + optimize!(model) + # At p=0, optimal is x=y=1, obj = 1+1+0 = 2 + @test objective_value(model) ≈ 2.0 atol=1e-6 +end + +# C2: PPV term - two parameters times one variable +function test_jump_cubic_ppv_basic() + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, x >= 0) + @variable(model, p in MOI.Parameter(2.0)) + @variable(model, q in MOI.Parameter(3.0)) + + # Minimize: x + p*q*x = x * (1 + p*q) + # With p=2, q=3: minimize x * (1 + 6) = 7x + # Subject to: x >= 1 + @constraint(model, x >= 1) + @objective(model, Min, x + p * q * x) + + optimize!(model) + @test termination_status(model) == OPTIMAL + # Optimal at x=1, obj = 7 + @test objective_value(model) ≈ 7.0 atol=1e-6 + + # Change p=1, q=1: minimize x*(1+1) = 2x + set_parameter_value(p, 1.0) + set_parameter_value(q, 1.0) + optimize!(model) + @test objective_value(model) ≈ 2.0 atol=1e-6 +end + +# C3: PPP term - three parameters (constant contribution) +function test_jump_cubic_ppp_basic() + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, x >= 0) + @variable(model, p in MOI.Parameter(2.0)) + @variable(model, q in MOI.Parameter(3.0)) + @variable(model, r in MOI.Parameter(4.0)) + + # Minimize: x + p*q*r + # With p=2, q=3, r=4: minimize x + 24 + # Subject to: x >= 1 + @constraint(model, x >= 1) + @objective(model, Min, x + p * q * r) + + optimize!(model) + @test termination_status(model) == OPTIMAL + # Optimal at x=1, obj = 1 + 24 = 25 + @test objective_value(model) ≈ 25.0 atol=1e-6 + + # Change p=1, q=1, r=1: minimize x + 1 + set_parameter_value(p, 1.0) + set_parameter_value(q, 1.0) + set_parameter_value(r, 1.0) + optimize!(model) + @test objective_value(model) ≈ 2.0 atol=1e-6 +end + +# C4: Mixed cubic terms +function test_jump_cubic_mixed_terms() + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, 0 <= x <= 10) + @variable(model, 0 <= y <= 10) + @variable(model, p in MOI.Parameter(1.0)) + @variable(model, q in MOI.Parameter(1.0)) + + # Minimize: p*x*y + p*q*x + x*y + p*x + x + 10 + # pvv + ppv + vv + pv + v + c + # (no ppp term in this test for simplicity) + @constraint(model, x + y >= 2) + @objective(model, Min, 1.0*p*x*y + p*q*x + x*y + p*x + x + 10) + + # With p=1, q=1: + # minimize: x*y + x + x*y + x + x + 10 = 2*x*y + 3x + 10 + optimize!(model) + @test termination_status(model) == OPTIMAL + # Verify result matches hand calculation +end + +# C5: Parameter changes affect optimization correctly +function test_jump_cubic_parameter_sensitivity() + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, x >= 0) + @variable(model, y >= 0) + @variable(model, p in MOI.Parameter(0.0)) + + @constraint(model, x + y == 1) + # Minimize: x² + y² + p*x*y + @objective(model, Min, x^2 + y^2 + p * x * y) + + # p=0: minimize x² + y² s.t. x+y=1 + # Solution: x=y=0.5, obj = 0.25 + 0.25 = 0.5 + optimize!(model) + @test value(x) ≈ 0.5 atol=1e-6 + @test value(y) ≈ 0.5 atol=1e-6 + @test objective_value(model) ≈ 0.5 atol=1e-6 + + # p=2: minimize x² + y² + 2xy = (x+y)² s.t. x+y=1 + # Any point on x+y=1 is optimal, obj = 1 + set_parameter_value(p, 2.0) + optimize!(model) + @test objective_value(model) ≈ 1.0 atol=1e-6 + @test value(x) + value(y) ≈ 1.0 atol=1e-6 + + # p=-2: minimize x² + y² - 2xy = (x-y)² s.t. x+y=1 + # Optimal at x=1,y=0 or x=0,y=1, obj = 0 + corner effect + set_parameter_value(p, -2.0) + optimize!(model) + # (x-y)² is minimized but x+y=1, so x² + y² - 2xy + # At x=0.5,y=0.5: 0.25 + 0.25 - 0.5 = 0 + @test objective_value(model) ≈ 0.0 atol=1e-6 +end +``` + +#### Category D: Edge Cases + +```julia +# D1: Cubic that simplifies when p=0 +@testset "cubic_parameter_zero" begin + # When p=0, x*y*p = 0 + # If all quadratic terms also vanish, result should be affine + # Test that _current_function returns correct type +end + +# D1b: Parameter initially zero, then updated to non-zero +# CRITICAL: Expression must be parsed as cubic even when p=0 initially, +# so that updating p later correctly adds the quadratic term +function test_jump_cubic_parameter_initially_zero() + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, x >= 0) + @variable(model, y >= 0) + @variable(model, p in MOI.Parameter(0.0)) # p = 0 initially + + @constraint(model, x + y >= 2) + # Objective: p*x*y + x + y + # With p=0: minimize 0 + x + y = x + y (effectively affine) + @objective(model, Min, p * x * y + x + y) + + # First solve with p=0 + optimize!(model) + @test termination_status(model) == OPTIMAL + @test objective_value(model) ≈ 2.0 atol=1e-6 # x=y=1, obj = 0 + 1 + 1 = 2 + + # NOW update p to non-zero - this is the critical test! + # The cubic term must have been stored, even though p was 0 + set_parameter_value(p, 2.0) + optimize!(model) + @test termination_status(model) == OPTIMAL + # With p=2: minimize 2*x*y + x + y s.t. x+y>=2 + # At x=y=1: obj = 2*1*1 + 1 + 1 = 4 + @test objective_value(model) ≈ 4.0 atol=1e-6 + + # Update p back to 0 - should return to original behavior + set_parameter_value(p, 0.0) + optimize!(model) + @test objective_value(model) ≈ 2.0 atol=1e-6 +end + +# D1c: Multiple cubic terms, some parameters zero +function test_jump_cubic_partial_zero_parameters() + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, x >= 0) + @variable(model, y >= 0) + @variable(model, p in MOI.Parameter(0.0)) # p = 0 initially + @variable(model, q in MOI.Parameter(1.0)) # q = 1 + + @constraint(model, x + y >= 2) + # Objective: p*x*y + q*x*y + x + y + # With p=0, q=1: minimize 0 + x*y + x + y + @objective(model, Min, p * x * y + q * x * y + x + y) + + # First solve + optimize!(model) + @test termination_status(model) == OPTIMAL + # At x=y=1: obj = 0 + 1 + 1 + 1 = 3 + @test objective_value(model) ≈ 3.0 atol=1e-6 + + # Update p to 2 (now both terms contribute) + set_parameter_value(p, 2.0) + optimize!(model) + # With p=2, q=1: minimize 2*x*y + x*y + x + y = 3*x*y + x + y + # At x=y=1: obj = 3 + 1 + 1 = 5 + @test objective_value(model) ≈ 5.0 atol=1e-6 + + # Set q to 0 as well + set_parameter_value(q, 0.0) + optimize!(model) + # With p=2, q=0: minimize 2*x*y + 0 + x + y = 2*x*y + x + y + # At x=y=1: obj = 2 + 1 + 1 = 4 + @test objective_value(model) ≈ 4.0 atol=1e-6 +end + +# D2: Cubic with negative parameter +@testset "cubic_negative_parameter" begin + # Verify sign handling is correct +end + +# D3: Cubic term where variable_1 == variable_2 +@testset "cubic_squared_variable" begin + # x^2 * p (same variable twice) +end + +# D4: Sign change warning for quadratic coefficients +function test_quadratic_sign_change_warning() + # Enable the warning option + inner_optimizer = HiGHS.Optimizer() + model = POI.Optimizer(inner_optimizer; warn_on_quadratic_sign_change = true) + MOI.set(model, MOI.Silent(), true) + + x = MOI.add_variable(model) + y = MOI.add_variable(model) + p, _ = MOI.add_constrained_variable(model, MOI.Parameter(2.0)) + + MOI.add_constraint(model, x, MOI.GreaterThan(0.0)) + MOI.add_constraint(model, y, MOI.GreaterThan(0.0)) + MOI.add_constraint(model, + MOI.ScalarAffineFunction([ + MOI.ScalarAffineTerm(1.0, x), + MOI.ScalarAffineTerm(1.0, y) + ], 0.0), + MOI.GreaterThan(1.0) + ) + + # Objective: p*x*y (starts with positive coefficient when p=2) + obj = MOI.ScalarNonlinearFunction(:*, Any[p, x, y]) + MOI.set(model, MOI.ObjectiveFunction{MOI.ScalarNonlinearFunction}(), obj) + MOI.set(model, MOI.ObjectiveSense(), MOI.MIN_SENSE) + + MOI.optimize!(model) + + # Change p from +2 to -2: should trigger warning + # The quadratic coefficient changes from +2 to -2 (sign change!) + @test_logs (:warn, r"Quadratic coefficient sign change") begin + MOI.set(model, POI.ParameterValue(), p, -2.0) + POI.update_parameters!(model) + end + + # Change p from -2 to -1: no warning (same sign) + @test_logs min_level=Logging.Warn begin + MOI.set(model, POI.ParameterValue(), p, -1.0) + POI.update_parameters!(model) + end +end + +# D5: No warning when option is disabled (default) +function test_quadratic_sign_change_no_warning_by_default() + # Default: warn_on_quadratic_sign_change = false + inner_optimizer = HiGHS.Optimizer() + model = POI.Optimizer(inner_optimizer) # default options + MOI.set(model, MOI.Silent(), true) + + x = MOI.add_variable(model) + y = MOI.add_variable(model) + p, _ = MOI.add_constrained_variable(model, MOI.Parameter(2.0)) + + MOI.add_constraint(model, x, MOI.GreaterThan(0.0)) + MOI.add_constraint(model, y, MOI.GreaterThan(0.0)) + + obj = MOI.ScalarNonlinearFunction(:*, Any[p, x, y]) + MOI.set(model, MOI.ObjectiveFunction{MOI.ScalarNonlinearFunction}(), obj) + MOI.set(model, MOI.ObjectiveSense(), MOI.MIN_SENSE) + + MOI.optimize!(model) + + # Sign change but no warning because option is disabled + @test_logs min_level=Logging.Warn begin + MOI.set(model, POI.ParameterValue(), p, -2.0) + POI.update_parameters!(model) + end +end +``` + +### 5.3 Simple Example Test Case + +**Test: `test_cubic_objective_simple`** + +```julia +function test_cubic_objective_simple() + # Setup: Simple QP that we can solve by hand + # + # minimize: x² + y² + x*y*p + # subject to: x + y >= 1 + # x, y >= 0 + # + # When p = 0: minimize x² + y² s.t. x+y>=1 + # Solution: x = y = 0.5, objective = 0.5 + # + # When p = 2: minimize x² + y² + 2xy = (x+y)² + # Solution: Any point on x+y=1, objective = 1 + # With x,y >= 0, optimal at x=1,y=0 or x=0,y=1 or any convex combo + + model = POI.Optimizer(HiGHS.Optimizer()) + MOI.set(model, MOI.Silent(), true) + + x = MOI.add_variable(model) + y = MOI.add_variable(model) + p, ci_p = MOI.add_constrained_variable(model, MOI.Parameter(0.0)) + + # Bounds + MOI.add_constraint(model, x, MOI.GreaterThan(0.0)) + MOI.add_constraint(model, y, MOI.GreaterThan(0.0)) + + # Constraint: x + y >= 1 + MOI.add_constraint(model, + MOI.ScalarAffineFunction([ + MOI.ScalarAffineTerm(1.0, x), + MOI.ScalarAffineTerm(1.0, y) + ], 0.0), + MOI.GreaterThan(1.0) + ) + + # Objective: x² + y² + x*y*p (as ScalarNonlinearFunction) + obj = MOI.ScalarNonlinearFunction(:+, Any[ + MOI.ScalarNonlinearFunction(:^, Any[x, 2]), + MOI.ScalarNonlinearFunction(:^, Any[y, 2]), + MOI.ScalarNonlinearFunction(:*, Any[x, y, p]) + ]) + MOI.set(model, MOI.ObjectiveFunction{MOI.ScalarNonlinearFunction}(), obj) + MOI.set(model, MOI.ObjectiveSense(), MOI.MIN_SENSE) + + # Solve with p = 0 + MOI.optimize!(model) + @test MOI.get(model, MOI.TerminationStatus()) == MOI.OPTIMAL + @test MOI.get(model, MOI.ObjectiveValue()) ≈ 0.5 atol=1e-6 + + # Update p = 2 and re-solve + MOI.set(model, POI.ParameterValue(), p, 2.0) + MOI.optimize!(model) + @test MOI.get(model, MOI.TerminationStatus()) == MOI.OPTIMAL + @test MOI.get(model, MOI.ObjectiveValue()) ≈ 1.0 atol=1e-6 +end +``` + +--- + +## Part 6: Implementation Order + +### Phase 1: Foundation (Tests First) + +1. **Write parser tests** (`test/parser_tests.jl`) + - Test expression tree traversal + - Test monomial classification + - Test valid/invalid detection + +2. **Implement parser** (`src/cubic_parser.jl`) + - `_parse_cubic_expression` + - `_expand_expression` + - `_classify_monomial` + - `_is_polynomial_operator` + +3. **Validate parser tests pass** + +### Phase 2: Data Structure + +4. **Write ParametricCubicFunction tests** + - Construction tests + - `_current_function` tests + +5. **Implement ParametricCubicFunction** (in `src/parametric_cubic_function.jl` - new file) + - Struct definition + - Constructor from `ParsedCubicExpression` + - `_current_function` (returns `ScalarQuadraticFunction` or `ScalarAffineFunction`) + - `_update_cache!` + - `_original_function` (reconstruct original expression) + +6. **Validate data structure tests pass** + +### Phase 3: Integration + +7. **Write objective integration tests** + - Setting cubic objectives + - Parameter updates + - Optimization verification + +8. **Implement MOI integration** (in `src/MOI_wrapper.jl`) + - Add `cubic_objective_cache` to Optimizer + - `MOI.set` for `ObjectiveFunction{ScalarNonlinearFunction}` + - `MOI.get` for objective retrieval + - Extend `_update_parameters!` + +9. **Validate all tests pass** + +### Phase 4: Documentation + +10. **Add docstrings** to all public functions + +11. **Update package documentation** + +### Phase 5: Tutorials (Post-Validation) + +12. **Progressive Hedging example** (after code is stable) + +--- + +## Part 7: File Organization + +**Design principle**: Most new code should be in **separate new files** to minimize changes to existing files. This keeps the codebase modular and reduces merge conflicts. + +### New Files (bulk of the implementation) + +``` +src/ +├── cubic_types.jl # NEW: _ScalarCubicTerm{T} struct and helpers +├── cubic_parser.jl # NEW: _parse_cubic_expression and helpers +├── parametric_cubic_function.jl # NEW: ParametricCubicFunction struct and methods +└── cubic_objective.jl # NEW: MOI objective setting/getting for cubic + +test/ +├── cubic_parser_tests.jl # NEW: Parser unit tests +└── cubic_jump_tests.jl # NEW: JuMP integration tests for cubic +``` + +### Minimal Changes to Existing Files + +``` +src/ +├── ParametricOptInterface.jl # MODIFY: Add includes and exports (few lines) +├── MOI_wrapper.jl # MODIFY: Add cubic_objective_cache field to Optimizer +│ # Add dispatch to _update_parameters! +└── update_parameters.jl # MODIFY: Call _update_cubic_objective! (few lines) + +test/ +├── runtests.jl # MODIFY: Include new test files +``` + +### File Responsibilities + +| File | Responsibility | Lines (est.) | +|------|----------------|--------------| +| `cubic_types.jl` | `_ScalarCubicTerm{T}`, helpers, accessors | ~60 | +| `cubic_parser.jl` | Expression tree parsing | ~200 | +| `parametric_cubic_function.jl` | Main data structure + methods | ~250 | +| `cubic_objective.jl` | MOI integration for objectives | ~150 | +| `cubic_parser_tests.jl` | Parser unit tests | ~300 | +| `cubic_jump_tests.jl` | JuMP integration tests | ~400 | + +### Include Order in ParametricOptInterface.jl + +```julia +# Add after existing includes: +include("cubic_types.jl") +include("cubic_parser.jl") +include("parametric_cubic_function.jl") +include("cubic_objective.jl") +``` + +--- + +## Part 8: Open Questions / Considerations + +### Q1: What if the solver doesn't support quadratic objectives? + +When `p` is substituted, the cubic becomes quadratic (or affine if all quadratic terms vanish). + +**Considerations**: +- If the inner optimizer doesn't support quadratic objectives but all quadratic terms have zero coefficients (e.g., all PVV parameters are 0), we can still proceed with an affine objective +- If quadratic terms are non-zero and the solver doesn't support them, throw a clear error +- `_current_function` should return the simplest possible type (`ScalarAffineFunction` when possible) + +**Decision**: Check `MOI.supports` dynamically based on the actual result of `_current_function`. + +### Q2: Should we support cubic in constraints? + +The user specified **objectives only**. This simplifies implementation significantly since: +- We don't need to handle constraint modifications +- We don't need dual computation for cubic constraints +- The inner optimizer sees only quadratic/affine constraints + +**Decision**: No constraint support in this implementation. + +### Q3: How to handle duals for cubic objectives? + +When a parameter appears in a cubic term, the dual interpretation is more complex. For now: +- Focus on primal optimization +- Document that dual sensitivity for cubic terms is not supported initially + +### Q3b: Should we accept ScalarNonlinearFunction with no cubic terms? + +If a user passes a `ScalarNonlinearFunction` that parses successfully but contains only quadratic/affine/constant terms (no PVV, PPV, or PPP), should we: + +**Option A**: Accept it and store in `cubic_objective_cache` +- Pro: Consistent handling of all ScalarNonlinearFunction +- Con: Overhead of cubic infrastructure for non-cubic functions + +**Option B**: Reject it with a helpful error suggesting to use ScalarQuadraticFunction +- Pro: Encourages proper function types +- Con: May be overly strict + +**Proposed**: Option A - accept and handle it. The overhead is minimal and it provides a smoother user experience. + +### Q4: JuMP integration + +Users will write `@objective(model, Min, x*y*p)` in JuMP. + +**Key insight**: If we correctly implement `MOI.set` for `ObjectiveFunction{ScalarNonlinearFunction}`, JuMP will automatically work because: +1. JuMP detects that `x*y*p` involves three "variables" (including the parameter) +2. JuMP constructs a `ScalarNonlinearFunction` for expressions beyond quadratic +3. JuMP calls `MOI.set(model, ObjectiveFunction{ScalarNonlinearFunction}(), f)` +4. Our implementation parses and handles it + +**Testing**: Full model tests should use JuMP syntax (`test/jump_tests.jl`) as these validate the end-to-end user experience. + +--- + +--- + +## Part 9: Verification Against Codebase + +This section documents verification of the plan against actual MOI, JuMP, and POI code. + +### 9.1 MOI ScalarNonlinearFunction (Verified ✓) + +**Location**: `MathOptInterface/src/functions.jl` (lines 276-351) + +**Confirmed structure:** +```julia +struct ScalarNonlinearFunction <: AbstractScalarFunction + head::Symbol + args::Vector{Any} +end +``` + +**Confirmed valid arg types:** +- `T <: Real` (constants) +- `VariableIndex` +- `ScalarAffineFunction` +- `ScalarQuadraticFunction` +- `ScalarNonlinearFunction` (nested) + +**Confirmed operators:** +- Multivariate: `:+`, `:-`, `:*`, `:^`, `:/`, `:ifelse`, `:min`, `:max` +- Unary: `:-` (negation), plus all math functions + +**Plan alignment**: ✓ Our parsing strategy correctly handles these types and operators. + +### 9.2 POI ParametricQuadraticFunction (Verified ✓) + +**Location**: `ParametricOptInterface/src/parametric_functions.jl` (lines 18-295) + +**Confirmed patterns:** +```julia +mutable struct ParametricQuadraticFunction{T} <: ParametricFunction{T} + affine_data::Dict{MOI.VariableIndex,T} # Variables in pv terms + affine_data_np::Dict{MOI.VariableIndex,T} # Variables NOT in pv terms + pv::Vector{MOI.ScalarQuadraticTerm{T}} + pp::Vector{MOI.ScalarQuadraticTerm{T}} + vv::Vector{MOI.ScalarQuadraticTerm{T}} + p::Vector{MOI.ScalarAffineTerm{T}} + v::Vector{MOI.ScalarAffineTerm{T}} + c::T + set_constant::T + current_terms_with_p::Dict{MOI.VariableIndex,T} + current_constant::T +end +``` + +**Key helper functions:** +- `_split_quadratic_terms()` - Categorizes into vv/pp/pv +- `_split_affine_terms()` - Categorizes into v/p +- `_parametric_constant()` - Computes constant with parameter values +- `_parametric_affine_terms()` - Computes affine coefficients with parameters +- `_is_parameter()` / `_is_variable()` - Index classification + +**Plan alignment**: ✓ Updated ParametricCubicFunction to follow the same Dict-based caching pattern. + +### 9.3 Current POI Limitation (Verified ✓) + +**Confirmed**: POI currently only supports up to quadratic expressions. + +**From documentation** (`docs/src/manual.md`): +- Supported: `ScalarAffineFunction`, `ScalarQuadraticFunction`, `VectorAffineFunction` +- NOT supported: `ScalarNonlinearFunction` + +**From tests** (`test/jump_tests.jl`, lines 854-862): +```julia +function test_jump_nlp() + # ... nonlinear objective throws ErrorException + @test_throws ErrorException optimize!(model) +end +``` + +**Plan alignment**: ✓ This confirms our implementation fills a real gap - cubic expressions with parameters are not currently supported. + +### 9.4 JuMP Expression Generation + +**Confirmed behavior:** +- JuMP treats parameters (via `MOI.Parameter`) as special `VariableIndex` values +- For expressions beyond quadratic degree, JuMP creates `ScalarNonlinearFunction` +- Expression `p * x * y` would generate a nonlinear function (currently rejected by POI) + +**Plan alignment**: ✓ When we implement `MOI.set` for `ObjectiveFunction{ScalarNonlinearFunction}`, JuMP's `@objective(model, Min, p*x*y)` will work automatically. + +### 9.5 Parameter Index Threshold + +**Location**: `ParametricOptInterface/src/ParametricOptInterface.jl` (lines 21-38) + +```julia +const PARAMETER_INDEX_THRESHOLD = 4_611_686_018_427_387_904 +``` + +**Plan alignment**: ✓ Our parser must use `_is_parameter()` to distinguish parameters from variables when classifying monomials. + +### 9.6 MOI Utilities Available (Verified ✓) + +**Location**: `MathOptInterface/src/Utilities/functions.jl` and `MathOptInterface/src/Nonlinear/` + +**Available utilities that could simplify implementation:** + +| Utility | What it does | Potential use | +|---------|--------------|---------------| +| `substitute_variables(fn, f)` | Replace variables via mapping function | Limited - returns SNF not polynomial | +| `canonical(f)` | Normalize (combine terms, sort) | Post-processing parsed result | +| `map_indices(fn, f)` | Remap variable indices in tree | Index transformations | +| `operate(op, T, args...)` | Compose functions with +, -, *, etc. | Building result functions | +| `eval_variables(value_fn, model, f)` | Evaluate expression numerically | Not useful - we need symbolic result | + +**Decision**: Use `canonical()` and `operate()` where beneficial. Build custom monomial expansion logic since MOI doesn't provide polynomial-specific utilities. + +**Key finding**: MOI's `substitute_variables` cannot convert `ScalarNonlinearFunction` to `ScalarQuadraticFunction` - it preserves the nonlinear type. Our custom parser is necessary. + +--- + +## Summary + +This plan provides a structured approach to implementing parametric cubic functions: + +1. **Parse** `ScalarNonlinearFunction` to detect valid cubic polynomials +2. **Store** in `ParametricCubicFunction` with proper term categorization +3. **Integrate** with POI's objective handling (objectives only) +4. **Test** thoroughly with simple, predictable examples +5. **Document** all functions + +The key insight is that when parameters are substituted with values, a cubic function `c*x*y*p` becomes a quadratic function `c*p_val*x*y`, which existing solvers can handle. diff --git a/src/MOI_wrapper.jl b/src/MOI_wrapper.jl index 646dda44..f561e593 100644 --- a/src/MOI_wrapper.jl +++ b/src/MOI_wrapper.jl @@ -435,7 +435,7 @@ function MOI.delete(model::Optimizer, v::MOI.VariableIndex) MOI.delete(model.optimizer, v) MOI.delete(model.original_objective_cache, v) # TODO - what happens if the variable was in a SAF that was converted to bounds? - # solution: do not allow if that is the case (requires going trhought the scalar affine cache) + # solution: do not allow if that is the case (requires going through the scalar affine cache) # TODO - deleting a variable also deletes constraints for (F, S) in MOI.Utilities.DoubleDicts.nonempty_outer_keys( model.constraint_outer_to_inner, @@ -1257,6 +1257,7 @@ end function _empty_objective_function_caches!(model::Optimizer{T}) where {T} model.affine_objective_cache = nothing model.quadratic_objective_cache = nothing + model.cubic_objective_cache = nothing model.original_objective_cache = MOI.Utilities.ObjectiveContainer{T}() return end diff --git a/src/ParametricOptInterface.jl b/src/ParametricOptInterface.jl index 5c2080ad..22b9b71d 100644 --- a/src/ParametricOptInterface.jl +++ b/src/ParametricOptInterface.jl @@ -74,11 +74,19 @@ const VariableMap = MOI.Utilities.CleverDicts.CleverDict{ const DoubleDict{T} = MOI.Utilities.DoubleDicts.DoubleDict{T} const DoubleDictInner{F,S,T} = MOI.Utilities.DoubleDicts.DoubleDictInner{F,S,T} +# +# cubic functions helpers +# + +include("cubic_types.jl") +include("cubic_parser.jl") + # # parametric functions # include("parametric_functions.jl") +include("parametric_cubic_function.jl") """ Optimizer{T, OT <: MOI.ModelLike} <: MOI.AbstractOptimizer @@ -151,6 +159,7 @@ mutable struct Optimizer{T,OT<:MOI.ModelLike} <: MOI.AbstractOptimizer # Clever cache of data (at most one can be !== nothing) affine_objective_cache::Union{Nothing,ParametricAffineFunction{T}} quadratic_objective_cache::Union{Nothing,ParametricQuadraticFunction{T}} + cubic_objective_cache::Union{Nothing,ParametricCubicFunction{T}} original_objective_cache::MOI.Utilities.ObjectiveContainer{T} # Store parametric expressions for product of variables quadratic_objective_cache_product::Dict{ @@ -226,7 +235,7 @@ mutable struct Optimizer{T,OT<:MOI.ModelLike} <: MOI.AbstractOptimizer # objective nothing, nothing, - # nothing, + nothing, # cubic_objective_cache MOI.Utilities.ObjectiveContainer{T}(), Dict{ Tuple{MOI.VariableIndex,MOI.VariableIndex}, @@ -275,5 +284,6 @@ end include("duals.jl") include("update_parameters.jl") include("MOI_wrapper.jl") +include("cubic_objective.jl") end # module diff --git a/src/cubic_objective.jl b/src/cubic_objective.jl new file mode 100644 index 00000000..353693e4 --- /dev/null +++ b/src/cubic_objective.jl @@ -0,0 +1,193 @@ +# Copyright (c) 2020: Tomás Gutierrez and contributors +# +# Use of this source code is governed by an MIT-style license that can be found +# in the LICENSE.md file or at https://opensource.org/licenses/MIT. + +function MOI.set( + model::Optimizer{T}, + ::MOI.ObjectiveFunction{MOI.ScalarNonlinearFunction}, + f::MOI.ScalarNonlinearFunction, +) where {T} + # 1. Attempt to parse as cubic + parsed = _parse_cubic_expression(f, T) + if parsed === nothing + error( + "ScalarNonlinearFunction must be a valid cubic polynomial with " * + "parameters multiplying at most quadratic variable terms. " * + "Non-polynomial operations or degree > 3 are not supported.", + ) + end + + # 2. Create ParametricCubicFunction + cubic_func = ParametricCubicFunction(parsed) + + # 3. Compute current function for inner optimizer + current = _current_function(cubic_func, model) + + # 4. Set current function on inner optimizer + try + MOI.set( + model.optimizer, + MOI.ObjectiveFunction{typeof(current)}(), + current, + ) + catch e + # rethrow the original error with the additional info of the objective function that caused it + error( + "Failed to set cubic objective function, f = $f, on inner " * + "optimizer. " * + "This may be due to unsupported features in the cubic " * + "expression. " * + "Original error: $(e.msg)", + ) + end + + # 5. Clear old caches + _empty_objective_function_caches!(model) + + # 6. Store new cache + model.cubic_objective_cache = cubic_func + + # 7. Store original for retrieval if option is enabled + if model.save_original_objective_and_constraints + MOI.set( + model.original_objective_cache, + MOI.ObjectiveFunction{MOI.ScalarNonlinearFunction}(), + f, + ) + end + + return nothing +end + +function MOI.get( + model::Optimizer, + attr::MOI.ObjectiveFunction{MOI.ScalarNonlinearFunction}, +) + if model.cubic_objective_cache === nothing + error("No ScalarNonlinearFunction objective is set") + end + if !model.save_original_objective_and_constraints + error( + "Cannot retrieve original objective: save_original_objective_and_constraints is false", + ) + end + return MOI.get(model.original_objective_cache, attr) +end + +function MOI.supports( + ::Optimizer, + ::MOI.ObjectiveFunction{MOI.ScalarNonlinearFunction}, +) + return true +end + +""" + _update_cubic_objective!(model::Optimizer{T}) where {T} + +Update the cubic objective after parameters have changed. +Uses incremental modifications (ScalarQuadraticCoefficientChange, ScalarCoefficientChange, +ScalarConstantChange) for efficiency when the solver supports them. +Falls back to rebuilding the full objective if incremental modifications are not supported. +""" +function _update_cubic_objective!(model::Optimizer{T}) where {T} + if model.cubic_objective_cache === nothing + return + end + pf = model.cubic_objective_cache + + # Check if any changes are needed by computing deltas + delta_constant = _delta_parametric_constant(model, pf) + delta_affine = _delta_parametric_affine_terms(model, pf) + delta_quadratic = _delta_parametric_quadratic_terms(model, pf) + + if iszero(delta_constant) && + isempty(delta_affine) && + isempty(delta_quadratic) + return # No changes needed + end + + # Try incremental modifications first (more efficient for solvers that support it) + if _try_incremental_cubic_update!( + model, + pf, + delta_constant, + delta_affine, + delta_quadratic, + ) + return nothing + end + + # Fallback: Rebuild and reset the complete objective function + # This is needed for solvers that don't support incremental modifications + current = _current_function(pf, model) + MOI.set(model.optimizer, MOI.ObjectiveFunction{typeof(current)}(), current) + + return nothing +end + +""" + _try_incremental_cubic_update!(model, pf, delta_constant, delta_affine, delta_quadratic) + +Try to apply incremental coefficient updates. Returns true if successful, false otherwise. +""" +function _try_incremental_cubic_update!( + model::Optimizer{T}, + pf::ParametricCubicFunction{T}, + delta_constant::T, + delta_affine::Dict{MOI.VariableIndex,T}, + delta_quadratic::Dict{Tuple{MOI.VariableIndex,MOI.VariableIndex},T}, +) where {T} + # Get the current objective function type from the inner optimizer + F = MOI.get(model.optimizer, MOI.ObjectiveFunctionType()) + + # Compute full new values (not deltas) for robustness + # The delta was used to detect changes; now apply full new coefficients + new_quad_terms = _parametric_quadratic_terms(model, pf) + new_affine_terms = _parametric_affine_terms(model, pf) + new_constant = _parametric_constant(model, pf) + + # Apply quadratic coefficient changes + # MOI convention: + # - Off-diagonal (v1 != v2): coefficient C means C*v1*v2 (use as-is) + # - Diagonal (v1 == v2): coefficient C means (C/2)*v1^2 (multiply by 2) + for ((var1, var2), _) in delta_quadratic + new_coef = new_quad_terms[(var1, var2)] + # Apply MOI coefficient convention + moi_coef = var1 == var2 ? new_coef * 2 : new_coef + try + MOI.modify( + model.optimizer, + MOI.ObjectiveFunction{F}(), + MOI.ScalarQuadraticCoefficientChange(var1, var2, moi_coef), + ) + catch e + if e isa MOI.ModifyObjectiveNotAllowed + return false + end + rethrow(e) + end + end + + # Apply affine coefficient changes (use full new coefficient) + for (var, _) in delta_affine + new_coef = new_affine_terms[var] + MOI.modify( + model.optimizer, + MOI.ObjectiveFunction{F}(), + MOI.ScalarCoefficientChange(var, new_coef), + ) + end + + # Apply constant change + if !iszero(delta_constant) + pf.current_constant = new_constant + MOI.modify( + model.optimizer, + MOI.ObjectiveFunction{F}(), + MOI.ScalarConstantChange(pf.current_constant), + ) + end + + return true +end diff --git a/src/cubic_parser.jl b/src/cubic_parser.jl new file mode 100644 index 00000000..2754cc13 --- /dev/null +++ b/src/cubic_parser.jl @@ -0,0 +1,514 @@ +# Copyright (c) 2020: Tomás Gutierrez and contributors +# +# Use of this source code is governed by an MIT-style license that can be found +# in the LICENSE.md file or at https://opensource.org/licenses/MIT. + +""" + _Monomial{T} + +Intermediate representation of a monomial during parsing. +""" +struct _Monomial{T} + coefficient::T + variables::Vector{MOI.VariableIndex} # includes both vars and params +end + +function _Monomial{T}(coefficient::T) where {T} + return _Monomial{T}(coefficient, MOI.VariableIndex[]) +end + +function _Monomial{T}(coefficient::T, var::MOI.VariableIndex) where {T} + return _Monomial{T}(coefficient, [var]) +end + +""" + _monomial_degree(m::_Monomial) -> Int + +Total degree of a monomial (number of variable/parameter factors). +""" +function _monomial_degree(m::_Monomial) + return length(m.variables) +end + +""" + _multiply_monomials(m1::_Monomial{T}, m2::_Monomial{T}) where {T} + +Multiply two monomials together. +""" +function _multiply_monomials(m1::_Monomial{T}, m2::_Monomial{T}) where {T} + return _Monomial{T}( + m1.coefficient * m2.coefficient, + vcat(m1.variables, m2.variables), + ) +end + +""" + _scale_monomial(m::_Monomial{T}, scalar::T) where {T} + +Scale a monomial by a scalar. +""" +function _scale_monomial(m::_Monomial{T}, scalar::T) where {T} + return _Monomial{T}(m.coefficient * scalar, copy(m.variables)) +end + +""" + _is_polynomial_operator(head::Symbol) -> Bool + +Check if the operator is valid for polynomial expressions. +""" +function _is_polynomial_operator(head::Symbol) + return head in (:+, :-, :*, :^, :/) +end + +""" + _ParsedCubicExpression{T} + +Result of parsing a ScalarNonlinearFunction into cubic polynomial form. +""" +struct _ParsedCubicExpression{T} + pvv::Vector{_ScalarCubicTerm{T}} # Cubic terms with 1 parameter and 2 variables + ppv::Vector{_ScalarCubicTerm{T}} # Cubic terms with 2 parameters and 1 variable + ppp::Vector{_ScalarCubicTerm{T}} # Cubic terms with 3 parameters + + vv::Vector{MOI.ScalarQuadraticTerm{T}} # Quadratic terms with 2 variables + pv::Vector{MOI.ScalarQuadraticTerm{T}} # Quadratic terms with 1 parameter and 1 variable + pp::Vector{MOI.ScalarQuadraticTerm{T}} # Quadratic terms with 2 parameters + + v::Vector{MOI.ScalarAffineTerm{T}} # Affine terms with 1 variable + p::Vector{MOI.ScalarAffineTerm{T}} # Affine terms with 1 parameter + + constant::T # Constant term +end + +""" + _expand_to_monomials(arg, ::Type{T}) where {T} -> Union{Vector{_Monomial{T}}, Nothing} + +Expand an expression argument to a list of monomials. +Returns `nothing` if the expression is not a valid polynomial. +""" +function _expand_to_monomials(arg::Real, ::Type{T}) where {T} + return [_Monomial{T}(T(arg))] +end + +function _expand_to_monomials(arg::MOI.VariableIndex, ::Type{T}) where {T} + return [_Monomial{T}(one(T), arg)] +end + +function _expand_to_monomials( + arg::MOI.ScalarAffineFunction{T}, + ::Type{T}, +) where {T} + monomials = _Monomial{T}[] + for term in arg.terms + push!(monomials, _Monomial{T}(term.coefficient, term.variable)) + end + if !iszero(arg.constant) + push!(monomials, _Monomial{T}(arg.constant)) + end + return monomials +end + +function _expand_to_monomials( + arg::MOI.ScalarQuadraticFunction{T}, + ::Type{T}, +) where {T} + monomials = _Monomial{T}[] + # Quadratic terms + # MOI convention: + # - Off-diagonal (v1 != v2): coefficient C represents C*v1*v2 + # - Diagonal (v1 == v2): coefficient C represents (C/2)*v1^2 + for term in arg.quadratic_terms + coef = term.coefficient + if term.variable_1 == term.variable_2 + coef = coef / 2 # Diagonal: undo MOI's factor of 2 + end + # Off-diagonal: use coefficient as-is + push!(monomials, _Monomial{T}(coef, [term.variable_1, term.variable_2])) + end + # Affine terms + for term in arg.affine_terms + push!(monomials, _Monomial{T}(term.coefficient, term.variable)) + end + # Constant + if !iszero(arg.constant) + push!(monomials, _Monomial{T}(arg.constant)) + end + return monomials +end + +function _expand_to_monomials( + f::MOI.ScalarNonlinearFunction, + ::Type{T}, +) where {T} + head = f.head + args = f.args + + if !_is_polynomial_operator(head) + return nothing # Non-polynomial operator + end + + if head == :+ + return _expand_addition(args, T) + elseif head == :- + return _expand_subtraction(args, T) + elseif head == :* + return _expand_multiplication(args, T) + elseif head == :/ + return _expand_division(args, T) + elseif head == :^ + return _expand_power(args, T) + end + + return nothing +end + +""" + _expand_addition(args, ::Type{T}) where {T} + +Expand addition: collect monomials from all arguments. +""" +function _expand_addition(args, ::Type{T}) where {T} + result = _Monomial{T}[] + for arg in args + monomials = _expand_to_monomials(arg, T) + if monomials === nothing + return nothing + end + append!(result, monomials) + end + return result +end + +""" + _expand_subtraction(args, ::Type{T}) where {T} + +Expand subtraction: first arg positive, rest negative. +""" +function _expand_subtraction(args, ::Type{T}) where {T} + result = _Monomial{T}[] + + if length(args) == 1 + # Unary minus + monomials = _expand_to_monomials(args[1], T) + if monomials === nothing + return nothing + end + for m in monomials + push!(result, _scale_monomial(m, -one(T))) + end + else + # Binary subtraction + for (i, arg) in enumerate(args) + monomials = _expand_to_monomials(arg, T) + if monomials === nothing + return nothing + end + if i == 1 + append!(result, monomials) + else + for m in monomials + push!(result, _scale_monomial(m, -one(T))) + end + end + end + end + return result +end + +""" + _expand_multiplication(args, ::Type{T}) where {T} + +Expand multiplication: multiply all arguments together. +""" +function _expand_multiplication(args, ::Type{T}) where {T} + # Start with identity monomial + result = [_Monomial{T}(one(T))] + + for arg in args + monomials = _expand_to_monomials(arg, T) + if monomials === nothing + return nothing + end + + # Multiply each result monomial with each new monomial + new_result = _Monomial{T}[] + for m1 in result + for m2 in monomials + push!(new_result, _multiply_monomials(m1, m2)) + end + end + result = new_result + end + + return result +end + +""" + _expand_division(args, ::Type{T}) where {T} + +Expand division: multiply numerator by the inverse of denominator +""" +function _expand_division(args, ::Type{T}) where {T} + if length(args) != 2 + return nothing + end + + numerator = args[1] + denominator = args[2] + + # denominator must be a nonzero constant (no variables or parameters) + if !(denominator isa Real) || iszero(denominator) + return nothing + end + + return _expand_multiplication([one(T) / denominator, numerator], T) +end + +""" + _expand_power(args, ::Type{T}) where {T} + +Expand power: x^n becomes x*x*...*x (n times). +""" +function _expand_power(args, ::Type{T}) where {T} + if length(args) != 2 + return nothing + end + + base = args[1] + exponent = args[2] + + # Exponent must be a non-negative integer + if !(exponent isa Integer) || exponent < 0 + return nothing + end + + n = Int(exponent) + + if n == 0 + return [_Monomial{T}(one(T))] + end + + base_monomials = _expand_to_monomials(base, T) + if base_monomials === nothing + return nothing + end + + # x^n = x * x * ... * x (n times) + result = base_monomials + for _ in 2:n + new_result = _Monomial{T}[] + for m1 in result + for m2 in base_monomials + push!(new_result, _multiply_monomials(m1, m2)) + end + end + result = new_result + end + + return result +end + +""" + _combine_like_monomials(monomials::Vector{_Monomial{T}}) where {T} + +Combine like monomials (same variables, regardless of order). +""" +function _combine_like_monomials(monomials::Vector{_Monomial{T}}) where {T} + # Use a dict keyed by sorted variable tuple + combined = Dict{Vector{MOI.VariableIndex},T}() + + for m in monomials + # Sort variables for canonical key + key = sort(m.variables, by = v -> v.value) + combined[key] = get(combined, key, zero(T)) + m.coefficient + end + + result = _Monomial{T}[] + for (vars, coef) in combined + if !iszero(coef) + push!(result, _Monomial{T}(coef, vars)) + end + end + + return result +end + +""" + _classify_monomial(m::_Monomial) -> Symbol + +Classify a monomial by its structure. +""" +function _classify_monomial(m::_Monomial) + degree = _monomial_degree(m) + num_params = count(_is_parameter, m.variables) + + if degree == 0 + return :constant + elseif degree == 1 + return num_params == 1 ? :p : :v + elseif degree == 2 + if num_params == 0 + return :vv + elseif num_params == 1 + return :pv + else + return :pp + end + elseif degree == 3 + if num_params == 0 + return :vvv # Invalid - no parameter + elseif num_params == 1 + return :pvv + elseif num_params == 2 + return :ppv + else + return :ppp + end + else + return :invalid # Degree > 3 + end +end + +""" + _parse_cubic_expression(f::MOI.ScalarNonlinearFunction, ::Type{T}) where {T} -> Union{_ParsedCubicExpression{T}, Nothing} + +Parse a ScalarNonlinearFunction and return a _ParsedCubicExpression if it represents +a valid cubic polynomial (with parameters multiplying at most quadratic variable terms). + +Returns `nothing` if the expression: +- Contains non-polynomial operations (sin, exp, etc.) +- Has degree > 3 in any monomial +- Has a cubic term with no parameters (x*y*z) +""" +function _parse_cubic_expression( + f::MOI.ScalarNonlinearFunction, + ::Type{T}, +) where {T} + # Expand to monomials + monomials = _expand_to_monomials(f, T) + if monomials === nothing + return nothing + end + + # Combine like terms + monomials = _combine_like_monomials(monomials) + + # Classify and collect terms + cubic_terms = _ScalarCubicTerm{T}[] + + cubic_ppp = _ScalarCubicTerm{T}[] + cubic_ppv = _ScalarCubicTerm{T}[] + cubic_pvv = _ScalarCubicTerm{T}[] + + quadratic_pp = MOI.ScalarQuadraticTerm{T}[] + quadratic_pv = MOI.ScalarQuadraticTerm{T}[] + quadratic_vv = MOI.ScalarQuadraticTerm{T}[] + + affine_p = MOI.ScalarAffineTerm{T}[] + affine_v = MOI.ScalarAffineTerm{T}[] + + constant = zero(T) + + for m in monomials + classification = _classify_monomial(m) + + if classification == :invalid || classification == :vvv + return nothing # Invalid degree or no parameter in cubic + elseif classification == :constant + constant += m.coefficient + elseif classification == :v + push!( + affine_v, + MOI.ScalarAffineTerm{T}(m.coefficient, m.variables[1]), + ) + elseif classification == :p + push!( + affine_p, + MOI.ScalarAffineTerm{T}(m.coefficient, m.variables[1]), + ) + elseif classification == :pp + p1 = m.variables[1] + p2 = m.variables[2] + # Sort for canonical order + if p1.value > p2.value + p1, p2 = p2, p1 + end + divisor = p1 == p2 ? T(2) : T(1) # Diagonal vs off-diagonal + push!( + quadratic_pp, + MOI.ScalarQuadraticTerm{T}(m.coefficient * divisor, p1, p2), + ) + elseif classification == :pv + v1 = m.variables[1] + v2 = m.variables[2] + # Sort for canonical order + if v1.value > v2.value + v1, v2 = v2, v1 + end + divisor = v1 == v2 ? T(2) : T(1) # Diagonal vs off-diagonal + push!( + quadratic_pv, + MOI.ScalarQuadraticTerm{T}(m.coefficient * divisor, v1, v2), + ) + elseif classification == :vv + v1 = m.variables[1] + v2 = m.variables[2] + # Sort for canonical order + if v1.value > v2.value + v1, v2 = v2, v1 + end + divisor = v1 == v2 ? T(2) : T(1) # Diagonal vs off-diagonal + push!( + quadratic_vv, + MOI.ScalarQuadraticTerm{T}(m.coefficient * divisor, v1, v2), + ) + elseif classification == :ppp + push!( + cubic_ppp, + _make_cubic_term( + m.coefficient, + m.variables[1], + m.variables[2], + m.variables[3], + ), + ) + elseif classification == :ppv + push!( + cubic_ppv, + _make_cubic_term( + m.coefficient, + m.variables[1], + m.variables[2], + m.variables[3], + ), + ) + else # classification == :pvv + push!( + cubic_pvv, + _make_cubic_term( + m.coefficient, + m.variables[1], + m.variables[2], + m.variables[3], + ), + ) + end + end + + return _ParsedCubicExpression{T}( + cubic_pvv, + cubic_ppv, + cubic_ppp, + quadratic_vv, + quadratic_pv, + quadratic_pp, + affine_v, + affine_p, + constant, + ) +end + +# Convenience method with type inference +function _parse_cubic_expression(f::MOI.ScalarNonlinearFunction) + return _parse_cubic_expression(f, Float64) +end diff --git a/src/cubic_types.jl b/src/cubic_types.jl new file mode 100644 index 00000000..cdf15d99 --- /dev/null +++ b/src/cubic_types.jl @@ -0,0 +1,97 @@ +# Copyright (c) 2020: Tomás Gutierrez and contributors +# +# Use of this source code is governed by an MIT-style license that can be found +# in the LICENSE.md file or at https://opensource.org/licenses/MIT. + +""" + _ScalarCubicTerm{T} + +Represents a cubic term of the form `coefficient * index_1 * index_2 * index_3`. + +Each index can be either a variable (MOI.VariableIndex) or a parameter (encoded as +VariableIndex with value > PARAMETER_INDEX_THRESHOLD). + +# Fields +- `coefficient::T`: The numeric coefficient +- `index_1::MOI.VariableIndex`: First factor (parameter always) +- `index_2::MOI.VariableIndex`: Second factor (variable or parameter) +- `index_3::MOI.VariableIndex`: Third factor (variable or parameter) + +# Convention +Indices are stored in canonical order: +- Parameters come before variables +- Within each group, sorted by index value +This ensures `2*p*x*y` and `2*x*p*y` produce the same term. +""" +struct _ScalarCubicTerm{T} + coefficient::T + index_1::MOI.VariableIndex + index_2::MOI.VariableIndex + index_3::MOI.VariableIndex +end + +""" + _cubic_term_type(term::_ScalarCubicTerm) -> Symbol + +Classify a cubic term by the number of parameters vs variables. + +Returns: +- `:pvv` - 1 parameter, 2 variables (becomes quadratic after substitution) +- `:ppv` - 2 parameters, 1 variable (becomes affine after substitution) +- `:ppp` - 3 parameters (becomes constant after substitution) +""" +function _cubic_term_type(term::_ScalarCubicTerm) + num_params = + _is_parameter(term.index_1) + + _is_parameter(term.index_2) + + _is_parameter(term.index_3) + if num_params == 1 + return :pvv + elseif num_params == 2 + return :ppv + else # num_params == 3 + return :ppp + end +end + +""" + _normalize_cubic_indices(idx1, idx2, idx3) -> (idx1, idx2, idx3) + +Normalize cubic term indices to canonical order: +- Parameters come before variables +- Within each group, sorted by index value +""" +function _normalize_cubic_indices( + idx1::MOI.VariableIndex, + idx2::MOI.VariableIndex, + idx3::MOI.VariableIndex, +) + params = MOI.VariableIndex[] + vars = MOI.VariableIndex[] + for idx in (idx1, idx2, idx3) + if _is_parameter(idx) + push!(params, idx) + else + push!(vars, idx) + end + end + sort!(params, by = v -> v.value) + sort!(vars, by = v -> v.value) + all_indices = vcat(params, vars) + return all_indices[1], all_indices[2], all_indices[3] +end + +""" + _make_cubic_term(coefficient::T, idx1, idx2, idx3) where {T} + +Create a cubic term with normalized index order. +""" +function _make_cubic_term( + coefficient::T, + idx1::MOI.VariableIndex, + idx2::MOI.VariableIndex, + idx3::MOI.VariableIndex, +) where {T} + n1, n2, n3 = _normalize_cubic_indices(idx1, idx2, idx3) + return _ScalarCubicTerm{T}(coefficient, n1, n2, n3) +end diff --git a/src/parametric_cubic_function.jl b/src/parametric_cubic_function.jl new file mode 100644 index 00000000..eac90cbe --- /dev/null +++ b/src/parametric_cubic_function.jl @@ -0,0 +1,475 @@ +# Copyright (c) 2020: Tomás Gutierrez and contributors +# +# Use of this source code is governed by an MIT-style license that can be found +# in the LICENSE.md file or at https://opensource.org/licenses/MIT. + +""" + ParametricCubicFunction{T} <: ParametricFunction{T} + +Represents a cubic function where parameters multiply up to quadratic variable terms. + +Supports the general form: + constant + Σ(affine) + Σ(quadratic) + Σ(cubic) + +After parameter substitution, cubic terms become: +- PVV (p*x*y) → quadratic term (c*p_val*x*y) +- PPV (p*q*x) → affine term (c*p_val*q_val*x) +- PPP (p*q*r) → constant (c*p_val*q_val*r_val) +""" +mutable struct ParametricCubicFunction{T} <: ParametricFunction{T} + # === Cubic terms (degree 3) - split by type like quadratic terms === + pvv::Vector{_ScalarCubicTerm{T}} # p*x*y → becomes quadratic + ppv::Vector{_ScalarCubicTerm{T}} # p*q*x → becomes affine + ppp::Vector{_ScalarCubicTerm{T}} # p*q*r → becomes constant + + # === Quadratic terms (degree 2) - same pattern as ParametricQuadraticFunction === + pv::Vector{MOI.ScalarQuadraticTerm{T}} # p*x → becomes affine + pp::Vector{MOI.ScalarQuadraticTerm{T}} # p*q → becomes constant + vv::Vector{MOI.ScalarQuadraticTerm{T}} # x*y → stays quadratic + + # === Affine terms (degree 1) === + p::Vector{MOI.ScalarAffineTerm{T}} # p → becomes constant + v::Vector{MOI.ScalarAffineTerm{T}} # x → stays affine + + # === Constant (degree 0) === + c::T + + # === Caches for efficient updates === + # Quadratic coefficients (from vv + pvv terms) - tracks current values in solver + quadratic_data::Dict{Tuple{MOI.VariableIndex,MOI.VariableIndex},T} + # Affine coefficients (from v + pv + ppv terms) + affine_data::Dict{MOI.VariableIndex,T} + # Affine coefficients not dependent on parameters + affine_data_np::Dict{MOI.VariableIndex,T} + # Current constant after parameter substitution + current_constant::T + # Set constant (for constraint handling, not used for objectives) + set_constant::T +end + +""" + ParametricCubicFunction(parsed::_ParsedCubicExpression{T}) where {T} + +Construct a ParametricCubicFunction from a _ParsedCubicExpression. +""" +function ParametricCubicFunction(parsed::_ParsedCubicExpression{T}) where {T} + + # Find variables related to parameters (from pv and ppv terms) + v_in_param_terms = Set{MOI.VariableIndex}() + for term in parsed.pv + push!(v_in_param_terms, term.variable_2) + end + for term in parsed.ppv + var = term.index_3 + push!(v_in_param_terms, var) + end + + # Split affine data + affine_data = Dict{MOI.VariableIndex,T}() + affine_data_np = Dict{MOI.VariableIndex,T}() + for term in parsed.v + if term.variable in v_in_param_terms + affine_data[term.variable] = + get(affine_data, term.variable, zero(T)) + term.coefficient + else + affine_data_np[term.variable] = + get(affine_data_np, term.variable, zero(T)) + term.coefficient + end + end + + # Find variable pairs related to parameters (from pvv terms) + var_pairs_in_param_terms = Set{Tuple{MOI.VariableIndex,MOI.VariableIndex}}() + for term in parsed.pvv + v1 = term.index_2 + v2 = term.index_3 + if v1.value > v2.value + v1, v2 = v2, v1 + end + push!(var_pairs_in_param_terms, (v1, v2)) + end + + # Initialize quadratic data + # Note: vv terms come from the parsed quadratic_func, which already has + # the MOI coefficient convention applied. We need to convert to internal form. + # MOI convention: + # - Off-diagonal (v1 != v2): coefficient C means C*v1*v2 (use as-is) + # - Diagonal (v1 == v2): coefficient C means (C/2)*v1^2 (divide by 2) + quadratic_data = Dict{Tuple{MOI.VariableIndex,MOI.VariableIndex},T}() + for term in parsed.vv + v1, v2 = term.variable_1, term.variable_2 + if v1.value > v2.value + v1, v2 = v2, v1 + end + coef = term.coefficient + if term.variable_1 == term.variable_2 + coef = coef / 2 # Diagonal: undo MOI's factor + end + quadratic_data[(v1, v2)] = get(quadratic_data, (v1, v2), zero(T)) + coef + end + # Add entries for pvv terms (will be updated with parameter values later) + for pair in var_pairs_in_param_terms + if !haskey(quadratic_data, pair) + quadratic_data[pair] = zero(T) + end + end + + return ParametricCubicFunction{T}( + parsed.pvv, + parsed.ppv, + parsed.ppp, + parsed.pv, + parsed.pp, + parsed.vv, + parsed.p, + parsed.v, + parsed.constant, + quadratic_data, + affine_data, + affine_data_np, + zero(T), # current_constant (computed later) + zero(T), # set_constant + ) +end + +# Accessors for cubic terms by type (direct field access) +_cubic_pvv_terms(f::ParametricCubicFunction) = f.pvv +_cubic_ppv_terms(f::ParametricCubicFunction) = f.ppv +_cubic_ppp_terms(f::ParametricCubicFunction) = f.ppp + +""" + _effective_param_value(model, pi::ParameterIndex) + +Get the effective parameter value: updated value if available, otherwise current value. +""" +function _effective_param_value(model, pi::ParameterIndex) + if haskey(model.updated_parameters, pi) && + !isnan(model.updated_parameters[pi]) + return model.updated_parameters[pi] + end + return model.parameters[pi] +end + +""" + _parametric_constant(model, f::ParametricCubicFunction{T}) where {T} + +Compute the constant term after parameter substitution. +Includes contributions from: c + p terms + pp terms + ppp cubic terms +""" +function _parametric_constant(model, f::ParametricCubicFunction{T}) where {T} + constant = f.c + + # From affine parameter terms (p) + for term in f.p + constant += + term.coefficient * + _effective_param_value(model, p_idx(term.variable)) + end + + # From quadratic parameter-parameter terms (pp) + for term in f.pp + divisor = term.variable_1 == term.variable_2 ? 1 : 2 + constant += + (term.coefficient / divisor) * + _effective_param_value(model, p_idx(term.variable_1)) * + _effective_param_value(model, p_idx(term.variable_2)) + end + + # From cubic ppp terms (all 3 indices are parameters) + for term in _cubic_ppp_terms(f) + p1 = term.index_1 + p2 = term.index_2 + p3 = term.index_3 + constant += + term.coefficient * + _effective_param_value(model, p_idx(p1)) * + _effective_param_value(model, p_idx(p2)) * + _effective_param_value(model, p_idx(p3)) + end + + return constant +end + +""" + _parametric_affine_terms(model, f::ParametricCubicFunction{T}) where {T} + +Compute affine coefficients after parameter substitution. +Includes contributions from: v terms + pv terms + ppv cubic terms +""" +function _parametric_affine_terms( + model, + f::ParametricCubicFunction{T}, +) where {T} + # Start with non-parametric terms + terms_dict = copy(f.affine_data) + + # Add contributions from pv terms (parameter * variable) + # These are always off-diagonal (p != v), so coefficient is used as-is + for term in f.pv + var = term.variable_2 + coef = term.coefficient + p_val = _effective_param_value(model, p_idx(term.variable_1)) + terms_dict[var] = get(terms_dict, var, zero(T)) + coef * p_val + end + + # Add contributions from ppv cubic terms + for term in _cubic_ppv_terms(f) + var = term.index_3 + p1_val = _effective_param_value(model, p_idx(term.index_1)) + p2_val = _effective_param_value(model, p_idx(term.index_2)) + terms_dict[var] = + get(terms_dict, var, zero(T)) + term.coefficient * p1_val * p2_val + end + + return terms_dict +end + +""" + _parametric_quadratic_terms(model, f::ParametricCubicFunction{T}) where {T} + +Compute quadratic coefficients after parameter substitution. +Includes contributions from: vv terms + pvv terms +""" +function _parametric_quadratic_terms( + model, + f::ParametricCubicFunction{T}, +) where {T} + # Start with vv terms + terms_dict = copy(f.quadratic_data) + + # Add contributions from pvv cubic terms + for term in _cubic_pvv_terms(f) + p = term.index_1 + v1 = term.index_2 + v2 = term.index_3 + if v1.value > v2.value + v1, v2 = v2, v1 + end + var_pair = (v1, v2) + p_val = _effective_param_value(model, p_idx(p)) + terms_dict[var_pair] = + get(terms_dict, var_pair, zero(T)) + term.coefficient * p_val + end + + return terms_dict +end + +""" + _current_function(f::ParametricCubicFunction{T}, model) where {T} + +Evaluate the cubic function with current parameter values and return +the appropriate MOI function type. +""" +function _current_function(f::ParametricCubicFunction{T}, model) where {T} + # Get current values + quad_data = _parametric_quadratic_terms(model, f) + affine_data = _parametric_affine_terms(model, f) + constant = _parametric_constant(model, f) + + # Build quadratic terms + # MOI convention: + # - Off-diagonal (v1 != v2): coefficient C means C*v1*v2 (use as-is) + # - Diagonal (v1 == v2): coefficient C means (C/2)*v1^2 (multiply by 2) + quadratic_terms = MOI.ScalarQuadraticTerm{T}[] + for ((v1, v2), coef) in quad_data + if !iszero(coef) + moi_coef = v1 == v2 ? coef * 2 : coef + push!(quadratic_terms, MOI.ScalarQuadraticTerm{T}(moi_coef, v1, v2)) + end + end + + # Build affine terms + affine_terms = MOI.ScalarAffineTerm{T}[] + for (v, coef) in affine_data + if !iszero(coef) + push!(affine_terms, MOI.ScalarAffineTerm{T}(coef, v)) + end + end + # Add non-parametric affine terms + for (v, coef) in f.affine_data_np + if !iszero(coef) + push!(affine_terms, MOI.ScalarAffineTerm{T}(coef, v)) + end + end + + # Note: We don't update f.affine_data or f.quadratic_data here. + # These store the BASE coefficients (from v and vv terms) and must remain unchanged. + # current_constant is the only cache we update for reference. + f.current_constant = constant + + # Always return a ScalarQuadraticFunction, even if it has no quadratic terms. + return MOI.ScalarQuadraticFunction{T}( + quadratic_terms, + affine_terms, + constant, + ) +end + +# === Delta functions for efficient updates === + +""" + _delta_parametric_constant(model, f::ParametricCubicFunction{T}) where {T} + +Compute the CHANGE in constant when parameters are updated. +""" +function _delta_parametric_constant( + model, + f::ParametricCubicFunction{T}, +) where {T} + delta = zero(T) + + # From p terms + for term in f.p + pi = p_idx(term.variable) + if haskey(model.updated_parameters, pi) && + !isnan(model.updated_parameters[pi]) + old_val = model.parameters[pi] + new_val = model.updated_parameters[pi] + delta += term.coefficient * (new_val - old_val) + end + end + + # From pp terms + for term in f.pp + pi1 = p_idx(term.variable_1) + pi2 = p_idx(term.variable_2) + updated1 = + haskey(model.updated_parameters, pi1) && + !isnan(model.updated_parameters[pi1]) + updated2 = + haskey(model.updated_parameters, pi2) && + !isnan(model.updated_parameters[pi2]) + + if updated1 || updated2 + divisor = term.variable_1 == term.variable_2 ? 1 : 2 + old_val = + (term.coefficient / divisor) * + model.parameters[pi1] * + model.parameters[pi2] + new_p1 = + updated1 ? model.updated_parameters[pi1] : model.parameters[pi1] + new_p2 = + updated2 ? model.updated_parameters[pi2] : model.parameters[pi2] + new_val = (term.coefficient / divisor) * new_p1 * new_p2 + delta += new_val - old_val + end + end + + # From ppp cubic terms + for term in _cubic_ppp_terms(f) + pi1 = p_idx(term.index_1) + pi2 = p_idx(term.index_2) + pi3 = p_idx(term.index_3) + updated1 = + haskey(model.updated_parameters, pi1) && + !isnan(model.updated_parameters[pi1]) + updated2 = + haskey(model.updated_parameters, pi2) && + !isnan(model.updated_parameters[pi2]) + updated3 = + haskey(model.updated_parameters, pi3) && + !isnan(model.updated_parameters[pi3]) + + if updated1 || updated2 || updated3 + old_val = + term.coefficient * + model.parameters[pi1] * + model.parameters[pi2] * + model.parameters[pi3] + new_p1 = + updated1 ? model.updated_parameters[pi1] : model.parameters[pi1] + new_p2 = + updated2 ? model.updated_parameters[pi2] : model.parameters[pi2] + new_p3 = + updated3 ? model.updated_parameters[pi3] : model.parameters[pi3] + new_val = term.coefficient * new_p1 * new_p2 * new_p3 + delta += new_val - old_val + end + end + + return delta +end + +""" + _delta_parametric_affine_terms(model, f::ParametricCubicFunction{T}) where {T} + +Compute the CHANGE in affine coefficients when parameters are updated. +""" +function _delta_parametric_affine_terms( + model, + f::ParametricCubicFunction{T}, +) where {T} + delta_dict = Dict{MOI.VariableIndex,T}() + + # From pv terms (parameter * variable, always off-diagonal) + for term in f.pv + pi = p_idx(term.variable_1) + if haskey(model.updated_parameters, pi) && + !isnan(model.updated_parameters[pi]) + var = term.variable_2 + coef = term.coefficient # Off-diagonal: use as-is + old_val = model.parameters[pi] + new_val = model.updated_parameters[pi] + delta_dict[var] = + get(delta_dict, var, zero(T)) + coef * (new_val - old_val) + end + end + + # From ppv cubic terms + for term in _cubic_ppv_terms(f) + var = term.index_3 + pi1 = p_idx(term.index_1) + pi2 = p_idx(term.index_2) + updated1 = + haskey(model.updated_parameters, pi1) && + !isnan(model.updated_parameters[pi1]) + updated2 = + haskey(model.updated_parameters, pi2) && + !isnan(model.updated_parameters[pi2]) + + if updated1 || updated2 + old_val = + term.coefficient * model.parameters[pi1] * model.parameters[pi2] + new_p1 = + updated1 ? model.updated_parameters[pi1] : model.parameters[pi1] + new_p2 = + updated2 ? model.updated_parameters[pi2] : model.parameters[pi2] + new_val = term.coefficient * new_p1 * new_p2 + delta_dict[var] = + get(delta_dict, var, zero(T)) + (new_val - old_val) + end + end + + return delta_dict +end + +""" + _delta_parametric_quadratic_terms(model, f::ParametricCubicFunction{T}) where {T} + +Compute the CHANGE in quadratic coefficients when parameters are updated. +""" +function _delta_parametric_quadratic_terms( + model, + f::ParametricCubicFunction{T}, +) where {T} + delta_dict = Dict{Tuple{MOI.VariableIndex,MOI.VariableIndex},T}() + + for term in _cubic_pvv_terms(f) + pi = p_idx(term.index_1) + v1 = term.index_2 + v2 = term.index_3 + + if haskey(model.updated_parameters, pi) && + !isnan(model.updated_parameters[pi]) + if v1.value > v2.value + v1, v2 = v2, v1 + end + var_pair = (v1, v2) + old_val = model.parameters[pi] + new_val = model.updated_parameters[pi] + delta = term.coefficient * (new_val - old_val) + delta_dict[var_pair] = get(delta_dict, var_pair, zero(T)) + delta + end + end + + return delta_dict +end diff --git a/src/update_parameters.jl b/src/update_parameters.jl index 4f4b08b7..0a365cd6 100644 --- a/src/update_parameters.jl +++ b/src/update_parameters.jl @@ -296,6 +296,7 @@ function update_parameters!(model::Optimizer) _update_vector_quadratic_constraints!(model) _update_affine_objective!(model) _update_quadratic_objective!(model) + _update_cubic_objective!(model) # Update parameters and put NaN to indicate that the parameter has been # updated diff --git a/test/cubic_tests.jl b/test/cubic_tests.jl new file mode 100644 index 00000000..5b73b0ed --- /dev/null +++ b/test/cubic_tests.jl @@ -0,0 +1,489 @@ +# Copyright (c) 2020: Tomás Gutierrez and contributors +# +# Use of this source code is governed by an MIT-style license that can be found +# in the LICENSE.md file or at https://opensource.org/licenses/MIT. + +# ============================================================================ +# Parser Tests +# ============================================================================ + +function test_cubic_parse_single_pvv_term() + x = MOI.VariableIndex(1) + y = MOI.VariableIndex(2) + p = POI.v_idx(POI.ParameterIndex(1)) + + # 2 * x * y * p + f = MOI.ScalarNonlinearFunction(:*, Any[2.0, x, y, p]) + result = POI._parse_cubic_expression(f, Float64) + + @test result !== nothing + pvv = result.pvv + @test length(pvv) == 1 + @test pvv[1].coefficient == 2.0 + @test pvv[1].index_1 == p + @test pvv[1].index_2 == x + @test pvv[1].index_3 == y + return +end + +function test_cubic_parse_squared_variable() + x = MOI.VariableIndex(1) + p = POI.v_idx(POI.ParameterIndex(1)) + + # 3 * p * x^2 using power operator + f = MOI.ScalarNonlinearFunction( + :*, + Any[3.0, p, MOI.ScalarNonlinearFunction(:^, Any[x, 2])], + ) + result = POI._parse_cubic_expression(f, Float64) + + @test result !== nothing + pvv = result.pvv + @test length(pvv) == 1 + @test pvv[1].coefficient == 3.0 + # Check that both variables are x (squared variable) + v1 = pvv[1].index_2 + v2 = pvv[1].index_3 + @test v1 == x + @test v2 == x + return +end + +function test_cubic_parse_parenthesis_variations() + x = MOI.VariableIndex(1) + y = MOI.VariableIndex(2) + p = POI.v_idx(POI.ParameterIndex(1)) + + # Flat: 2 * x * y * p + f1 = MOI.ScalarNonlinearFunction(:*, Any[2.0, x, y, p]) + + # Left-associative: ((2*x)*y)*p + f2 = MOI.ScalarNonlinearFunction( + :*, + Any[ + MOI.ScalarNonlinearFunction( + :*, + Any[MOI.ScalarNonlinearFunction(:*, Any[2.0, x]), y], + ), + p, + ], + ) + + # Grouped: (2*p) * (x*y) + f3 = MOI.ScalarNonlinearFunction( + :*, + Any[ + MOI.ScalarNonlinearFunction(:*, Any[2.0, p]), + MOI.ScalarNonlinearFunction(:*, Any[x, y]), + ], + ) + + r1 = POI._parse_cubic_expression(f1, Float64) + r2 = POI._parse_cubic_expression(f2, Float64) + r3 = POI._parse_cubic_expression(f3, Float64) + + # All should parse to equivalent results + for r in [r1, r2, r3] + @test r !== nothing + pvv = r.pvv + @test length(pvv) == 1 + @test pvv[1].coefficient == 2.0 + end + return +end + +function test_cubic_parse_ppv_term() + x = MOI.VariableIndex(1) + p = POI.v_idx(POI.ParameterIndex(1)) + q = POI.v_idx(POI.ParameterIndex(2)) + + # 2 * p * q * x + f = MOI.ScalarNonlinearFunction(:*, Any[2.0, p, q, x]) + result = POI._parse_cubic_expression(f, Float64) + + @test result !== nothing + ppv = result.ppv + @test length(ppv) == 1 + @test ppv[1].coefficient == 2.0 + return +end + +function test_cubic_parse_ppp_term() + p = POI.v_idx(POI.ParameterIndex(1)) + q = POI.v_idx(POI.ParameterIndex(2)) + r = POI.v_idx(POI.ParameterIndex(3)) + + # 3 * p * q * r + f = MOI.ScalarNonlinearFunction(:*, Any[3.0, p, q, r]) + result = POI._parse_cubic_expression(f, Float64) + + @test result !== nothing + ppp = result.ppp + @test length(ppp) == 1 + @test ppp[1].coefficient == 3.0 + return +end + +function test_cubic_parse_invalid_degree_4() + x = MOI.VariableIndex(1) + y = MOI.VariableIndex(2) + z = MOI.VariableIndex(3) + p = POI.v_idx(POI.ParameterIndex(1)) + + # x * y * z * p (degree 4) should return nothing + f = MOI.ScalarNonlinearFunction(:*, Any[x, y, z, p]) + result = POI._parse_cubic_expression(f, Float64) + + @test result === nothing + return +end + +function test_cubic_parse_three_vars_no_param() + x = MOI.VariableIndex(1) + y = MOI.VariableIndex(2) + z = MOI.VariableIndex(3) + + # x * y * z (3 variables, 0 parameters) should be rejected + f = MOI.ScalarNonlinearFunction(:*, Any[x, y, z]) + result = POI._parse_cubic_expression(f, Float64) + + @test result === nothing + return +end + +function test_cubic_parse_subtraction() + x = MOI.VariableIndex(1) + y = MOI.VariableIndex(2) + p = POI.v_idx(POI.ParameterIndex(1)) + + # x*y*p - 2*x (one cubic, one affine with negative coef) + f = MOI.ScalarNonlinearFunction( + :-, + Any[ + MOI.ScalarNonlinearFunction(:*, Any[x, y, p]), + MOI.ScalarNonlinearFunction(:*, Any[2.0, x]), + ], + ) + result = POI._parse_cubic_expression(f, Float64) + + @test result !== nothing + pvv = result.pvv + @test length(pvv) == 1 + # Check affine term via quadratic_func + affine = result.v + @test length(affine) == 1 + @test affine[1].coefficient == -2.0 + return +end + +function test_cubic_parse_unary_minus() + x = MOI.VariableIndex(1) + y = MOI.VariableIndex(2) + p = POI.v_idx(POI.ParameterIndex(1)) + + # -x*y*p (negation of cubic term) + f = MOI.ScalarNonlinearFunction( + :-, + Any[MOI.ScalarNonlinearFunction(:*, Any[x, y, p])], + ) + result = POI._parse_cubic_expression(f, Float64) + + @test result !== nothing + pvv = result.pvv + @test length(pvv) == 1 + @test pvv[1].coefficient == -1.0 + return +end + +function test_cubic_parse_term_combination() + x = MOI.VariableIndex(1) + y = MOI.VariableIndex(2) + p = POI.v_idx(POI.ParameterIndex(1)) + + # x*y*p + 2*x*y*p = 3*x*y*p (should combine into single term) + f = MOI.ScalarNonlinearFunction( + :+, + Any[ + MOI.ScalarNonlinearFunction(:*, Any[x, y, p]), + MOI.ScalarNonlinearFunction(:*, Any[2.0, x, y, p]), + ], + ) + result = POI._parse_cubic_expression(f, Float64) + + @test result !== nothing + pvv = result.pvv + @test length(pvv) == 1 # combined into single term + @test pvv[1].coefficient == 3.0 + return +end + +function test_cubic_parse_non_polynomial_rejected() + x = MOI.VariableIndex(1) + p = POI.v_idx(POI.ParameterIndex(1)) + + # sin(x) * p - should be rejected + f = MOI.ScalarNonlinearFunction( + :*, + Any[MOI.ScalarNonlinearFunction(:sin, Any[x]), p], + ) + result = POI._parse_cubic_expression(f, Float64) + + @test result === nothing + return +end + +# ============================================================================ +# JuMP Integration Tests +# ============================================================================ + +function test_jump_cubic_pvv_basic() + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, 0 <= x <= 10) + @variable(model, -1 <= y <= 10) + @variable(model, p in MOI.Parameter(1.0)) + + # a convex quadratic with cross terms + # Minimize: x ^ 2 + 2 * x * y + y ^ 2 - 3 x + # Subject to: x + y >= 2 + @constraint(model, x + y >= 0) + @objective(model, Min, x^2 + p * x * y + y^2 - 3 * x) + + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ -3.0 atol = ATOL + @test value(x) ≈ 2.0 atol = ATOL + @test value(y) ≈ -1.0 atol = ATOL + + # Change p to 0.5 + set_parameter_value(p, 0.5) + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ -2.4 atol = ATOL + @test value(x) ≈ 1.6 atol = ATOL + @test value(y) ≈ -0.4 atol = ATOL + + # Change p to 0 (removes cross term) + set_parameter_value(p, 0.0) + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ -9 / 4 atol = ATOL + @test value(x) ≈ 3 / 2 atol = ATOL + @test value(y) ≈ 0.0 atol = ATOL + return +end + +function test_jump_cubic_pvv_same() + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, 0 <= x <= 10) + @variable(model, p in MOI.Parameter(1.0)) + + # a convex quadratic with cross terms + # Minimize: p x ^ 2 - 3 x + # Subject to: x >= 0 + @constraint(model, x >= 0) + @objective(model, Min, p * x^2 - 3 * x) + + # Optimize with p=1 + # Optimal at x=3/2, obj = -9/4 + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ -9 / 4 atol = ATOL + @test value(x) ≈ 3 / 2 atol = ATOL + + # Change p to 0.5 + # Optimal at x=3.0, obj = -9/2 + set_parameter_value(p, 0.5) + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ -9 / 2 atol = ATOL + @test value(x) ≈ 3 atol = ATOL + + # Change p to 0 (removes cross term) + set_parameter_value(p, 0.0) + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ -30.0 atol = ATOL + @test value(x) ≈ 10.0 atol = ATOL + return +end + +function test_jump_cubic_ppv_basic() + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, x >= 0) + @variable(model, p in MOI.Parameter(2.0)) + @variable(model, q in MOI.Parameter(3.0)) + + # Minimize: x + p*q*x = x * (1 + p*q) + # With p=2, q=3: minimize x * (1 + 6) = 7x + # Subject to: x >= 1 + @constraint(model, x >= 1) + @objective(model, Min, x + p * q * x) + + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + # Optimal at x=1, obj = 7 + @test objective_value(model) ≈ 7.0 atol = ATOL + + # Change p=1, q=1: minimize x*(1+1) = 2x + set_parameter_value(p, 1.0) + set_parameter_value(q, 1.0) + optimize!(model) + @test objective_value(model) ≈ 2.0 atol = ATOL +end + +function test_jump_cubic_ppv_same() + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, x >= 0) + @variable(model, p in MOI.Parameter(2.0)) + + # Minimize: x + p^2 * x = x * (1 + p^2) + # With p=2: minimize x * (1 + 4) = 5x + # Subject to: x >= 1 + @constraint(model, x >= 1) + @objective(model, Min, x + p * p * x) + + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + # Optimal at x=1, obj = 5 + @test objective_value(model) ≈ 5.0 atol = ATOL + + # Change p=1: minimize x*(1+1) = 2x + set_parameter_value(p, 1.0) + optimize!(model) + @test objective_value(model) ≈ 2.0 atol = ATOL +end + +function test_jump_cubic_ppp_basic() + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, x >= 0) + @variable(model, p in MOI.Parameter(2.0)) + @variable(model, q in MOI.Parameter(3.0)) + @variable(model, r in MOI.Parameter(4.0)) + + # Minimize: x + p*q*r + # With p=2, q=3, r=4: minimize x + 24 + # Subject to: x >= 1 + @constraint(model, x >= 1) + @objective(model, Min, x + p * q * r) + + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + # Optimal at x=1, obj = 1 + 24 = 25 + @test objective_value(model) ≈ 25.0 atol = ATOL + + # Change p=1, q=1, r=1: minimize x + 1 + set_parameter_value(p, 1.0) + set_parameter_value(q, 1.0) + set_parameter_value(r, 1.0) + optimize!(model) + @test objective_value(model) ≈ 2.0 atol = ATOL + return +end + +function test_jump_cubic_ppp_same() + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, x >= 0) + @variable(model, p in MOI.Parameter(2.0)) + + # Minimize: x + p^3 + # With p=2: minimize x + 8 + # Subject to: x >= 1 + @constraint(model, x >= 1) + @objective(model, Min, x + p * p * p) + + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + # Optimal at x=1, obj = 1 + 8 = 9 + @test objective_value(model) ≈ 9.0 atol = ATOL + + # Change p=1: minimize x + 1 + set_parameter_value(p, 1.0) + optimize!(model) + @test objective_value(model) ≈ 2.0 atol = ATOL + return +end + +function test_jump_cubic_parameter_initially_zero() + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, 0 <= x <= 10) + @variable(model, -1 <= y <= 10) + @variable(model, p in MOI.Parameter(0.0)) + + # a convex quadratic with cross terms + # Minimize: x ^ 2 + 2 * x * y + y ^ 2 - 3 x + # Subject to: x + y >= 2 + @constraint(model, x + y >= 0) + @objective(model, Min, x^2 + p * x * y + y^2 - 3 * x) + + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ -9 / 4 atol = ATOL + @test value(x) ≈ 3 / 2 atol = ATOL + @test value(y) ≈ 0.0 atol = ATOL + + # Change p to 0.5 + set_parameter_value(p, 0.5) + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ -2.4 atol = ATOL + @test value(x) ≈ 1.6 atol = ATOL + @test value(y) ≈ -0.4 atol = ATOL + + return +end + +function test_jump_cubic_parameter_division_by_constant() + model = direct_model(POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, 0 <= x <= 10) + @variable(model, 0 <= y <= 10) + @variable(model, p in MOI.Parameter(0.0)) + + # a convex quadratic with cross terms + # Minimize: x ^ 2 + 2 * x * y + y ^ 2 - 3 x + # Subject to: x + y >= 2 + @constraint(model, x + y >= 0) + @objective(model, Min, x^2 + p * x * y / 1 + y^2 - 3 * x) + + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ -9 / 4 atol = ATOL + @test value(x) ≈ 3 / 2 atol = ATOL + @test value(y) ≈ 0.0 atol = ATOL + + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, 0 <= x <= 10) + @variable(model, -1 <= y <= 10) + @variable(model, p in MOI.Parameter(1.0)) + + # a convex quadratic with cross terms + # Minimize: x ^ 2 + 0.5 * x * y + y ^ 2 - 3 x + # Subject to: x + y >= 2 + @constraint(model, x + y >= 0) + @objective(model, Min, x^2 + p * x * y / 2 + y^2 - 3 * x) + + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ -2.4 atol = ATOL + @test value(x) ≈ 1.6 atol = ATOL + @test value(y) ≈ -0.4 atol = ATOL + + return +end diff --git a/test/moi_tests.jl b/test/moi_tests.jl index 9bae2f2f..3f754635 100644 --- a/test/moi_tests.jl +++ b/test/moi_tests.jl @@ -229,14 +229,27 @@ function test_moi_highs() ) MOI.set(model, MOI.Silent(), true) MOI.set(model, MOI.RawOptimizerAttribute("presolve"), "off") - MOI.Test.runtests(model, MOI.Test.Config(; atol = 1e-7); exclude = []) + # Exclude nonlinear tests that use functions outside POI's cubic polynomial support + # (general nonlinear functions like sin/cos/exp, or degree > 3 polynomials) + # POI only supports ScalarNonlinearFunction when it's a cubic polynomial with + # parameters multiplying at most quadratic variable terms + nonlinear_excludes = ["test_nonlinear_duals", "test_nonlinear_expression_"] + MOI.Test.runtests( + model, + MOI.Test.Config(; atol = 1e-7); + exclude = nonlinear_excludes, + ) model = POI.Optimizer( MOI.instantiate(HiGHS.Optimizer; with_bridge_type = Float64), ) MOI.set(model, MOI.Silent(), true) MOI.set(model, MOI.RawOptimizerAttribute("presolve"), "off") - MOI.Test.runtests(model, MOI.Test.Config(; atol = 1e-7); exclude = []) + MOI.Test.runtests( + model, + MOI.Test.Config(; atol = 1e-7); + exclude = nonlinear_excludes, + ) return end @@ -283,6 +296,9 @@ function test_moi_ipopt() # - CachingOptimizer does not throw if optimizer not attached "test_model_copy_to_UnsupportedAttribute", "test_model_copy_to_UnsupportedConstraint", + # - POI only supports cubic polynomial ScalarNonlinearFunction + "test_nonlinear_duals", + "test_nonlinear_expression_", ], ) return @@ -294,9 +310,13 @@ function test_moi_ListOfConstraintTypesPresent() model = POI.Optimizer(ipopt) MOI.set(model, MOI.Silent(), true) x = MOI.add_variables(model, N / 2) - y = first.( - MOI.add_constrained_variable.(model, MOI.Parameter.(ones(Int(N / 2)))), - ) + y = + first.( + MOI.add_constrained_variable.( + model, + MOI.Parameter.(ones(Int(N / 2))), + ), + ) MOI.add_constraint( model, @@ -598,10 +618,11 @@ function test_vector_parameter_affine_nonnegatives() t, ct = MOI.add_constrained_variable(model, MOI.Parameter(5.0)) A = [1.0 0 -1; 0 1 -1] b = [1.0; 2] - terms = MOI.VectorAffineTerm.( - 1:2, - MOI.ScalarAffineTerm.(A, reshape([x, y, t], 1, 3)), - ) + terms = + MOI.VectorAffineTerm.( + 1:2, + MOI.ScalarAffineTerm.(A, reshape([x, y, t], 1, 3)), + ) f = MOI.VectorAffineFunction(vec(terms), b) set = MOI.Nonnegatives(2) cnn = MOI.add_constraint(model, f, MOI.Nonnegatives(2)) @@ -657,10 +678,11 @@ function test_vector_parameter_affine_nonpositives() t, ct = MOI.add_constrained_variable(model, MOI.Parameter(5.0)) A = [-1.0 0 1; 0 -1 1] b = [-1.0; -2] - terms = MOI.VectorAffineTerm.( - 1:2, - MOI.ScalarAffineTerm.(A, reshape([x, y, t], 1, 3)), - ) + terms = + MOI.VectorAffineTerm.( + 1:2, + MOI.ScalarAffineTerm.(A, reshape([x, y, t], 1, 3)), + ) f = MOI.VectorAffineFunction(vec(terms), b) set = MOI.Nonnegatives(2) cnn = MOI.add_constraint(model, f, MOI.Nonpositives(2)) diff --git a/test/runtests.jl b/test/runtests.jl index fbf8dc44..2b4ec39b 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -23,6 +23,7 @@ end include("moi_tests.jl") include("jump_tests.jl") +include("cubic_tests.jl") for name in names(@__MODULE__; all = true) if startswith("$name", "test_") From 587367d63e93c04cacd950f8b7de1043dc481249 Mon Sep 17 00:00:00 2001 From: joaquimg Date: Sun, 15 Feb 2026 20:01:17 -0300 Subject: [PATCH 02/14] remove plan.md --- plan.md | 2216 ------------------------------------------------------- 1 file changed, 2216 deletions(-) delete mode 100644 plan.md diff --git a/plan.md b/plan.md deleted file mode 100644 index 5c53a08c..00000000 --- a/plan.md +++ /dev/null @@ -1,2216 +0,0 @@ -# Implementation Plan: Parametric Cubic Functions in POI - -Note: do not change this plan during implementation. If changes are needed, create a new version of the plan called plan_v2.md to track changes to the plan. - -## Overview - -This document details the implementation plan for supporting parameters multiplying quadratic terms in ParametricOptInterface (POI). - -### The Problem - -We want to support expressions of the form: - -``` -c * x * y * p -``` - -Where: -- `c` = coefficient (constant) -- `x`, `y` = decision variables -- `p` = parameter - -This creates a **cubic function** since a parameter behaves like a decision variable in the expression structure. However, MOI does not have a native cubic function type. - -### Current State - -POI currently supports: -- `ParametricAffineFunction`: handles `c + c*x + c*p + c*x*p` -- `ParametricQuadraticFunction`: handles `c + c*x + c*p + c*x*y + c*p*x + c*p*q` - -The missing pieces are: -- `c*x*y*p` - one parameter multiplying a quadratic term (becomes quadratic after substitution) -- `c*p1*p2*x` - two parameters multiplying a variable (becomes affine after substitution) - -### Approach - -Since MOI lacks cubic functions, users must express `c*x*y*p` as a `ScalarNonlinearFunction`. We will: -1. Parse `ScalarNonlinearFunction` to detect if it represents exactly a cubic polynomial -2. Store the parsed data in a new `ParametricCubicFunction` structure -3. Support this **only in objectives** (not constraints) - ---- - -## Part 1: Understanding MOI.ScalarNonlinearFunction - -### Structure - -```julia -MOI.ScalarNonlinearFunction( - head::Symbol, # Operator (:+, :*, :^, etc.) - args::Vector{Any} # Operands (constants, variables, nested expressions) -) -``` - -### Expression Tree Examples - -**Example 1**: `2 * x * y * p` could be represented as: -```julia -ScalarNonlinearFunction(:*, Any[2.0, x, y, p]) -``` - -**Example 2**: `x * y * p + 3 * x * z * q` (sum of cubic terms): -```julia -ScalarNonlinearFunction(:+, Any[ - ScalarNonlinearFunction(:*, Any[x, y, p]), - ScalarNonlinearFunction(:*, Any[3.0, x, z, q]) -]) -``` - -### Parsing Strategy - -We need to recursively traverse the expression tree and: -1. Identify if all operations are `+`, `*`, `-`, or `^` with integer exponents -2. Expand products into monomials -3. Classify each monomial by degree in variables and parameters -4. Reject if any monomial exceeds cubic degree - ---- - -## Part 1.5: Parsing Corner Cases (Critical) - -The parser must handle various equivalent representations of the same mathematical expression. This section documents the corner cases that **must** be tested and handled correctly. - -### 1.5.1 Mixed Parenthesis Orderings - -The same expression `c * x * y * p` can have many different tree structures: - -**Flat multiplication:** -```julia -# 2 * x * y * p as a single :* node with 4 children -ScalarNonlinearFunction(:*, Any[2.0, x, y, p]) -``` - -**Left-associative (typical from parsing `((2*x)*y)*p`):** -```julia -ScalarNonlinearFunction(:*, Any[ - ScalarNonlinearFunction(:*, Any[ - ScalarNonlinearFunction(:*, Any[2.0, x]), - y - ]), - p -]) -``` - -**Right-associative (`2*(x*(y*p))`):** -```julia -ScalarNonlinearFunction(:*, Any[ - 2.0, - ScalarNonlinearFunction(:*, Any[ - x, - ScalarNonlinearFunction(:*, Any[y, p]) - ]) -]) -``` - -**Mixed groupings (`(2*x) * (y*p)`):** -```julia -ScalarNonlinearFunction(:*, Any[ - ScalarNonlinearFunction(:*, Any[2.0, x]), - ScalarNonlinearFunction(:*, Any[y, p]) -]) -``` - -**Coefficient grouped with parameter (`(2*p) * (x*y)`):** -```julia -ScalarNonlinearFunction(:*, Any[ - ScalarNonlinearFunction(:*, Any[2.0, p]), - ScalarNonlinearFunction(:*, Any[x, y]) -]) -``` - -**Parser requirement**: All of the above must produce the same result: `_ScalarCubicTerm(2.0, p, x, y)` with type `:pvv`. - -### 1.5.2 Squared Variables: `p * c * x^2` - -This is a valid cubic term where `variable_1 == variable_2`. Representations: - -**Using power operator:** -```julia -# 3 * p * x^2 -ScalarNonlinearFunction(:*, Any[ - 3.0, - p, - ScalarNonlinearFunction(:^, Any[x, 2]) -]) -``` - -**Using explicit multiplication:** -```julia -# 3 * p * x * x -ScalarNonlinearFunction(:*, Any[3.0, p, x, x]) -``` - -**Nested with power:** -```julia -# (3*p) * x^2 -ScalarNonlinearFunction(:*, Any[ - ScalarNonlinearFunction(:*, Any[3.0, p]), - ScalarNonlinearFunction(:^, Any[x, 2]) -]) -``` - -**Parser requirement**: All must produce `_ScalarCubicTerm(3.0, p, x, x)` with type `:pvv`. - -### 1.5.3 Power Operator Variations - -**x^2 (valid quadratic):** -```julia -ScalarNonlinearFunction(:^, Any[x, 2]) -# Should expand to: x * x (two variables) -``` - -**p^2 (valid quadratic in parameters):** -```julia -ScalarNonlinearFunction(:^, Any[p, 2]) -# Should expand to: p * p (two parameters) -``` - -**(x*y)^2 = x^2 * y^2 (degree 4 - INVALID):** -```julia -ScalarNonlinearFunction(:^, Any[ - ScalarNonlinearFunction(:*, Any[x, y]), - 2 -]) -# Should be rejected - exceeds cubic degree -``` - -**x^3 (degree 3 in one variable - special case):** -```julia -ScalarNonlinearFunction(:^, Any[x, 3]) -# This is x*x*x - three variables, no parameters -# Should be REJECTED for our use case (no parameter involved) -# OR could be stored as a degenerate cubic term if we allow it -``` - -**Parser requirement**: Must correctly expand `^` operator and track resulting degree. - -### 1.5.4 Addition/Subtraction Variations - -**Sum of terms:** -```julia -# x*y*p + x*z*q -ScalarNonlinearFunction(:+, Any[ - ScalarNonlinearFunction(:*, Any[x, y, p]), - ScalarNonlinearFunction(:*, Any[x, z, q]) -]) -``` - -**Subtraction (which is addition with negation):** -```julia -# x*y*p - 2*x -ScalarNonlinearFunction(:-, Any[ - ScalarNonlinearFunction(:*, Any[x, y, p]), - ScalarNonlinearFunction(:*, Any[2.0, x]) -]) -# The second term should have coefficient -2.0 -``` - -**Nested sums:** -```julia -# (a + b) + (c + d) with various term types -ScalarNonlinearFunction(:+, Any[ - ScalarNonlinearFunction(:+, Any[term_a, term_b]), - ScalarNonlinearFunction(:+, Any[term_c, term_d]) -]) -``` - -**Unary minus:** -```julia -# -x*y*p (negation of a term) -ScalarNonlinearFunction(:-, Any[ - MOI.ScalarNonlinearFunction(:*, Any[x, y, p]) -]) -# Should produce coefficient = -1.0 -``` - -**Parser requirement**: Must correctly propagate signs through subtraction, unary minus, and flatten additions. - -### 1.5.5 Coefficient Positions - -The numeric coefficient can appear anywhere in a product: - -```julia -# All equivalent to 5*x*y*p: -ScalarNonlinearFunction(:*, Any[5.0, x, y, p]) # coefficient first -ScalarNonlinearFunction(:*, Any[x, 5.0, y, p]) # coefficient second -ScalarNonlinearFunction(:*, Any[x, y, 5.0, p]) # coefficient third -ScalarNonlinearFunction(:*, Any[x, y, p, 5.0]) # coefficient last -``` - -**Multiple coefficients (must multiply):** -```julia -# 2 * 3 * x * y * p = 6*x*y*p -ScalarNonlinearFunction(:*, Any[2.0, 3.0, x, y, p]) -``` - -**Parser requirement**: Accumulate all numeric values by multiplication. - -### 1.5.6 Term Combination - -Like terms must be combined: - -```julia -# x*y*p + 2*x*y*p should become 3*x*y*p -ScalarNonlinearFunction(:+, Any[ - ScalarNonlinearFunction(:*, Any[x, y, p]), - ScalarNonlinearFunction(:*, Any[2.0, x, y, p]) -]) -# Result: single _ScalarCubicTerm with coefficient = 3.0 (type=:pvv) -``` - -**Parser requirement**: After expanding to monomials, combine terms with identical variable/parameter sets. - -### 1.5.7 Nested MOI Functions - -`ScalarNonlinearFunction` args can contain other MOI function types: - -```julia -# ScalarAffineFunction nested inside -ScalarNonlinearFunction(:*, Any[ - MOI.ScalarAffineFunction([MOI.ScalarAffineTerm(2.0, x)], 1.0), # 2x + 1 - p -]) -# Should expand to: 2*x*p + p (one pv term + one p term) -``` - -```julia -# ScalarQuadraticFunction nested inside -ScalarNonlinearFunction(:*, Any[ - MOI.ScalarQuadraticFunction( - [MOI.ScalarQuadraticTerm(1.0, x, y)], # x*y - MOI.ScalarAffineTerm{Float64}[], - 0.0 - ), - p -]) -# Should expand to: x*y*p (one pvv term) -``` - -**Parser requirement**: Recursively handle `ScalarAffineFunction` and `ScalarQuadraticFunction` as leaf nodes that expand to their constituent terms. - -### 1.5.8 Edge Cases Summary Table - -| Expression | Tree Variations | Expected Result | -|------------|-----------------|-----------------| -| `2*x*y*p` | 5+ structures | `_ScalarCubicTerm(2.0, p, x, y)` (type=:pvv) | -| `3*p*x^2` | 3+ structures | `_ScalarCubicTerm(3.0, p, x, x)` (type=:pvv) | -| `x*y*p - 2*x` | subtraction | 1 pvv term + 1 affine (coef=-2) | -| `-x*y*p` | unary minus | `_ScalarCubicTerm(-1.0, p, x, y)` (type=:pvv) | -| `(2*3)*x*y*p` | nested coefficients | `_ScalarCubicTerm(6.0, p, x, y)` (type=:pvv) | -| `x*y*p + 2*x*y*p` | like terms | `_ScalarCubicTerm(3.0, p, x, y)` (type=:pvv) | -| `(2x+1)*p` | nested affine | 1 pv + 1 p term | -| `x^2*y^2` | power of product | REJECT (degree 4) | -| `x*y*z` | 3 vars, 0 params | REJECT (no parameter) | -| `sin(x)*p` | non-poly operator | REJECT | -| `x/y*p` | division | REJECT | -| `p*x*y` with p=0 | zero parameter value | MUST parse as cubic (see 1.5.10) | - -### 1.5.9 Parser Implementation Strategy - -Given these corner cases, the parser should: - -1. **Normalize to monomials**: Recursively expand the tree into a sum of monomials -2. **Track factors per monomial**: Each monomial has: - - `coefficient::Float64` (product of all numeric values) - - `variables::Vector{MOI.VariableIndex}` (including repeats for x^2) - - `parameters::Vector{ParameterIndex}` (including repeats for p^2) -3. **Handle operators**: - - `:+` → collect monomials from each arg - - `:-` → negate coefficients of args after the first (unary: negate single arg) - - `:*` → multiply monomials (combine factors) - - `:^` → expand to repeated factors (only for small integer exponents) -4. **Handle nested MOI functions**: - - `ScalarAffineFunction` → expand to affine monomials - - `ScalarQuadraticFunction` → expand to quadratic + affine monomials -5. **Combine like terms**: Group monomials by (sorted variables, sorted parameters), sum coefficients -6. **Classify after expansion**: Once we have flat, combined monomials, classification is straightforward - -```julia -struct Monomial{T} - coefficient::T - variables::Vector{MOI.VariableIndex} # length = variable degree - parameters::Vector{ParameterIndex} # length = parameter degree -end - -# Total degree = length(variables) + length(parameters) -# For valid cubic: total degree <= 3 AND length(variables) <= 2 -``` - -### 1.5.10 Parameter Values at Parse Time (CRITICAL) - -**The parser must NOT consider parameter values when classifying terms.** - -When parsing `p * x * y`, the parser must always create a PVV cubic term, regardless of whether `p`'s current value is 0, 1, or any other number. The parameter value only affects the *evaluation* of the term, not its *classification*. - -**Why this matters:** - -```julia -# User creates model with p=0 -@variable(model, p in MOI.Parameter(0.0)) -@objective(model, Min, p * x * y + x + y) - -# First solve: p=0, so effectively minimizing x + y -optimize!(model) # Works fine - -# Later, user updates p -set_parameter_value(p, 2.0) -optimize!(model) # Must now minimize 2*x*y + x + y -``` - -If the parser had ignored the `p * x * y` term because `p=0`, the second optimization would be wrong. - -**Implementation requirement:** - -- Parser classifies terms based on **structure** (which indices are parameters vs variables) -- Parser does NOT read parameter values from the model -- All cubic terms are stored, even if their parameters are currently zero -- During `_update_cubic_objective!`, zero-valued parameters correctly result in zero contributions - -**Test cases:** See D1b and D1c in the test plan. - -### 1.5.11 MOI Utilities That May Simplify Implementation - -MOI provides several utilities in `MOI.Utilities` and `MOI.Nonlinear` that could simplify our work: - -#### Potentially Useful Utilities - -| Utility | Location | Purpose | Use Case | -|---------|----------|---------|----------| -| `substitute_variables(fn, f)` | `MOI.Utilities` | Replace variables using a mapping function | Could substitute parameter values | -| `filter_variables(keep, f)` | `MOI.Utilities` | Remove variables not satisfying predicate | Separate params from vars | -| `canonical(f)` | `MOI.Utilities` | Normalize function (combine terms, sort) | Simplify parsed result | -| `eval_variables(value_fn, model, f)` | `MOI.Nonlinear` | Evaluate expression with variable values | Evaluate with param values | -| `operate(op, T, args...)` | `MOI.Utilities` | Compose functions with operators | Build result functions | -| `map_indices(fn, f)` | `MOI.Utilities` | Remap variable indices | Index transformations | - -#### Key Insight: Alternative Approach Using `substitute_variables` - -Instead of building a full custom parser, we could potentially: - -```julia -function _current_function_alternative(f::MOI.ScalarNonlinearFunction, model) - # Substitute parameters with their values - result = MOI.Utilities.substitute_variables(f) do vi - if _is_parameter(vi) - # Return the parameter value as a constant - return model.parameters[p_idx(vi)] - else - # Keep variables as-is - return vi - end - end - # Result should now be a polynomial in variables only - # Convert to ScalarQuadraticFunction or ScalarAffineFunction - return result -end -``` - -**Limitation**: `substitute_variables` returns `ScalarNonlinearFunction` even after substitution. We'd still need to detect that the result is polynomial and convert it. - -#### Recommended Approach - -Use MOI utilities for: -1. **`canonical()`** - After parsing, to combine like terms and normalize -2. **`map_indices()`** - If we need to remap variable indices -3. **`operate()`** - To build the final `ScalarQuadraticFunction` or `ScalarAffineFunction` - -Build custom logic for: -1. **Expression tree traversal** - To expand into monomials -2. **Polynomial detection** - To verify the expression is valid cubic -3. **Term classification** - To categorize by parameter/variable composition - -This hybrid approach leverages MOI utilities where beneficial while maintaining control over the polynomial-specific logic. - ---- - -## Part 2: Data Structures - -### 2.1 Unified Cubic Term Type - -We use a single unified type for all cubic terms, similar to how MOI uses `ScalarQuadraticTerm`: - -```julia -""" - _ScalarCubicTerm{T} - -Represents a cubic term of the form `coefficient * index_1 * index_2 * index_3`. - -Each index can be either a variable (MOI.VariableIndex) or a parameter (encoded as -VariableIndex with value > PARAMETER_INDEX_THRESHOLD). - -The term type is determined by counting parameters vs variables: -- PVV (1 param, 2 vars): becomes quadratic after substitution -- PPV (2 params, 1 var): becomes affine after substitution -- PPP (3 params, 0 vars): becomes constant after substitution - -# Fields -- `coefficient::T`: The numeric coefficient -- `index_1::MOI.VariableIndex`: First factor (variable or parameter) -- `index_2::MOI.VariableIndex`: Second factor (variable or parameter) -- `index_3::MOI.VariableIndex`: Third factor (variable or parameter) - -# Convention -Indices are stored in canonical order: -- Parameters come before variables -- Within each group, sorted by index value -This ensures `2*p*x*y` and `2*x*p*y` produce the same term. - -# Examples -```julia -# p * x * y (PVV): 1 parameter, 2 variables -_ScalarCubicTerm(2.0, p_vi, x, y) # becomes 2*p_val*x*y - -# p * q * x (PPV): 2 parameters, 1 variable -_ScalarCubicTerm(3.0, p_vi, q_vi, x) # becomes 3*p_val*q_val*x - -# p * q * r (PPP): 3 parameters, 0 variables -_ScalarCubicTerm(4.0, p_vi, q_vi, r_vi) # becomes 4*p_val*q_val*r_val -``` -""" -struct _ScalarCubicTerm{T} - coefficient::T - index_1::MOI.VariableIndex - index_2::MOI.VariableIndex - index_3::MOI.VariableIndex -end - -# Helper to classify a cubic term -function _cubic_term_type(term::_ScalarCubicTerm) - num_params = _is_parameter(term.index_1) + _is_parameter(term.index_2) + _is_parameter(term.index_3) - if num_params == 1 - return :pvv # 1 param, 2 vars → quadratic - elseif num_params == 2 - return :ppv # 2 params, 1 var → affine - else # num_params == 3 - return :ppp # 3 params → constant - end -end - -# Helper to extract parameters and variables from a term -function _split_cubic_term(term::_ScalarCubicTerm) - params = MOI.VariableIndex[] - vars = MOI.VariableIndex[] - for idx in (term.index_1, term.index_2, term.index_3) - if _is_parameter(idx) - push!(params, idx) - else - push!(vars, idx) - end - end - return params, vars -end -``` - -#### 2.1.1 Summary of Cubic Term Classifications - -| Classification | # Params | # Vars | After Substitution | Example | -|----------------|----------|--------|-------------------|---------| -| PVV | 1 | 2 | Quadratic: `c*p_val*x*y` | `2*p*x*y` → `6*x*y` (p=3) | -| PPV | 2 | 1 | Affine: `c*p_val*q_val*x` | `2*p*q*x` → `12*x` (p=2,q=3) | -| PPP | 3 | 0 | Constant: `c*p_val*q_val*r_val` | `2*p*q*r` → `24` (p=2,q=3,r=4) | - -### 2.2 ParametricCubicFunction - -Storage for a full cubic function with parametric terms. - -```julia -""" - ParametricCubicFunction{T} <: ParametricFunction{T} - -Represents a cubic function where parameters multiply up to quadratic variable terms. - -Supports the general form: - constant + Σ(affine) + Σ(quadratic) + Σ(cubic) - -# Fields - -## Cubic terms (degree 3) - unified type -- `cubic::Vector{_ScalarCubicTerm{T}}`: All cubic terms (pvv, ppv, ppp combined) - - Classification done at runtime via `_cubic_term_type()` - - pvv terms → become quadratic after substitution - - ppv terms → become affine after substitution - - ppp terms → become constant after substitution - -## Quadratic terms (degree 2) - inherited pattern from ParametricQuadraticFunction -- `pv::Vector{...}`: Parameter-variable terms (c*p*x) → become affine -- `pp::Vector{...}`: Parameter-parameter terms (c*p*q) → become constant -- `vv::Vector{...}`: Variable-variable terms (c*x*y) → stay quadratic - -## Affine terms (degree 1) -- `p::Vector{...}`: Parameter affine terms (c*p) → become constant -- `v::Vector{...}`: Variable affine terms (c*x) → stay affine - -## Constants (degree 0) -- `c::T`: Constant term - -## Caches (for efficient updates) -- `current_quadratic_terms::Vector{MOI.ScalarQuadraticTerm{T}}`: From vv + pvv -- `current_affine_terms::Vector{MOI.ScalarAffineTerm{T}}`: From v + pv + ppv -- `current_constant::T`: From c + p + pp - -# Substitution Summary - -| Original Term | After Parameter Substitution | -|---------------|------------------------------| -| `pvv` (p*x*y) | quadratic term (c*p_val*x*y) | -| `ppv` (p*q*x) | affine term (c*p_val*q_val*x)| -| `ppp` (p*q*r) | constant (c*p_val*q_val*r_val)| -| `pv` (p*x) | affine term (c*p_val*x) | -| `pp` (p*q) | constant (c*p_val*q_val) | -| `vv` (x*y) | quadratic term (unchanged) | -| `v` (x) | affine term (unchanged) | -| `p` (p) | constant (c*p_val) | -| `c` | constant (unchanged) | -""" -mutable struct ParametricCubicFunction{T} <: ParametricFunction{T} - # === Cubic terms (degree 3) - unified storage === - cubic::Vector{_ScalarCubicTerm{T}} # All cubic terms (pvv, ppv, ppp) - # Classification done at runtime via _cubic_term_type() - - # === Quadratic terms (degree 2) - same as ParametricQuadraticFunction === - pv::Vector{MOI.ScalarQuadraticTerm{T}} # p*x → becomes affine - pp::Vector{MOI.ScalarQuadraticTerm{T}} # p*q → becomes constant - vv::Vector{MOI.ScalarQuadraticTerm{T}} # x*y → stays quadratic - - # === Affine terms (degree 1) === - p::Vector{MOI.ScalarAffineTerm{T}} # p → becomes constant - v::Vector{MOI.ScalarAffineTerm{T}} # x → stays affine - - # === Constant (degree 0) === - c::T - - # === Caches for efficient updates (following POI pattern) === - # Variable pairs in pvv terms (quadratic coefficients depend on parameters) - quadratic_data::Dict{Tuple{MOI.VariableIndex,MOI.VariableIndex}, T} - # Variables in ppv or pv terms (affine coefficients depend on parameters) - affine_data::Dict{MOI.VariableIndex, T} - # Variables NOT in parameter-dependent terms (fixed coefficients) - affine_data_np::Dict{MOI.VariableIndex, T} - # Current constant after parameter substitution - current_constant::T -end - -# Accessors for cubic terms by type (iterate and filter) -function _cubic_pvv_terms(f::ParametricCubicFunction) - return Iterators.filter(t -> _cubic_term_type(t) == :pvv, f.cubic) -end - -function _cubic_ppv_terms(f::ParametricCubicFunction) - return Iterators.filter(t -> _cubic_term_type(t) == :ppv, f.cubic) -end - -function _cubic_ppp_terms(f::ParametricCubicFunction) - return Iterators.filter(t -> _cubic_term_type(t) == :ppp, f.cubic) -end -``` - -### 2.3 Parser Result - -```julia -""" - ParsedCubicExpression{T} - -Result of parsing a ScalarNonlinearFunction into cubic polynomial form. - -Returns `nothing` from the parser if the expression is not a valid cubic polynomial. - -Note: Like-terms should be combined during parsing (e.g., `x*y*p + 2*x*y*p` → single term with coef=3). - -# Design - -The structure contains: -1. A `MOI.ScalarQuadraticFunction{T}` for all non-cubic terms (quadratic, affine, constant) -2. A vector of `_ScalarCubicTerm{T}` for cubic terms only - -This reuses MOI's existing structure and simplifies construction of the final function -after parameter substitution. - -# Term Classification - -Cubic terms are stored in a unified vector and classified at runtime: -- Use `_cubic_term_type(term)` to get `:pvv`, `:ppv`, or `:ppp` -- Use filter functions `_filter_pvv_terms()`, etc. if needed - -# Note on Parameter Encoding - -In the `quadratic_func`, parameters appear as `MOI.VariableIndex` with values above -`PARAMETER_INDEX_THRESHOLD`. The caller must use `_is_parameter()` to distinguish -parameter terms (pp, pv, p) from pure variable terms (vv, v, constant). -""" -struct ParsedCubicExpression{T} - # Cubic terms (degree 3) - unified storage - cubic_terms::Vector{_ScalarCubicTerm{T}} # All cubic: p*x*y, p*q*x, p*q*r - # Classification done via _cubic_term_type() at runtime - - # Non-cubic terms (degree ≤ 2) - reuse MOI's structure - # Contains: vv, pv, pp (quadratic), v, p (affine), constant - # Parameters encoded as VariableIndex > PARAMETER_INDEX_THRESHOLD - quadratic_func::MOI.ScalarQuadraticFunction{T} -end - -# Helper functions to filter cubic terms by type -function _filter_pvv_terms(parsed::ParsedCubicExpression) - return filter(t -> _cubic_term_type(t) == :pvv, parsed.cubic_terms) -end - -function _filter_ppv_terms(parsed::ParsedCubicExpression) - return filter(t -> _cubic_term_type(t) == :ppv, parsed.cubic_terms) -end - -function _filter_ppp_terms(parsed::ParsedCubicExpression) - return filter(t -> _cubic_term_type(t) == :ppp, parsed.cubic_terms) -end - -# Convenience accessors for non-cubic terms -function _quadratic_terms(parsed::ParsedCubicExpression) - return parsed.quadratic_func.quadratic_terms -end - -function _affine_terms(parsed::ParsedCubicExpression) - return parsed.quadratic_func.affine_terms -end - -function _constant(parsed::ParsedCubicExpression) - return parsed.quadratic_func.constant -end -``` - ---- - -## Part 3: Core Functions - -### 3.1 Parser Functions - -```julia -""" - _parse_cubic_expression(f::MOI.ScalarNonlinearFunction) -> Union{ParsedCubicExpression, Nothing} - -Parse a ScalarNonlinearFunction and return a ParsedCubicExpression if it represents -a valid cubic polynomial (with parameters multiplying at most quadratic variable terms). - -Returns `nothing` if the expression: -- Contains non-polynomial operations (sin, exp, etc.) -- Has degree > 3 in any monomial -- Has invalid structure - -# Example -```julia -x, y = MOI.VariableIndex(1), MOI.VariableIndex(2) -p = POI.ParameterIndex(1) - -# 2*x*y*p + 3*x -f = MOI.ScalarNonlinearFunction(:+, Any[ - MOI.ScalarNonlinearFunction(:*, Any[2.0, x, y, v_idx(p)]), - MOI.ScalarNonlinearFunction(:*, Any[3.0, x]) -]) - -result = _parse_cubic_expression(f) -# result.cubic_terms = [_ScalarCubicTerm(2.0, p, x, y)] -# result.quadratic_func.affine_terms = [ScalarAffineTerm(3.0, x)] -``` -""" -function _parse_cubic_expression(f::MOI.ScalarNonlinearFunction) - # Implementation -end -``` - -### 3.2 Helper Functions for Parsing - -```julia -""" - _expand_expression(f::MOI.ScalarNonlinearFunction) -> Vector{Monomial} - -Recursively expand the expression tree into a list of monomials. -Each monomial tracks: coefficient, list of variables, list of parameters. -""" -function _expand_expression(f) -end - -""" - _classify_monomial(m::Monomial) -> Symbol - -Classify a monomial by its structure (num_params, num_vars): - -Degree 0: -- :constant - (0, 0) no variables or parameters - -Degree 1: -- :affine_v - (0, 1) one variable, no parameters -- :affine_p - (1, 0) one parameter, no variables - -Degree 2: -- :quadratic_vv - (0, 2) two variables, no parameters -- :quadratic_pv - (1, 1) one parameter, one variable -- :quadratic_pp - (2, 0) two parameters, no variables - -Degree 3 (valid - at least one parameter): -- :cubic_pvv - (1, 2) one parameter, two variables -- :cubic_ppv - (2, 1) two parameters, one variable -- :cubic_ppp - (3, 0) three parameters, no variables - -Invalid: -- :cubic_vvv - (0, 3) three variables, no parameters → REJECT (no parameter) -- :invalid - degree > 3, or any other invalid combination -""" -function _classify_monomial(m) -end - -""" - _is_polynomial_operator(head::Symbol) -> Bool - -Check if the operator is valid for polynomial expressions. -Valid: :+, :-, :*, :^ -Invalid: :sin, :cos, :exp, :log, :/, etc. -""" -function _is_polynomial_operator(head::Symbol) -end -``` - -### 3.3 Conversion Functions - -```julia -""" - ParametricCubicFunction(f::MOI.ScalarNonlinearFunction) - -Construct a ParametricCubicFunction from a ScalarNonlinearFunction. - -Throws an error if the expression is not a valid cubic polynomial. -""" -function ParametricCubicFunction(f::MOI.ScalarNonlinearFunction) -end - -""" - _current_function(f::ParametricCubicFunction{T}, model) where {T} -> Union{MOI.ScalarQuadraticFunction{T}, MOI.ScalarAffineFunction{T}} - -Evaluate the cubic function with current parameter values and return -the appropriate MOI function type. Follows the same pattern as -ParametricQuadraticFunction._current_function. - -# Implementation -1. Build quadratic terms from: - - `vv` terms (unchanged) - - `pvv` terms with parameter substituted: coef * p_val * x * y - -2. Build affine terms from: - - `affine_data` (variables in parameter-dependent terms, with updated coefficients) - - `affine_data_np` (variables with fixed coefficients) - -3. Use `current_constant` for the constant term - -# Returns -- `ScalarQuadraticFunction{T}` if there are any quadratic terms -- `ScalarAffineFunction{T}` if all quadratic terms have zero coefficient - -# Example -```julia -# f = 2*p*x*y + 3*x + 5 with p=3 -# _current_function returns: 6*x*y + 3*x + 5 -``` -""" -function _current_function(f::ParametricCubicFunction{T}, model) where {T} - # Build quadratic terms - quadratic = MOI.ScalarQuadraticTerm{T}[] - # Add vv terms (unchanged) - append!(quadratic, f.vv) - # Add pvv terms with parameter values substituted - for (vars, coef) in f.quadratic_data - if !iszero(coef) - push!(quadratic, MOI.ScalarQuadraticTerm{T}(coef, vars[1], vars[2])) - end - end - - # Build affine terms - affine = MOI.ScalarAffineTerm{T}[] - for (v, coef) in f.affine_data - push!(affine, MOI.ScalarAffineTerm{T}(coef, v)) - end - for (v, coef) in f.affine_data_np - push!(affine, MOI.ScalarAffineTerm{T}(coef, v)) - end - - # Return appropriate type - if isempty(quadratic) - return MOI.ScalarAffineFunction{T}(affine, f.current_constant) - else - return MOI.ScalarQuadraticFunction{T}(quadratic, affine, f.current_constant) - end -end - -""" - _update_cache!(f::ParametricCubicFunction{T}, model) where {T} - -Update the cached current values based on new parameter values. -Follows the same pattern as ParametricQuadraticFunction._update_cache! -""" -function _update_cache!(f::ParametricCubicFunction{T}, model) where {T} - f.current_constant = _parametric_constant(model, f) - f.affine_data = _parametric_affine_terms(model, f) - f.quadratic_data = _parametric_quadratic_terms(model, f) - return nothing -end - -""" - _parametric_constant(model, f::ParametricCubicFunction{T}) where {T} - -Compute the constant term after parameter substitution. -Includes contributions from: c + p terms + pp terms + ppp cubic terms -""" -function _parametric_constant(model, f::ParametricCubicFunction{T}) where {T} - constant = f.c - # From affine parameter terms (p) - for term in f.p - constant += term.coefficient * model.parameters[p_idx(term.variable)] - end - # From quadratic parameter-parameter terms (pp) - for term in f.pp - constant += (term.coefficient / ifelse(term.variable_1 == term.variable_2, 2, 1)) * - model.parameters[p_idx(term.variable_1)] * - model.parameters[p_idx(term.variable_2)] - end - # From cubic ppp terms (all 3 indices are parameters) - for term in _cubic_ppp_terms(f) - params, _ = _split_cubic_term(term) - divisor = _cubic_divisor(params) # handles p^3, p^2*q, p*q*r cases - constant += (term.coefficient / divisor) * - model.parameters[p_idx(params[1])] * - model.parameters[p_idx(params[2])] * - model.parameters[p_idx(params[3])] - end - return constant -end - -# Helper to compute divisor for repeated indices (for symmetric terms) -function _cubic_divisor(indices::Vector{MOI.VariableIndex}) - if indices[1] == indices[2] == indices[3] - return 6 # p^3: divide by 3! - elseif indices[1] == indices[2] || indices[2] == indices[3] || indices[1] == indices[3] - return 2 # p^2*q: divide by 2! - else - return 1 # p*q*r: no division needed - end -end - -""" - _parametric_affine_terms(model, f::ParametricCubicFunction{T}) where {T} - -Compute affine coefficients after parameter substitution. -Includes contributions from: v terms + pv terms + ppv cubic terms -""" -function _parametric_affine_terms(model, f::ParametricCubicFunction{T}) where {T} - terms_dict = Dict{MOI.VariableIndex, T}() - # From pv terms (same as ParametricQuadraticFunction) - for term in f.pv - var = term.variable_2 - base = get(terms_dict, var, zero(T)) - terms_dict[var] = base + term.coefficient * model.parameters[p_idx(term.variable_1)] - end - # From ppv cubic terms (2 params, 1 var) - p * q * x becomes coef * p_val * q_val * x - for term in _cubic_ppv_terms(f) - params, vars = _split_cubic_term(term) - var = vars[1] # The single variable - p1_val = model.parameters[p_idx(params[1])] - p2_val = model.parameters[p_idx(params[2])] - divisor = ifelse(params[1] == params[2], 2, 1) - base = get(terms_dict, var, zero(T)) - terms_dict[var] = base + (term.coefficient / divisor) * p1_val * p2_val - end - # Add fixed affine terms from v (stored in affine_data) - for (var, coef) in f.affine_data - terms_dict[var] = get(terms_dict, var, zero(T)) + coef - end - return terms_dict -end - -""" - _parametric_quadratic_terms(model, f::ParametricCubicFunction{T}) where {T} - -Compute quadratic coefficients after parameter substitution. -Includes contributions from: pvv terms (p * x * y becomes coef * p_val * x * y) -""" -function _parametric_quadratic_terms(model, f::ParametricCubicFunction{T}) where {T} - terms_dict = Dict{Tuple{MOI.VariableIndex, MOI.VariableIndex}, T}() - for term in _cubic_pvv_terms(f) - params, vars = _split_cubic_term(term) - p = params[1] # The single parameter - var_pair = (vars[1], vars[2]) # The two variables - p_val = model.parameters[p_idx(p)] - base = get(terms_dict, var_pair, zero(T)) - terms_dict[var_pair] = base + term.coefficient * p_val - end - return terms_dict -end - -# === Delta functions for efficient updates (following POI pattern) === - -""" - _delta_parametric_constant(model, f::ParametricCubicFunction{T}) where {T} - -Compute the CHANGE in constant when parameters are updated. -Only computes delta for parameters that have been updated (not NaN). -""" -function _delta_parametric_constant(model, f::ParametricCubicFunction{T}) where {T} - # Similar to ParametricQuadraticFunction but also handles ppp terms - # ... (implementation follows existing pattern) -end - -""" - _delta_parametric_affine_terms(model, f::ParametricCubicFunction{T}) where {T} - -Compute the CHANGE in affine coefficients when parameters are updated. -Returns Dict{MOI.VariableIndex, T} of delta values. -""" -function _delta_parametric_affine_terms(model, f::ParametricCubicFunction{T}) where {T} - # Similar to ParametricQuadraticFunction but also handles ppv terms - # ... (implementation follows existing pattern) -end - -""" - _delta_parametric_quadratic_terms(model, f::ParametricCubicFunction{T}) where {T} - -Compute the CHANGE in quadratic coefficients when parameters are updated. -Returns Dict{Tuple{MOI.VariableIndex, MOI.VariableIndex}, T} of delta values. - -This is NEW for cubic functions - quadratic terms can change when pvv parameters change. -""" -function _delta_parametric_quadratic_terms(model, f::ParametricCubicFunction{T}) where {T} - delta_dict = Dict{Tuple{MOI.VariableIndex, MOI.VariableIndex}, T}() - for term in _cubic_pvv_terms(f) - params, vars = _split_cubic_term(term) - p = p_idx(params[1]) # The single parameter - if !isnan(model.updated_parameters[p]) - var_pair = (vars[1], vars[2]) # The two variables - old_val = model.parameters[p] - new_val = model.updated_parameters[p] - delta = term.coefficient * (new_val - old_val) - base = get(delta_dict, var_pair, zero(T)) - delta_dict[var_pair] = base + delta - end - end - return delta_dict -end -``` - ---- - -## Part 4: Integration with POI - -### 4.1 Optimizer Storage - -Add to the `Optimizer` struct: - -```julia -# In Optimizer struct definition -cubic_objective_cache::Union{Nothing, ParametricCubicFunction{T}} - -# Option to warn on quadratic coefficient sign changes (can affect convexity) -warn_on_quadratic_sign_change::Bool -``` - -**New constructor parameter:** - -```julia -function Optimizer{T}( - optimizer::OT; - evaluate_duals::Bool = true, - save_original_objective_and_constraints::Bool = true, - warn_on_quadratic_sign_change::Bool = false, # NEW -) where {T,OT} - # ... -end -``` - -**Purpose:** When `warn_on_quadratic_sign_change = true`, POI will check if any quadratic coefficient changes sign during parameter updates (e.g., from positive to negative or vice versa). A sign change in quadratic terms can change the problem's convexity, which may: -- Cause solvers to fail or produce suboptimal results -- Change the problem from having a unique solution to multiple local optima -- Affect convergence behavior - -**Note:** This check is disabled by default for performance. Enable it during development/debugging if you suspect convexity issues. - -### 4.2 Objective Setting - -Add method for setting ScalarNonlinearFunction as objective: - -```julia -function MOI.set( - model::Optimizer, - ::MOI.ObjectiveFunction{MOI.ScalarNonlinearFunction}, - f::MOI.ScalarNonlinearFunction, -) - # 1. Attempt to parse as cubic - parsed = _parse_cubic_expression(f) - if parsed === nothing - error("ScalarNonlinearFunction must be a cubic polynomial for POI") - end - - # 2. Create ParametricCubicFunction - cubic_func = ParametricCubicFunction(parsed) - - # 3. Clear old caches, store new cache - _empty_objective_function_caches!(model) - model.cubic_objective_cache = cubic_func - - # 4. Compute current function and set on inner optimizer - current = _current_function(cubic_func, model) - MOI.set(model.optimizer, MOI.ObjectiveFunction{typeof(current)}(), current) - - # 5. Store original for retrieval - MOI.Utilities.set_objective(model.original_objective_cache, f) -end -``` - -### 4.3 Parameter Updates - -Extend `_update_parameters!` to handle cubic objectives: - -```julia -function _update_cubic_objective!(model::Optimizer{T}) where {T} - if model.cubic_objective_cache === nothing - return - end - pf = model.cubic_objective_cache - - # 1. Update constant (from p, pp, ppp terms) - delta_constant = _delta_parametric_constant(model, pf) - if !iszero(delta_constant) - pf.current_constant += delta_constant - F = MOI.get(model.optimizer, MOI.ObjectiveFunctionType()) - MOI.modify( - model.optimizer, - MOI.ObjectiveFunction{F}(), - MOI.ScalarConstantChange(pf.current_constant), - ) - end - - # 2. Update affine terms (from pv, ppv terms) - delta_affine = _delta_parametric_affine_terms(model, pf) - if !isempty(delta_affine) - # Update cache and build changes - changes = _affine_build_change_and_up_param_func(pf, delta_affine) - F = MOI.get(model.optimizer, MOI.ObjectiveFunctionType()) - MOI.modify(model.optimizer, MOI.ObjectiveFunction{F}(), changes) - end - - # 3. Update quadratic terms (from pvv terms) - NEW for cubic - delta_quadratic = _delta_parametric_quadratic_terms(model, pf) - if !isempty(delta_quadratic) - F = MOI.get(model.optimizer, MOI.ObjectiveFunctionType()) - for ((var1, var2), delta) in delta_quadratic - # Update cache - old_coef = get(pf.quadratic_data, (var1, var2), zero(T)) - new_coef = old_coef + delta - pf.quadratic_data[(var1, var2)] = new_coef - - # Check for sign change if option is enabled - if model.warn_on_quadratic_sign_change - _check_quadratic_sign_change(old_coef, new_coef, var1, var2) - end - - # Apply change using MOI.ScalarQuadraticCoefficientChange - MOI.modify( - model.optimizer, - MOI.ObjectiveFunction{F}(), - MOI.ScalarQuadraticCoefficientChange(var1, var2, new_coef), - ) - end - end - - return -end - -""" - _check_quadratic_sign_change(old_coef, new_coef, var1, var2) - -Check if a quadratic coefficient changed sign and emit a warning if so. -Sign changes can affect problem convexity. -""" -function _check_quadratic_sign_change(old_coef::T, new_coef::T, var1, var2) where {T} - # Skip if either coefficient is zero (not a true sign change) - if iszero(old_coef) || iszero(new_coef) - return - end - # Check for sign change: positive → negative or negative → positive - if (old_coef > zero(T)) != (new_coef > zero(T)) - @warn "Quadratic coefficient sign change detected" var1 var2 old_coef new_coef - end -end -``` - -**Note**: MOI supports `ScalarQuadraticCoefficientChange` for modifying quadratic coefficients in-place. See [MOI modification documentation](https://jump.dev/MathOptInterface.jl/stable/manual/modification/). - -```julia -# In update_parameters.jl, add call to cubic update: -function update_parameters!(model::Optimizer) - _update_affine_constraints!(model) - _update_vector_affine_constraints!(model) - _update_quadratic_constraints!(model) - _update_vector_quadratic_constraints!(model) - _update_affine_objective!(model) - _update_quadratic_objective!(model) - _update_cubic_objective!(model) # NEW - - # Update parameters and put NaN to indicate updated - for (parameter_index, val) in model.updated_parameters - if !isnan(val) - model.parameters[parameter_index] = val - model.updated_parameters[parameter_index] = NaN - end - end - return -end -``` - ---- - -## Part 5: Test Plan - -### 5.1 Test Philosophy - -Tests should be: -- **Simple**: Easy to compute expected results by hand -- **Predictable**: Use integer/simple coefficients -- **Focused**: Each test validates one specific behavior -- **Complete**: Cover parameter changes and result verification - -### 5.1.1 JuMP vs MOI Tests - -**MOI-level tests** (`test/moi_tests.jl`): -- Parser unit tests (expression tree parsing) -- Data structure construction tests -- Low-level API verification - -**JuMP-level tests** (`test/jump_tests.jl`) - **PRIMARY**: -- Full model integration tests -- Parameter update and re-optimization tests -- User-facing API validation - -JuMP tests are preferred for full model validation because: -1. If MOI's ScalarNonlinearFunction is implemented correctly, JuMP will work automatically -2. JuMP syntax is closer to what users will write -3. Easier to read and verify expected behavior - -### 5.2 Test Categories - -#### Category A: Parser Tests - -```julia -# A1: Valid cubic expression - single PVV term -@testset "parse_cubic_single_term" begin - x, y = MOI.VariableIndex(1), MOI.VariableIndex(2) - p_vi = v_idx(ParameterIndex(1)) - - # 2 * x * y * p - f = MOI.ScalarNonlinearFunction(:*, Any[2.0, x, y, p_vi]) - result = _parse_cubic_expression(f) - - @test result !== nothing - pvv = _filter_pvv_terms(result) - @test length(pvv) == 1 - @test pvv[1].coefficient == 2.0 -end - -# A2: Valid cubic expression - mixed terms -@testset "parse_cubic_mixed_terms" begin - # 3*x*y*p + 2*x + 5 - # Should parse into: 1 cubic, 1 affine, 1 constant -end - -# A3: Invalid expression - degree too high (4 factors) -@testset "parse_cubic_invalid_degree_4" begin - x, y, z = MOI.VariableIndex(1), MOI.VariableIndex(2), MOI.VariableIndex(3) - p_vi = v_idx(ParameterIndex(1)) - - # x * y * z * p (degree 4) should return nothing - f = MOI.ScalarNonlinearFunction(:*, Any[x, y, z, p_vi]) - result = _parse_cubic_expression(f) - - @test result === nothing -end - -# A3b: Invalid expression - three variables, no parameter -@testset "parse_cubic_three_vars_no_param" begin - x, y, z = MOI.VariableIndex(1), MOI.VariableIndex(2), MOI.VariableIndex(3) - - # x * y * z (3 variables, 0 parameters) should be rejected - # This is cubic in variables but has no parameter - not useful for POI - f = MOI.ScalarNonlinearFunction(:*, Any[x, y, z]) - result = _parse_cubic_expression(f) - - @test result === nothing -end - -# A4: Invalid expression - non-polynomial operator -@testset "parse_cubic_invalid_operator" begin - # sin(x) * p should return nothing -end - -# A5: Squared variable - p * x^2 -@testset "parse_cubic_squared_variable" begin - x = MOI.VariableIndex(1) - p_vi = v_idx(ParameterIndex(1)) - - # 3 * p * x^2 using power operator - f = MOI.ScalarNonlinearFunction(:*, Any[ - 3.0, - p_vi, - MOI.ScalarNonlinearFunction(:^, Any[x, 2]) - ]) - result = _parse_cubic_expression(f) - - @test result !== nothing - pvv = _filter_pvv_terms(result) - @test length(pvv) == 1 - @test pvv[1].coefficient == 3.0 - # Check that both variables are x (squared variable) - _, vars = _split_cubic_term(pvv[1]) - @test vars[1] == x - @test vars[2] == x # same variable -end - -# A6: Mixed parenthesis orderings - all should give same result -@testset "parse_cubic_parenthesis_variations" begin - x, y = MOI.VariableIndex(1), MOI.VariableIndex(2) - p_vi = v_idx(ParameterIndex(1)) - - # Flat: 2 * x * y * p - f1 = MOI.ScalarNonlinearFunction(:*, Any[2.0, x, y, p_vi]) - - # Left-associative: ((2*x)*y)*p - f2 = MOI.ScalarNonlinearFunction(:*, Any[ - MOI.ScalarNonlinearFunction(:*, Any[ - MOI.ScalarNonlinearFunction(:*, Any[2.0, x]), - y - ]), - p_vi - ]) - - # Grouped: (2*p) * (x*y) - f3 = MOI.ScalarNonlinearFunction(:*, Any[ - MOI.ScalarNonlinearFunction(:*, Any[2.0, p_vi]), - MOI.ScalarNonlinearFunction(:*, Any[x, y]) - ]) - - r1 = _parse_cubic_expression(f1) - r2 = _parse_cubic_expression(f2) - r3 = _parse_cubic_expression(f3) - - # All should parse to equivalent results - for r in [r1, r2, r3] - @test r !== nothing - pvv = _filter_pvv_terms(r) - @test length(pvv) == 1 - @test pvv[1].coefficient == 2.0 - end -end - -# A7: Multiple numeric coefficients -@testset "parse_cubic_multiple_coefficients" begin - x, y = MOI.VariableIndex(1), MOI.VariableIndex(2) - p_vi = v_idx(ParameterIndex(1)) - - # 2 * 3 * x * y * p = 6*x*y*p - f = MOI.ScalarNonlinearFunction(:*, Any[2.0, 3.0, x, y, p_vi]) - result = _parse_cubic_expression(f) - - @test result !== nothing - pvv = _filter_pvv_terms(result) - @test pvv[1].coefficient == 6.0 -end - -# A8: Subtraction handling (binary minus) -@testset "parse_cubic_subtraction" begin - x, y = MOI.VariableIndex(1), MOI.VariableIndex(2) - p_vi = v_idx(ParameterIndex(1)) - - # x*y*p - 2*x (one cubic, one affine with negative coef) - f = MOI.ScalarNonlinearFunction(:-, Any[ - MOI.ScalarNonlinearFunction(:*, Any[x, y, p_vi]), - MOI.ScalarNonlinearFunction(:*, Any[2.0, x]) - ]) - result = _parse_cubic_expression(f) - - @test result !== nothing - pvv = _filter_pvv_terms(result) - @test length(pvv) == 1 - # Check affine term via quadratic_func - affine = _affine_terms(result) - v_affine = filter(t -> !_is_parameter(t.variable), affine) - @test length(v_affine) == 1 - @test v_affine[1].coefficient == -2.0 -end - -# A8b: Unary minus handling -@testset "parse_cubic_unary_minus" begin - x, y = MOI.VariableIndex(1), MOI.VariableIndex(2) - p_vi = v_idx(ParameterIndex(1)) - - # -x*y*p (negation of cubic term) - f = MOI.ScalarNonlinearFunction(:-, Any[ - MOI.ScalarNonlinearFunction(:*, Any[x, y, p_vi]) - ]) - result = _parse_cubic_expression(f) - - @test result !== nothing - pvv = _filter_pvv_terms(result) - @test length(pvv) == 1 - @test pvv[1].coefficient == -1.0 -end - -# A9: Explicit x*x vs x^2 should be equivalent -@testset "parse_cubic_explicit_square" begin - x = MOI.VariableIndex(1) - p_vi = v_idx(ParameterIndex(1)) - - # Using x^2 - f1 = MOI.ScalarNonlinearFunction(:*, Any[ - p_vi, - MOI.ScalarNonlinearFunction(:^, Any[x, 2]) - ]) - - # Using x*x explicitly - f2 = MOI.ScalarNonlinearFunction(:*, Any[p_vi, x, x]) - - r1 = _parse_cubic_expression(f1) - r2 = _parse_cubic_expression(f2) - - @test r1 !== nothing - @test r2 !== nothing - pvv1 = _filter_pvv_terms(r1) - pvv2 = _filter_pvv_terms(r2) - _, vars1 = _split_cubic_term(pvv1[1]) - _, vars2 = _split_cubic_term(pvv2[1]) - @test vars1[1] == vars2[1] - @test vars1[2] == vars2[2] -end - -# A10: Division should be rejected -@testset "parse_cubic_division_rejected" begin - x, y = MOI.VariableIndex(1), MOI.VariableIndex(2) - p_vi = v_idx(ParameterIndex(1)) - - # x/y * p - division is not a polynomial operation - f = MOI.ScalarNonlinearFunction(:*, Any[ - MOI.ScalarNonlinearFunction(:/, Any[x, y]), - p_vi - ]) - result = _parse_cubic_expression(f) - - @test result === nothing -end - -# A11: Two parameters times one variable (PPV term) -@testset "parse_cubic_ppv_term" begin - x = MOI.VariableIndex(1) - p_vi = v_idx(ParameterIndex(1)) - q_vi = v_idx(ParameterIndex(2)) - - # 2 * p * q * x - f = MOI.ScalarNonlinearFunction(:*, Any[2.0, p_vi, q_vi, x]) - result = _parse_cubic_expression(f) - - @test result !== nothing - ppv = _filter_ppv_terms(result) - @test length(ppv) == 1 - @test ppv[1].coefficient == 2.0 -end - -# A12: Three parameters (PPP term) -@testset "parse_cubic_ppp_term" begin - p_vi = v_idx(ParameterIndex(1)) - q_vi = v_idx(ParameterIndex(2)) - r_vi = v_idx(ParameterIndex(3)) - - # 3 * p * q * r - f = MOI.ScalarNonlinearFunction(:*, Any[3.0, p_vi, q_vi, r_vi]) - result = _parse_cubic_expression(f) - - @test result !== nothing - ppp = _filter_ppp_terms(result) - @test length(ppp) == 1 - @test ppp[1].coefficient == 3.0 -end - -# A13: Like terms should be combined -@testset "parse_cubic_term_combination" begin - x, y = MOI.VariableIndex(1), MOI.VariableIndex(2) - p_vi = v_idx(ParameterIndex(1)) - - # x*y*p + 2*x*y*p = 3*x*y*p (should combine into single term) - f = MOI.ScalarNonlinearFunction(:+, Any[ - MOI.ScalarNonlinearFunction(:*, Any[x, y, p_vi]), - MOI.ScalarNonlinearFunction(:*, Any[2.0, x, y, p_vi]) - ]) - result = _parse_cubic_expression(f) - - @test result !== nothing - pvv = _filter_pvv_terms(result) - @test length(pvv) == 1 # combined into single term - @test pvv[1].coefficient == 3.0 -end - -# A14: Nested ScalarAffineFunction inside ScalarNonlinearFunction -@testset "parse_cubic_nested_affine" begin - x = MOI.VariableIndex(1) - p_vi = v_idx(ParameterIndex(1)) - - # (2x + 1) * p = 2*x*p + p (one pv term + one p term) - affine_func = MOI.ScalarAffineFunction( - [MOI.ScalarAffineTerm(2.0, x)], - 1.0 - ) - f = MOI.ScalarNonlinearFunction(:*, Any[affine_func, p_vi]) - result = _parse_cubic_expression(f) - - @test result !== nothing - # Check quadratic terms: should have 1 pv term (2*x*p) - quad = _quadratic_terms(result) - pv_terms = filter(t -> _is_parameter(t.variable_1) != _is_parameter(t.variable_2), quad) - @test length(pv_terms) == 1 - # Check affine terms: should have 1 p term (1*p) - affine = _affine_terms(result) - p_affine = filter(t -> _is_parameter(t.variable), affine) - @test length(p_affine) == 1 -end - -# A15: Mixed cubic expression with all term types -@testset "parse_cubic_all_term_types" begin - x, y = MOI.VariableIndex(1), MOI.VariableIndex(2) - p_vi = v_idx(ParameterIndex(1)) - q_vi = v_idx(ParameterIndex(2)) - - # x*y*p + p*q*x + p*q*p + x*y + p*x + p*q + x + p + 5 - # pvv + ppv + ppp + vv + pv + pp + v + p + c - f = MOI.ScalarNonlinearFunction(:+, Any[ - MOI.ScalarNonlinearFunction(:*, Any[x, y, p_vi]), # pvv - MOI.ScalarNonlinearFunction(:*, Any[p_vi, q_vi, x]), # ppv - MOI.ScalarNonlinearFunction(:*, Any[p_vi, q_vi, p_vi]), # ppp (p²*q) - MOI.ScalarNonlinearFunction(:*, Any[x, y]), # vv - MOI.ScalarNonlinearFunction(:*, Any[p_vi, x]), # pv - MOI.ScalarNonlinearFunction(:*, Any[p_vi, q_vi]), # pp - x, # v - p_vi, # p - 5.0 # c - ]) - result = _parse_cubic_expression(f) - - @test result !== nothing - # Check cubic terms via filters - @test length(_filter_pvv_terms(result)) == 1 - @test length(_filter_ppv_terms(result)) == 1 - @test length(_filter_ppp_terms(result)) == 1 - - # Check quadratic terms via quadratic_func - quad = _quadratic_terms(result) - vv_terms = filter(t -> !_is_parameter(t.variable_1) && !_is_parameter(t.variable_2), quad) - pv_terms = filter(t -> _is_parameter(t.variable_1) != _is_parameter(t.variable_2), quad) - pp_terms = filter(t -> _is_parameter(t.variable_1) && _is_parameter(t.variable_2), quad) - @test length(vv_terms) == 1 - @test length(pv_terms) == 1 - @test length(pp_terms) == 1 - - # Check affine terms via quadratic_func - affine = _affine_terms(result) - v_affine = filter(t -> !_is_parameter(t.variable), affine) - p_affine = filter(t -> _is_parameter(t.variable), affine) - @test length(v_affine) == 1 - @test length(p_affine) == 1 - - # Check constant - @test _constant(result) == 5.0 -end -``` - -#### Category B: ParametricCubicFunction Construction - -```julia -# B1: Construct from parsed expression -@testset "cubic_function_construction" begin - # Verify all term categories are correctly stored -end - -# B2: Verify _current_function produces correct quadratic -@testset "cubic_function_current" begin - # With p=2: 3*x*y*p -> 6*x*y (quadratic term) -end -``` - -#### Category C: JuMP Integration Tests (Primary) - -These are the main validation tests using JuMP syntax. - -```julia -# C1: Basic PVV term - parameter times quadratic -function test_jump_cubic_pvv_basic() - model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) - set_silent(model) - - @variable(model, 0 <= x <= 10) - @variable(model, 0 <= y <= 10) - @variable(model, p in MOI.Parameter(2.0)) - - # Minimize: x + y + p*x*y - # With p=2: minimize x + y + 2*x*y - # Subject to: x + y >= 2 - @constraint(model, x + y >= 2) - @objective(model, Min, x + y + p * x * y) - - optimize!(model) - @test termination_status(model) == OPTIMAL - # At p=2, optimal is x=y=1, obj = 1+1+2*1*1 = 4 - @test objective_value(model) ≈ 4.0 atol=1e-6 - - # Change p to 0 (removes cross term) - set_parameter_value(p, 0.0) - optimize!(model) - # At p=0, optimal is x=y=1, obj = 1+1+0 = 2 - @test objective_value(model) ≈ 2.0 atol=1e-6 -end - -# C2: PPV term - two parameters times one variable -function test_jump_cubic_ppv_basic() - model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) - set_silent(model) - - @variable(model, x >= 0) - @variable(model, p in MOI.Parameter(2.0)) - @variable(model, q in MOI.Parameter(3.0)) - - # Minimize: x + p*q*x = x * (1 + p*q) - # With p=2, q=3: minimize x * (1 + 6) = 7x - # Subject to: x >= 1 - @constraint(model, x >= 1) - @objective(model, Min, x + p * q * x) - - optimize!(model) - @test termination_status(model) == OPTIMAL - # Optimal at x=1, obj = 7 - @test objective_value(model) ≈ 7.0 atol=1e-6 - - # Change p=1, q=1: minimize x*(1+1) = 2x - set_parameter_value(p, 1.0) - set_parameter_value(q, 1.0) - optimize!(model) - @test objective_value(model) ≈ 2.0 atol=1e-6 -end - -# C3: PPP term - three parameters (constant contribution) -function test_jump_cubic_ppp_basic() - model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) - set_silent(model) - - @variable(model, x >= 0) - @variable(model, p in MOI.Parameter(2.0)) - @variable(model, q in MOI.Parameter(3.0)) - @variable(model, r in MOI.Parameter(4.0)) - - # Minimize: x + p*q*r - # With p=2, q=3, r=4: minimize x + 24 - # Subject to: x >= 1 - @constraint(model, x >= 1) - @objective(model, Min, x + p * q * r) - - optimize!(model) - @test termination_status(model) == OPTIMAL - # Optimal at x=1, obj = 1 + 24 = 25 - @test objective_value(model) ≈ 25.0 atol=1e-6 - - # Change p=1, q=1, r=1: minimize x + 1 - set_parameter_value(p, 1.0) - set_parameter_value(q, 1.0) - set_parameter_value(r, 1.0) - optimize!(model) - @test objective_value(model) ≈ 2.0 atol=1e-6 -end - -# C4: Mixed cubic terms -function test_jump_cubic_mixed_terms() - model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) - set_silent(model) - - @variable(model, 0 <= x <= 10) - @variable(model, 0 <= y <= 10) - @variable(model, p in MOI.Parameter(1.0)) - @variable(model, q in MOI.Parameter(1.0)) - - # Minimize: p*x*y + p*q*x + x*y + p*x + x + 10 - # pvv + ppv + vv + pv + v + c - # (no ppp term in this test for simplicity) - @constraint(model, x + y >= 2) - @objective(model, Min, 1.0*p*x*y + p*q*x + x*y + p*x + x + 10) - - # With p=1, q=1: - # minimize: x*y + x + x*y + x + x + 10 = 2*x*y + 3x + 10 - optimize!(model) - @test termination_status(model) == OPTIMAL - # Verify result matches hand calculation -end - -# C5: Parameter changes affect optimization correctly -function test_jump_cubic_parameter_sensitivity() - model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) - set_silent(model) - - @variable(model, x >= 0) - @variable(model, y >= 0) - @variable(model, p in MOI.Parameter(0.0)) - - @constraint(model, x + y == 1) - # Minimize: x² + y² + p*x*y - @objective(model, Min, x^2 + y^2 + p * x * y) - - # p=0: minimize x² + y² s.t. x+y=1 - # Solution: x=y=0.5, obj = 0.25 + 0.25 = 0.5 - optimize!(model) - @test value(x) ≈ 0.5 atol=1e-6 - @test value(y) ≈ 0.5 atol=1e-6 - @test objective_value(model) ≈ 0.5 atol=1e-6 - - # p=2: minimize x² + y² + 2xy = (x+y)² s.t. x+y=1 - # Any point on x+y=1 is optimal, obj = 1 - set_parameter_value(p, 2.0) - optimize!(model) - @test objective_value(model) ≈ 1.0 atol=1e-6 - @test value(x) + value(y) ≈ 1.0 atol=1e-6 - - # p=-2: minimize x² + y² - 2xy = (x-y)² s.t. x+y=1 - # Optimal at x=1,y=0 or x=0,y=1, obj = 0 + corner effect - set_parameter_value(p, -2.0) - optimize!(model) - # (x-y)² is minimized but x+y=1, so x² + y² - 2xy - # At x=0.5,y=0.5: 0.25 + 0.25 - 0.5 = 0 - @test objective_value(model) ≈ 0.0 atol=1e-6 -end -``` - -#### Category D: Edge Cases - -```julia -# D1: Cubic that simplifies when p=0 -@testset "cubic_parameter_zero" begin - # When p=0, x*y*p = 0 - # If all quadratic terms also vanish, result should be affine - # Test that _current_function returns correct type -end - -# D1b: Parameter initially zero, then updated to non-zero -# CRITICAL: Expression must be parsed as cubic even when p=0 initially, -# so that updating p later correctly adds the quadratic term -function test_jump_cubic_parameter_initially_zero() - model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) - set_silent(model) - - @variable(model, x >= 0) - @variable(model, y >= 0) - @variable(model, p in MOI.Parameter(0.0)) # p = 0 initially - - @constraint(model, x + y >= 2) - # Objective: p*x*y + x + y - # With p=0: minimize 0 + x + y = x + y (effectively affine) - @objective(model, Min, p * x * y + x + y) - - # First solve with p=0 - optimize!(model) - @test termination_status(model) == OPTIMAL - @test objective_value(model) ≈ 2.0 atol=1e-6 # x=y=1, obj = 0 + 1 + 1 = 2 - - # NOW update p to non-zero - this is the critical test! - # The cubic term must have been stored, even though p was 0 - set_parameter_value(p, 2.0) - optimize!(model) - @test termination_status(model) == OPTIMAL - # With p=2: minimize 2*x*y + x + y s.t. x+y>=2 - # At x=y=1: obj = 2*1*1 + 1 + 1 = 4 - @test objective_value(model) ≈ 4.0 atol=1e-6 - - # Update p back to 0 - should return to original behavior - set_parameter_value(p, 0.0) - optimize!(model) - @test objective_value(model) ≈ 2.0 atol=1e-6 -end - -# D1c: Multiple cubic terms, some parameters zero -function test_jump_cubic_partial_zero_parameters() - model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) - set_silent(model) - - @variable(model, x >= 0) - @variable(model, y >= 0) - @variable(model, p in MOI.Parameter(0.0)) # p = 0 initially - @variable(model, q in MOI.Parameter(1.0)) # q = 1 - - @constraint(model, x + y >= 2) - # Objective: p*x*y + q*x*y + x + y - # With p=0, q=1: minimize 0 + x*y + x + y - @objective(model, Min, p * x * y + q * x * y + x + y) - - # First solve - optimize!(model) - @test termination_status(model) == OPTIMAL - # At x=y=1: obj = 0 + 1 + 1 + 1 = 3 - @test objective_value(model) ≈ 3.0 atol=1e-6 - - # Update p to 2 (now both terms contribute) - set_parameter_value(p, 2.0) - optimize!(model) - # With p=2, q=1: minimize 2*x*y + x*y + x + y = 3*x*y + x + y - # At x=y=1: obj = 3 + 1 + 1 = 5 - @test objective_value(model) ≈ 5.0 atol=1e-6 - - # Set q to 0 as well - set_parameter_value(q, 0.0) - optimize!(model) - # With p=2, q=0: minimize 2*x*y + 0 + x + y = 2*x*y + x + y - # At x=y=1: obj = 2 + 1 + 1 = 4 - @test objective_value(model) ≈ 4.0 atol=1e-6 -end - -# D2: Cubic with negative parameter -@testset "cubic_negative_parameter" begin - # Verify sign handling is correct -end - -# D3: Cubic term where variable_1 == variable_2 -@testset "cubic_squared_variable" begin - # x^2 * p (same variable twice) -end - -# D4: Sign change warning for quadratic coefficients -function test_quadratic_sign_change_warning() - # Enable the warning option - inner_optimizer = HiGHS.Optimizer() - model = POI.Optimizer(inner_optimizer; warn_on_quadratic_sign_change = true) - MOI.set(model, MOI.Silent(), true) - - x = MOI.add_variable(model) - y = MOI.add_variable(model) - p, _ = MOI.add_constrained_variable(model, MOI.Parameter(2.0)) - - MOI.add_constraint(model, x, MOI.GreaterThan(0.0)) - MOI.add_constraint(model, y, MOI.GreaterThan(0.0)) - MOI.add_constraint(model, - MOI.ScalarAffineFunction([ - MOI.ScalarAffineTerm(1.0, x), - MOI.ScalarAffineTerm(1.0, y) - ], 0.0), - MOI.GreaterThan(1.0) - ) - - # Objective: p*x*y (starts with positive coefficient when p=2) - obj = MOI.ScalarNonlinearFunction(:*, Any[p, x, y]) - MOI.set(model, MOI.ObjectiveFunction{MOI.ScalarNonlinearFunction}(), obj) - MOI.set(model, MOI.ObjectiveSense(), MOI.MIN_SENSE) - - MOI.optimize!(model) - - # Change p from +2 to -2: should trigger warning - # The quadratic coefficient changes from +2 to -2 (sign change!) - @test_logs (:warn, r"Quadratic coefficient sign change") begin - MOI.set(model, POI.ParameterValue(), p, -2.0) - POI.update_parameters!(model) - end - - # Change p from -2 to -1: no warning (same sign) - @test_logs min_level=Logging.Warn begin - MOI.set(model, POI.ParameterValue(), p, -1.0) - POI.update_parameters!(model) - end -end - -# D5: No warning when option is disabled (default) -function test_quadratic_sign_change_no_warning_by_default() - # Default: warn_on_quadratic_sign_change = false - inner_optimizer = HiGHS.Optimizer() - model = POI.Optimizer(inner_optimizer) # default options - MOI.set(model, MOI.Silent(), true) - - x = MOI.add_variable(model) - y = MOI.add_variable(model) - p, _ = MOI.add_constrained_variable(model, MOI.Parameter(2.0)) - - MOI.add_constraint(model, x, MOI.GreaterThan(0.0)) - MOI.add_constraint(model, y, MOI.GreaterThan(0.0)) - - obj = MOI.ScalarNonlinearFunction(:*, Any[p, x, y]) - MOI.set(model, MOI.ObjectiveFunction{MOI.ScalarNonlinearFunction}(), obj) - MOI.set(model, MOI.ObjectiveSense(), MOI.MIN_SENSE) - - MOI.optimize!(model) - - # Sign change but no warning because option is disabled - @test_logs min_level=Logging.Warn begin - MOI.set(model, POI.ParameterValue(), p, -2.0) - POI.update_parameters!(model) - end -end -``` - -### 5.3 Simple Example Test Case - -**Test: `test_cubic_objective_simple`** - -```julia -function test_cubic_objective_simple() - # Setup: Simple QP that we can solve by hand - # - # minimize: x² + y² + x*y*p - # subject to: x + y >= 1 - # x, y >= 0 - # - # When p = 0: minimize x² + y² s.t. x+y>=1 - # Solution: x = y = 0.5, objective = 0.5 - # - # When p = 2: minimize x² + y² + 2xy = (x+y)² - # Solution: Any point on x+y=1, objective = 1 - # With x,y >= 0, optimal at x=1,y=0 or x=0,y=1 or any convex combo - - model = POI.Optimizer(HiGHS.Optimizer()) - MOI.set(model, MOI.Silent(), true) - - x = MOI.add_variable(model) - y = MOI.add_variable(model) - p, ci_p = MOI.add_constrained_variable(model, MOI.Parameter(0.0)) - - # Bounds - MOI.add_constraint(model, x, MOI.GreaterThan(0.0)) - MOI.add_constraint(model, y, MOI.GreaterThan(0.0)) - - # Constraint: x + y >= 1 - MOI.add_constraint(model, - MOI.ScalarAffineFunction([ - MOI.ScalarAffineTerm(1.0, x), - MOI.ScalarAffineTerm(1.0, y) - ], 0.0), - MOI.GreaterThan(1.0) - ) - - # Objective: x² + y² + x*y*p (as ScalarNonlinearFunction) - obj = MOI.ScalarNonlinearFunction(:+, Any[ - MOI.ScalarNonlinearFunction(:^, Any[x, 2]), - MOI.ScalarNonlinearFunction(:^, Any[y, 2]), - MOI.ScalarNonlinearFunction(:*, Any[x, y, p]) - ]) - MOI.set(model, MOI.ObjectiveFunction{MOI.ScalarNonlinearFunction}(), obj) - MOI.set(model, MOI.ObjectiveSense(), MOI.MIN_SENSE) - - # Solve with p = 0 - MOI.optimize!(model) - @test MOI.get(model, MOI.TerminationStatus()) == MOI.OPTIMAL - @test MOI.get(model, MOI.ObjectiveValue()) ≈ 0.5 atol=1e-6 - - # Update p = 2 and re-solve - MOI.set(model, POI.ParameterValue(), p, 2.0) - MOI.optimize!(model) - @test MOI.get(model, MOI.TerminationStatus()) == MOI.OPTIMAL - @test MOI.get(model, MOI.ObjectiveValue()) ≈ 1.0 atol=1e-6 -end -``` - ---- - -## Part 6: Implementation Order - -### Phase 1: Foundation (Tests First) - -1. **Write parser tests** (`test/parser_tests.jl`) - - Test expression tree traversal - - Test monomial classification - - Test valid/invalid detection - -2. **Implement parser** (`src/cubic_parser.jl`) - - `_parse_cubic_expression` - - `_expand_expression` - - `_classify_monomial` - - `_is_polynomial_operator` - -3. **Validate parser tests pass** - -### Phase 2: Data Structure - -4. **Write ParametricCubicFunction tests** - - Construction tests - - `_current_function` tests - -5. **Implement ParametricCubicFunction** (in `src/parametric_cubic_function.jl` - new file) - - Struct definition - - Constructor from `ParsedCubicExpression` - - `_current_function` (returns `ScalarQuadraticFunction` or `ScalarAffineFunction`) - - `_update_cache!` - - `_original_function` (reconstruct original expression) - -6. **Validate data structure tests pass** - -### Phase 3: Integration - -7. **Write objective integration tests** - - Setting cubic objectives - - Parameter updates - - Optimization verification - -8. **Implement MOI integration** (in `src/MOI_wrapper.jl`) - - Add `cubic_objective_cache` to Optimizer - - `MOI.set` for `ObjectiveFunction{ScalarNonlinearFunction}` - - `MOI.get` for objective retrieval - - Extend `_update_parameters!` - -9. **Validate all tests pass** - -### Phase 4: Documentation - -10. **Add docstrings** to all public functions - -11. **Update package documentation** - -### Phase 5: Tutorials (Post-Validation) - -12. **Progressive Hedging example** (after code is stable) - ---- - -## Part 7: File Organization - -**Design principle**: Most new code should be in **separate new files** to minimize changes to existing files. This keeps the codebase modular and reduces merge conflicts. - -### New Files (bulk of the implementation) - -``` -src/ -├── cubic_types.jl # NEW: _ScalarCubicTerm{T} struct and helpers -├── cubic_parser.jl # NEW: _parse_cubic_expression and helpers -├── parametric_cubic_function.jl # NEW: ParametricCubicFunction struct and methods -└── cubic_objective.jl # NEW: MOI objective setting/getting for cubic - -test/ -├── cubic_parser_tests.jl # NEW: Parser unit tests -└── cubic_jump_tests.jl # NEW: JuMP integration tests for cubic -``` - -### Minimal Changes to Existing Files - -``` -src/ -├── ParametricOptInterface.jl # MODIFY: Add includes and exports (few lines) -├── MOI_wrapper.jl # MODIFY: Add cubic_objective_cache field to Optimizer -│ # Add dispatch to _update_parameters! -└── update_parameters.jl # MODIFY: Call _update_cubic_objective! (few lines) - -test/ -├── runtests.jl # MODIFY: Include new test files -``` - -### File Responsibilities - -| File | Responsibility | Lines (est.) | -|------|----------------|--------------| -| `cubic_types.jl` | `_ScalarCubicTerm{T}`, helpers, accessors | ~60 | -| `cubic_parser.jl` | Expression tree parsing | ~200 | -| `parametric_cubic_function.jl` | Main data structure + methods | ~250 | -| `cubic_objective.jl` | MOI integration for objectives | ~150 | -| `cubic_parser_tests.jl` | Parser unit tests | ~300 | -| `cubic_jump_tests.jl` | JuMP integration tests | ~400 | - -### Include Order in ParametricOptInterface.jl - -```julia -# Add after existing includes: -include("cubic_types.jl") -include("cubic_parser.jl") -include("parametric_cubic_function.jl") -include("cubic_objective.jl") -``` - ---- - -## Part 8: Open Questions / Considerations - -### Q1: What if the solver doesn't support quadratic objectives? - -When `p` is substituted, the cubic becomes quadratic (or affine if all quadratic terms vanish). - -**Considerations**: -- If the inner optimizer doesn't support quadratic objectives but all quadratic terms have zero coefficients (e.g., all PVV parameters are 0), we can still proceed with an affine objective -- If quadratic terms are non-zero and the solver doesn't support them, throw a clear error -- `_current_function` should return the simplest possible type (`ScalarAffineFunction` when possible) - -**Decision**: Check `MOI.supports` dynamically based on the actual result of `_current_function`. - -### Q2: Should we support cubic in constraints? - -The user specified **objectives only**. This simplifies implementation significantly since: -- We don't need to handle constraint modifications -- We don't need dual computation for cubic constraints -- The inner optimizer sees only quadratic/affine constraints - -**Decision**: No constraint support in this implementation. - -### Q3: How to handle duals for cubic objectives? - -When a parameter appears in a cubic term, the dual interpretation is more complex. For now: -- Focus on primal optimization -- Document that dual sensitivity for cubic terms is not supported initially - -### Q3b: Should we accept ScalarNonlinearFunction with no cubic terms? - -If a user passes a `ScalarNonlinearFunction` that parses successfully but contains only quadratic/affine/constant terms (no PVV, PPV, or PPP), should we: - -**Option A**: Accept it and store in `cubic_objective_cache` -- Pro: Consistent handling of all ScalarNonlinearFunction -- Con: Overhead of cubic infrastructure for non-cubic functions - -**Option B**: Reject it with a helpful error suggesting to use ScalarQuadraticFunction -- Pro: Encourages proper function types -- Con: May be overly strict - -**Proposed**: Option A - accept and handle it. The overhead is minimal and it provides a smoother user experience. - -### Q4: JuMP integration - -Users will write `@objective(model, Min, x*y*p)` in JuMP. - -**Key insight**: If we correctly implement `MOI.set` for `ObjectiveFunction{ScalarNonlinearFunction}`, JuMP will automatically work because: -1. JuMP detects that `x*y*p` involves three "variables" (including the parameter) -2. JuMP constructs a `ScalarNonlinearFunction` for expressions beyond quadratic -3. JuMP calls `MOI.set(model, ObjectiveFunction{ScalarNonlinearFunction}(), f)` -4. Our implementation parses and handles it - -**Testing**: Full model tests should use JuMP syntax (`test/jump_tests.jl`) as these validate the end-to-end user experience. - ---- - ---- - -## Part 9: Verification Against Codebase - -This section documents verification of the plan against actual MOI, JuMP, and POI code. - -### 9.1 MOI ScalarNonlinearFunction (Verified ✓) - -**Location**: `MathOptInterface/src/functions.jl` (lines 276-351) - -**Confirmed structure:** -```julia -struct ScalarNonlinearFunction <: AbstractScalarFunction - head::Symbol - args::Vector{Any} -end -``` - -**Confirmed valid arg types:** -- `T <: Real` (constants) -- `VariableIndex` -- `ScalarAffineFunction` -- `ScalarQuadraticFunction` -- `ScalarNonlinearFunction` (nested) - -**Confirmed operators:** -- Multivariate: `:+`, `:-`, `:*`, `:^`, `:/`, `:ifelse`, `:min`, `:max` -- Unary: `:-` (negation), plus all math functions - -**Plan alignment**: ✓ Our parsing strategy correctly handles these types and operators. - -### 9.2 POI ParametricQuadraticFunction (Verified ✓) - -**Location**: `ParametricOptInterface/src/parametric_functions.jl` (lines 18-295) - -**Confirmed patterns:** -```julia -mutable struct ParametricQuadraticFunction{T} <: ParametricFunction{T} - affine_data::Dict{MOI.VariableIndex,T} # Variables in pv terms - affine_data_np::Dict{MOI.VariableIndex,T} # Variables NOT in pv terms - pv::Vector{MOI.ScalarQuadraticTerm{T}} - pp::Vector{MOI.ScalarQuadraticTerm{T}} - vv::Vector{MOI.ScalarQuadraticTerm{T}} - p::Vector{MOI.ScalarAffineTerm{T}} - v::Vector{MOI.ScalarAffineTerm{T}} - c::T - set_constant::T - current_terms_with_p::Dict{MOI.VariableIndex,T} - current_constant::T -end -``` - -**Key helper functions:** -- `_split_quadratic_terms()` - Categorizes into vv/pp/pv -- `_split_affine_terms()` - Categorizes into v/p -- `_parametric_constant()` - Computes constant with parameter values -- `_parametric_affine_terms()` - Computes affine coefficients with parameters -- `_is_parameter()` / `_is_variable()` - Index classification - -**Plan alignment**: ✓ Updated ParametricCubicFunction to follow the same Dict-based caching pattern. - -### 9.3 Current POI Limitation (Verified ✓) - -**Confirmed**: POI currently only supports up to quadratic expressions. - -**From documentation** (`docs/src/manual.md`): -- Supported: `ScalarAffineFunction`, `ScalarQuadraticFunction`, `VectorAffineFunction` -- NOT supported: `ScalarNonlinearFunction` - -**From tests** (`test/jump_tests.jl`, lines 854-862): -```julia -function test_jump_nlp() - # ... nonlinear objective throws ErrorException - @test_throws ErrorException optimize!(model) -end -``` - -**Plan alignment**: ✓ This confirms our implementation fills a real gap - cubic expressions with parameters are not currently supported. - -### 9.4 JuMP Expression Generation - -**Confirmed behavior:** -- JuMP treats parameters (via `MOI.Parameter`) as special `VariableIndex` values -- For expressions beyond quadratic degree, JuMP creates `ScalarNonlinearFunction` -- Expression `p * x * y` would generate a nonlinear function (currently rejected by POI) - -**Plan alignment**: ✓ When we implement `MOI.set` for `ObjectiveFunction{ScalarNonlinearFunction}`, JuMP's `@objective(model, Min, p*x*y)` will work automatically. - -### 9.5 Parameter Index Threshold - -**Location**: `ParametricOptInterface/src/ParametricOptInterface.jl` (lines 21-38) - -```julia -const PARAMETER_INDEX_THRESHOLD = 4_611_686_018_427_387_904 -``` - -**Plan alignment**: ✓ Our parser must use `_is_parameter()` to distinguish parameters from variables when classifying monomials. - -### 9.6 MOI Utilities Available (Verified ✓) - -**Location**: `MathOptInterface/src/Utilities/functions.jl` and `MathOptInterface/src/Nonlinear/` - -**Available utilities that could simplify implementation:** - -| Utility | What it does | Potential use | -|---------|--------------|---------------| -| `substitute_variables(fn, f)` | Replace variables via mapping function | Limited - returns SNF not polynomial | -| `canonical(f)` | Normalize (combine terms, sort) | Post-processing parsed result | -| `map_indices(fn, f)` | Remap variable indices in tree | Index transformations | -| `operate(op, T, args...)` | Compose functions with +, -, *, etc. | Building result functions | -| `eval_variables(value_fn, model, f)` | Evaluate expression numerically | Not useful - we need symbolic result | - -**Decision**: Use `canonical()` and `operate()` where beneficial. Build custom monomial expansion logic since MOI doesn't provide polynomial-specific utilities. - -**Key finding**: MOI's `substitute_variables` cannot convert `ScalarNonlinearFunction` to `ScalarQuadraticFunction` - it preserves the nonlinear type. Our custom parser is necessary. - ---- - -## Summary - -This plan provides a structured approach to implementing parametric cubic functions: - -1. **Parse** `ScalarNonlinearFunction` to detect valid cubic polynomials -2. **Store** in `ParametricCubicFunction` with proper term categorization -3. **Integrate** with POI's objective handling (objectives only) -4. **Test** thoroughly with simple, predictable examples -5. **Document** all functions - -The key insight is that when parameters are substituted with values, a cubic function `c*x*y*p` becomes a quadratic function `c*p_val*x*y`, which existing solvers can handle. From df4a99b8205d5fc3a0160da29c65893a7d38be1d Mon Sep 17 00:00:00 2001 From: joaquimg Date: Sun, 15 Feb 2026 20:11:23 -0300 Subject: [PATCH 03/14] rm session --- docs/src/manual.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/docs/src/manual.md b/docs/src/manual.md index 6420141b..94bb5371 100644 --- a/docs/src/manual.md +++ b/docs/src/manual.md @@ -130,10 +130,6 @@ handle. - Pure cubic variable terms (e.g., `x * y * z` with no parameters) are **not supported** -#### Example using MOI - -# TODO - #### Example using JuMP ```julia From 094123931880dcb09a06d2d0b63ab3dcfd3fc6d7 Mon Sep 17 00:00:00 2001 From: joaquimg Date: Sun, 15 Feb 2026 20:59:54 -0300 Subject: [PATCH 04/14] fix --- test/test_cubic.jl | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/test_cubic.jl b/test/test_cubic.jl index a9042da6..78068b1b 100644 --- a/test/test_cubic.jl +++ b/test/test_cubic.jl @@ -510,3 +510,5 @@ function test_jump_cubic_parameter_division_by_constant() end end # module + +TestCubic.runtests() From 8e27cf49b1aab81360508b3a19546b87022021cb Mon Sep 17 00:00:00 2001 From: joaquimg Date: Sun, 15 Feb 2026 21:00:02 -0300 Subject: [PATCH 05/14] format --- test/test_MathOptInterface.jl | 32 +++++++++++++------------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/test/test_MathOptInterface.jl b/test/test_MathOptInterface.jl index b3cbbb92..de82313c 100644 --- a/test/test_MathOptInterface.jl +++ b/test/test_MathOptInterface.jl @@ -378,13 +378,9 @@ function test_moi_ListOfConstraintTypesPresent() model = POI.Optimizer(ipopt) MOI.set(model, MOI.Silent(), true) x = MOI.add_variables(model, N / 2) - y = - first.( - MOI.add_constrained_variable.( - model, - MOI.Parameter.(ones(Int(N / 2))), - ), - ) + y = first.( + MOI.add_constrained_variable.(model, MOI.Parameter.(ones(Int(N / 2)))), + ) MOI.add_constraint( model, @@ -686,11 +682,10 @@ function test_vector_parameter_affine_nonnegatives() t, ct = MOI.add_constrained_variable(model, MOI.Parameter(5.0)) A = [1.0 0 -1; 0 1 -1] b = [1.0; 2] - terms = - MOI.VectorAffineTerm.( - 1:2, - MOI.ScalarAffineTerm.(A, reshape([x, y, t], 1, 3)), - ) + terms = MOI.VectorAffineTerm.( + 1:2, + MOI.ScalarAffineTerm.(A, reshape([x, y, t], 1, 3)), + ) f = MOI.VectorAffineFunction(vec(terms), b) set = MOI.Nonnegatives(2) cnn = MOI.add_constraint(model, f, MOI.Nonnegatives(2)) @@ -746,11 +741,10 @@ function test_vector_parameter_affine_nonpositives() t, ct = MOI.add_constrained_variable(model, MOI.Parameter(5.0)) A = [-1.0 0 1; 0 -1 1] b = [-1.0; -2] - terms = - MOI.VectorAffineTerm.( - 1:2, - MOI.ScalarAffineTerm.(A, reshape([x, y, t], 1, 3)), - ) + terms = MOI.VectorAffineTerm.( + 1:2, + MOI.ScalarAffineTerm.(A, reshape([x, y, t], 1, 3)), + ) f = MOI.VectorAffineFunction(vec(terms), b) set = MOI.Nonnegatives(2) cnn = MOI.add_constraint(model, f, MOI.Nonpositives(2)) @@ -2128,7 +2122,7 @@ MOI.Utilities.@model( (MOI.ScalarAffineFunction,), (), () -); +) MOI.Utilities.@model( Model185_2, @@ -2140,7 +2134,7 @@ MOI.Utilities.@model( (), (), (MOI.VectorAffineFunction,) -); +) function test_issue_185() inner = Model185{Float64}() From 6c7c991db5d62d15627a63d724a4cc695f529657 Mon Sep 17 00:00:00 2001 From: joaquimg Date: Sun, 15 Feb 2026 21:10:59 -0300 Subject: [PATCH 06/14] rm tutorial --- docs/src/Examples/progressive_hedging.md | 153 ----------------------- 1 file changed, 153 deletions(-) delete mode 100644 docs/src/Examples/progressive_hedging.md diff --git a/docs/src/Examples/progressive_hedging.md b/docs/src/Examples/progressive_hedging.md deleted file mode 100644 index ab87e57c..00000000 --- a/docs/src/Examples/progressive_hedging.md +++ /dev/null @@ -1,153 +0,0 @@ -# Progressive Hedging - -Progressive Hedging (PH) is a popular decomposition algorithm for stochastic programming. It decomposes a stochastic problem into scenario subproblems that are solved iteratively, with penalty terms driving solutions toward consensus. POI is well-suited for PH because the penalty parameters and target values can be updated efficiently without rebuilding the model. - -## Background - -In progressive hedging, each scenario subproblem includes a quadratic penalty term: - -``` -minimize: f_s(x) + (ρ/2) * ||x - x̄||² + w' * x -``` - -where: -- `f_s(x)` is the original scenario objective -- `ρ` is the penalty parameter -- `x̄` is the current consensus (average) solution -- `w` is the dual price (Lagrangian multiplier) - -The penalty term `(ρ/2) * (x - x̄)²` expands to `(ρ/2) * x² - ρ * x̄ * x + (ρ/2) * x̄²`. Using POI parameters for `ρ` and `x̄` allows efficient updates between PH iterations. - -## Simple Example: Two-Stage Stochastic Program - -Consider a simple production planning problem with two scenarios: - -```julia -using JuMP, HiGHS -import ParametricOptInterface as POI - -# Problem data -scenarios = [ - (demand = 100, probability = 0.4), - (demand = 150, probability = 0.6), -] -production_cost = 10 -penalty_cost = 25 # unmet demand penalty - -# PH parameters -ρ = 1.0 # penalty parameter -max_iterations = 20 -tolerance = 1e-4 - -# Build scenario subproblems with POI -function build_subproblem(scenario, ρ_init, x_bar_init, w_init) - model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) - set_silent(model) - - # First-stage variable (production quantity to decide before demand is known) - @variable(model, x >= 0) - - # Second-stage variable (unmet demand) - @variable(model, y >= 0) - - # Parameters for PH updates - @variable(model, ρ_param in MOI.Parameter(ρ_init)) - @variable(model, x_bar in MOI.Parameter(x_bar_init)) - @variable(model, w in MOI.Parameter(w_init)) - - # Demand satisfaction constraint - @constraint(model, x + y >= scenario.demand) - - # Objective: original cost + PH penalty terms - @objective(model, Min, - production_cost * x + penalty_cost * y + # original objective - w * x + # dual price term - 0.5 * ρ_param * (x - x_bar)^2 # quadratic penalty - ) - - return model, x, ρ_param, x_bar, w -end - -# Initialize subproblems -subproblems = [] -x_vars = [] -ρ_params = [] -x_bar_params = [] -w_params = [] - -for s in scenarios - model, x, ρ_p, x_bar_p, w_p = build_subproblem(s, ρ, 0.0, 0.0) - push!(subproblems, model) - push!(x_vars, x) - push!(ρ_params, ρ_p) - push!(x_bar_params, x_bar_p) - push!(w_params, w_p) -end - -# Progressive Hedging iterations -x_bar = 0.0 -w_values = zeros(length(scenarios)) - -for iter in 1:max_iterations - # Solve all subproblems - x_values = Float64[] - for (i, model) in enumerate(subproblems) - optimize!(model) - push!(x_values, value(x_vars[i])) - end - - # Compute new consensus (probability-weighted average) - x_bar_new = sum(scenarios[i].probability * x_values[i] for i in eachindex(scenarios)) - - # Check convergence - max_deviation = maximum(abs.(x_values .- x_bar_new)) - println("Iteration $iter: x̄ = $(round(x_bar_new, digits=2)), max deviation = $(round(max_deviation, digits=4))") - - if max_deviation < tolerance - println("Converged!") - break - end - - # Update dual prices - for i in eachindex(scenarios) - w_values[i] += ρ * (x_values[i] - x_bar_new) - end - - # Update parameters for next iteration (this is where POI shines!) - # Parameters are automatically updated when optimize! is called - for i in eachindex(scenarios) - set_parameter_value(x_bar_params[i], x_bar_new) - set_parameter_value(w_params[i], w_values[i]) - end - - x_bar = x_bar_new -end - -println("\nFinal consensus solution: x̄ = $(round(x_bar, digits=2))") -``` - -## Why POI for Progressive Hedging? - -1. **Efficient updates**: Parameters `x̄` and `w` change every iteration. Without POI, you would need to rebuild the model or modify constraints manually. - -2. **Quadratic penalties**: The term `ρ * x̄ * x` is a parameter times a variable, which POI handles natively. This creates the cross-term needed for the quadratic penalty. - -3. **Warm starting**: Since the model structure is preserved, solvers can warm-start from the previous solution, significantly speeding up convergence. - -4. **Clean separation**: The scenario-specific data stays fixed while PH-specific parameters are clearly identified and updated. - -## Advanced: Adaptive Penalty Parameter - -You can also make the penalty parameter `ρ` adaptive: - -```julia -# Increase penalty if not converging fast enough -if iter > 5 && max_deviation > prev_deviation * 0.95 - ρ *= 1.5 - for i in eachindex(scenarios) - set_parameter_value(ρ_params[i], ρ) - end -end -``` - -This showcases POI's flexibility: both the consensus target and the penalty strength can be parameters that evolve during the algorithm. From 27065a011cad84804bf993075228d42312d36c30 Mon Sep 17 00:00:00 2001 From: joaquimg Date: Sun, 15 Feb 2026 23:29:01 -0300 Subject: [PATCH 07/14] remove unused --- src/cubic_types.jl | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/src/cubic_types.jl b/src/cubic_types.jl index cdf15d99..12b19b29 100644 --- a/src/cubic_types.jl +++ b/src/cubic_types.jl @@ -30,30 +30,6 @@ struct _ScalarCubicTerm{T} index_3::MOI.VariableIndex end -""" - _cubic_term_type(term::_ScalarCubicTerm) -> Symbol - -Classify a cubic term by the number of parameters vs variables. - -Returns: -- `:pvv` - 1 parameter, 2 variables (becomes quadratic after substitution) -- `:ppv` - 2 parameters, 1 variable (becomes affine after substitution) -- `:ppp` - 3 parameters (becomes constant after substitution) -""" -function _cubic_term_type(term::_ScalarCubicTerm) - num_params = - _is_parameter(term.index_1) + - _is_parameter(term.index_2) + - _is_parameter(term.index_3) - if num_params == 1 - return :pvv - elseif num_params == 2 - return :ppv - else # num_params == 3 - return :ppp - end -end - """ _normalize_cubic_indices(idx1, idx2, idx3) -> (idx1, idx2, idx3) From d3d317c39e9dfcee1b4ff1ee1565c8e0c70aa657 Mon Sep 17 00:00:00 2001 From: joaquimg Date: Sun, 15 Feb 2026 23:29:13 -0300 Subject: [PATCH 08/14] add tests --- test/test_cubic.jl | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/test_cubic.jl b/test/test_cubic.jl index 78068b1b..69d13f23 100644 --- a/test/test_cubic.jl +++ b/test/test_cubic.jl @@ -250,6 +250,26 @@ function test_cubic_parse_non_polynomial_rejected() result = POI._parse_cubic_expression(f, Float64) @test result === nothing + + # sin(x) * p - should be rejected + f = MOI.ScalarNonlinearFunction( + :+, + Any[MOI.ScalarNonlinearFunction(:sin, Any[x]), p], + ) + result = POI._parse_cubic_expression(f, Float64) + + @test result === nothing + return +end + +function test_parse_nonlinear_with_saf() + saf = MOI.ScalarAffineFunction( + [MOI.ScalarAffineTerm(1.0, MOI.VariableIndex(1))], + 1.3, + ) + f = MOI.ScalarNonlinearFunction(:+, Any[saf, 1.0]) + result = POI._parse_cubic_expression(f, Float64) + @test result.constant == 2.3 return end From 24614e3220fe4936cd2b7dc9bf93f91396a56d11 Mon Sep 17 00:00:00 2001 From: Oscar Dowson Date: Mon, 16 Feb 2026 15:39:57 +1300 Subject: [PATCH 09/14] Update documentation on cubic polynomial parameters Clarify the conditions for cubic terms and remove redundant attention section. --- docs/src/index.md | 40 ++++++++++------------------------------ 1 file changed, 10 insertions(+), 30 deletions(-) diff --git a/docs/src/index.md b/docs/src/index.md index af01a9b8..2cc7668f 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -288,44 +288,24 @@ optimize!(model) POI supports parameters that multiply quadratic variable terms in objectives **only**. This creates cubic polynomial expressions of the form `c * p * x * y` -where `c` is a number, `p` is a parameter and `x`, `y` are variables. After -parameter substitution, these become standard quadratic terms that solvers can -handle. +where `c` is a number, `p` is a parameter, and `x` and `y` are variables. After +parameter substitution, the objective is quadratic instead of cubic. - -### Attention - -- Maximum degree is 3 (cubic) -- At least one factor in each cubic term must be a parameter -- Pure cubic variable terms (e.g., `x * y * z` with no parameters) are - **not supported** - -### Example using JuMP +Note that the maximum degree is 3 (cubic), at least one factor in each cubic +term must be a parameter, and pure cubic variable terms (for example, +`x * y * z` with no parameters) are not supported. ```@repl using JuMP, HiGHS import ParametricOptInterface as POI - -# Create model with POI optimizer model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) set_silent(model) - -# Define variables and parameter @variable(model, 0 <= x <= 10) -@variable(model, p in MOI.Parameter(2.0)) - -# Set cubic objective: p * x * y -@objective(model, Min, p * x ^ 2 - 3x) - -# Solve +@variable(model, p in Parameter(2)) +@objective(model, Min, p * x^2 - 3x) optimize!(model) - -@show value(x) # == 3 / (2 * p) - -# Update parameter and re-solve -# Parameters are automatically updated when optimize! is called -set_parameter_value(p, 3.0) +value(x) # x = 3 / 2p = 0.75 +set_parameter_value(p, 3) optimize!(model) - -@show value(x) # == 3 / (2 * p) +value(x) # x = 3 / 2p = 0.5 ``` From 3303edc73e634057083603dbcd7b6e702fdf9410 Mon Sep 17 00:00:00 2001 From: joaquimg Date: Mon, 16 Feb 2026 00:27:23 -0300 Subject: [PATCH 10/14] fixes and tests --- src/cubic_parser.jl | 19 +- src/parametric_cubic_function.jl | 5 +- test/test_cubic.jl | 1153 ++++++++++++++++++++++++++++++ 3 files changed, 1168 insertions(+), 9 deletions(-) diff --git a/src/cubic_parser.jl b/src/cubic_parser.jl index 2754cc13..f5e15ad7 100644 --- a/src/cubic_parser.jl +++ b/src/cubic_parser.jl @@ -439,16 +439,21 @@ function _parse_cubic_expression( MOI.ScalarQuadraticTerm{T}(m.coefficient * divisor, p1, p2), ) elseif classification == :pv - v1 = m.variables[1] - v2 = m.variables[2] - # Sort for canonical order - if v1.value > v2.value - v1, v2 = v2, v1 + # Convention: variable_1 = parameter, variable_2 = variable + # This matches the expectation in _parametric_affine_terms and + # _delta_parametric_affine_terms + if _is_parameter(m.variables[1]) + p_idx_v, v_idx_v = m.variables[1], m.variables[2] + else + p_idx_v, v_idx_v = m.variables[2], m.variables[1] end - divisor = v1 == v2 ? T(2) : T(1) # Diagonal vs off-diagonal push!( quadratic_pv, - MOI.ScalarQuadraticTerm{T}(m.coefficient * divisor, v1, v2), + MOI.ScalarQuadraticTerm{T}( + m.coefficient, + p_idx_v, + v_idx_v, + ), ) elseif classification == :vv v1 = m.variables[1] diff --git a/src/parametric_cubic_function.jl b/src/parametric_cubic_function.jl index eac90cbe..1422c95f 100644 --- a/src/parametric_cubic_function.jl +++ b/src/parametric_cubic_function.jl @@ -166,8 +166,9 @@ function _parametric_constant(model, f::ParametricCubicFunction{T}) where {T} end # From quadratic parameter-parameter terms (pp) + # MOI convention: diagonal C means C/2*p^2, off-diagonal C means C*p1*p2 for term in f.pp - divisor = term.variable_1 == term.variable_2 ? 1 : 2 + divisor = term.variable_1 == term.variable_2 ? 2 : 1 constant += (term.coefficient / divisor) * _effective_param_value(model, p_idx(term.variable_1)) * @@ -340,7 +341,7 @@ function _delta_parametric_constant( !isnan(model.updated_parameters[pi2]) if updated1 || updated2 - divisor = term.variable_1 == term.variable_2 ? 1 : 2 + divisor = term.variable_1 == term.variable_2 ? 2 : 1 old_val = (term.coefficient / divisor) * model.parameters[pi1] * diff --git a/test/test_cubic.jl b/test/test_cubic.jl index 69d13f23..dbbcf5ba 100644 --- a/test/test_cubic.jl +++ b/test/test_cubic.jl @@ -529,6 +529,1159 @@ function test_jump_cubic_parameter_division_by_constant() return end +# ============================================================================ +# Parser Tests - Additional Coverage +# ============================================================================ + +function test_cubic_parse_quadratic_function_input() + x = MOI.VariableIndex(1) + y = MOI.VariableIndex(2) + p = POI.v_idx(POI.ParameterIndex(1)) + + # ScalarQuadraticFunction as argument inside a nonlinear expression + sqf = MOI.ScalarQuadraticFunction( + [MOI.ScalarQuadraticTerm(2.0, x, y)], # off-diagonal: 2*x*y + [MOI.ScalarAffineTerm(3.0, x)], + 1.5, + ) + f = MOI.ScalarNonlinearFunction(:+, Any[sqf, p]) + result = POI._parse_cubic_expression(f, Float64) + @test result !== nothing + @test length(result.vv) == 1 + @test result.vv[1].coefficient == 2.0 + @test length(result.v) == 1 + @test result.v[1].coefficient == 3.0 + @test length(result.p) == 1 + @test result.constant == 1.5 + return +end + +function test_cubic_parse_quadratic_function_diagonal() + x = MOI.VariableIndex(1) + + # ScalarQuadraticFunction with diagonal term: x^2 + # MOI convention: diagonal coefficient C means (C/2)*x^2, so C=2 means x^2 + sqf = MOI.ScalarQuadraticFunction( + [MOI.ScalarQuadraticTerm(2.0, x, x)], # diagonal: (2/2)*x^2 = x^2 + MOI.ScalarAffineTerm{Float64}[], + 0.0, + ) + f = MOI.ScalarNonlinearFunction(:+, Any[sqf, 0.0]) + result = POI._parse_cubic_expression(f, Float64) + @test result !== nothing + @test length(result.vv) == 1 + # After parsing: coef should have MOI convention applied + return +end + +function test_cubic_parse_power_zero_exponent() + x = MOI.VariableIndex(1) + + # x^0 = 1 (constant) + f = MOI.ScalarNonlinearFunction( + :+, + Any[MOI.ScalarNonlinearFunction(:^, Any[x, 0]), 5.0], + ) + result = POI._parse_cubic_expression(f, Float64) + @test result !== nothing + @test result.constant == 6.0 + return +end + +function test_cubic_parse_power_negative_exponent() + x = MOI.VariableIndex(1) + + # x^(-1) should be rejected + f = MOI.ScalarNonlinearFunction(:^, Any[x, -1]) + result = POI._parse_cubic_expression(f, Float64) + @test result === nothing + return +end + +function test_cubic_parse_power_non_integer_exponent() + x = MOI.VariableIndex(1) + + # x^1.5 should be rejected + f = MOI.ScalarNonlinearFunction(:^, Any[x, 1.5]) + result = POI._parse_cubic_expression(f, Float64) + @test result === nothing + return +end + +function test_cubic_parse_division_by_zero() + x = MOI.VariableIndex(1) + + # x / 0 should be rejected + f = MOI.ScalarNonlinearFunction(:/, Any[x, 0.0]) + result = POI._parse_cubic_expression(f, Float64) + @test result === nothing + return +end + +function test_cubic_parse_division_by_variable() + x = MOI.VariableIndex(1) + y = MOI.VariableIndex(2) + + # x / y should be rejected (variable denominator) + f = MOI.ScalarNonlinearFunction(:/, Any[x, y]) + result = POI._parse_cubic_expression(f, Float64) + @test result === nothing + return +end + +function test_cubic_parse_division_wrong_arity() + x = MOI.VariableIndex(1) + + # Division with 1 or 3 args should be rejected + f = MOI.ScalarNonlinearFunction(:/, Any[x]) + result = POI._parse_cubic_expression(f, Float64) + @test result === nothing + + f = MOI.ScalarNonlinearFunction(:/, Any[x, 2.0, 3.0]) + result = POI._parse_cubic_expression(f, Float64) + @test result === nothing + return +end + +function test_cubic_parse_power_wrong_arity() + x = MOI.VariableIndex(1) + + # Power with 1 or 3 args should be rejected + f = MOI.ScalarNonlinearFunction(:^, Any[x]) + result = POI._parse_cubic_expression(f, Float64) + @test result === nothing + + f = MOI.ScalarNonlinearFunction(:^, Any[x, 2, 3]) + result = POI._parse_cubic_expression(f, Float64) + @test result === nothing + return +end + +function test_cubic_parse_convenience_method() + x = MOI.VariableIndex(1) + p = POI.v_idx(POI.ParameterIndex(1)) + + # Test the convenience method without specifying type + f = MOI.ScalarNonlinearFunction(:*, Any[2.0, p, x, x]) + result = POI._parse_cubic_expression(f) + @test result !== nothing + @test length(result.pvv) == 1 + @test result.pvv[1].coefficient == 2.0 + return +end + +function test_cubic_parse_pp_terms() + p = POI.v_idx(POI.ParameterIndex(1)) + q = POI.v_idx(POI.ParameterIndex(2)) + + # p * q (quadratic in parameters only) + f = MOI.ScalarNonlinearFunction(:*, Any[3.0, p, q]) + result = POI._parse_cubic_expression(f, Float64) + @test result !== nothing + @test length(result.pp) == 1 + @test result.pp[1].coefficient == 3.0 + return +end + +function test_cubic_parse_pp_same_parameter() + p = POI.v_idx(POI.ParameterIndex(1)) + + # p^2 (diagonal quadratic in parameters) + f = MOI.ScalarNonlinearFunction(:^, Any[p, 2]) + result = POI._parse_cubic_expression(f, Float64) + @test result !== nothing + @test length(result.pp) == 1 + return +end + +function test_cubic_parse_pv_terms() + x = MOI.VariableIndex(1) + p = POI.v_idx(POI.ParameterIndex(1)) + + # 4 * p * x (quadratic with one parameter and one variable) + f = MOI.ScalarNonlinearFunction(:*, Any[4.0, p, x]) + result = POI._parse_cubic_expression(f, Float64) + @test result !== nothing + @test length(result.pv) == 1 + @test result.pv[1].coefficient == 4.0 + return +end + +function test_cubic_parse_mixed_all_degrees() + x = MOI.VariableIndex(1) + y = MOI.VariableIndex(2) + p = POI.v_idx(POI.ParameterIndex(1)) + q = POI.v_idx(POI.ParameterIndex(2)) + + # 2*p*x*y + 3*p*q*x + p*q*p + x^2 + 5*p*x + 7*x + 2*p + 10 + f = MOI.ScalarNonlinearFunction( + :+, + Any[ + MOI.ScalarNonlinearFunction(:*, Any[2.0, p, x, y]), # pvv + MOI.ScalarNonlinearFunction(:*, Any[3.0, p, q, x]), # ppv + MOI.ScalarNonlinearFunction(:*, Any[1.0, p, q, p]), # ppp + MOI.ScalarNonlinearFunction(:^, Any[x, 2]), # vv + MOI.ScalarNonlinearFunction(:*, Any[5.0, p, x]), # pv + MOI.ScalarNonlinearFunction(:*, Any[7.0, x]), # v + MOI.ScalarNonlinearFunction(:*, Any[2.0, p]), # p + 10.0, # constant + ], + ) + result = POI._parse_cubic_expression(f, Float64) + @test result !== nothing + @test length(result.pvv) == 1 + @test result.pvv[1].coefficient == 2.0 + @test length(result.ppv) == 1 + @test result.ppv[1].coefficient == 3.0 + @test length(result.ppp) == 1 + @test length(result.vv) == 1 + @test length(result.pv) == 1 + @test length(result.v) == 1 + @test result.v[1].coefficient == 7.0 + @test length(result.p) == 1 + @test result.p[1].coefficient == 2.0 + @test result.constant == 10.0 + return +end + +function test_cubic_parse_zero_coefficient_elimination() + x = MOI.VariableIndex(1) + y = MOI.VariableIndex(2) + p = POI.v_idx(POI.ParameterIndex(1)) + + # p*x*y - p*x*y = 0 (coefficients cancel) + f = MOI.ScalarNonlinearFunction( + :-, + Any[ + MOI.ScalarNonlinearFunction(:*, Any[1.0, p, x, y]), + MOI.ScalarNonlinearFunction(:*, Any[1.0, p, x, y]), + ], + ) + result = POI._parse_cubic_expression(f, Float64) + @test result !== nothing + @test isempty(result.pvv) + return +end + +function test_cubic_parse_multiple_pvv_different_vars() + x = MOI.VariableIndex(1) + y = MOI.VariableIndex(2) + z = MOI.VariableIndex(3) + p = POI.v_idx(POI.ParameterIndex(1)) + + # p*x*y + 2*p*x*z (two different pvv terms) + f = MOI.ScalarNonlinearFunction( + :+, + Any[ + MOI.ScalarNonlinearFunction(:*, Any[1.0, p, x, y]), + MOI.ScalarNonlinearFunction(:*, Any[2.0, p, x, z]), + ], + ) + result = POI._parse_cubic_expression(f, Float64) + @test result !== nothing + @test length(result.pvv) == 2 + return +end + +function test_cubic_parse_subtraction_multiple_args() + x = MOI.VariableIndex(1) + y = MOI.VariableIndex(2) + p = POI.v_idx(POI.ParameterIndex(1)) + + # p*x*y - x - 1 (binary subtraction, second operand is sum) + f = MOI.ScalarNonlinearFunction( + :-, + Any[ + MOI.ScalarNonlinearFunction(:*, Any[p, x, y]), + MOI.ScalarNonlinearFunction(:+, Any[x, 1.0]), + ], + ) + result = POI._parse_cubic_expression(f, Float64) + @test result !== nothing + @test length(result.pvv) == 1 + @test length(result.v) == 1 + @test result.v[1].coefficient == -1.0 + @test result.constant == -1.0 + return +end + +# ============================================================================ +# Cubic Types - Direct Unit Tests +# ============================================================================ + +function test_normalize_cubic_indices_all_params() + p1 = POI.v_idx(POI.ParameterIndex(3)) + p2 = POI.v_idx(POI.ParameterIndex(1)) + p3 = POI.v_idx(POI.ParameterIndex(2)) + + n1, n2, n3 = POI._normalize_cubic_indices(p1, p2, p3) + # Should be sorted by index value (all params) + @test n1.value <= n2.value + @test n2.value <= n3.value + return +end + +function test_normalize_cubic_indices_mixed() + x = MOI.VariableIndex(2) + y = MOI.VariableIndex(1) + p = POI.v_idx(POI.ParameterIndex(1)) + + n1, n2, n3 = POI._normalize_cubic_indices(x, p, y) + # Parameter should come first, then variables sorted + @test POI._is_parameter(n1) + @test !POI._is_parameter(n2) + @test !POI._is_parameter(n3) + @test n2.value <= n3.value + return +end + +function test_make_cubic_term_normalization() + x = MOI.VariableIndex(2) + y = MOI.VariableIndex(1) + p = POI.v_idx(POI.ParameterIndex(1)) + + term = POI._make_cubic_term(3.0, x, p, y) + @test term.coefficient == 3.0 + # index_1 should be parameter + @test POI._is_parameter(term.index_1) + # index_2 and index_3 should be variables sorted + @test term.index_2.value <= term.index_3.value + return +end + +# ============================================================================ +# ParametricCubicFunction - Unit Tests +# ============================================================================ + +function test_parametric_cubic_function_constructor_with_ppv() + x = MOI.VariableIndex(1) + p = POI.v_idx(POI.ParameterIndex(1)) + q = POI.v_idx(POI.ParameterIndex(2)) + + # p*q*x + 2*x (ppv term + affine term on same variable) + f = MOI.ScalarNonlinearFunction( + :+, + Any[ + MOI.ScalarNonlinearFunction(:*, Any[1.0, p, q, x]), + MOI.ScalarNonlinearFunction(:*, Any[2.0, x]), + ], + ) + parsed = POI._parse_cubic_expression(f, Float64) + @test parsed !== nothing + pcf = POI.ParametricCubicFunction(parsed) + @test length(pcf.ppv) == 1 + # x should be in affine_data since it's shared with ppv + @test haskey(pcf.affine_data, x) + @test pcf.affine_data[x] == 2.0 + return +end + +function test_parametric_cubic_function_constructor_np_affine() + x = MOI.VariableIndex(1) + y = MOI.VariableIndex(2) + p = POI.v_idx(POI.ParameterIndex(1)) + + # p*x^2 + 3*y (pvv term on x, non-parametric affine on y) + f = MOI.ScalarNonlinearFunction( + :+, + Any[ + MOI.ScalarNonlinearFunction(:*, Any[1.0, p, x, x]), + MOI.ScalarNonlinearFunction(:*, Any[3.0, y]), + ], + ) + parsed = POI._parse_cubic_expression(f, Float64) + @test parsed !== nothing + pcf = POI.ParametricCubicFunction(parsed) + # y should be in affine_data_np since it's not related to any pv or ppv term + @test haskey(pcf.affine_data_np, y) + @test pcf.affine_data_np[y] == 3.0 + return +end + +# ============================================================================ +# MOI Objective Interface Tests +# ============================================================================ + +function test_cubic_objective_supports() + model = POI.Optimizer(HiGHS.Optimizer()) + @test MOI.supports( + model, + MOI.ObjectiveFunction{MOI.ScalarNonlinearFunction}(), + ) + return +end + +function test_cubic_objective_set_invalid_expression() + model = POI.Optimizer(HiGHS.Optimizer()) + + x = MOI.add_variable(model) + y = MOI.add_variable(model) + z = MOI.add_variable(model) + + # x*y*z (3 variables, no parameter) should error + f = MOI.ScalarNonlinearFunction(:*, Any[x, y, z]) + @test_throws ErrorException MOI.set( + model, + MOI.ObjectiveFunction{MOI.ScalarNonlinearFunction}(), + f, + ) + return +end + +function test_cubic_objective_get_no_cache() + model = POI.Optimizer(HiGHS.Optimizer()) + + @test_throws ErrorException MOI.get( + model, + MOI.ObjectiveFunction{MOI.ScalarNonlinearFunction}(), + ) + return +end + +function test_cubic_objective_get_save_original() + model = POI.Optimizer( + HiGHS.Optimizer(); + save_original_objective_and_constraints = true, + ) + MOI.set(model, MOI.Silent(), true) + + x = MOI.add_variable(model) + p, _ = MOI.add_constrained_variable(model, MOI.Parameter(2.0)) + + # Set a cubic objective: p*x^2 + p_v = POI.v_idx(POI.p_idx(p)) + f = MOI.ScalarNonlinearFunction(:*, Any[1.0, p_v, x, x]) + MOI.set(model, MOI.ObjectiveFunction{MOI.ScalarNonlinearFunction}(), f) + + # Should be able to retrieve + retrieved = + MOI.get(model, MOI.ObjectiveFunction{MOI.ScalarNonlinearFunction}()) + @test retrieved isa MOI.ScalarNonlinearFunction + return +end + +function test_cubic_objective_get_no_save() + model = POI.Optimizer( + HiGHS.Optimizer(); + save_original_objective_and_constraints = false, + ) + MOI.set(model, MOI.Silent(), true) + + x = MOI.add_variable(model) + p, _ = MOI.add_constrained_variable(model, MOI.Parameter(2.0)) + + # Set a cubic objective: p*x^2 + p_v = POI.v_idx(POI.p_idx(p)) + f = MOI.ScalarNonlinearFunction(:*, Any[1.0, p_v, x, x]) + MOI.set(model, MOI.ObjectiveFunction{MOI.ScalarNonlinearFunction}(), f) + + # Should error since save_original is false + @test_throws ErrorException MOI.get( + model, + MOI.ObjectiveFunction{MOI.ScalarNonlinearFunction}(), + ) + return +end + +# ============================================================================ +# JuMP Integration Tests - Additional Coverage +# ============================================================================ + +function test_jump_cubic_mixed_pvv_ppv_ppp() + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, 0 <= x <= 10) + @variable(model, 0 <= y <= 10) + @variable(model, p in MOI.Parameter(1.0)) + @variable(model, q in MOI.Parameter(2.0)) + + # Minimize: p*x*y + p*q*x + p*q*p + x^2 + y^2 + # With p=1, q=2: x*y + 2*x + 2 + x^2 + y^2 + @constraint(model, x >= 0) + @constraint(model, y >= 0) + @objective(model, Min, p * x * y + p * q * x + p * q * p + x^2 + y^2) + + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + obj1 = objective_value(model) + x1 = value(x) + y1 = value(y) + # Verify objective matches the formula + @test obj1 ≈ 1.0 * x1 * y1 + 2.0 * x1 + 2.0 + x1^2 + y1^2 atol = ATOL + + # Change p=2, q=3: 2*x*y + 6*x + 12 + x^2 + y^2 + set_parameter_value(p, 2.0) + set_parameter_value(q, 3.0) + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + obj2 = objective_value(model) + x2 = value(x) + y2 = value(y) + @test obj2 ≈ 2.0 * x2 * y2 + 6.0 * x2 + 12.0 + x2^2 + y2^2 atol = ATOL + return +end + +function test_jump_cubic_negative_parameters() + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, 0 <= x <= 10) + @variable(model, p in MOI.Parameter(-1.0)) + + # Minimize: p*x^2 - x + # With p=-1: -x^2 - x (concave, so min at boundary) + # Actually this is concave so HiGHS may struggle. + # Use a positive leading term: x^2 + p*x^2 = (1+p)*x^2 + # With p=-0.5: 0.5*x^2 - x, optimal at x=1, obj=-0.5 + @constraint(model, x >= 0) + @objective(model, Min, x^2 + p * x^2 - x) + + set_parameter_value(p, -0.5) + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ -0.5 atol = ATOL + @test value(x) ≈ 1.0 atol = ATOL + + # Change p to 0: x^2 - x, optimal at x=0.5, obj=-0.25 + set_parameter_value(p, 0.0) + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ -0.25 atol = ATOL + @test value(x) ≈ 0.5 atol = ATOL + return +end + +function test_jump_cubic_multiple_parameter_updates() + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, 0 <= x <= 10) + @variable(model, p in MOI.Parameter(1.0)) + + @constraint(model, x >= 0) + @objective(model, Min, p * x^2 - 4 * x) + + # p=1: x^2 - 4x, optimal at x=2, obj=-4 + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test value(x) ≈ 2.0 atol = ATOL + @test objective_value(model) ≈ -4.0 atol = ATOL + + # p=2: 2*x^2 - 4x, optimal at x=1, obj=-2 + set_parameter_value(p, 2.0) + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test value(x) ≈ 1.0 atol = ATOL + @test objective_value(model) ≈ -2.0 atol = ATOL + + # p=4: 4*x^2 - 4x, optimal at x=0.5, obj=-1 + set_parameter_value(p, 4.0) + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test value(x) ≈ 0.5 atol = ATOL + @test objective_value(model) ≈ -1.0 atol = ATOL + + # p=0.5: 0.5*x^2 - 4x, optimal at x=4, obj=-8 + set_parameter_value(p, 0.5) + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test value(x) ≈ 4.0 atol = ATOL + @test objective_value(model) ≈ -8.0 atol = ATOL + return +end + +function test_jump_cubic_ppv_negative_params() + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, x >= 0) + @variable(model, p in MOI.Parameter(-1.0)) + @variable(model, q in MOI.Parameter(2.0)) + + # Minimize: x + p*q*x = x*(1 + p*q) = x*(1-2) = -x + # Subject to: 0 <= x <= 5 + @constraint(model, x <= 5) + @objective(model, Min, x + p * q * x) + + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + # min(-x) with 0<=x<=5 is at x=5, obj=-5+5=-5... wait + # f(x) = x + (-1)(2)(x) = x - 2x = -x, so min at x=5, obj=-5 + @test objective_value(model) ≈ -5.0 atol = ATOL + @test value(x) ≈ 5.0 atol = ATOL + + # Change p=1, q=1: x*(1+1) = 2x, min at x=0 + set_parameter_value(p, 1.0) + set_parameter_value(q, 1.0) + optimize!(model) + @test objective_value(model) ≈ 0.0 atol = ATOL + @test value(x) ≈ 0.0 atol = ATOL + return +end + +function test_jump_cubic_ppp_negative_params() + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, x >= 0) + @variable(model, p in MOI.Parameter(-2.0)) + @variable(model, q in MOI.Parameter(3.0)) + @variable(model, r in MOI.Parameter(1.0)) + + # Minimize: x + p*q*r = x + (-2)(3)(1) = x - 6 + # Subject to: x >= 1 + @constraint(model, x >= 1) + @objective(model, Min, x + p * q * r) + + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ -5.0 atol = ATOL + @test value(x) ≈ 1.0 atol = ATOL + + # Change to positive: p=1,q=1,r=1 -> x+1, min at x=1, obj=2 + set_parameter_value(p, 1.0) + set_parameter_value(q, 1.0) + set_parameter_value(r, 1.0) + optimize!(model) + @test objective_value(model) ≈ 2.0 atol = ATOL + return +end + +function test_jump_cubic_partial_parameter_update() + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, x >= 0) + @variable(model, p in MOI.Parameter(2.0)) + @variable(model, q in MOI.Parameter(3.0)) + + # Minimize: x + p*q*x = x*(1 + p*q) + # With p=2, q=3: x*(1+6)=7x, min at x=0 -> obj=0 + # Subject to: x >= 1 + @constraint(model, x >= 1) + @objective(model, Min, x + p * q * x) + + optimize!(model) + @test objective_value(model) ≈ 7.0 atol = ATOL + + # Only update p to 0, keep q=3: x*(1+0)=x, min at x=1 -> obj=1 + set_parameter_value(p, 0.0) + optimize!(model) + @test objective_value(model) ≈ 1.0 atol = ATOL + return +end + +function test_jump_cubic_all_term_types_combined() + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, 0 <= x <= 10) + @variable(model, 0 <= y <= 10) + @variable(model, p in MOI.Parameter(1.0)) + @variable(model, q in MOI.Parameter(1.0)) + + # f = p*x*y + p*q*x + p*q*p + x^2 + y^2 + p*x + 2*p + 3*x + 5 + # With p=1,q=1: + # x*y + x + 1 + x^2 + y^2 + x + 2 + 3x + 5 = x^2 + y^2 + x*y + 5x + 8 + @constraint(model, x >= 0) + @constraint(model, y >= 0) + @objective( + model, + Min, + p * x * y + + p * q * x + + p * q * p + + x^2 + + y^2 + + p * x + + 2 * p + + 3 * x + + 5, + ) + + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + x1 = value(x) + y1 = value(y) + obj1 = objective_value(model) + expected1 = x1^2 + y1^2 + x1 * y1 + 5 * x1 + 8 + @test obj1 ≈ expected1 atol = ATOL + + # p=2, q=0.5: + # 2*x*y + 1*x + 2 + x^2 + y^2 + 2*x + 4 + 3*x + 5 + # = x^2 + y^2 + 2*x*y + 6*x + 11 + set_parameter_value(p, 2.0) + set_parameter_value(q, 0.5) + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + x2 = value(x) + y2 = value(y) + obj2 = objective_value(model) + expected2 = x2^2 + y2^2 + 2 * x2 * y2 + 6 * x2 + 11 + @test obj2 ≈ expected2 atol = ATOL + return +end + +function test_jump_cubic_pvv_with_constant_and_affine() + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, 0 <= x <= 10) + @variable(model, 0 <= y <= 10) + @variable(model, p in MOI.Parameter(2.0)) + + # Minimize: p*x*y + x^2 + y^2 - 6*x - 6*y + 10 + # With p=2: 2*x*y + x^2 + y^2 - 6x - 6y + 10 = (x+y)^2 - 6(x+y) + 10 + # Let s=x+y. f = s^2 - 6s + 10 = (s-3)^2 + 1 + # Optimal at x+y=3, many solutions (e.g. x=1.5, y=1.5), obj=1 + @constraint(model, x >= 0) + @constraint(model, y >= 0) + @objective(model, Min, p * x * y + x^2 + y^2 - 6 * x - 6 * y + 10) + + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ 1.0 atol = ATOL + @test value(x) + value(y) ≈ 3.0 atol = ATOL + + # Change p=0: x^2 + y^2 - 6x - 6y + 10 + # Optimal at x=3, y=3, obj = 9+9-18-18+10 = -8 + set_parameter_value(p, 0.0) + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ -8.0 atol = ATOL + @test value(x) ≈ 3.0 atol = ATOL + @test value(y) ≈ 3.0 atol = ATOL + return +end + +function test_jump_cubic_direct_model_ppv() + model = direct_model(POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, x >= 0) + @variable(model, p in MOI.Parameter(3.0)) + @variable(model, q in MOI.Parameter(2.0)) + + # Minimize: x + p*q*x = x*(1 + 6) = 7x + @constraint(model, x >= 1) + @objective(model, Min, x + p * q * x) + + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ 7.0 atol = ATOL + + # Update p=0: x*(1+0)=x, obj=1 + set_parameter_value(p, 0.0) + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ 1.0 atol = ATOL + return +end + +function test_jump_cubic_direct_model_ppp() + model = direct_model(POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, x >= 0) + @variable(model, p in MOI.Parameter(2.0)) + @variable(model, q in MOI.Parameter(3.0)) + @variable(model, r in MOI.Parameter(4.0)) + + # Minimize: x + p*q*r = x + 24 + @constraint(model, x >= 1) + @objective(model, Min, x + p * q * r) + + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ 25.0 atol = ATOL + + set_parameter_value(p, 0.0) + optimize!(model) + @test objective_value(model) ≈ 1.0 atol = ATOL + return +end + +function test_jump_cubic_pvv_multiple_cross_terms() + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, 0 <= x <= 10) + @variable(model, 0 <= y <= 10) + @variable(model, 0 <= z <= 10) + @variable(model, p in MOI.Parameter(1.0)) + + # Minimize: x^2 + y^2 + z^2 + p*x*y + p*x*z - 6x - 4y - 2z + # With p=0: separable quadratics, x=3,y=2,z=1, obj=-9-4-1=-14 + @constraint(model, x >= 0) + @constraint(model, y >= 0) + @constraint(model, z >= 0) + @objective( + model, + Min, + x^2 + y^2 + z^2 + p * x * y + p * x * z - 6 * x - 4 * y - 2 * z, + ) + + set_parameter_value(p, 0.0) + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ -14.0 atol = ATOL + + # With p=1: coupled system + set_parameter_value(p, 1.0) + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + x1 = value(x) + y1 = value(y) + z1 = value(z) + obj = objective_value(model) + expected = x1^2 + y1^2 + z1^2 + x1 * y1 + x1 * z1 - 6x1 - 4y1 - 2z1 + @test obj ≈ expected atol = ATOL + return +end + +function test_jump_cubic_pp_in_objective() + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, x >= 0) + @variable(model, p in MOI.Parameter(3.0)) + @variable(model, q in MOI.Parameter(2.0)) + + # Minimize: x + p*q (pp quadratic in parameters contributes to constant) + # With p=3, q=2: x + 6 + @constraint(model, x >= 1) + @objective(model, Min, x + p * q) + + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ 7.0 atol = ATOL + + # Change p=0, q=0: x + 0 + set_parameter_value(p, 0.0) + set_parameter_value(q, 0.0) + optimize!(model) + @test objective_value(model) ≈ 1.0 atol = ATOL + return +end + +function test_jump_cubic_pv_in_objective() + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, 0 <= x <= 10) + @variable(model, p in MOI.Parameter(2.0)) + + # Minimize: x^2 + p*x - 4x = x^2 + (p-4)*x + # With p=2: x^2 - 2x, optimal at x=1, obj=-1 + @constraint(model, x >= 0) + @objective(model, Min, x^2 + p * x - 4 * x) + + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ -1.0 atol = ATOL + @test value(x) ≈ 1.0 atol = ATOL + + # Change p=6: x^2 + 2x, optimal at x=0, obj=0 + set_parameter_value(p, 6.0) + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ 0.0 atol = ATOL + @test value(x) ≈ 0.0 atol = ATOL + return +end + +function test_jump_cubic_p_affine_in_objective() + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, x >= 0) + @variable(model, p in MOI.Parameter(5.0)) + + # Minimize: x + 3*p (affine in parameter contributes to constant) + # With p=5: x + 15 + @constraint(model, x >= 1) + @objective(model, Min, x + 3 * p) + + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ 16.0 atol = ATOL + + set_parameter_value(p, 0.0) + optimize!(model) + @test objective_value(model) ≈ 1.0 atol = ATOL + return +end + +function test_jump_cubic_division_in_ppv() + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, x >= 0) + @variable(model, p in MOI.Parameter(4.0)) + @variable(model, q in MOI.Parameter(6.0)) + + # Minimize: x + p*q*x/2 = x*(1 + p*q/2) + # With p=4, q=6: x*(1+12)=13x + @constraint(model, x >= 1) + @objective(model, Min, x + p * q * x / 2) + + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ 13.0 atol = ATOL + return +end + +function test_jump_cubic_pvv_update_no_change() + # Test the case where parameter update results in no actual change + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, 0 <= x <= 10) + @variable(model, p in MOI.Parameter(1.0)) + + @constraint(model, x >= 0) + @objective(model, Min, p * x^2 - 2 * x) + + # First solve + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + obj1 = objective_value(model) + + # "Update" parameter to same value + set_parameter_value(p, 1.0) + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ obj1 atol = ATOL + return +end + +# ============================================================================ +# Parser - Error propagation through subtraction and power +# ============================================================================ + +function test_cubic_parse_unary_minus_invalid() + x = MOI.VariableIndex(1) + + # -(sin(x)) should propagate nothing from unary minus + f = MOI.ScalarNonlinearFunction( + :-, + Any[MOI.ScalarNonlinearFunction(:sin, Any[x])], + ) + result = POI._parse_cubic_expression(f, Float64) + @test result === nothing + return +end + +function test_cubic_parse_binary_subtraction_invalid_rhs() + x = MOI.VariableIndex(1) + + # x - sin(x) should propagate nothing from binary subtraction + f = MOI.ScalarNonlinearFunction( + :-, + Any[x, MOI.ScalarNonlinearFunction(:sin, Any[x])], + ) + result = POI._parse_cubic_expression(f, Float64) + @test result === nothing + return +end + +function test_cubic_parse_power_of_invalid_base() + x = MOI.VariableIndex(1) + + # sin(x)^2 should propagate nothing from power + f = MOI.ScalarNonlinearFunction( + :^, + Any[MOI.ScalarNonlinearFunction(:sin, Any[x]), 2], + ) + result = POI._parse_cubic_expression(f, Float64) + @test result === nothing + return +end + +# ============================================================================ +# Parser - Sorting branches for reverse-ordered indices +# ============================================================================ + +function test_cubic_parse_pp_reverse_order() + # Create two parameters where p2 has a lower ParameterIndex than p1 + # to exercise the p1.value > p2.value sort branch + p1 = POI.v_idx(POI.ParameterIndex(2)) + p2 = POI.v_idx(POI.ParameterIndex(1)) + + # p1 * p2 where p1.value > p2.value + f = MOI.ScalarNonlinearFunction(:*, Any[3.0, p1, p2]) + result = POI._parse_cubic_expression(f, Float64) + @test result !== nothing + @test length(result.pp) == 1 + # After sorting, variable_1 should have smaller value + @test result.pp[1].variable_1.value <= result.pp[1].variable_2.value + return +end + +function test_cubic_parse_vv_reverse_order() + # Create two variables where v1.value > v2.value + x = MOI.VariableIndex(2) + y = MOI.VariableIndex(1) + + # x * y where x.value > y.value (needs sorting) + f = MOI.ScalarNonlinearFunction(:*, Any[2.0, x, y]) + result = POI._parse_cubic_expression(f, Float64) + @test result !== nothing + @test length(result.vv) == 1 + @test result.vv[1].variable_1.value <= result.vv[1].variable_2.value + return +end + +function test_cubic_parse_pv_param_first() + # Test the branch where m.variables[1] is already the parameter + p = POI.v_idx(POI.ParameterIndex(1)) + x = MOI.VariableIndex(1) + + # The monomial from _combine_like_monomials sorts by value, so the + # variable (lower value) comes first. We construct a monomial explicitly + # where the parameter is first in the raw expression to trigger both + # branches of the pv classification. + # With flat mult: p * x — monomials get combined with sorted key, + # so let's just verify both orderings work. + f1 = MOI.ScalarNonlinearFunction(:*, Any[p, x]) + f2 = MOI.ScalarNonlinearFunction(:*, Any[x, p]) + r1 = POI._parse_cubic_expression(f1, Float64) + r2 = POI._parse_cubic_expression(f2, Float64) + + for r in [r1, r2] + @test r !== nothing + @test length(r.pv) == 1 + # Convention: variable_1 = parameter, variable_2 = variable + @test POI._is_parameter(r.pv[1].variable_1) + @test !POI._is_parameter(r.pv[1].variable_2) + end + return +end + +# ============================================================================ +# JuMP Integration - pp terms through cubic path +# ============================================================================ + +function test_jump_cubic_with_pp_terms() + # To exercise pp handling in _parametric_constant and + # _delta_parametric_constant, we need pp terms alongside a cubic term + # so JuMP sends everything as ScalarNonlinearFunction. + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, 0 <= x <= 10) + @variable(model, p in MOI.Parameter(2.0)) + @variable(model, q in MOI.Parameter(3.0)) + + # p*x^2 + p*q + x (pvv + pp + v) + # With p=2, q=3: 2*x^2 + 6 + x + # Optimal: 2x^2 + x + 6, d/dx = 4x + 1 = 0 -> x = -0.25 (bound at 0) + # At x=0: obj = 6 + # Actually with 0<=x<=10: min at x=-1/4 but bounded, so x=0, obj=6 + @constraint(model, x >= 0) + @objective(model, Min, p * x^2 + p * q + x) + + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ 6.0 atol = ATOL + @test value(x) ≈ 0.0 atol = ATOL + + # Change p=1, q=1: x^2 + 1 + x, at x=0: obj=1 + set_parameter_value(p, 1.0) + set_parameter_value(q, 1.0) + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ 1.0 atol = ATOL + @test value(x) ≈ 0.0 atol = ATOL + return +end + +function test_jump_cubic_with_pp_same_param() + # p^2 term alongside a cubic term + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, 0 <= x <= 10) + @variable(model, p in MOI.Parameter(3.0)) + + # p*x^2 + p*p + x (pvv + pp + v) + # With p=3: 3*x^2 + 9 + x, at x=0: obj=9 + @constraint(model, x >= 0) + @objective(model, Min, p * x^2 + p * p + x) + + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ 9.0 atol = ATOL + @test value(x) ≈ 0.0 atol = ATOL + + # Change p=0: 0 + 0 + x = x, at x=0: obj=0 + set_parameter_value(p, 0.0) + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ 0.0 atol = ATOL + @test value(x) ≈ 0.0 atol = ATOL + return +end + +function test_jump_cubic_pvv_reverse_var_order() + # Exercise the v1.value > v2.value sorting branches in + # _parametric_quadratic_terms and _delta_parametric_quadratic_terms + # by having pvv with y*x where y.index > x.index + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, 0 <= x <= 10) + @variable(model, -1 <= y <= 10) + @variable(model, p in MOI.Parameter(2.0)) + + # Minimize: x^2 + y^2 + p*y*x - 3x (y comes before x in term) + @constraint(model, x + y >= 0) + @objective(model, Min, x^2 + y^2 + p * y * x - 3 * x) + + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + x1 = value(x) + y1 = value(y) + @test objective_value(model) ≈ x1^2 + y1^2 + 2 * y1 * x1 - 3 * x1 atol = + ATOL + + # Change p=0 + set_parameter_value(p, 0.0) + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test value(x) ≈ 3 / 2 atol = ATOL + @test value(y) ≈ 0.0 atol = ATOL + return +end + +function test_jump_cubic_p_affine_in_cubic_expr() + # Exercise p affine terms inside a cubic expression + # (p terms alongside pvv so it goes through cubic path) + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, 0 <= x <= 10) + @variable(model, p in MOI.Parameter(5.0)) + + # p*x^2 + 3*p + x (pvv + p_affine + v) + # With p=5: 5*x^2 + 15 + x, at x=0: obj=15 + @constraint(model, x >= 0) + @objective(model, Min, p * x^2 + 3 * p + x) + + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ 15.0 atol = ATOL + @test value(x) ≈ 0.0 atol = ATOL + + # Change p=0: x, at x=0: obj=0 + set_parameter_value(p, 0.0) + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ 0.0 atol = ATOL + return +end + end # module TestCubic.runtests() From 1b8808cf4368066ccd489c861907e4d21129fcd3 Mon Sep 17 00:00:00 2001 From: joaquimg Date: Mon, 16 Feb 2026 01:42:34 -0300 Subject: [PATCH 11/14] add test and cleanup --- src/cubic_objective.jl | 31 +++------- src/cubic_parser.jl | 35 ++--------- src/cubic_types.jl | 4 -- src/parametric_cubic_function.jl | 12 ---- test/test_cubic.jl | 103 +++++++++++++++++-------------- 5 files changed, 69 insertions(+), 116 deletions(-) diff --git a/src/cubic_objective.jl b/src/cubic_objective.jl index 353693e4..15bd0534 100644 --- a/src/cubic_objective.jl +++ b/src/cubic_objective.jl @@ -107,21 +107,13 @@ function _update_cubic_objective!(model::Optimizer{T}) where {T} return # No changes needed end - # Try incremental modifications first (more efficient for solvers that support it) - if _try_incremental_cubic_update!( + _try_incremental_cubic_update!( model, pf, delta_constant, delta_affine, delta_quadratic, ) - return nothing - end - - # Fallback: Rebuild and reset the complete objective function - # This is needed for solvers that don't support incremental modifications - current = _current_function(pf, model) - MOI.set(model.optimizer, MOI.ObjectiveFunction{typeof(current)}(), current) return nothing end @@ -129,7 +121,7 @@ end """ _try_incremental_cubic_update!(model, pf, delta_constant, delta_affine, delta_quadratic) -Try to apply incremental coefficient updates. Returns true if successful, false otherwise. +Apply incremental coefficient updates to the inner optimizer's objective. """ function _try_incremental_cubic_update!( model::Optimizer{T}, @@ -155,18 +147,11 @@ function _try_incremental_cubic_update!( new_coef = new_quad_terms[(var1, var2)] # Apply MOI coefficient convention moi_coef = var1 == var2 ? new_coef * 2 : new_coef - try - MOI.modify( - model.optimizer, - MOI.ObjectiveFunction{F}(), - MOI.ScalarQuadraticCoefficientChange(var1, var2, moi_coef), - ) - catch e - if e isa MOI.ModifyObjectiveNotAllowed - return false - end - rethrow(e) - end + MOI.modify( + model.optimizer, + MOI.ObjectiveFunction{F}(), + MOI.ScalarQuadraticCoefficientChange(var1, var2, moi_coef), + ) end # Apply affine coefficient changes (use full new coefficient) @@ -189,5 +174,5 @@ function _try_incremental_cubic_update!( ) end - return true + return nothing end diff --git a/src/cubic_parser.jl b/src/cubic_parser.jl index f5e15ad7..e18f58dd 100644 --- a/src/cubic_parser.jl +++ b/src/cubic_parser.jl @@ -51,15 +51,6 @@ function _scale_monomial(m::_Monomial{T}, scalar::T) where {T} return _Monomial{T}(m.coefficient * scalar, copy(m.variables)) end -""" - _is_polynomial_operator(head::Symbol) -> Bool - -Check if the operator is valid for polynomial expressions. -""" -function _is_polynomial_operator(head::Symbol) - return head in (:+, :-, :*, :^, :/) -end - """ _ParsedCubicExpression{T} @@ -143,10 +134,6 @@ function _expand_to_monomials( head = f.head args = f.args - if !_is_polynomial_operator(head) - return nothing # Non-polynomial operator - end - if head == :+ return _expand_addition(args, T) elseif head == :- @@ -429,10 +416,6 @@ function _parse_cubic_expression( elseif classification == :pp p1 = m.variables[1] p2 = m.variables[2] - # Sort for canonical order - if p1.value > p2.value - p1, p2 = p2, p1 - end divisor = p1 == p2 ? T(2) : T(1) # Diagonal vs off-diagonal push!( quadratic_pp, @@ -442,26 +425,16 @@ function _parse_cubic_expression( # Convention: variable_1 = parameter, variable_2 = variable # This matches the expectation in _parametric_affine_terms and # _delta_parametric_affine_terms - if _is_parameter(m.variables[1]) - p_idx_v, v_idx_v = m.variables[1], m.variables[2] - else - p_idx_v, v_idx_v = m.variables[2], m.variables[1] - end + is_param = _is_parameter(m.variables[1]) + p_idx_v = ifelse(is_param, m.variables[1], m.variables[2]) + v_idx_v = ifelse(is_param, m.variables[2], m.variables[1]) push!( quadratic_pv, - MOI.ScalarQuadraticTerm{T}( - m.coefficient, - p_idx_v, - v_idx_v, - ), + MOI.ScalarQuadraticTerm{T}(m.coefficient, p_idx_v, v_idx_v), ) elseif classification == :vv v1 = m.variables[1] v2 = m.variables[2] - # Sort for canonical order - if v1.value > v2.value - v1, v2 = v2, v1 - end divisor = v1 == v2 ? T(2) : T(1) # Diagonal vs off-diagonal push!( quadratic_vv, diff --git a/src/cubic_types.jl b/src/cubic_types.jl index 12b19b29..6afe667f 100644 --- a/src/cubic_types.jl +++ b/src/cubic_types.jl @@ -20,7 +20,6 @@ VariableIndex with value > PARAMETER_INDEX_THRESHOLD). # Convention Indices are stored in canonical order: - Parameters come before variables -- Within each group, sorted by index value This ensures `2*p*x*y` and `2*x*p*y` produce the same term. """ struct _ScalarCubicTerm{T} @@ -35,7 +34,6 @@ end Normalize cubic term indices to canonical order: - Parameters come before variables -- Within each group, sorted by index value """ function _normalize_cubic_indices( idx1::MOI.VariableIndex, @@ -51,8 +49,6 @@ function _normalize_cubic_indices( push!(vars, idx) end end - sort!(params, by = v -> v.value) - sort!(vars, by = v -> v.value) all_indices = vcat(params, vars) return all_indices[1], all_indices[2], all_indices[3] end diff --git a/src/parametric_cubic_function.jl b/src/parametric_cubic_function.jl index 1422c95f..632542f9 100644 --- a/src/parametric_cubic_function.jl +++ b/src/parametric_cubic_function.jl @@ -82,9 +82,6 @@ function ParametricCubicFunction(parsed::_ParsedCubicExpression{T}) where {T} for term in parsed.pvv v1 = term.index_2 v2 = term.index_3 - if v1.value > v2.value - v1, v2 = v2, v1 - end push!(var_pairs_in_param_terms, (v1, v2)) end @@ -97,9 +94,6 @@ function ParametricCubicFunction(parsed::_ParsedCubicExpression{T}) where {T} quadratic_data = Dict{Tuple{MOI.VariableIndex,MOI.VariableIndex},T}() for term in parsed.vv v1, v2 = term.variable_1, term.variable_2 - if v1.value > v2.value - v1, v2 = v2, v1 - end coef = term.coefficient if term.variable_1 == term.variable_2 coef = coef / 2 # Diagonal: undo MOI's factor @@ -242,9 +236,6 @@ function _parametric_quadratic_terms( p = term.index_1 v1 = term.index_2 v2 = term.index_3 - if v1.value > v2.value - v1, v2 = v2, v1 - end var_pair = (v1, v2) p_val = _effective_param_value(model, p_idx(p)) terms_dict[var_pair] = @@ -461,9 +452,6 @@ function _delta_parametric_quadratic_terms( if haskey(model.updated_parameters, pi) && !isnan(model.updated_parameters[pi]) - if v1.value > v2.value - v1, v2 = v2, v1 - end var_pair = (v1, v2) old_val = model.parameters[pi] new_val = model.updated_parameters[pi] diff --git a/test/test_cubic.jl b/test/test_cubic.jl index dbbcf5ba..139f601b 100644 --- a/test/test_cubic.jl +++ b/test/test_cubic.jl @@ -10,6 +10,7 @@ using JuMP import HiGHS import ParametricOptInterface as POI +import MathOptInterface as MOI const ATOL = 1e-4 @@ -24,6 +25,39 @@ function runtests() return end +# ============================================================================ +# Helper mock optimizer +# ============================================================================ + +""" +Mock optimizer that rejects ScalarQuadraticFunction objectives. +Defined in its own module to avoid method-table invalidation. +""" +struct NoQuadObjModel <: MOI.ModelLike + inner::MOI.Utilities.Model{Float64} +end + +NoQuadObjModel() = NoQuadObjModel(MOI.Utilities.Model{Float64}()) + +MOI.add_variable(m::NoQuadObjModel) = MOI.add_variable(m.inner) +MOI.is_empty(m::NoQuadObjModel) = MOI.is_empty(m.inner) +MOI.empty!(m::NoQuadObjModel) = MOI.empty!(m.inner) +function MOI.is_valid(m::NoQuadObjModel, vi::MOI.VariableIndex) + return MOI.is_valid(m.inner, vi) +end + +function MOI.set( + ::NoQuadObjModel, + ::MOI.ObjectiveFunction{<:MOI.ScalarQuadraticFunction}, + ::MOI.ScalarQuadraticFunction, +) + return error("Quadratic objectives not supported") +end + +function MOI.set(m::NoQuadObjModel, attr::MOI.ObjectiveSense, v) + return MOI.set(m.inner, attr, v) +end + # ============================================================================ # Parser Tests # ============================================================================ @@ -809,29 +843,16 @@ end # Cubic Types - Direct Unit Tests # ============================================================================ -function test_normalize_cubic_indices_all_params() - p1 = POI.v_idx(POI.ParameterIndex(3)) - p2 = POI.v_idx(POI.ParameterIndex(1)) - p3 = POI.v_idx(POI.ParameterIndex(2)) - - n1, n2, n3 = POI._normalize_cubic_indices(p1, p2, p3) - # Should be sorted by index value (all params) - @test n1.value <= n2.value - @test n2.value <= n3.value - return -end - function test_normalize_cubic_indices_mixed() x = MOI.VariableIndex(2) y = MOI.VariableIndex(1) p = POI.v_idx(POI.ParameterIndex(1)) n1, n2, n3 = POI._normalize_cubic_indices(x, p, y) - # Parameter should come first, then variables sorted + # Parameter should come first @test POI._is_parameter(n1) @test !POI._is_parameter(n2) @test !POI._is_parameter(n3) - @test n2.value <= n3.value return end @@ -844,8 +865,6 @@ function test_make_cubic_term_normalization() @test term.coefficient == 3.0 # index_1 should be parameter @test POI._is_parameter(term.index_1) - # index_2 and index_3 should be variables sorted - @test term.index_2.value <= term.index_3.value return end @@ -1503,36 +1522,6 @@ end # Parser - Sorting branches for reverse-ordered indices # ============================================================================ -function test_cubic_parse_pp_reverse_order() - # Create two parameters where p2 has a lower ParameterIndex than p1 - # to exercise the p1.value > p2.value sort branch - p1 = POI.v_idx(POI.ParameterIndex(2)) - p2 = POI.v_idx(POI.ParameterIndex(1)) - - # p1 * p2 where p1.value > p2.value - f = MOI.ScalarNonlinearFunction(:*, Any[3.0, p1, p2]) - result = POI._parse_cubic_expression(f, Float64) - @test result !== nothing - @test length(result.pp) == 1 - # After sorting, variable_1 should have smaller value - @test result.pp[1].variable_1.value <= result.pp[1].variable_2.value - return -end - -function test_cubic_parse_vv_reverse_order() - # Create two variables where v1.value > v2.value - x = MOI.VariableIndex(2) - y = MOI.VariableIndex(1) - - # x * y where x.value > y.value (needs sorting) - f = MOI.ScalarNonlinearFunction(:*, Any[2.0, x, y]) - result = POI._parse_cubic_expression(f, Float64) - @test result !== nothing - @test length(result.vv) == 1 - @test result.vv[1].variable_1.value <= result.vv[1].variable_2.value - return -end - function test_cubic_parse_pv_param_first() # Test the branch where m.variables[1] is already the parameter p = POI.v_idx(POI.ParameterIndex(1)) @@ -1682,6 +1671,28 @@ function test_jump_cubic_p_affine_in_cubic_expr() return end +function test_cubic_objective_set_error_on_inner_optimizer() + mock = NoQuadObjModel() + model = POI.Optimizer(mock) + + x = MOI.add_variable(model) + p, _ = MOI.add_constrained_variable(model, MOI.Parameter(2.0)) + p_v = POI.v_idx(POI.p_idx(p)) + + # p * x^2 — parsed successfully but inner optimizer rejects SQF + f = MOI.ScalarNonlinearFunction(:*, Any[1.0, p_v, x, x]) + err = try + MOI.set(model, MOI.ObjectiveFunction{MOI.ScalarNonlinearFunction}(), f) + nothing + catch e + e + end + @test err isa ErrorException + @test occursin("Failed to set cubic objective function", err.msg) + @test occursin("Quadratic objectives not supported", err.msg) + return +end + end # module TestCubic.runtests() From c941741cc501ade4b58d2de5b23ce1f6e7d18e6b Mon Sep 17 00:00:00 2001 From: joaquimg Date: Mon, 16 Feb 2026 01:56:25 -0300 Subject: [PATCH 12/14] organize tests --- test/test_cubic.jl | 1700 ++++++++++++++++++++++---------------------- 1 file changed, 848 insertions(+), 852 deletions(-) diff --git a/test/test_cubic.jl b/test/test_cubic.jl index 139f601b..aeda1c78 100644 --- a/test/test_cubic.jl +++ b/test/test_cubic.jl @@ -31,7 +31,6 @@ end """ Mock optimizer that rejects ScalarQuadraticFunction objectives. -Defined in its own module to avoid method-table invalidation. """ struct NoQuadObjModel <: MOI.ModelLike inner::MOI.Utilities.Model{Float64} @@ -59,7 +58,36 @@ function MOI.set(m::NoQuadObjModel, attr::MOI.ObjectiveSense, v) end # ============================================================================ -# Parser Tests +# Cubic Types +# ============================================================================ + +function test_normalize_cubic_indices_mixed() + x = MOI.VariableIndex(2) + y = MOI.VariableIndex(1) + p = POI.v_idx(POI.ParameterIndex(1)) + + n1, n2, n3 = POI._normalize_cubic_indices(x, p, y) + # Parameter should come first + @test POI._is_parameter(n1) + @test !POI._is_parameter(n2) + @test !POI._is_parameter(n3) + return +end + +function test_make_cubic_term_normalization() + x = MOI.VariableIndex(2) + y = MOI.VariableIndex(1) + p = POI.v_idx(POI.ParameterIndex(1)) + + term = POI._make_cubic_term(3.0, x, p, y) + @test term.coefficient == 3.0 + # index_1 should be parameter + @test POI._is_parameter(term.index_1) + return +end + +# ============================================================================ +# Parser - Valid cubic terms (pvv) # ============================================================================ function test_cubic_parse_single_pvv_term() @@ -147,6 +175,30 @@ function test_cubic_parse_parenthesis_variations() return end +function test_cubic_parse_multiple_pvv_different_vars() + x = MOI.VariableIndex(1) + y = MOI.VariableIndex(2) + z = MOI.VariableIndex(3) + p = POI.v_idx(POI.ParameterIndex(1)) + + # p*x*y + 2*p*x*z (two different pvv terms) + f = MOI.ScalarNonlinearFunction( + :+, + Any[ + MOI.ScalarNonlinearFunction(:*, Any[1.0, p, x, y]), + MOI.ScalarNonlinearFunction(:*, Any[2.0, p, x, z]), + ], + ) + result = POI._parse_cubic_expression(f, Float64) + @test result !== nothing + @test length(result.pvv) == 2 + return +end + +# ============================================================================ +# Parser - Valid cubic terms (ppv, ppp) +# ============================================================================ + function test_cubic_parse_ppv_term() x = MOI.VariableIndex(1) p = POI.v_idx(POI.ParameterIndex(1)) @@ -179,33 +231,146 @@ function test_cubic_parse_ppp_term() return end -function test_cubic_parse_invalid_degree_4() +# ============================================================================ +# Parser - Valid quadratic terms (pp, pv, vv) +# ============================================================================ + +function test_cubic_parse_pp_terms() + p = POI.v_idx(POI.ParameterIndex(1)) + q = POI.v_idx(POI.ParameterIndex(2)) + + # p * q (quadratic in parameters only) + f = MOI.ScalarNonlinearFunction(:*, Any[3.0, p, q]) + result = POI._parse_cubic_expression(f, Float64) + @test result !== nothing + @test length(result.pp) == 1 + @test result.pp[1].coefficient == 3.0 + return +end + +function test_cubic_parse_pp_same_parameter() + p = POI.v_idx(POI.ParameterIndex(1)) + + # p^2 (diagonal quadratic in parameters) + f = MOI.ScalarNonlinearFunction(:^, Any[p, 2]) + result = POI._parse_cubic_expression(f, Float64) + @test result !== nothing + @test length(result.pp) == 1 + return +end + +function test_cubic_parse_pv_terms() x = MOI.VariableIndex(1) - y = MOI.VariableIndex(2) - z = MOI.VariableIndex(3) p = POI.v_idx(POI.ParameterIndex(1)) - # x * y * z * p (degree 4) should return nothing - f = MOI.ScalarNonlinearFunction(:*, Any[x, y, z, p]) + # 4 * p * x (quadratic with one parameter and one variable) + f = MOI.ScalarNonlinearFunction(:*, Any[4.0, p, x]) result = POI._parse_cubic_expression(f, Float64) + @test result !== nothing + @test length(result.pv) == 1 + @test result.pv[1].coefficient == 4.0 + return +end - @test result === nothing +function test_cubic_parse_pv_param_first() + # Test the branch where m.variables[1] is already the parameter + p = POI.v_idx(POI.ParameterIndex(1)) + x = MOI.VariableIndex(1) + + # The monomial from _combine_like_monomials sorts by value, so the + # variable (lower value) comes first. We construct a monomial explicitly + # where the parameter is first in the raw expression to trigger both + # branches of the pv classification. + # With flat mult: p * x - monomials get combined with sorted key, + # so let's just verify both orderings work. + f1 = MOI.ScalarNonlinearFunction(:*, Any[p, x]) + f2 = MOI.ScalarNonlinearFunction(:*, Any[x, p]) + r1 = POI._parse_cubic_expression(f1, Float64) + r2 = POI._parse_cubic_expression(f2, Float64) + + for r in [r1, r2] + @test r !== nothing + @test length(r.pv) == 1 + # Convention: variable_1 = parameter, variable_2 = variable + @test POI._is_parameter(r.pv[1].variable_1) + @test !POI._is_parameter(r.pv[1].variable_2) + end return end -function test_cubic_parse_three_vars_no_param() +# ============================================================================ +# Parser - Input type handling (SAF, SQF) +# ============================================================================ + +function test_parse_nonlinear_with_saf() + saf = MOI.ScalarAffineFunction( + [MOI.ScalarAffineTerm(1.0, MOI.VariableIndex(1))], + 1.3, + ) + f = MOI.ScalarNonlinearFunction(:+, Any[saf, 1.0]) + result = POI._parse_cubic_expression(f, Float64) + @test result.constant == 2.3 + return +end + +function test_cubic_parse_quadratic_function_input() x = MOI.VariableIndex(1) y = MOI.VariableIndex(2) - z = MOI.VariableIndex(3) + p = POI.v_idx(POI.ParameterIndex(1)) - # x * y * z (3 variables, 0 parameters) should be rejected - f = MOI.ScalarNonlinearFunction(:*, Any[x, y, z]) + # ScalarQuadraticFunction as argument inside a nonlinear expression + sqf = MOI.ScalarQuadraticFunction( + [MOI.ScalarQuadraticTerm(2.0, x, y)], # off-diagonal: 2*x*y + [MOI.ScalarAffineTerm(3.0, x)], + 1.5, + ) + f = MOI.ScalarNonlinearFunction(:+, Any[sqf, p]) result = POI._parse_cubic_expression(f, Float64) + @test result !== nothing + @test length(result.vv) == 1 + @test result.vv[1].coefficient == 2.0 + @test length(result.v) == 1 + @test result.v[1].coefficient == 3.0 + @test length(result.p) == 1 + @test result.constant == 1.5 + return +end - @test result === nothing +function test_cubic_parse_quadratic_function_diagonal() + x = MOI.VariableIndex(1) + + # ScalarQuadraticFunction with diagonal term: x^2 + # MOI convention: diagonal coefficient C means (C/2)*x^2, so C=2 means x^2 + sqf = MOI.ScalarQuadraticFunction( + [MOI.ScalarQuadraticTerm(2.0, x, x)], # diagonal: (2/2)*x^2 = x^2 + MOI.ScalarAffineTerm{Float64}[], + 0.0, + ) + f = MOI.ScalarNonlinearFunction(:+, Any[sqf, 0.0]) + result = POI._parse_cubic_expression(f, Float64) + @test result !== nothing + @test length(result.vv) == 1 + # After parsing: coef should have MOI convention applied + return +end + +function test_cubic_parse_convenience_method() + x = MOI.VariableIndex(1) + p = POI.v_idx(POI.ParameterIndex(1)) + + # Test the convenience method without specifying type + f = MOI.ScalarNonlinearFunction(:*, Any[2.0, p, x, x]) + result = POI._parse_cubic_expression(f) + @test result !== nothing + @test length(result.pvv) == 1 + @test result.pvv[1].coefficient == 2.0 return end +# ============================================================================ +# Parser - Operations (addition, subtraction, combination) +# ============================================================================ + function test_cubic_parse_subtraction() x = MOI.VariableIndex(1) y = MOI.VariableIndex(2) @@ -231,6 +396,28 @@ function test_cubic_parse_subtraction() return end +function test_cubic_parse_subtraction_multiple_args() + x = MOI.VariableIndex(1) + y = MOI.VariableIndex(2) + p = POI.v_idx(POI.ParameterIndex(1)) + + # p*x*y - x - 1 (binary subtraction, second operand is sum) + f = MOI.ScalarNonlinearFunction( + :-, + Any[ + MOI.ScalarNonlinearFunction(:*, Any[p, x, y]), + MOI.ScalarNonlinearFunction(:+, Any[x, 1.0]), + ], + ) + result = POI._parse_cubic_expression(f, Float64) + @test result !== nothing + @test length(result.pvv) == 1 + @test length(result.v) == 1 + @test result.v[1].coefficient == -1.0 + @test result.constant == -1.0 + return +end + function test_cubic_parse_unary_minus() x = MOI.VariableIndex(1) y = MOI.VariableIndex(2) @@ -272,342 +459,164 @@ function test_cubic_parse_term_combination() return end -function test_cubic_parse_non_polynomial_rejected() +function test_cubic_parse_zero_coefficient_elimination() x = MOI.VariableIndex(1) + y = MOI.VariableIndex(2) p = POI.v_idx(POI.ParameterIndex(1)) - # sin(x) * p - should be rejected - f = MOI.ScalarNonlinearFunction( - :*, - Any[MOI.ScalarNonlinearFunction(:sin, Any[x]), p], - ) - result = POI._parse_cubic_expression(f, Float64) - - @test result === nothing - - # sin(x) * p - should be rejected + # p*x*y - p*x*y = 0 (coefficients cancel) f = MOI.ScalarNonlinearFunction( - :+, - Any[MOI.ScalarNonlinearFunction(:sin, Any[x]), p], - ) - result = POI._parse_cubic_expression(f, Float64) - - @test result === nothing - return -end - -function test_parse_nonlinear_with_saf() - saf = MOI.ScalarAffineFunction( - [MOI.ScalarAffineTerm(1.0, MOI.VariableIndex(1))], - 1.3, + :-, + Any[ + MOI.ScalarNonlinearFunction(:*, Any[1.0, p, x, y]), + MOI.ScalarNonlinearFunction(:*, Any[1.0, p, x, y]), + ], ) - f = MOI.ScalarNonlinearFunction(:+, Any[saf, 1.0]) result = POI._parse_cubic_expression(f, Float64) - @test result.constant == 2.3 + @test result !== nothing + @test isempty(result.pvv) return end # ============================================================================ -# JuMP Integration Tests +# Parser - Mixed degrees # ============================================================================ -function test_jump_cubic_pvv_basic() - model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) - set_silent(model) - - @variable(model, 0 <= x <= 10) - @variable(model, -1 <= y <= 10) - @variable(model, p in MOI.Parameter(1.0)) - - # a convex quadratic with cross terms - # Minimize: x ^ 2 + 2 * x * y + y ^ 2 - 3 x - # Subject to: x + y >= 2 - @constraint(model, x + y >= 0) - @objective(model, Min, x^2 + p * x * y + y^2 - 3 * x) +function test_cubic_parse_mixed_all_degrees() + x = MOI.VariableIndex(1) + y = MOI.VariableIndex(2) + p = POI.v_idx(POI.ParameterIndex(1)) + q = POI.v_idx(POI.ParameterIndex(2)) - optimize!(model) - @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - @test objective_value(model) ≈ -3.0 atol = ATOL - @test value(x) ≈ 2.0 atol = ATOL - @test value(y) ≈ -1.0 atol = ATOL - - # Change p to 0.5 - set_parameter_value(p, 0.5) - optimize!(model) - @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - @test objective_value(model) ≈ -2.4 atol = ATOL - @test value(x) ≈ 1.6 atol = ATOL - @test value(y) ≈ -0.4 atol = ATOL - - # Change p to 0 (removes cross term) - set_parameter_value(p, 0.0) - optimize!(model) - @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - @test objective_value(model) ≈ -9 / 4 atol = ATOL - @test value(x) ≈ 3 / 2 atol = ATOL - @test value(y) ≈ 0.0 atol = ATOL - return -end - -function test_jump_cubic_pvv_same() - model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) - set_silent(model) - - @variable(model, 0 <= x <= 10) - @variable(model, p in MOI.Parameter(1.0)) - - # a convex quadratic with cross terms - # Minimize: p x ^ 2 - 3 x - # Subject to: x >= 0 - @constraint(model, x >= 0) - @objective(model, Min, p * x^2 - 3 * x) - - # Optimize with p=1 - # Optimal at x=3/2, obj = -9/4 - optimize!(model) - @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - @test objective_value(model) ≈ -9 / 4 atol = ATOL - @test value(x) ≈ 3 / 2 atol = ATOL - - # Change p to 0.5 - # Optimal at x=3.0, obj = -9/2 - set_parameter_value(p, 0.5) - optimize!(model) - @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - @test objective_value(model) ≈ -9 / 2 atol = ATOL - @test value(x) ≈ 3 atol = ATOL - - # Change p to 0 (removes cross term) - set_parameter_value(p, 0.0) - optimize!(model) - @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - @test objective_value(model) ≈ -30.0 atol = ATOL - @test value(x) ≈ 10.0 atol = ATOL + # 2*p*x*y + 3*p*q*x + p*q*p + x^2 + 5*p*x + 7*x + 2*p + 10 + f = MOI.ScalarNonlinearFunction( + :+, + Any[ + MOI.ScalarNonlinearFunction(:*, Any[2.0, p, x, y]), # pvv + MOI.ScalarNonlinearFunction(:*, Any[3.0, p, q, x]), # ppv + MOI.ScalarNonlinearFunction(:*, Any[1.0, p, q, p]), # ppp + MOI.ScalarNonlinearFunction(:^, Any[x, 2]), # vv + MOI.ScalarNonlinearFunction(:*, Any[5.0, p, x]), # pv + MOI.ScalarNonlinearFunction(:*, Any[7.0, x]), # v + MOI.ScalarNonlinearFunction(:*, Any[2.0, p]), # p + 10.0, # constant + ], + ) + result = POI._parse_cubic_expression(f, Float64) + @test result !== nothing + @test length(result.pvv) == 1 + @test result.pvv[1].coefficient == 2.0 + @test length(result.ppv) == 1 + @test result.ppv[1].coefficient == 3.0 + @test length(result.ppp) == 1 + @test length(result.vv) == 1 + @test length(result.pv) == 1 + @test length(result.v) == 1 + @test result.v[1].coefficient == 7.0 + @test length(result.p) == 1 + @test result.p[1].coefficient == 2.0 + @test result.constant == 10.0 return end -function test_jump_cubic_ppv_basic() - model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) - set_silent(model) - - @variable(model, x >= 0) - @variable(model, p in MOI.Parameter(2.0)) - @variable(model, q in MOI.Parameter(3.0)) - - # Minimize: x + p*q*x = x * (1 + p*q) - # With p=2, q=3: minimize x * (1 + 6) = 7x - # Subject to: x >= 1 - @constraint(model, x >= 1) - @objective(model, Min, x + p * q * x) - - optimize!(model) - @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - # Optimal at x=1, obj = 7 - @test objective_value(model) ≈ 7.0 atol = ATOL - - # Change p=1, q=1: minimize x*(1+1) = 2x - set_parameter_value(p, 1.0) - set_parameter_value(q, 1.0) - optimize!(model) - @test objective_value(model) ≈ 2.0 atol = ATOL -end - -function test_jump_cubic_ppv_same() - model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) - set_silent(model) - - @variable(model, x >= 0) - @variable(model, p in MOI.Parameter(2.0)) - - # Minimize: x + p^2 * x = x * (1 + p^2) - # With p=2: minimize x * (1 + 4) = 5x - # Subject to: x >= 1 - @constraint(model, x >= 1) - @objective(model, Min, x + p * p * x) - - optimize!(model) - @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - # Optimal at x=1, obj = 5 - @test objective_value(model) ≈ 5.0 atol = ATOL - - # Change p=1: minimize x*(1+1) = 2x - set_parameter_value(p, 1.0) - optimize!(model) - @test objective_value(model) ≈ 2.0 atol = ATOL -end +# ============================================================================ +# Parser - Invalid expressions (rejection) +# ============================================================================ -function test_jump_cubic_ppp_basic() - model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) - set_silent(model) +function test_cubic_parse_non_polynomial_rejected() + x = MOI.VariableIndex(1) + p = POI.v_idx(POI.ParameterIndex(1)) - @variable(model, x >= 0) - @variable(model, p in MOI.Parameter(2.0)) - @variable(model, q in MOI.Parameter(3.0)) - @variable(model, r in MOI.Parameter(4.0)) + # sin(x) * p - should be rejected + f = MOI.ScalarNonlinearFunction( + :*, + Any[MOI.ScalarNonlinearFunction(:sin, Any[x]), p], + ) + result = POI._parse_cubic_expression(f, Float64) - # Minimize: x + p*q*r - # With p=2, q=3, r=4: minimize x + 24 - # Subject to: x >= 1 - @constraint(model, x >= 1) - @objective(model, Min, x + p * q * r) + @test result === nothing - optimize!(model) - @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - # Optimal at x=1, obj = 1 + 24 = 25 - @test objective_value(model) ≈ 25.0 atol = ATOL + # sin(x) * p - should be rejected + f = MOI.ScalarNonlinearFunction( + :+, + Any[MOI.ScalarNonlinearFunction(:sin, Any[x]), p], + ) + result = POI._parse_cubic_expression(f, Float64) - # Change p=1, q=1, r=1: minimize x + 1 - set_parameter_value(p, 1.0) - set_parameter_value(q, 1.0) - set_parameter_value(r, 1.0) - optimize!(model) - @test objective_value(model) ≈ 2.0 atol = ATOL + @test result === nothing return end -function test_jump_cubic_ppp_same() - model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) - set_silent(model) - - @variable(model, x >= 0) - @variable(model, p in MOI.Parameter(2.0)) - - # Minimize: x + p^3 - # With p=2: minimize x + 8 - # Subject to: x >= 1 - @constraint(model, x >= 1) - @objective(model, Min, x + p * p * p) +function test_cubic_parse_invalid_degree_4() + x = MOI.VariableIndex(1) + y = MOI.VariableIndex(2) + z = MOI.VariableIndex(3) + p = POI.v_idx(POI.ParameterIndex(1)) - optimize!(model) - @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - # Optimal at x=1, obj = 1 + 8 = 9 - @test objective_value(model) ≈ 9.0 atol = ATOL + # x * y * z * p (degree 4) should return nothing + f = MOI.ScalarNonlinearFunction(:*, Any[x, y, z, p]) + result = POI._parse_cubic_expression(f, Float64) - # Change p=1: minimize x + 1 - set_parameter_value(p, 1.0) - optimize!(model) - @test objective_value(model) ≈ 2.0 atol = ATOL + @test result === nothing return end -function test_jump_cubic_parameter_initially_zero() - model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) - set_silent(model) - - @variable(model, 0 <= x <= 10) - @variable(model, -1 <= y <= 10) - @variable(model, p in MOI.Parameter(0.0)) - - # a convex quadratic with cross terms - # Minimize: x ^ 2 + 2 * x * y + y ^ 2 - 3 x - # Subject to: x + y >= 2 - @constraint(model, x + y >= 0) - @objective(model, Min, x^2 + p * x * y + y^2 - 3 * x) - - optimize!(model) - @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - @test objective_value(model) ≈ -9 / 4 atol = ATOL - @test value(x) ≈ 3 / 2 atol = ATOL - @test value(y) ≈ 0.0 atol = ATOL +function test_cubic_parse_three_vars_no_param() + x = MOI.VariableIndex(1) + y = MOI.VariableIndex(2) + z = MOI.VariableIndex(3) - # Change p to 0.5 - set_parameter_value(p, 0.5) - optimize!(model) - @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - @test objective_value(model) ≈ -2.4 atol = ATOL - @test value(x) ≈ 1.6 atol = ATOL - @test value(y) ≈ -0.4 atol = ATOL + # x * y * z (3 variables, 0 parameters) should be rejected + f = MOI.ScalarNonlinearFunction(:*, Any[x, y, z]) + result = POI._parse_cubic_expression(f, Float64) + @test result === nothing return end -function test_jump_cubic_parameter_division_by_constant() - model = direct_model(POI.Optimizer(HiGHS.Optimizer())) - set_silent(model) - - @variable(model, 0 <= x <= 10) - @variable(model, 0 <= y <= 10) - @variable(model, p in MOI.Parameter(0.0)) - - # a convex quadratic with cross terms - # Minimize: x ^ 2 + 2 * x * y + y ^ 2 - 3 x - # Subject to: x + y >= 2 - @constraint(model, x + y >= 0) - @objective(model, Min, x^2 + p * x * y / 1 + y^2 - 3 * x) - - optimize!(model) - @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - @test objective_value(model) ≈ -9 / 4 atol = ATOL - @test value(x) ≈ 3 / 2 atol = ATOL - @test value(y) ≈ 0.0 atol = ATOL - - model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) - set_silent(model) - - @variable(model, 0 <= x <= 10) - @variable(model, -1 <= y <= 10) - @variable(model, p in MOI.Parameter(1.0)) - - # a convex quadratic with cross terms - # Minimize: x ^ 2 + 0.5 * x * y + y ^ 2 - 3 x - # Subject to: x + y >= 2 - @constraint(model, x + y >= 0) - @objective(model, Min, x^2 + p * x * y / 2 + y^2 - 3 * x) - - optimize!(model) - @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - @test objective_value(model) ≈ -2.4 atol = ATOL - @test value(x) ≈ 1.6 atol = ATOL - @test value(y) ≈ -0.4 atol = ATOL +function test_cubic_parse_unary_minus_invalid() + x = MOI.VariableIndex(1) + # -(sin(x)) should propagate nothing from unary minus + f = MOI.ScalarNonlinearFunction( + :-, + Any[MOI.ScalarNonlinearFunction(:sin, Any[x])], + ) + result = POI._parse_cubic_expression(f, Float64) + @test result === nothing return end -# ============================================================================ -# Parser Tests - Additional Coverage -# ============================================================================ - -function test_cubic_parse_quadratic_function_input() +function test_cubic_parse_binary_subtraction_invalid_rhs() x = MOI.VariableIndex(1) - y = MOI.VariableIndex(2) - p = POI.v_idx(POI.ParameterIndex(1)) - # ScalarQuadraticFunction as argument inside a nonlinear expression - sqf = MOI.ScalarQuadraticFunction( - [MOI.ScalarQuadraticTerm(2.0, x, y)], # off-diagonal: 2*x*y - [MOI.ScalarAffineTerm(3.0, x)], - 1.5, + # x - sin(x) should propagate nothing from binary subtraction + f = MOI.ScalarNonlinearFunction( + :-, + Any[x, MOI.ScalarNonlinearFunction(:sin, Any[x])], ) - f = MOI.ScalarNonlinearFunction(:+, Any[sqf, p]) result = POI._parse_cubic_expression(f, Float64) - @test result !== nothing - @test length(result.vv) == 1 - @test result.vv[1].coefficient == 2.0 - @test length(result.v) == 1 - @test result.v[1].coefficient == 3.0 - @test length(result.p) == 1 - @test result.constant == 1.5 + @test result === nothing return end -function test_cubic_parse_quadratic_function_diagonal() +function test_cubic_parse_power_of_invalid_base() x = MOI.VariableIndex(1) - # ScalarQuadraticFunction with diagonal term: x^2 - # MOI convention: diagonal coefficient C means (C/2)*x^2, so C=2 means x^2 - sqf = MOI.ScalarQuadraticFunction( - [MOI.ScalarQuadraticTerm(2.0, x, x)], # diagonal: (2/2)*x^2 = x^2 - MOI.ScalarAffineTerm{Float64}[], - 0.0, + # sin(x)^2 should propagate nothing from power + f = MOI.ScalarNonlinearFunction( + :^, + Any[MOI.ScalarNonlinearFunction(:sin, Any[x]), 2], ) - f = MOI.ScalarNonlinearFunction(:+, Any[sqf, 0.0]) result = POI._parse_cubic_expression(f, Float64) - @test result !== nothing - @test length(result.vv) == 1 - # After parsing: coef should have MOI convention applied + @test result === nothing return end +# ============================================================================ +# Parser - Power operator edge cases +# ============================================================================ + function test_cubic_parse_power_zero_exponent() x = MOI.VariableIndex(1) @@ -642,41 +651,6 @@ function test_cubic_parse_power_non_integer_exponent() return end -function test_cubic_parse_division_by_zero() - x = MOI.VariableIndex(1) - - # x / 0 should be rejected - f = MOI.ScalarNonlinearFunction(:/, Any[x, 0.0]) - result = POI._parse_cubic_expression(f, Float64) - @test result === nothing - return -end - -function test_cubic_parse_division_by_variable() - x = MOI.VariableIndex(1) - y = MOI.VariableIndex(2) - - # x / y should be rejected (variable denominator) - f = MOI.ScalarNonlinearFunction(:/, Any[x, y]) - result = POI._parse_cubic_expression(f, Float64) - @test result === nothing - return -end - -function test_cubic_parse_division_wrong_arity() - x = MOI.VariableIndex(1) - - # Division with 1 or 3 args should be rejected - f = MOI.ScalarNonlinearFunction(:/, Any[x]) - result = POI._parse_cubic_expression(f, Float64) - @test result === nothing - - f = MOI.ScalarNonlinearFunction(:/, Any[x, 2.0, 3.0]) - result = POI._parse_cubic_expression(f, Float64) - @test result === nothing - return -end - function test_cubic_parse_power_wrong_arity() x = MOI.VariableIndex(1) @@ -691,185 +665,47 @@ function test_cubic_parse_power_wrong_arity() return end -function test_cubic_parse_convenience_method() - x = MOI.VariableIndex(1) - p = POI.v_idx(POI.ParameterIndex(1)) - - # Test the convenience method without specifying type - f = MOI.ScalarNonlinearFunction(:*, Any[2.0, p, x, x]) - result = POI._parse_cubic_expression(f) - @test result !== nothing - @test length(result.pvv) == 1 - @test result.pvv[1].coefficient == 2.0 - return -end - -function test_cubic_parse_pp_terms() - p = POI.v_idx(POI.ParameterIndex(1)) - q = POI.v_idx(POI.ParameterIndex(2)) - - # p * q (quadratic in parameters only) - f = MOI.ScalarNonlinearFunction(:*, Any[3.0, p, q]) - result = POI._parse_cubic_expression(f, Float64) - @test result !== nothing - @test length(result.pp) == 1 - @test result.pp[1].coefficient == 3.0 - return -end - -function test_cubic_parse_pp_same_parameter() - p = POI.v_idx(POI.ParameterIndex(1)) - - # p^2 (diagonal quadratic in parameters) - f = MOI.ScalarNonlinearFunction(:^, Any[p, 2]) - result = POI._parse_cubic_expression(f, Float64) - @test result !== nothing - @test length(result.pp) == 1 - return -end - -function test_cubic_parse_pv_terms() - x = MOI.VariableIndex(1) - p = POI.v_idx(POI.ParameterIndex(1)) - - # 4 * p * x (quadratic with one parameter and one variable) - f = MOI.ScalarNonlinearFunction(:*, Any[4.0, p, x]) - result = POI._parse_cubic_expression(f, Float64) - @test result !== nothing - @test length(result.pv) == 1 - @test result.pv[1].coefficient == 4.0 - return -end +# ============================================================================ +# Parser - Division edge cases +# ============================================================================ -function test_cubic_parse_mixed_all_degrees() +function test_cubic_parse_division_by_zero() x = MOI.VariableIndex(1) - y = MOI.VariableIndex(2) - p = POI.v_idx(POI.ParameterIndex(1)) - q = POI.v_idx(POI.ParameterIndex(2)) - # 2*p*x*y + 3*p*q*x + p*q*p + x^2 + 5*p*x + 7*x + 2*p + 10 - f = MOI.ScalarNonlinearFunction( - :+, - Any[ - MOI.ScalarNonlinearFunction(:*, Any[2.0, p, x, y]), # pvv - MOI.ScalarNonlinearFunction(:*, Any[3.0, p, q, x]), # ppv - MOI.ScalarNonlinearFunction(:*, Any[1.0, p, q, p]), # ppp - MOI.ScalarNonlinearFunction(:^, Any[x, 2]), # vv - MOI.ScalarNonlinearFunction(:*, Any[5.0, p, x]), # pv - MOI.ScalarNonlinearFunction(:*, Any[7.0, x]), # v - MOI.ScalarNonlinearFunction(:*, Any[2.0, p]), # p - 10.0, # constant - ], - ) + # x / 0 should be rejected + f = MOI.ScalarNonlinearFunction(:/, Any[x, 0.0]) result = POI._parse_cubic_expression(f, Float64) - @test result !== nothing - @test length(result.pvv) == 1 - @test result.pvv[1].coefficient == 2.0 - @test length(result.ppv) == 1 - @test result.ppv[1].coefficient == 3.0 - @test length(result.ppp) == 1 - @test length(result.vv) == 1 - @test length(result.pv) == 1 - @test length(result.v) == 1 - @test result.v[1].coefficient == 7.0 - @test length(result.p) == 1 - @test result.p[1].coefficient == 2.0 - @test result.constant == 10.0 + @test result === nothing return end -function test_cubic_parse_zero_coefficient_elimination() +function test_cubic_parse_division_by_variable() x = MOI.VariableIndex(1) y = MOI.VariableIndex(2) - p = POI.v_idx(POI.ParameterIndex(1)) - # p*x*y - p*x*y = 0 (coefficients cancel) - f = MOI.ScalarNonlinearFunction( - :-, - Any[ - MOI.ScalarNonlinearFunction(:*, Any[1.0, p, x, y]), - MOI.ScalarNonlinearFunction(:*, Any[1.0, p, x, y]), - ], - ) + # x / y should be rejected (variable denominator) + f = MOI.ScalarNonlinearFunction(:/, Any[x, y]) result = POI._parse_cubic_expression(f, Float64) - @test result !== nothing - @test isempty(result.pvv) + @test result === nothing return end -function test_cubic_parse_multiple_pvv_different_vars() +function test_cubic_parse_division_wrong_arity() x = MOI.VariableIndex(1) - y = MOI.VariableIndex(2) - z = MOI.VariableIndex(3) - p = POI.v_idx(POI.ParameterIndex(1)) - # p*x*y + 2*p*x*z (two different pvv terms) - f = MOI.ScalarNonlinearFunction( - :+, - Any[ - MOI.ScalarNonlinearFunction(:*, Any[1.0, p, x, y]), - MOI.ScalarNonlinearFunction(:*, Any[2.0, p, x, z]), - ], - ) + # Division with 1 or 3 args should be rejected + f = MOI.ScalarNonlinearFunction(:/, Any[x]) result = POI._parse_cubic_expression(f, Float64) - @test result !== nothing - @test length(result.pvv) == 2 - return -end - -function test_cubic_parse_subtraction_multiple_args() - x = MOI.VariableIndex(1) - y = MOI.VariableIndex(2) - p = POI.v_idx(POI.ParameterIndex(1)) + @test result === nothing - # p*x*y - x - 1 (binary subtraction, second operand is sum) - f = MOI.ScalarNonlinearFunction( - :-, - Any[ - MOI.ScalarNonlinearFunction(:*, Any[p, x, y]), - MOI.ScalarNonlinearFunction(:+, Any[x, 1.0]), - ], - ) + f = MOI.ScalarNonlinearFunction(:/, Any[x, 2.0, 3.0]) result = POI._parse_cubic_expression(f, Float64) - @test result !== nothing - @test length(result.pvv) == 1 - @test length(result.v) == 1 - @test result.v[1].coefficient == -1.0 - @test result.constant == -1.0 - return -end - -# ============================================================================ -# Cubic Types - Direct Unit Tests -# ============================================================================ - -function test_normalize_cubic_indices_mixed() - x = MOI.VariableIndex(2) - y = MOI.VariableIndex(1) - p = POI.v_idx(POI.ParameterIndex(1)) - - n1, n2, n3 = POI._normalize_cubic_indices(x, p, y) - # Parameter should come first - @test POI._is_parameter(n1) - @test !POI._is_parameter(n2) - @test !POI._is_parameter(n3) - return -end - -function test_make_cubic_term_normalization() - x = MOI.VariableIndex(2) - y = MOI.VariableIndex(1) - p = POI.v_idx(POI.ParameterIndex(1)) - - term = POI._make_cubic_term(3.0, x, p, y) - @test term.coefficient == 3.0 - # index_1 should be parameter - @test POI._is_parameter(term.index_1) + @test result === nothing return end # ============================================================================ -# ParametricCubicFunction - Unit Tests +# ParametricCubicFunction - Constructor # ============================================================================ function test_parametric_cubic_function_constructor_with_ppv() @@ -918,7 +754,7 @@ function test_parametric_cubic_function_constructor_np_affine() end # ============================================================================ -# MOI Objective Interface Tests +# MOI Objective Interface # ============================================================================ function test_cubic_objective_supports() @@ -947,6 +783,28 @@ function test_cubic_objective_set_invalid_expression() return end +function test_cubic_objective_set_error_on_inner_optimizer() + mock = NoQuadObjModel() + model = POI.Optimizer(mock) + + x = MOI.add_variable(model) + p, _ = MOI.add_constrained_variable(model, MOI.Parameter(2.0)) + p_v = POI.v_idx(POI.p_idx(p)) + + # p * x^2 -- parsed successfully but inner optimizer rejects SQF + f = MOI.ScalarNonlinearFunction(:*, Any[1.0, p_v, x, x]) + err = try + MOI.set(model, MOI.ObjectiveFunction{MOI.ScalarNonlinearFunction}(), f) + nothing + catch e + e + end + @test err isa ErrorException + @test occursin("Failed to set cubic objective function", err.msg) + @test occursin("Quadratic objectives not supported", err.msg) + return +end + function test_cubic_objective_get_no_cache() model = POI.Optimizer(HiGHS.Optimizer()) @@ -1003,162 +861,247 @@ function test_cubic_objective_get_no_save() end # ============================================================================ -# JuMP Integration Tests - Additional Coverage +# JuMP Integration - Basic pvv # ============================================================================ -function test_jump_cubic_mixed_pvv_ppv_ppp() +function test_jump_cubic_pvv_basic() model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) set_silent(model) @variable(model, 0 <= x <= 10) - @variable(model, 0 <= y <= 10) + @variable(model, -1 <= y <= 10) @variable(model, p in MOI.Parameter(1.0)) - @variable(model, q in MOI.Parameter(2.0)) - # Minimize: p*x*y + p*q*x + p*q*p + x^2 + y^2 - # With p=1, q=2: x*y + 2*x + 2 + x^2 + y^2 + # Minimize: x^2 + p*x*y + y^2 - 3x + @constraint(model, x + y >= 0) + @objective(model, Min, x^2 + p * x * y + y^2 - 3 * x) + + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ -3.0 atol = ATOL + @test value(x) ≈ 2.0 atol = ATOL + @test value(y) ≈ -1.0 atol = ATOL + + # Change p to 0.5 + set_parameter_value(p, 0.5) + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ -2.4 atol = ATOL + @test value(x) ≈ 1.6 atol = ATOL + @test value(y) ≈ -0.4 atol = ATOL + + # Change p to 0 (removes cross term) + set_parameter_value(p, 0.0) + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ -9 / 4 atol = ATOL + @test value(x) ≈ 3 / 2 atol = ATOL + @test value(y) ≈ 0.0 atol = ATOL + return +end + +function test_jump_cubic_pvv_same() + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, 0 <= x <= 10) + @variable(model, p in MOI.Parameter(1.0)) + + # Minimize: p*x^2 - 3x @constraint(model, x >= 0) - @constraint(model, y >= 0) - @objective(model, Min, p * x * y + p * q * x + p * q * p + x^2 + y^2) + @objective(model, Min, p * x^2 - 3 * x) + # p=1: optimal at x=3/2, obj=-9/4 optimize!(model) @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - obj1 = objective_value(model) - x1 = value(x) - y1 = value(y) - # Verify objective matches the formula - @test obj1 ≈ 1.0 * x1 * y1 + 2.0 * x1 + 2.0 + x1^2 + y1^2 atol = ATOL + @test objective_value(model) ≈ -9 / 4 atol = ATOL + @test value(x) ≈ 3 / 2 atol = ATOL - # Change p=2, q=3: 2*x*y + 6*x + 12 + x^2 + y^2 - set_parameter_value(p, 2.0) - set_parameter_value(q, 3.0) + # p=0.5: optimal at x=3, obj=-9/2 + set_parameter_value(p, 0.5) optimize!(model) @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - obj2 = objective_value(model) - x2 = value(x) - y2 = value(y) - @test obj2 ≈ 2.0 * x2 * y2 + 6.0 * x2 + 12.0 + x2^2 + y2^2 atol = ATOL + @test objective_value(model) ≈ -9 / 2 atol = ATOL + @test value(x) ≈ 3 atol = ATOL + + # p=0: linear, optimal at x=10, obj=-30 + set_parameter_value(p, 0.0) + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ -30.0 atol = ATOL + @test value(x) ≈ 10.0 atol = ATOL return end -function test_jump_cubic_negative_parameters() +function test_jump_cubic_pvv_with_constant_and_affine() model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) set_silent(model) @variable(model, 0 <= x <= 10) - @variable(model, p in MOI.Parameter(-1.0)) + @variable(model, 0 <= y <= 10) + @variable(model, p in MOI.Parameter(2.0)) - # Minimize: p*x^2 - x - # With p=-1: -x^2 - x (concave, so min at boundary) - # Actually this is concave so HiGHS may struggle. - # Use a positive leading term: x^2 + p*x^2 = (1+p)*x^2 - # With p=-0.5: 0.5*x^2 - x, optimal at x=1, obj=-0.5 + # Minimize: p*x*y + x^2 + y^2 - 6x - 6y + 10 + # With p=2: (x+y)^2 - 6(x+y) + 10 = (s-3)^2 + 1, optimal at s=3, obj=1 @constraint(model, x >= 0) - @objective(model, Min, x^2 + p * x^2 - x) + @constraint(model, y >= 0) + @objective(model, Min, p * x * y + x^2 + y^2 - 6 * x - 6 * y + 10) - set_parameter_value(p, -0.5) optimize!(model) @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - @test objective_value(model) ≈ -0.5 atol = ATOL - @test value(x) ≈ 1.0 atol = ATOL + @test objective_value(model) ≈ 1.0 atol = ATOL + @test value(x) + value(y) ≈ 3.0 atol = ATOL - # Change p to 0: x^2 - x, optimal at x=0.5, obj=-0.25 + # p=0: x^2 + y^2 - 6x - 6y + 10, optimal at x=3, y=3, obj=-8 set_parameter_value(p, 0.0) optimize!(model) @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - @test objective_value(model) ≈ -0.25 atol = ATOL - @test value(x) ≈ 0.5 atol = ATOL + @test objective_value(model) ≈ -8.0 atol = ATOL + @test value(x) ≈ 3.0 atol = ATOL + @test value(y) ≈ 3.0 atol = ATOL return end -function test_jump_cubic_multiple_parameter_updates() +function test_jump_cubic_pvv_multiple_cross_terms() model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) set_silent(model) @variable(model, 0 <= x <= 10) + @variable(model, 0 <= y <= 10) + @variable(model, 0 <= z <= 10) @variable(model, p in MOI.Parameter(1.0)) + # Minimize: x^2 + y^2 + z^2 + p*x*y + p*x*z - 6x - 4y - 2z + # With p=0: separable quadratics, x=3,y=2,z=1, obj=-14 @constraint(model, x >= 0) - @objective(model, Min, p * x^2 - 4 * x) + @constraint(model, y >= 0) + @constraint(model, z >= 0) + @objective( + model, + Min, + x^2 + y^2 + z^2 + p * x * y + p * x * z - 6 * x - 4 * y - 2 * z, + ) - # p=1: x^2 - 4x, optimal at x=2, obj=-4 + set_parameter_value(p, 0.0) optimize!(model) @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - @test value(x) ≈ 2.0 atol = ATOL - @test objective_value(model) ≈ -4.0 atol = ATOL + @test objective_value(model) ≈ -14.0 atol = ATOL - # p=2: 2*x^2 - 4x, optimal at x=1, obj=-2 - set_parameter_value(p, 2.0) + # With p=1: coupled system + set_parameter_value(p, 1.0) optimize!(model) @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - @test value(x) ≈ 1.0 atol = ATOL - @test objective_value(model) ≈ -2.0 atol = ATOL + x1 = value(x) + y1 = value(y) + z1 = value(z) + obj = objective_value(model) + expected = x1^2 + y1^2 + z1^2 + x1 * y1 + x1 * z1 - 6x1 - 4y1 - 2z1 + @test obj ≈ expected atol = ATOL + return +end + +function test_jump_cubic_pvv_reverse_var_order() + # Exercise variable ordering by having pvv with y*x where y.index > x.index + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, 0 <= x <= 10) + @variable(model, -1 <= y <= 10) + @variable(model, p in MOI.Parameter(2.0)) + + # Minimize: x^2 + y^2 + p*y*x - 3x (y comes before x in term) + @constraint(model, x + y >= 0) + @objective(model, Min, x^2 + y^2 + p * y * x - 3 * x) - # p=4: 4*x^2 - 4x, optimal at x=0.5, obj=-1 - set_parameter_value(p, 4.0) optimize!(model) @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - @test value(x) ≈ 0.5 atol = ATOL - @test objective_value(model) ≈ -1.0 atol = ATOL + x1 = value(x) + y1 = value(y) + @test objective_value(model) ≈ x1^2 + y1^2 + 2 * y1 * x1 - 3 * x1 atol = + ATOL - # p=0.5: 0.5*x^2 - 4x, optimal at x=4, obj=-8 - set_parameter_value(p, 0.5) + # Change p=0 + set_parameter_value(p, 0.0) optimize!(model) @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - @test value(x) ≈ 4.0 atol = ATOL - @test objective_value(model) ≈ -8.0 atol = ATOL + @test value(x) ≈ 3 / 2 atol = ATOL + @test value(y) ≈ 0.0 atol = ATOL return end -function test_jump_cubic_ppv_negative_params() +# ============================================================================ +# JuMP Integration - Basic ppv +# ============================================================================ + +function test_jump_cubic_ppv_basic() model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) set_silent(model) @variable(model, x >= 0) - @variable(model, p in MOI.Parameter(-1.0)) - @variable(model, q in MOI.Parameter(2.0)) + @variable(model, p in MOI.Parameter(2.0)) + @variable(model, q in MOI.Parameter(3.0)) - # Minimize: x + p*q*x = x*(1 + p*q) = x*(1-2) = -x - # Subject to: 0 <= x <= 5 - @constraint(model, x <= 5) + # Minimize: x + p*q*x = x*(1 + p*q) + # With p=2, q=3: 7x, optimal at x=1, obj=7 + @constraint(model, x >= 1) @objective(model, Min, x + p * q * x) optimize!(model) @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - # min(-x) with 0<=x<=5 is at x=5, obj=-5+5=-5... wait - # f(x) = x + (-1)(2)(x) = x - 2x = -x, so min at x=5, obj=-5 - @test objective_value(model) ≈ -5.0 atol = ATOL - @test value(x) ≈ 5.0 atol = ATOL + @test objective_value(model) ≈ 7.0 atol = ATOL - # Change p=1, q=1: x*(1+1) = 2x, min at x=0 + # p=1, q=1: 2x, obj=2 set_parameter_value(p, 1.0) set_parameter_value(q, 1.0) optimize!(model) - @test objective_value(model) ≈ 0.0 atol = ATOL - @test value(x) ≈ 0.0 atol = ATOL - return + @test objective_value(model) ≈ 2.0 atol = ATOL end -function test_jump_cubic_ppp_negative_params() +function test_jump_cubic_ppv_same() model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) set_silent(model) @variable(model, x >= 0) - @variable(model, p in MOI.Parameter(-2.0)) + @variable(model, p in MOI.Parameter(2.0)) + + # Minimize: x + p^2*x = x*(1 + p^2) + # With p=2: 5x, optimal at x=1, obj=5 + @constraint(model, x >= 1) + @objective(model, Min, x + p * p * x) + + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ 5.0 atol = ATOL + + # p=1: 2x, obj=2 + set_parameter_value(p, 1.0) + optimize!(model) + @test objective_value(model) ≈ 2.0 atol = ATOL +end + +# ============================================================================ +# JuMP Integration - Basic ppp +# ============================================================================ + +function test_jump_cubic_ppp_basic() + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, x >= 0) + @variable(model, p in MOI.Parameter(2.0)) @variable(model, q in MOI.Parameter(3.0)) - @variable(model, r in MOI.Parameter(1.0)) + @variable(model, r in MOI.Parameter(4.0)) - # Minimize: x + p*q*r = x + (-2)(3)(1) = x - 6 - # Subject to: x >= 1 + # Minimize: x + p*q*r + # With p=2, q=3, r=4: x + 24, optimal at x=1, obj=25 @constraint(model, x >= 1) @objective(model, Min, x + p * q * r) optimize!(model) @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - @test objective_value(model) ≈ -5.0 atol = ATOL - @test value(x) ≈ 1.0 atol = ATOL + @test objective_value(model) ≈ 25.0 atol = ATOL - # Change to positive: p=1,q=1,r=1 -> x+1, min at x=1, obj=2 + # p=1, q=1, r=1: x + 1, obj=2 set_parameter_value(p, 1.0) set_parameter_value(q, 1.0) set_parameter_value(r, 1.0) @@ -1167,287 +1110,365 @@ function test_jump_cubic_ppp_negative_params() return end -function test_jump_cubic_partial_parameter_update() +function test_jump_cubic_ppp_same() + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, x >= 0) + @variable(model, p in MOI.Parameter(2.0)) + + # Minimize: x + p^3 + # With p=2: x + 8, optimal at x=1, obj=9 + @constraint(model, x >= 1) + @objective(model, Min, x + p * p * p) + + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ 9.0 atol = ATOL + + # p=1: x + 1, obj=2 + set_parameter_value(p, 1.0) + optimize!(model) + @test objective_value(model) ≈ 2.0 atol = ATOL + return +end + +# ============================================================================ +# JuMP Integration - Specific term types in objective (pp, pv, p affine) +# ============================================================================ + +function test_jump_cubic_pp_in_objective() + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, x >= 0) + @variable(model, p in MOI.Parameter(3.0)) + @variable(model, q in MOI.Parameter(2.0)) + + # Minimize: x + p*q (pp contributes to constant) + # With p=3, q=2: x + 6 + @constraint(model, x >= 1) + @objective(model, Min, x + p * q) + + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ 7.0 atol = ATOL + + # p=0, q=0: x + 0 + set_parameter_value(p, 0.0) + set_parameter_value(q, 0.0) + optimize!(model) + @test objective_value(model) ≈ 1.0 atol = ATOL + return +end + +function test_jump_cubic_with_pp_terms() + # pp terms alongside a cubic term (forces ScalarNonlinearFunction path) model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) set_silent(model) - @variable(model, x >= 0) + @variable(model, 0 <= x <= 10) @variable(model, p in MOI.Parameter(2.0)) @variable(model, q in MOI.Parameter(3.0)) - # Minimize: x + p*q*x = x*(1 + p*q) - # With p=2, q=3: x*(1+6)=7x, min at x=0 -> obj=0 - # Subject to: x >= 1 - @constraint(model, x >= 1) - @objective(model, Min, x + p * q * x) + # p*x^2 + p*q + x (pvv + pp + v) + # With p=2, q=3: 2x^2 + 6 + x, at x=0: obj=6 + @constraint(model, x >= 0) + @objective(model, Min, p * x^2 + p * q + x) optimize!(model) - @test objective_value(model) ≈ 7.0 atol = ATOL + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ 6.0 atol = ATOL + @test value(x) ≈ 0.0 atol = ATOL - # Only update p to 0, keep q=3: x*(1+0)=x, min at x=1 -> obj=1 - set_parameter_value(p, 0.0) + # p=1, q=1: x^2 + 1 + x, at x=0: obj=1 + set_parameter_value(p, 1.0) + set_parameter_value(q, 1.0) optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) @test objective_value(model) ≈ 1.0 atol = ATOL + @test value(x) ≈ 0.0 atol = ATOL return end -function test_jump_cubic_all_term_types_combined() +function test_jump_cubic_with_pp_same_param() + # p^2 term alongside a cubic term model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) set_silent(model) @variable(model, 0 <= x <= 10) - @variable(model, 0 <= y <= 10) - @variable(model, p in MOI.Parameter(1.0)) - @variable(model, q in MOI.Parameter(1.0)) + @variable(model, p in MOI.Parameter(3.0)) - # f = p*x*y + p*q*x + p*q*p + x^2 + y^2 + p*x + 2*p + 3*x + 5 - # With p=1,q=1: - # x*y + x + 1 + x^2 + y^2 + x + 2 + 3x + 5 = x^2 + y^2 + x*y + 5x + 8 + # p*x^2 + p*p + x (pvv + pp + v) + # With p=3: 3x^2 + 9 + x, at x=0: obj=9 @constraint(model, x >= 0) - @constraint(model, y >= 0) - @objective( - model, - Min, - p * x * y + - p * q * x + - p * q * p + - x^2 + - y^2 + - p * x + - 2 * p + - 3 * x + - 5, - ) + @objective(model, Min, p * x^2 + p * p + x) optimize!(model) @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - x1 = value(x) - y1 = value(y) - obj1 = objective_value(model) - expected1 = x1^2 + y1^2 + x1 * y1 + 5 * x1 + 8 - @test obj1 ≈ expected1 atol = ATOL + @test objective_value(model) ≈ 9.0 atol = ATOL + @test value(x) ≈ 0.0 atol = ATOL - # p=2, q=0.5: - # 2*x*y + 1*x + 2 + x^2 + y^2 + 2*x + 4 + 3*x + 5 - # = x^2 + y^2 + 2*x*y + 6*x + 11 - set_parameter_value(p, 2.0) - set_parameter_value(q, 0.5) + # p=0: x, at x=0: obj=0 + set_parameter_value(p, 0.0) optimize!(model) @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - x2 = value(x) - y2 = value(y) - obj2 = objective_value(model) - expected2 = x2^2 + y2^2 + 2 * x2 * y2 + 6 * x2 + 11 - @test obj2 ≈ expected2 atol = ATOL + @test objective_value(model) ≈ 0.0 atol = ATOL + @test value(x) ≈ 0.0 atol = ATOL return end -function test_jump_cubic_pvv_with_constant_and_affine() +function test_jump_cubic_pv_in_objective() model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) set_silent(model) @variable(model, 0 <= x <= 10) - @variable(model, 0 <= y <= 10) @variable(model, p in MOI.Parameter(2.0)) - # Minimize: p*x*y + x^2 + y^2 - 6*x - 6*y + 10 - # With p=2: 2*x*y + x^2 + y^2 - 6x - 6y + 10 = (x+y)^2 - 6(x+y) + 10 - # Let s=x+y. f = s^2 - 6s + 10 = (s-3)^2 + 1 - # Optimal at x+y=3, many solutions (e.g. x=1.5, y=1.5), obj=1 + # Minimize: x^2 + p*x - 4x = x^2 + (p-4)*x + # With p=2: x^2 - 2x, optimal at x=1, obj=-1 @constraint(model, x >= 0) - @constraint(model, y >= 0) - @objective(model, Min, p * x * y + x^2 + y^2 - 6 * x - 6 * y + 10) + @objective(model, Min, x^2 + p * x - 4 * x) optimize!(model) @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - @test objective_value(model) ≈ 1.0 atol = ATOL - @test value(x) + value(y) ≈ 3.0 atol = ATOL + @test objective_value(model) ≈ -1.0 atol = ATOL + @test value(x) ≈ 1.0 atol = ATOL - # Change p=0: x^2 + y^2 - 6x - 6y + 10 - # Optimal at x=3, y=3, obj = 9+9-18-18+10 = -8 - set_parameter_value(p, 0.0) + # p=6: x^2 + 2x, optimal at x=0, obj=0 + set_parameter_value(p, 6.0) optimize!(model) @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - @test objective_value(model) ≈ -8.0 atol = ATOL - @test value(x) ≈ 3.0 atol = ATOL - @test value(y) ≈ 3.0 atol = ATOL + @test objective_value(model) ≈ 0.0 atol = ATOL + @test value(x) ≈ 0.0 atol = ATOL return end -function test_jump_cubic_direct_model_ppv() - model = direct_model(POI.Optimizer(HiGHS.Optimizer())) +function test_jump_cubic_p_affine_in_objective() + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) set_silent(model) @variable(model, x >= 0) - @variable(model, p in MOI.Parameter(3.0)) - @variable(model, q in MOI.Parameter(2.0)) + @variable(model, p in MOI.Parameter(5.0)) - # Minimize: x + p*q*x = x*(1 + 6) = 7x + # Minimize: x + 3*p (affine in parameter contributes to constant) + # With p=5: x + 15 @constraint(model, x >= 1) - @objective(model, Min, x + p * q * x) + @objective(model, Min, x + 3 * p) optimize!(model) @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - @test objective_value(model) ≈ 7.0 atol = ATOL + @test objective_value(model) ≈ 16.0 atol = ATOL - # Update p=0: x*(1+0)=x, obj=1 set_parameter_value(p, 0.0) optimize!(model) - @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) @test objective_value(model) ≈ 1.0 atol = ATOL return end -function test_jump_cubic_direct_model_ppp() - model = direct_model(POI.Optimizer(HiGHS.Optimizer())) +function test_jump_cubic_p_affine_in_cubic_expr() + # p affine terms alongside pvv (forces cubic path) + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) set_silent(model) - @variable(model, x >= 0) - @variable(model, p in MOI.Parameter(2.0)) - @variable(model, q in MOI.Parameter(3.0)) - @variable(model, r in MOI.Parameter(4.0)) + @variable(model, 0 <= x <= 10) + @variable(model, p in MOI.Parameter(5.0)) - # Minimize: x + p*q*r = x + 24 - @constraint(model, x >= 1) - @objective(model, Min, x + p * q * r) + # p*x^2 + 3*p + x (pvv + p_affine + v) + # With p=5: 5x^2 + 15 + x, at x=0: obj=15 + @constraint(model, x >= 0) + @objective(model, Min, p * x^2 + 3 * p + x) optimize!(model) @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - @test objective_value(model) ≈ 25.0 atol = ATOL + @test objective_value(model) ≈ 15.0 atol = ATOL + @test value(x) ≈ 0.0 atol = ATOL + # p=0: x, at x=0: obj=0 set_parameter_value(p, 0.0) optimize!(model) - @test objective_value(model) ≈ 1.0 atol = ATOL + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ 0.0 atol = ATOL return end -function test_jump_cubic_pvv_multiple_cross_terms() +# ============================================================================ +# JuMP Integration - Mixed term types +# ============================================================================ + +function test_jump_cubic_mixed_pvv_ppv_ppp() model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) set_silent(model) @variable(model, 0 <= x <= 10) @variable(model, 0 <= y <= 10) - @variable(model, 0 <= z <= 10) @variable(model, p in MOI.Parameter(1.0)) + @variable(model, q in MOI.Parameter(2.0)) - # Minimize: x^2 + y^2 + z^2 + p*x*y + p*x*z - 6x - 4y - 2z - # With p=0: separable quadratics, x=3,y=2,z=1, obj=-9-4-1=-14 + # Minimize: p*x*y + p*q*x + p*q*p + x^2 + y^2 + # With p=1, q=2: x*y + 2*x + 2 + x^2 + y^2 @constraint(model, x >= 0) @constraint(model, y >= 0) - @constraint(model, z >= 0) - @objective( - model, - Min, - x^2 + y^2 + z^2 + p * x * y + p * x * z - 6 * x - 4 * y - 2 * z, - ) + @objective(model, Min, p * x * y + p * q * x + p * q * p + x^2 + y^2) - set_parameter_value(p, 0.0) optimize!(model) @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - @test objective_value(model) ≈ -14.0 atol = ATOL + obj1 = objective_value(model) + x1 = value(x) + y1 = value(y) + @test obj1 ≈ 1.0 * x1 * y1 + 2.0 * x1 + 2.0 + x1^2 + y1^2 atol = ATOL - # With p=1: coupled system - set_parameter_value(p, 1.0) + # p=2, q=3: 2*x*y + 6*x + 12 + x^2 + y^2 + set_parameter_value(p, 2.0) + set_parameter_value(q, 3.0) optimize!(model) @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - x1 = value(x) - y1 = value(y) - z1 = value(z) - obj = objective_value(model) - expected = x1^2 + y1^2 + z1^2 + x1 * y1 + x1 * z1 - 6x1 - 4y1 - 2z1 - @test obj ≈ expected atol = ATOL + obj2 = objective_value(model) + x2 = value(x) + y2 = value(y) + @test obj2 ≈ 2.0 * x2 * y2 + 6.0 * x2 + 12.0 + x2^2 + y2^2 atol = ATOL return end -function test_jump_cubic_pp_in_objective() +function test_jump_cubic_all_term_types_combined() model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) set_silent(model) - @variable(model, x >= 0) - @variable(model, p in MOI.Parameter(3.0)) - @variable(model, q in MOI.Parameter(2.0)) + @variable(model, 0 <= x <= 10) + @variable(model, 0 <= y <= 10) + @variable(model, p in MOI.Parameter(1.0)) + @variable(model, q in MOI.Parameter(1.0)) - # Minimize: x + p*q (pp quadratic in parameters contributes to constant) - # With p=3, q=2: x + 6 - @constraint(model, x >= 1) - @objective(model, Min, x + p * q) + # f = p*x*y + p*q*x + p*q*p + x^2 + y^2 + p*x + 2*p + 3*x + 5 + # With p=1,q=1: x^2 + y^2 + x*y + 5x + 8 + @constraint(model, x >= 0) + @constraint(model, y >= 0) + @objective( + model, + Min, + p * x * y + + p * q * x + + p * q * p + + x^2 + + y^2 + + p * x + + 2 * p + + 3 * x + + 5, + ) optimize!(model) @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - @test objective_value(model) ≈ 7.0 atol = ATOL + x1 = value(x) + y1 = value(y) + obj1 = objective_value(model) + expected1 = x1^2 + y1^2 + x1 * y1 + 5 * x1 + 8 + @test obj1 ≈ expected1 atol = ATOL - # Change p=0, q=0: x + 0 - set_parameter_value(p, 0.0) - set_parameter_value(q, 0.0) + # p=2, q=0.5: x^2 + y^2 + 2*x*y + 6*x + 11 + set_parameter_value(p, 2.0) + set_parameter_value(q, 0.5) optimize!(model) - @test objective_value(model) ≈ 1.0 atol = ATOL + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + x2 = value(x) + y2 = value(y) + obj2 = objective_value(model) + expected2 = x2^2 + y2^2 + 2 * x2 * y2 + 6 * x2 + 11 + @test obj2 ≈ expected2 atol = ATOL return end -function test_jump_cubic_pv_in_objective() +# ============================================================================ +# JuMP Integration - Parameter updates +# ============================================================================ + +function test_jump_cubic_parameter_initially_zero() model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) set_silent(model) @variable(model, 0 <= x <= 10) - @variable(model, p in MOI.Parameter(2.0)) + @variable(model, -1 <= y <= 10) + @variable(model, p in MOI.Parameter(0.0)) - # Minimize: x^2 + p*x - 4x = x^2 + (p-4)*x - # With p=2: x^2 - 2x, optimal at x=1, obj=-1 - @constraint(model, x >= 0) - @objective(model, Min, x^2 + p * x - 4 * x) + @constraint(model, x + y >= 0) + @objective(model, Min, x^2 + p * x * y + y^2 - 3 * x) optimize!(model) @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - @test objective_value(model) ≈ -1.0 atol = ATOL - @test value(x) ≈ 1.0 atol = ATOL + @test objective_value(model) ≈ -9 / 4 atol = ATOL + @test value(x) ≈ 3 / 2 atol = ATOL + @test value(y) ≈ 0.0 atol = ATOL - # Change p=6: x^2 + 2x, optimal at x=0, obj=0 - set_parameter_value(p, 6.0) + # Change p to 0.5 + set_parameter_value(p, 0.5) optimize!(model) @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - @test objective_value(model) ≈ 0.0 atol = ATOL - @test value(x) ≈ 0.0 atol = ATOL + @test objective_value(model) ≈ -2.4 atol = ATOL + @test value(x) ≈ 1.6 atol = ATOL + @test value(y) ≈ -0.4 atol = ATOL + return end -function test_jump_cubic_p_affine_in_objective() +function test_jump_cubic_multiple_parameter_updates() model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) set_silent(model) - @variable(model, x >= 0) - @variable(model, p in MOI.Parameter(5.0)) + @variable(model, 0 <= x <= 10) + @variable(model, p in MOI.Parameter(1.0)) - # Minimize: x + 3*p (affine in parameter contributes to constant) - # With p=5: x + 15 - @constraint(model, x >= 1) - @objective(model, Min, x + 3 * p) + @constraint(model, x >= 0) + @objective(model, Min, p * x^2 - 4 * x) + + # p=1: x^2 - 4x, optimal at x=2, obj=-4 + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test value(x) ≈ 2.0 atol = ATOL + @test objective_value(model) ≈ -4.0 atol = ATOL + # p=2: 2x^2 - 4x, optimal at x=1, obj=-2 + set_parameter_value(p, 2.0) optimize!(model) @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - @test objective_value(model) ≈ 16.0 atol = ATOL + @test value(x) ≈ 1.0 atol = ATOL + @test objective_value(model) ≈ -2.0 atol = ATOL - set_parameter_value(p, 0.0) + # p=4: 4x^2 - 4x, optimal at x=0.5, obj=-1 + set_parameter_value(p, 4.0) + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test value(x) ≈ 0.5 atol = ATOL + @test objective_value(model) ≈ -1.0 atol = ATOL + + # p=0.5: 0.5x^2 - 4x, optimal at x=4, obj=-8 + set_parameter_value(p, 0.5) optimize!(model) - @test objective_value(model) ≈ 1.0 atol = ATOL + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test value(x) ≈ 4.0 atol = ATOL + @test objective_value(model) ≈ -8.0 atol = ATOL return end -function test_jump_cubic_division_in_ppv() +function test_jump_cubic_partial_parameter_update() model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) set_silent(model) @variable(model, x >= 0) - @variable(model, p in MOI.Parameter(4.0)) - @variable(model, q in MOI.Parameter(6.0)) + @variable(model, p in MOI.Parameter(2.0)) + @variable(model, q in MOI.Parameter(3.0)) - # Minimize: x + p*q*x/2 = x*(1 + p*q/2) - # With p=4, q=6: x*(1+12)=13x + # Minimize: x + p*q*x = x*(1 + p*q) + # With p=2, q=3: 7x, obj=7 @constraint(model, x >= 1) - @objective(model, Min, x + p * q * x / 2) + @objective(model, Min, x + p * q * x) optimize!(model) - @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - @test objective_value(model) ≈ 13.0 atol = ATOL + @test objective_value(model) ≈ 7.0 atol = ATOL + + # Only update p to 0, keep q=3: x, obj=1 + set_parameter_value(p, 0.0) + optimize!(model) + @test objective_value(model) ≈ 1.0 atol = ATOL return end @@ -1476,220 +1497,195 @@ function test_jump_cubic_pvv_update_no_change() end # ============================================================================ -# Parser - Error propagation through subtraction and power +# JuMP Integration - Negative parameters # ============================================================================ -function test_cubic_parse_unary_minus_invalid() - x = MOI.VariableIndex(1) - - # -(sin(x)) should propagate nothing from unary minus - f = MOI.ScalarNonlinearFunction( - :-, - Any[MOI.ScalarNonlinearFunction(:sin, Any[x])], - ) - result = POI._parse_cubic_expression(f, Float64) - @test result === nothing - return -end +function test_jump_cubic_negative_parameters() + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) -function test_cubic_parse_binary_subtraction_invalid_rhs() - x = MOI.VariableIndex(1) + @variable(model, 0 <= x <= 10) + @variable(model, p in MOI.Parameter(-1.0)) - # x - sin(x) should propagate nothing from binary subtraction - f = MOI.ScalarNonlinearFunction( - :-, - Any[x, MOI.ScalarNonlinearFunction(:sin, Any[x])], - ) - result = POI._parse_cubic_expression(f, Float64) - @test result === nothing - return -end + # x^2 + p*x^2 - x = (1+p)*x^2 - x + # With p=-0.5: 0.5x^2 - x, optimal at x=1, obj=-0.5 + @constraint(model, x >= 0) + @objective(model, Min, x^2 + p * x^2 - x) -function test_cubic_parse_power_of_invalid_base() - x = MOI.VariableIndex(1) + set_parameter_value(p, -0.5) + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ -0.5 atol = ATOL + @test value(x) ≈ 1.0 atol = ATOL - # sin(x)^2 should propagate nothing from power - f = MOI.ScalarNonlinearFunction( - :^, - Any[MOI.ScalarNonlinearFunction(:sin, Any[x]), 2], - ) - result = POI._parse_cubic_expression(f, Float64) - @test result === nothing + # p=0: x^2 - x, optimal at x=0.5, obj=-0.25 + set_parameter_value(p, 0.0) + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ -0.25 atol = ATOL + @test value(x) ≈ 0.5 atol = ATOL return end -# ============================================================================ -# Parser - Sorting branches for reverse-ordered indices -# ============================================================================ +function test_jump_cubic_ppv_negative_params() + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) -function test_cubic_parse_pv_param_first() - # Test the branch where m.variables[1] is already the parameter - p = POI.v_idx(POI.ParameterIndex(1)) - x = MOI.VariableIndex(1) + @variable(model, x >= 0) + @variable(model, p in MOI.Parameter(-1.0)) + @variable(model, q in MOI.Parameter(2.0)) - # The monomial from _combine_like_monomials sorts by value, so the - # variable (lower value) comes first. We construct a monomial explicitly - # where the parameter is first in the raw expression to trigger both - # branches of the pv classification. - # With flat mult: p * x — monomials get combined with sorted key, - # so let's just verify both orderings work. - f1 = MOI.ScalarNonlinearFunction(:*, Any[p, x]) - f2 = MOI.ScalarNonlinearFunction(:*, Any[x, p]) - r1 = POI._parse_cubic_expression(f1, Float64) - r2 = POI._parse_cubic_expression(f2, Float64) + # Minimize: x + p*q*x = x*(1 + p*q) = x*(1-2) = -x + # Subject to: 0 <= x <= 5 + @constraint(model, x <= 5) + @objective(model, Min, x + p * q * x) - for r in [r1, r2] - @test r !== nothing - @test length(r.pv) == 1 - # Convention: variable_1 = parameter, variable_2 = variable - @test POI._is_parameter(r.pv[1].variable_1) - @test !POI._is_parameter(r.pv[1].variable_2) - end + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + # f(x) = x + (-1)(2)(x) = -x, min at x=5, obj=-5 + @test objective_value(model) ≈ -5.0 atol = ATOL + @test value(x) ≈ 5.0 atol = ATOL + + # p=1, q=1: 2x, min at x=0 + set_parameter_value(p, 1.0) + set_parameter_value(q, 1.0) + optimize!(model) + @test objective_value(model) ≈ 0.0 atol = ATOL + @test value(x) ≈ 0.0 atol = ATOL return end -# ============================================================================ -# JuMP Integration - pp terms through cubic path -# ============================================================================ - -function test_jump_cubic_with_pp_terms() - # To exercise pp handling in _parametric_constant and - # _delta_parametric_constant, we need pp terms alongside a cubic term - # so JuMP sends everything as ScalarNonlinearFunction. +function test_jump_cubic_ppp_negative_params() model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) set_silent(model) - @variable(model, 0 <= x <= 10) - @variable(model, p in MOI.Parameter(2.0)) + @variable(model, x >= 0) + @variable(model, p in MOI.Parameter(-2.0)) @variable(model, q in MOI.Parameter(3.0)) + @variable(model, r in MOI.Parameter(1.0)) - # p*x^2 + p*q + x (pvv + pp + v) - # With p=2, q=3: 2*x^2 + 6 + x - # Optimal: 2x^2 + x + 6, d/dx = 4x + 1 = 0 -> x = -0.25 (bound at 0) - # At x=0: obj = 6 - # Actually with 0<=x<=10: min at x=-1/4 but bounded, so x=0, obj=6 - @constraint(model, x >= 0) - @objective(model, Min, p * x^2 + p * q + x) + # Minimize: x + p*q*r = x + (-2)(3)(1) = x - 6 + @constraint(model, x >= 1) + @objective(model, Min, x + p * q * r) optimize!(model) @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - @test objective_value(model) ≈ 6.0 atol = ATOL - @test value(x) ≈ 0.0 atol = ATOL + @test objective_value(model) ≈ -5.0 atol = ATOL + @test value(x) ≈ 1.0 atol = ATOL - # Change p=1, q=1: x^2 + 1 + x, at x=0: obj=1 + # p=1, q=1, r=1: x + 1, obj=2 set_parameter_value(p, 1.0) set_parameter_value(q, 1.0) + set_parameter_value(r, 1.0) optimize!(model) - @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - @test objective_value(model) ≈ 1.0 atol = ATOL - @test value(x) ≈ 0.0 atol = ATOL + @test objective_value(model) ≈ 2.0 atol = ATOL return end -function test_jump_cubic_with_pp_same_param() - # p^2 term alongside a cubic term - model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) +# ============================================================================ +# JuMP Integration - Division and direct model +# ============================================================================ + +function test_jump_cubic_parameter_division_by_constant() + model = direct_model(POI.Optimizer(HiGHS.Optimizer())) set_silent(model) @variable(model, 0 <= x <= 10) - @variable(model, p in MOI.Parameter(3.0)) - - # p*x^2 + p*p + x (pvv + pp + v) - # With p=3: 3*x^2 + 9 + x, at x=0: obj=9 - @constraint(model, x >= 0) - @objective(model, Min, p * x^2 + p * p + x) + @variable(model, 0 <= y <= 10) + @variable(model, p in MOI.Parameter(0.0)) - optimize!(model) - @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - @test objective_value(model) ≈ 9.0 atol = ATOL - @test value(x) ≈ 0.0 atol = ATOL + @constraint(model, x + y >= 0) + @objective(model, Min, x^2 + p * x * y / 1 + y^2 - 3 * x) - # Change p=0: 0 + 0 + x = x, at x=0: obj=0 - set_parameter_value(p, 0.0) optimize!(model) @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - @test objective_value(model) ≈ 0.0 atol = ATOL - @test value(x) ≈ 0.0 atol = ATOL - return -end + @test objective_value(model) ≈ -9 / 4 atol = ATOL + @test value(x) ≈ 3 / 2 atol = ATOL + @test value(y) ≈ 0.0 atol = ATOL -function test_jump_cubic_pvv_reverse_var_order() - # Exercise the v1.value > v2.value sorting branches in - # _parametric_quadratic_terms and _delta_parametric_quadratic_terms - # by having pvv with y*x where y.index > x.index model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) set_silent(model) @variable(model, 0 <= x <= 10) @variable(model, -1 <= y <= 10) - @variable(model, p in MOI.Parameter(2.0)) + @variable(model, p in MOI.Parameter(1.0)) - # Minimize: x^2 + y^2 + p*y*x - 3x (y comes before x in term) + # Minimize: x^2 + 0.5*x*y + y^2 - 3x @constraint(model, x + y >= 0) - @objective(model, Min, x^2 + y^2 + p * y * x - 3 * x) + @objective(model, Min, x^2 + p * x * y / 2 + y^2 - 3 * x) optimize!(model) @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - x1 = value(x) - y1 = value(y) - @test objective_value(model) ≈ x1^2 + y1^2 + 2 * y1 * x1 - 3 * x1 atol = - ATOL + @test objective_value(model) ≈ -2.4 atol = ATOL + @test value(x) ≈ 1.6 atol = ATOL + @test value(y) ≈ -0.4 atol = ATOL + + return +end + +function test_jump_cubic_division_in_ppv() + model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) + + @variable(model, x >= 0) + @variable(model, p in MOI.Parameter(4.0)) + @variable(model, q in MOI.Parameter(6.0)) + + # Minimize: x + p*q*x/2 = x*(1 + p*q/2) + # With p=4, q=6: x*(1+12)=13x + @constraint(model, x >= 1) + @objective(model, Min, x + p * q * x / 2) - # Change p=0 - set_parameter_value(p, 0.0) optimize!(model) @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - @test value(x) ≈ 3 / 2 atol = ATOL - @test value(y) ≈ 0.0 atol = ATOL + @test objective_value(model) ≈ 13.0 atol = ATOL return end -function test_jump_cubic_p_affine_in_cubic_expr() - # Exercise p affine terms inside a cubic expression - # (p terms alongside pvv so it goes through cubic path) - model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) +function test_jump_cubic_direct_model_ppv() + model = direct_model(POI.Optimizer(HiGHS.Optimizer())) set_silent(model) - @variable(model, 0 <= x <= 10) - @variable(model, p in MOI.Parameter(5.0)) + @variable(model, x >= 0) + @variable(model, p in MOI.Parameter(3.0)) + @variable(model, q in MOI.Parameter(2.0)) - # p*x^2 + 3*p + x (pvv + p_affine + v) - # With p=5: 5*x^2 + 15 + x, at x=0: obj=15 - @constraint(model, x >= 0) - @objective(model, Min, p * x^2 + 3 * p + x) + # Minimize: x + p*q*x = x*(1 + 6) = 7x + @constraint(model, x >= 1) + @objective(model, Min, x + p * q * x) optimize!(model) @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - @test objective_value(model) ≈ 15.0 atol = ATOL - @test value(x) ≈ 0.0 atol = ATOL + @test objective_value(model) ≈ 7.0 atol = ATOL - # Change p=0: x, at x=0: obj=0 + # p=0: x, obj=1 set_parameter_value(p, 0.0) optimize!(model) @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - @test objective_value(model) ≈ 0.0 atol = ATOL + @test objective_value(model) ≈ 1.0 atol = ATOL return end -function test_cubic_objective_set_error_on_inner_optimizer() - mock = NoQuadObjModel() - model = POI.Optimizer(mock) +function test_jump_cubic_direct_model_ppp() + model = direct_model(POI.Optimizer(HiGHS.Optimizer())) + set_silent(model) - x = MOI.add_variable(model) - p, _ = MOI.add_constrained_variable(model, MOI.Parameter(2.0)) - p_v = POI.v_idx(POI.p_idx(p)) + @variable(model, x >= 0) + @variable(model, p in MOI.Parameter(2.0)) + @variable(model, q in MOI.Parameter(3.0)) + @variable(model, r in MOI.Parameter(4.0)) - # p * x^2 — parsed successfully but inner optimizer rejects SQF - f = MOI.ScalarNonlinearFunction(:*, Any[1.0, p_v, x, x]) - err = try - MOI.set(model, MOI.ObjectiveFunction{MOI.ScalarNonlinearFunction}(), f) - nothing - catch e - e - end - @test err isa ErrorException - @test occursin("Failed to set cubic objective function", err.msg) - @test occursin("Quadratic objectives not supported", err.msg) + # Minimize: x + p*q*r = x + 24 + @constraint(model, x >= 1) + @objective(model, Min, x + p * q * r) + + optimize!(model) + @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) + @test objective_value(model) ≈ 25.0 atol = ATOL + + set_parameter_value(p, 0.0) + optimize!(model) + @test objective_value(model) ≈ 1.0 atol = ATOL return end From 5805c74cc5cea9044d08d46a86627b9619630f59 Mon Sep 17 00:00:00 2001 From: joaquimg Date: Mon, 16 Feb 2026 02:22:29 -0300 Subject: [PATCH 13/14] simplify tests --- test/test_cubic.jl | 82 +++++----------------------------------------- 1 file changed, 8 insertions(+), 74 deletions(-) diff --git a/test/test_cubic.jl b/test/test_cubic.jl index aeda1c78..4da52914 100644 --- a/test/test_cubic.jl +++ b/test/test_cubic.jl @@ -757,6 +757,13 @@ end # MOI Objective Interface # ============================================================================ +function test_update_cubic_objective_no_cache() + model = POI.Optimizer(HiGHS.Optimizer()) + # No cubic objective set, cache is nothing — should return early + POI._update_cubic_objective!(model) + return +end + function test_cubic_objective_supports() model = POI.Optimizer(HiGHS.Optimizer()) @test MOI.supports( @@ -1134,34 +1141,9 @@ function test_jump_cubic_ppp_same() end # ============================================================================ -# JuMP Integration - Specific term types in objective (pp, pv, p affine) +# JuMP Integration - Specific term types in objective (pp, p affine) # ============================================================================ -function test_jump_cubic_pp_in_objective() - model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) - set_silent(model) - - @variable(model, x >= 0) - @variable(model, p in MOI.Parameter(3.0)) - @variable(model, q in MOI.Parameter(2.0)) - - # Minimize: x + p*q (pp contributes to constant) - # With p=3, q=2: x + 6 - @constraint(model, x >= 1) - @objective(model, Min, x + p * q) - - optimize!(model) - @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - @test objective_value(model) ≈ 7.0 atol = ATOL - - # p=0, q=0: x + 0 - set_parameter_value(p, 0.0) - set_parameter_value(q, 0.0) - optimize!(model) - @test objective_value(model) ≈ 1.0 atol = ATOL - return -end - function test_jump_cubic_with_pp_terms() # pp terms alongside a cubic term (forces ScalarNonlinearFunction path) model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) @@ -1218,54 +1200,6 @@ function test_jump_cubic_with_pp_same_param() return end -function test_jump_cubic_pv_in_objective() - model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) - set_silent(model) - - @variable(model, 0 <= x <= 10) - @variable(model, p in MOI.Parameter(2.0)) - - # Minimize: x^2 + p*x - 4x = x^2 + (p-4)*x - # With p=2: x^2 - 2x, optimal at x=1, obj=-1 - @constraint(model, x >= 0) - @objective(model, Min, x^2 + p * x - 4 * x) - - optimize!(model) - @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - @test objective_value(model) ≈ -1.0 atol = ATOL - @test value(x) ≈ 1.0 atol = ATOL - - # p=6: x^2 + 2x, optimal at x=0, obj=0 - set_parameter_value(p, 6.0) - optimize!(model) - @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - @test objective_value(model) ≈ 0.0 atol = ATOL - @test value(x) ≈ 0.0 atol = ATOL - return -end - -function test_jump_cubic_p_affine_in_objective() - model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) - set_silent(model) - - @variable(model, x >= 0) - @variable(model, p in MOI.Parameter(5.0)) - - # Minimize: x + 3*p (affine in parameter contributes to constant) - # With p=5: x + 15 - @constraint(model, x >= 1) - @objective(model, Min, x + 3 * p) - - optimize!(model) - @test termination_status(model) in (OPTIMAL, LOCALLY_SOLVED) - @test objective_value(model) ≈ 16.0 atol = ATOL - - set_parameter_value(p, 0.0) - optimize!(model) - @test objective_value(model) ≈ 1.0 atol = ATOL - return -end - function test_jump_cubic_p_affine_in_cubic_expr() # p affine terms alongside pvv (forces cubic path) model = Model(() -> POI.Optimizer(HiGHS.Optimizer())) From 8fde4f204c4c94977c8b8dadd20f7eade01e1c23 Mon Sep 17 00:00:00 2001 From: joaquimg Date: Mon, 16 Feb 2026 12:15:06 -0300 Subject: [PATCH 14/14] cleanup --- src/parametric_cubic_function.jl | 53 +++++++++++++++++--------------- 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/src/parametric_cubic_function.jl b/src/parametric_cubic_function.jl index 632542f9..99578e77 100644 --- a/src/parametric_cubic_function.jl +++ b/src/parametric_cubic_function.jl @@ -80,8 +80,9 @@ function ParametricCubicFunction(parsed::_ParsedCubicExpression{T}) where {T} # Find variable pairs related to parameters (from pvv terms) var_pairs_in_param_terms = Set{Tuple{MOI.VariableIndex,MOI.VariableIndex}}() for term in parsed.pvv - v1 = term.index_2 - v2 = term.index_3 + first_is_greater = term.index_2.value > term.index_3.value + v1 = ifelse(first_is_greater, term.index_3, term.index_2) + v2 = ifelse(first_is_greater, term.index_2, term.index_3) push!(var_pairs_in_param_terms, (v1, v2)) end @@ -93,7 +94,9 @@ function ParametricCubicFunction(parsed::_ParsedCubicExpression{T}) where {T} # - Diagonal (v1 == v2): coefficient C means (C/2)*v1^2 (divide by 2) quadratic_data = Dict{Tuple{MOI.VariableIndex,MOI.VariableIndex},T}() for term in parsed.vv - v1, v2 = term.variable_1, term.variable_2 + first_is_greater = term.variable_1.value > term.variable_2.value + v1 = ifelse(first_is_greater, term.variable_2, term.variable_1) + v2 = ifelse(first_is_greater, term.variable_1, term.variable_2) coef = term.coefficient if term.variable_1 == term.variable_2 coef = coef / 2 # Diagonal: undo MOI's factor @@ -234,8 +237,9 @@ function _parametric_quadratic_terms( # Add contributions from pvv cubic terms for term in _cubic_pvv_terms(f) p = term.index_1 - v1 = term.index_2 - v2 = term.index_3 + first_is_greater = term.index_2.value > term.index_3.value + v1 = ifelse(first_is_greater, term.index_3, term.index_2) + v2 = ifelse(first_is_greater, term.index_2, term.index_3) var_pair = (v1, v2) p_val = _effective_param_value(model, p_idx(p)) terms_dict[var_pair] = @@ -311,11 +315,11 @@ function _delta_parametric_constant( # From p terms for term in f.p - pi = p_idx(term.variable) - if haskey(model.updated_parameters, pi) && - !isnan(model.updated_parameters[pi]) - old_val = model.parameters[pi] - new_val = model.updated_parameters[pi] + p_i = p_idx(term.variable) + if haskey(model.updated_parameters, p_i) && + !isnan(model.updated_parameters[p_i]) + old_val = model.parameters[p_i] + new_val = model.updated_parameters[p_i] delta += term.coefficient * (new_val - old_val) end end @@ -394,13 +398,13 @@ function _delta_parametric_affine_terms( # From pv terms (parameter * variable, always off-diagonal) for term in f.pv - pi = p_idx(term.variable_1) - if haskey(model.updated_parameters, pi) && - !isnan(model.updated_parameters[pi]) + p_i = p_idx(term.variable_1) + if haskey(model.updated_parameters, p_i) && + !isnan(model.updated_parameters[p_i]) var = term.variable_2 coef = term.coefficient # Off-diagonal: use as-is - old_val = model.parameters[pi] - new_val = model.updated_parameters[pi] + old_val = model.parameters[p_i] + new_val = model.updated_parameters[p_i] delta_dict[var] = get(delta_dict, var, zero(T)) + coef * (new_val - old_val) end @@ -446,15 +450,16 @@ function _delta_parametric_quadratic_terms( delta_dict = Dict{Tuple{MOI.VariableIndex,MOI.VariableIndex},T}() for term in _cubic_pvv_terms(f) - pi = p_idx(term.index_1) - v1 = term.index_2 - v2 = term.index_3 - - if haskey(model.updated_parameters, pi) && - !isnan(model.updated_parameters[pi]) - var_pair = (v1, v2) - old_val = model.parameters[pi] - new_val = model.updated_parameters[pi] + p_i = p_idx(term.index_1) + first_is_greater = term.index_2.value > term.index_3.value + v1 = ifelse(first_is_greater, term.index_3, term.index_2) + v2 = ifelse(first_is_greater, term.index_2, term.index_3) + var_pair = (v1, v2) + + if haskey(model.updated_parameters, p_i) && + !isnan(model.updated_parameters[p_i]) + old_val = model.parameters[p_i] + new_val = model.updated_parameters[p_i] delta = term.coefficient * (new_val - old_val) delta_dict[var_pair] = get(delta_dict, var_pair, zero(T)) + delta end