From 356752fbcf714a999c6f2d54076fcd4359bea187 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Sun, 4 Oct 2026 11:49:44 +0200 Subject: [PATCH 1/8] Fix arguments of SNF --- src/FileFormats/MOF/write.jl | 22 ++++++++++- test/FileFormats/MOF/test_MOF.jl | 66 ++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/src/FileFormats/MOF/write.jl b/src/FileFormats/MOF/write.jl index 4ebd149617..06ae79664e 100644 --- a/src/FileFormats/MOF/write.jl +++ b/src/FileFormats/MOF/write.jl @@ -408,12 +408,32 @@ function _convert_nonlinear_to_mof( ) node = (type = string(f.head), args = Any[]) for arg in f.args - push!(node[:args], _convert_nonlinear_to_mof(arg, node_list, name_map)) + push!( + node[:args], + _convert_nonlinear_argument_to_mof(arg, node_list, name_map), + ) end push!(node_list, node) return (type = "node", index = length(node_list)) end +@inline function _convert_nonlinear_argument_to_mof( + arg, + node_list::Vector{Any}, + name_map::Dict{MOI.VariableIndex,String}, +) + # Nonlinear arguments are stored as Any. Keep common dispatch targets + # visible to the compiler without restricting the supported arguments. + if arg isa MOI.VariableIndex + return _convert_nonlinear_to_mof(arg, node_list, name_map) + elseif arg isa MOI.ScalarNonlinearFunction + return _convert_nonlinear_to_mof(arg, node_list, name_map) + elseif arg isa Union{Int,Float64} + return _convert_nonlinear_to_mof(arg, node_list, name_map) + end + return _convert_nonlinear_to_mof(arg, node_list, name_map) +end + function _convert_nonlinear_to_mof( variable::MOI.VariableIndex, ::Vector{Any}, diff --git a/test/FileFormats/MOF/test_MOF.jl b/test/FileFormats/MOF/test_MOF.jl index c32145d84b..683230b55e 100644 --- a/test/FileFormats/MOF/test_MOF.jl +++ b/test/FileFormats/MOF/test_MOF.jl @@ -1807,6 +1807,72 @@ function test_nonlinear_variable_complex_nodes() return end +function test_nonlinear_argument_fast_paths() + x = MOI.VariableIndex(1) + name_map = Dict(x => "x") + node_list = Any[] + @test (@inferred MOF._convert_nonlinear_argument_to_mof( + x, + node_list, + name_map, + )) == "x" + for value in (2, 3.0) + @test (@inferred MOF._convert_nonlinear_argument_to_mof( + value, + node_list, + name_map, + )) === value + end + @test isempty(node_list) + f = MOI.ScalarNonlinearFunction( + :+, + Any[MOI.ScalarNonlinearFunction(:sin, Any[x]), 2, 3.0], + ) + object = MOF.moi_to_object(f, name_map) + @test object == ( + type = "ScalarNonlinearFunction", + root = (type = "node", index = 2), + node_list = Any[ + (type = "sin", args = Any["x"]), + (type = "+", args = Any[(type = "node", index = 1), 2, 3.0]), + ], + ) + @test object.node_list[2].args[2] === 2 + @test object.node_list[2].args[3] === 3.0 + return +end + +struct CustomNonlinearArgument <: MOI.AbstractScalarFunction + value::Float64 +end + +function MOF._convert_nonlinear_to_mof( + arg::CustomNonlinearArgument, + ::Vector{Any}, + ::Dict{MOI.VariableIndex,String}, +) + return arg.value +end + +function test_nonlinear_argument_fallback() + name_map = Dict{MOI.VariableIndex,String}() + for (arg, expected) in ( + (2.0f0, 2.0f0), + (2 // 3, 2 // 3), + (2 + 3im, (type = "complex", real = 2, imag = 3)), + (CustomNonlinearArgument(4.0), 4.0), + ) + f = MOI.ScalarNonlinearFunction(:sin, Any[arg]) + object = MOF.moi_to_object(f, name_map) + @test object.root == (type = "node", index = 1) + @test only(object.node_list).args == Any[expected] + @test only(only(object.node_list).args) === expected + end + f = MOI.ScalarNonlinearFunction(:sin, Any[:unsupported_argument]) + @test_throws MethodError MOF.moi_to_object(f, name_map) + return +end + function test_mof_scalaraffinefunction() x = MOI.VariableIndex(1) f = 1.0 * x + 2.0 From 128974f62cf50047002245b0d0b0edb8d64ebfa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Sun, 4 Oct 2026 20:42:07 +0200 Subject: [PATCH 2/8] Variable bounds --- src/FileFormats/MOF/write.jl | 30 +++++++++ test/FileFormats/MOF/test_MOF.jl | 104 +++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) diff --git a/src/FileFormats/MOF/write.jl b/src/FileFormats/MOF/write.jl index 06ae79664e..5c202b15a4 100644 --- a/src/FileFormats/MOF/write.jl +++ b/src/FileFormats/MOF/write.jl @@ -294,6 +294,36 @@ function _write_constraints( return has_scalar_nonlinear end +function _write_constraints( + constraints::Vector{NamedTuple}, + model::Model, + name_map::Dict{MOI.VariableIndex,String}, + storage::MOI.Utilities.VariablesContainer{T}, +) where {T} + function write_bound(::Type{S}) where {S} + return _write_constraints( + constraints, + model, + name_map, + storage, + MOI.VariableIndex, + S, + ) + end + # Match ListOfConstraintTypesPresent order. A tuple of types would erase + # the concrete set types, so call the typed helper for each set directly. + write_bound(MOI.EqualTo{T}) + write_bound(MOI.GreaterThan{T}) + write_bound(MOI.LessThan{T}) + write_bound(MOI.Interval{T}) + write_bound(MOI.Semicontinuous{T}) + write_bound(MOI.Semiinteger{T}) + write_bound(MOI.Integer) + write_bound(MOI.ZeroOne) + write_bound(MOI.Parameter{T}) + return false +end + function _write_constraints( constraints::Vector{NamedTuple}, model::Model, diff --git a/test/FileFormats/MOF/test_MOF.jl b/test/FileFormats/MOF/test_MOF.jl index 683230b55e..324c18f558 100644 --- a/test/FileFormats/MOF/test_MOF.jl +++ b/test/FileFormats/MOF/test_MOF.jl @@ -1562,6 +1562,110 @@ function test_write_constraints_storage_traversal() return end +function test_write_constraints_variable_bounds() + for T in (Float32, Float64) + model = MOF.Model(; coefficient_type = T) + x = MOI.add_variables(model, 10) + name_map = Dict(x[i] => "x$i" for i in eachindex(x)) + for (variable, name) in name_map + MOI.set(model, MOI.VariableName(), variable, name) + end + sets = ( + MOI.EqualTo(T(1)), + MOI.GreaterThan(T(2)), + MOI.LessThan(T(3)), + MOI.Interval(T(4), T(5)), + MOI.Semicontinuous(T(6), T(7)), + MOI.Semiinteger(T(8), T(9)), + MOI.Integer(), + MOI.ZeroOne(), + MOI.Parameter(T(10)), + ) + # Neither insertion order nor variable order is the traversal order. + for i in reverse(eachindex(sets)) + MOI.add_constraint(model, x[i], sets[i]) + end + MOI.add_constraint(model, x[10], MOI.GreaterThan(T(-1))) + bound = MOI.add_constraint(model, x[2], MOI.LessThan(T(11))) + # Starts are stored by UniversalFallback, not VariablesContainer. + MOI.set(model, MOI.ConstraintPrimalStart(), bound, T(3)) + MOI.set(model, MOI.ConstraintDualStart(), bound, T(-4)) + constraints = NamedTuple[] + @test !(@inferred MOF._write_constraints( + constraints, + model, + name_map, + model.model.variables, + )) + @test constraints == _constraint_objects_reference(model, name_map) + @test [object.set.type for object in constraints] == [ + "EqualTo", + "GreaterThan", + "GreaterThan", + "LessThan", + "LessThan", + "Interval", + "Semicontinuous", + "Semiinteger", + "Integer", + "ZeroOne", + "Parameter", + ] + @test [object[:function].name for object in constraints] == [ + "x1", + "x2", + "x10", + "x2", + "x3", + "x4", + "x5", + "x6", + "x7", + "x8", + "x9", + ] + @test all( + value isa T for object in constraints for + value in Base.tail(values(object.set)) + ) + @test constraints[4].primal_start === T(3) + @test constraints[4].dual_start === T(-4) + @test !haskey(constraints[2], :primal_start) + object = JSON.parse(sprint(write, model)) + @test object["constraints"] == JSON.parse(JSON.json(constraints)) + @test !haskey(object, "has_scalar_nonlinear") + end + return +end + +function test_write_constraints_variable_bounds_deleted() + model = MOF.Model() + name_map = Dict{MOI.VariableIndex,String}() + constraints = NamedTuple[] + @test !MOF._write_constraints( + constraints, + model, + name_map, + model.model.variables, + ) + @test isempty(constraints) + x, y = MOI.add_variables(model, 2) + name_map[x], name_map[y] = "x", "y" + MOI.add_constraint(model, x, MOI.EqualTo(1.0)) + lower = MOI.add_constraint(model, y, MOI.GreaterThan(0.0)) + upper = MOI.add_constraint(model, y, MOI.LessThan(2.0)) + MOI.delete(model, x) + MOI.delete(model, upper) + @test !MOF._write_constraints(constraints, model, name_map) + @test constraints == _constraint_objects_reference(model, name_map) + @test constraints == [MOF.moi_to_object(lower, model, name_map)] + MOI.delete(model, lower) + empty!(constraints) + @test !MOF._write_constraints(constraints, model, name_map) + @test isempty(constraints) + return +end + function test_write_constraints_empty_nonlinear_storage() # Both concrete inner stores and fallback stores retain their type after # deleting the final constraint. Neither should set has_scalar_nonlinear. From ddea302f772f6d173c2971d55acfa71193dcd06f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Wed, 7 Oct 2026 13:33:44 +0200 Subject: [PATCH 3/8] document layout --- src/FileFormats/MOF/write.jl | 33 ++++++++++++++++-------- test/FileFormats/MOF/test_MOF.jl | 43 ++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 11 deletions(-) diff --git a/src/FileFormats/MOF/write.jl b/src/FileFormats/MOF/write.jl index 5c202b15a4..e21067663d 100644 --- a/src/FileFormats/MOF/write.jl +++ b/src/FileFormats/MOF/write.jl @@ -24,18 +24,29 @@ function Base.write(io::IO, model::Model) objective, has_scalar_nonlinear = _write_objective(model, name_map) end has_scalar_nonlinear |= _write_constraints(constraints, model, name_map) - object = (; - name = "MathOptFormat Model", - version = ( - major = Int(_SUPPORTED_VERSIONS[1].major), - minor = Int(_SUPPORTED_VERSIONS[1].minor), - ), - variables = variables, - objective = objective, - constraints = constraints, + version = ( + major = Int(_SUPPORTED_VERSIONS[1].major), + minor = Int(_SUPPORTED_VERSIONS[1].minor), ) - if has_scalar_nonlinear - object = (; has_scalar_nonlinear = true, object...) + # Construct both layouts directly instead of merging a tuple whose + # objective type may not be inferred. + object = if has_scalar_nonlinear + (; + has_scalar_nonlinear = true, + name = "MathOptFormat Model", + version = version, + variables = variables, + objective = objective, + constraints = constraints, + ) + else + (; + name = "MathOptFormat Model", + version = version, + variables = variables, + objective = objective, + constraints = constraints, + ) end Base.write(io, JSON.json(object)) return diff --git a/test/FileFormats/MOF/test_MOF.jl b/test/FileFormats/MOF/test_MOF.jl index 324c18f558..92b73edbd0 100644 --- a/test/FileFormats/MOF/test_MOF.jl +++ b/test/FileFormats/MOF/test_MOF.jl @@ -457,6 +457,49 @@ function test_FEASIBILITY_SENSE() return MOI.Test.util_test_models_equal(model, model_2, ["x"], String[]) end +function test_write_document_layout() + for mode in + (:feasibility, :linear, :nonlinear_objective, :nonlinear_constraint) + model = MOF.Model() + x = MOI.add_variable(model) + MOI.set(model, MOI.VariableName(), x, "x") + name_map = Dict(x => "x") + nonlinear = MOI.ScalarNonlinearFunction(:sin, Any[x]) + objective = (sense = "feasibility",) + if mode != :feasibility + f = mode == :nonlinear_objective ? nonlinear : 1.0 * x + MOI.set(model, MOI.ObjectiveSense(), MOI.MIN_SENSE) + MOI.set(model, MOI.ObjectiveFunction{typeof(f)}(), f) + objective = + (; :sense => "min", :function => MOF.moi_to_object(f, name_map)) + end + constraints = NamedTuple[] + if mode == :nonlinear_constraint + c = MOI.add_constraint(model, nonlinear, MOI.LessThan(1.0)) + MOI.set(model, MOI.ConstraintName(), c, "c") + push!(constraints, MOF.moi_to_object(c, model, name_map)) + end + version = MOF._SUPPORTED_VERSIONS[1] + expected = ( + name = "MathOptFormat Model", + version = (major = Int(version.major), minor = Int(version.minor)), + variables = [(name = "x",)], + objective = objective, + constraints = constraints, + ) + has_nonlinear = mode in (:nonlinear_objective, :nonlinear_constraint) + if has_nonlinear + expected = (; has_scalar_nonlinear = true, expected...) + end + # Compare bytes as well as the flag, including root field order. + contents = sprint(write, model) + @test contents == JSON.json(expected) + @test haskey(JSON.parse(contents), "has_scalar_nonlinear") == + has_nonlinear + end + return +end + function test_empty_function_term() model = MOF.Model() x = MOI.add_variable(model) From f0e83959f2b798683a5fc319ae880c03b520f7b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Thu, 8 Oct 2026 12:01:21 +0200 Subject: [PATCH 4/8] JSON fix --- Project.toml | 2 +- src/FileFormats/MOF/write.jl | 41 +++++++++++++++++++-- test/FileFormats/MOF/test_MOF.jl | 62 ++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 4 deletions(-) diff --git a/Project.toml b/Project.toml index ac81ff2b40..c3d7af0fd5 100644 --- a/Project.toml +++ b/Project.toml @@ -31,7 +31,7 @@ CliqueTrees = "1.17.1" CodecBzip2 = "0.6, 0.7, 0.8" CodecZlib = "0.6, 0.7" ForwardDiff = "1" -JSON = "0.21, 1" +JSON = "1" JSONSchema = "1" JuliaC = "0.3" LinearAlgebra = "1" diff --git a/src/FileFormats/MOF/write.jl b/src/FileFormats/MOF/write.jl index e21067663d..83c76cc36a 100644 --- a/src/FileFormats/MOF/write.jl +++ b/src/FileFormats/MOF/write.jl @@ -48,10 +48,44 @@ function Base.write(io::IO, model::Model) constraints = constraints, ) end - Base.write(io, JSON.json(object)) + tree = _json_tree(object)::JSON.Object{String,Any} + Base.write(io, JSON.json(tree)) return end +# Keep object shapes in data, rather than in NamedTuple types, so JSON can +# traverse heterogeneous records with its existing Any-value fast paths. +@noinline function _json_tree(@nospecialize(value)) + if value isa NamedTuple + object = JSON.Object{String,Any}() + # Inline accessors so trimming does not need the concrete record type. + names = @inline keys(value) + for i in 1:nfields(value) + name = getfield(names, i) + object[String(name)] = _json_tree(getfield(value, i)) + end + return object + elseif value isa Vector && !(value isa AbstractVector{<:Pair}) + # Vectors of pairs are JSON objects, not arrays. + array = Any[] + sizehint!(array, length(value)) + for i in eachindex(value) + # Like JSON, write undefined array entries as null. + item = try + @inline @inbounds value[i] + catch err + if !(err isa UndefRefError) + rethrow() + end + nothing + end + push!(array, _json_tree(item)) + end + return array + end + return value +end + function _write_variables(variables::Vector{NamedTuple}, model::Model) name_map = Dict{MOI.VariableIndex,String}() for index in MOI.get(model, MOI.ListOfVariableIndices()) @@ -394,11 +428,12 @@ function moi_to_object( end push!(pairs, :function => moi_to_object(func, name_map)) push!(pairs, :set => moi_to_object(set, name_map)) + # Start values have type Any, so construct the destination pair directly. if !isnothing(dual_start) - push!(pairs, :dual_start => dual_start) + push!(pairs, Pair{Symbol,Any}(:dual_start, dual_start)) end if !isnothing(primal_start) - push!(pairs, :primal_start => primal_start) + push!(pairs, Pair{Symbol,Any}(:primal_start, primal_start)) end return NamedTuple(pairs) end diff --git a/test/FileFormats/MOF/test_MOF.jl b/test/FileFormats/MOF/test_MOF.jl index 92b73edbd0..5c4d2c7d91 100644 --- a/test/FileFormats/MOF/test_MOF.jl +++ b/test/FileFormats/MOF/test_MOF.jl @@ -500,6 +500,68 @@ function test_write_document_layout() return end +struct JSONNumber <: Number + text::String +end + +JSON.tostring(value::JSONNumber) = value.text + +function test_json_tree_nested_records() + object = (z = (b = Any[(last = 1,), (first = "x",)], a = 2), m = [3, 4]) + tree = MOF._json_tree(object) + @test tree isa JSON.Object{String,Any} + @test tree["z"] isa JSON.Object{String,Any} + @test tree["z"]["b"] isa Vector{Any} + @test all(item -> item isa JSON.Object{String,Any}, tree["z"]["b"]) + @test tree["m"] isa Vector{Any} + @test JSON.json(tree) == JSON.json(object) + @test JSON.json(tree) == + "{\"z\":{\"b\":[{\"last\":1},{\"first\":\"x\"}],\"a\":2},\"m\":[3,4]}" + @test isempty(MOF._json_tree((;))) + @test MOF._json_tree(NamedTuple[]) == Any[] + return +end + +function test_json_tree_preserves_values() + values = Any[ + nothing, + true, + 2, + Float32(1.5), + big"123456789012345678901234567890", + big"1.234567890123456789", + 1//3, + 1+2im, + JSONNumber("0.125"), + "quoted \" Unicode ☃", + :symbol, + ] + object = (values = values,) + tree = MOF._json_tree(object) + for (original, normalized) in zip(values, tree["values"]) + @test normalized === original + end + @test JSON.json(tree) == JSON.json(object) + @test JSON.json(tree["values"][9]) == "0.125" + return +end + +function test_json_tree_vector_edge_cases() + values = Vector{Any}(undef, 2) + values[2] = (value = 3,) + tree = MOF._json_tree(values) + @test tree[1] === nothing + @test tree[2] isa JSON.Object{String,Any} + @test !isassigned(values, 1) + @test JSON.json(tree) == JSON.json(values) == "[null,{\"value\":3}]" + pairs = ["z" => (b = 1,), "a" => (value = 2,)] + @test MOF._json_tree(pairs) === pairs + @test JSON.json(MOF._json_tree((values = pairs,))) == + JSON.json((values = pairs,)) + @test JSON.json(pairs) == "{\"z\":{\"b\":1},\"a\":{\"value\":2}}" + return +end + function test_empty_function_term() model = MOF.Model() x = MOI.add_variable(model) From 93f4117e6431245f5b5f1359ac803e57618e777b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Thu, 8 Oct 2026 15:01:26 +0200 Subject: [PATCH 5/8] Add missing mutable copy --- src/Utilities/mutable_arithmetics.jl | 4 ++ test/Utilities/test_mutable_arithmetics.jl | 62 ++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/src/Utilities/mutable_arithmetics.jl b/src/Utilities/mutable_arithmetics.jl index c8c8019db3..ed37a2996a 100644 --- a/src/Utilities/mutable_arithmetics.jl +++ b/src/Utilities/mutable_arithmetics.jl @@ -37,6 +37,10 @@ function MA.mutable_copy(func::MOI.ScalarQuadraticFunction) ) end +function MA.mutable_copy(func::TypedVectorLike) + return operate_coefficients(MA.copy_if_mutable, func) +end + function MA.isequal_canonical( f::F, g::F, diff --git a/test/Utilities/test_mutable_arithmetics.jl b/test/Utilities/test_mutable_arithmetics.jl index 33a30d72c5..6e8aff4263 100644 --- a/test/Utilities/test_mutable_arithmetics.jl +++ b/test/Utilities/test_mutable_arithmetics.jl @@ -96,6 +96,68 @@ end test_scaling_Float64() = _test_scaling(Float64) test_scaling_Float32() = _test_scaling(Float32) +function _test_vector_mutable_copy(f, term_fields) + for copy_function in (MA.mutable_copy, MA.copy_if_mutable) + g = @inferred copy_function(f) + @test g isa typeof(f) + @test g ≈ f + @test g.constants !== f.constants + coefficients = copy(f.constants) + copied_coefficients = copy(g.constants) + for field in term_fields + terms = getfield(f, field) + copied_terms = getfield(g, field) + @test copied_terms !== terms + @test MOI.term_indices.(copied_terms) == MOI.term_indices.(terms) + append!(coefficients, MOI.coefficient.(terms)) + append!(copied_coefficients, MOI.coefficient.(copied_terms)) + end + for (coefficient, copied_coefficient) in + zip(coefficients, copied_coefficients) + @test copied_coefficient == coefficient + if MA.mutability(typeof(coefficient)) == MA.IsMutable() + @test copied_coefficient !== coefficient + MA.operate!(*, copied_coefficient, 2) + @test copied_coefficient == 2 * coefficient + end + end + end + return +end + +function test_mutable_copy_VectorAffineFunction() + x = MOI.VariableIndex(1) + for T in (Float64, BigInt, BigFloat) + f = MOI.VectorAffineFunction( + [MOI.VectorAffineTerm(2, MOI.ScalarAffineTerm(T(2), x))], + T[3, 4], + ) + _test_vector_mutable_copy(f, (:terms,)) + f = MOI.VectorAffineFunction(MOI.VectorAffineTerm{T}[], T[]) + _test_vector_mutable_copy(f, (:terms,)) + end + return +end + +function test_mutable_copy_VectorQuadraticFunction() + x, y = MOI.VariableIndex(1), MOI.VariableIndex(2) + for T in (Float64, BigInt, BigFloat) + f = MOI.VectorQuadraticFunction( + [MOI.VectorQuadraticTerm(2, MOI.ScalarQuadraticTerm(T(2), x, y))], + [MOI.VectorAffineTerm(1, MOI.ScalarAffineTerm(T(3), y))], + T[4, 5], + ) + _test_vector_mutable_copy(f, (:quadratic_terms, :affine_terms)) + f = MOI.VectorQuadraticFunction( + MOI.VectorQuadraticTerm{T}[], + MOI.VectorAffineTerm{T}[], + T[], + ) + _test_vector_mutable_copy(f, (:quadratic_terms, :affine_terms)) + end + return +end + function test_unary_minus() for T in [Float64, Float32] x = MOI.VariableIndex(1) From 11e8e58e783321b6bc06d42fc906f3505b05ffc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Thu, 8 Oct 2026 15:51:11 +0200 Subject: [PATCH 6/8] Temporarily disable ScalarNonlinearFunction copying for JuliaC --- src/functions.jl | 20 +++----------------- test/General/test_functions.jl | 28 +++++++++++++++++----------- 2 files changed, 20 insertions(+), 28 deletions(-) diff --git a/src/functions.jl b/src/functions.jl index 2b798ed0ab..1985fd35f7 100644 --- a/src/functions.jl +++ b/src/functions.jl @@ -350,24 +350,10 @@ struct ScalarNonlinearFunction <: AbstractScalarFunction end end -# copy() doesn't recursively copy the children, and deepcopy seems to have a -# performance problem for deeply nested structs. +# Temporary JuliaC experiment: share nonlinear graphs instead of copying them. +# Remove this override before merging the general JuliaC improvements. function Base.copy(f::ScalarNonlinearFunction) - result = ScalarNonlinearFunction(f.head, similar(f.args)) - stack = Tuple{ScalarNonlinearFunction,ScalarNonlinearFunction}[(f, result)] - while !isempty(stack) - source, destination = pop!(stack) - for (i, arg) in enumerate(source.args) - if arg isa ScalarNonlinearFunction - child = ScalarNonlinearFunction(arg.head, similar(arg.args)) - destination.args[i] = child - push!(stack, (arg, child)) - else - destination.args[i] = copy(arg) - end - end - end - return result + return f end constant(f::ScalarNonlinearFunction, ::Type{T} = Float64) where {T} = zero(T) diff --git a/test/General/test_functions.jl b/test/General/test_functions.jl index 66ffc197c5..ea78b6c58d 100644 --- a/test/General/test_functions.jl +++ b/test/General/test_functions.jl @@ -562,6 +562,8 @@ function test_convert_VectorNonlinearFunction_VectorQuadraticFunction() return end +# Temporary JuliaC experiment: nonlinear copy returns the original function, +# so these tests deliberately check shared mutable storage. function test_copy_ScalarNonlinearFunction() N = 10_000 x = MOI.VariableIndex.(1:N) @@ -576,10 +578,11 @@ function test_copy_ScalarNonlinearFunction() f2 = MOI.ScalarNonlinearFunction(:+, Any[f2, g]) end f_copy = @inferred copy(f1) + @test f_copy === f1 @test ≈(f_copy, f2) f1.args[2].args[2] = 2.0 # x[1]^1 --> x[1]^2 - @test !isapprox(f_copy, f1) - @test isapprox(f_copy, f2) + @test isapprox(f_copy, f1) + @test !isapprox(f_copy, f2) return end @@ -597,10 +600,11 @@ function test_copy_ScalarNonlinearFunction_with_arg() f2 = MOI.ScalarNonlinearFunction(:+, Any[f2, g]) end f_copy = @inferred copy(f1) + @test f_copy === f1 @test ≈(f_copy, f2) f1.args[2].constant += 1 - @test !isapprox(f_copy, f1) - @test isapprox(f_copy, f2) + @test isapprox(f_copy, f1) + @test !isapprox(f_copy, f2) return end @@ -618,24 +622,26 @@ function test_copy_ScalarNonlinearFunction_leaf_types() Any[child, 1.0f0, big"4.0", 1//5, big"6"], ) g = @inferred copy(f) + @test g === f @test g ≈ f - @test g.args !== f.args - @test g.args[1].args !== child.args - @test g.args[1].args[1].terms !== affine.terms - @test g.args[1].args[2].quadratic_terms !== quadratic.quadratic_terms + @test g.args === f.args + @test g.args[1].args === child.args + @test g.args[1].args[1].terms === affine.terms + @test g.args[1].args[2].quadratic_terms === quadratic.quadratic_terms @test typeof(g.args[2]) == Float32 @test typeof(g.args[3]) == BigFloat @test typeof(g.args[4]) == Rational{Int} @test typeof(g.args[5]) == BigInt affine.constant += 1 quadratic.constant += 1 - @test g.args[1].args[1].constant == 3 - @test g.args[1].args[2].constant == 1 // 2 + @test g.args[1].args[1].constant == 4 + @test g.args[1].args[2].constant == 3 // 2 empty_f = MOI.ScalarNonlinearFunction(:+, Any[]) empty_g = @inferred copy(empty_f) + @test empty_g === empty_f @test empty_g.head == :+ @test isempty(empty_g.args) - @test empty_g.args !== empty_f.args + @test empty_g.args === empty_f.args return end From 3a0e9095c3c025db9fe9506c2e33e8053861e122 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Thu, 8 Oct 2026 16:04:32 +0200 Subject: [PATCH 7/8] Temporarily skip nonlinear canonicality checks for JuliaC --- src/Utilities/functions.jl | 19 +++---------------- test/Utilities/test_functions.jl | 17 +++++++++++++++-- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/Utilities/functions.jl b/src/Utilities/functions.jl index ba69f0893e..2ff03ad6a8 100644 --- a/src/Utilities/functions.jl +++ b/src/Utilities/functions.jl @@ -994,22 +994,9 @@ is_canonical(::MOI.AbstractFunction) = false is_canonical(::Union{MOI.VariableIndex,MOI.VectorOfVariables}) = true -function is_canonical(f::MOI.ScalarNonlinearFunction) - # Don't use recursion here. This gets called for all scalar nonlinear - # constraints. - stack = Any[arg for arg in f.args] - while !isempty(stack) - arg = pop!(stack) - if arg isa MOI.ScalarNonlinearFunction - for a in arg.args - push!(stack, a) - end - else - if !is_canonical(arg) - return false - end - end - end +# Temporary JuliaC workaround: skip checking nonlinear argument canonicality. +# Remove this override before merging the general JuliaC improvements. +function is_canonical(::MOI.ScalarNonlinearFunction) return true end diff --git a/test/Utilities/test_functions.jl b/test/Utilities/test_functions.jl index b49427bf65..587f252d0f 100644 --- a/test/Utilities/test_functions.jl +++ b/test/Utilities/test_functions.jl @@ -2074,10 +2074,23 @@ function test_ScalarNonlinearFunction_is_canonical() f = MOI.ScalarNonlinearFunction(:log, Any[1.0*x]) @test MOI.Utilities.is_canonical(f) g = MOI.ScalarNonlinearFunction(:log, Any[1.0*x+1.0*x]) - @test !MOI.Utilities.is_canonical(g) + # Temporary JuliaC workaround treats nonlinear trees as canonical. + @test MOI.Utilities.is_canonical(g) MOI.Utilities.canonicalize!(g) @test MOI.Utilities.is_canonical(g) @test g.args[1] ≈ 2.0 * x + affine = 1.0 * x + 1.0 * x + terms = copy(affine.terms) + nested = MOI.ScalarNonlinearFunction( + :log, + Any[MOI.ScalarNonlinearFunction(:+, Any[affine, 1.0])], + ) + @test !MOI.Utilities.is_canonical(affine) + @test (@inferred MOI.Utilities.is_canonical(nested)) + @test MOI.Utilities.canonical(nested) === nested + @test nested.args[1].args[1] === affine + @test affine.terms == terms + @test !MOI.Utilities.is_canonical(affine) f = MOI.ScalarNonlinearFunction(:^, Any[x, 2]) @test MOI.Utilities.is_canonical(f) # Test deep recursion @@ -2090,7 +2103,7 @@ function test_ScalarNonlinearFunction_is_canonical() for _ in 1:100_000 g = MOI.ScalarNonlinearFunction(:sin, Any[g]) end - @test !MOI.Utilities.is_canonical(g) + @test MOI.Utilities.is_canonical(g) MOI.Utilities.canonicalize!(g) h = MOI.ScalarNonlinearFunction(:^, Any[2.0*x, 2]) for _ in 1:100_000 From b430f362645cfc164474055c25c8a46928356466 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Legat?= Date: Thu, 8 Oct 2026 16:57:28 +0200 Subject: [PATCH 8/8] no -> unsafe-warn --- test/JuliaC/test_juliac.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/JuliaC/test_juliac.jl b/test/JuliaC/test_juliac.jl index 5130bb520d..ff5cdd1851 100644 --- a/test/JuliaC/test_juliac.jl +++ b/test/JuliaC/test_juliac.jl @@ -30,7 +30,7 @@ function compile() image_recipe = JuliaC.ImageRecipe( output_type = "--output-exe", file = joinpath(@__DIR__, "MyApp"), - trim_mode = "no", + trim_mode = "unsafe-warn", add_ccallables = false, verbose = true, )