From ae2a792f9722090da571e19117d18795aa6b0534 Mon Sep 17 00:00:00 2001 From: Rafael Benchimol Klausner Date: Wed, 11 Mar 2026 21:47:32 -0300 Subject: [PATCH 1/2] Add testing, CI/CD, coverage, documentation, and license - Add MIT license - Create comprehensive test suite (121 tests) using Test stdlib with Pkg.test() support - Add GitHub Actions CI workflow (Julia 1.11 + nightly, ubuntu/windows/macos, coverage via Codecov) - Set up Documenter.jl with API reference and usage docs - Add docstrings to all exported functions and structs - Fix stale read_from_file export to readmodel, export all public structs - Add CI/coverage/docs badges to README, fix usage example to use positional args - Update .gitignore for docs build artifacts Co-Authored-By: Claude Opus 4.6 --- .github/workflows/CI.yml | 64 +++++++ .gitignore | 4 +- LICENSE | 21 +++ Project.toml | 6 + README.md | 10 +- docs/Project.toml | 3 + docs/make.jl | 16 ++ docs/src/api.md | 51 ++++++ docs/src/index.md | 64 +++++++ src/ModelCompare.jl | 9 +- src/compare.jl | 14 ++ src/constraints.jl | 29 ++- src/expression.jl | 17 ++ src/objective.jl | 14 ++ src/sort.jl | 6 + src/utils.jl | 5 + src/variables.jl | 15 ++ test/runtests.jl | 375 +++++++++++++++++++++++++++++++++++++++ 18 files changed, 714 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/CI.yml create mode 100644 LICENSE create mode 100644 docs/Project.toml create mode 100644 docs/make.jl create mode 100644 docs/src/api.md create mode 100644 docs/src/index.md create mode 100644 test/runtests.jl diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml new file mode 100644 index 0000000..919f98b --- /dev/null +++ b/.github/workflows/CI.yml @@ -0,0 +1,64 @@ +name: CI +on: + push: + branches: [master] + tags: ["*"] + pull_request: + branches: [master] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ startsWith(github.ref, 'refs/pull/') }} + +jobs: + test: + name: Julia ${{ matrix.version }} - ${{ matrix.os }} - ${{ matrix.arch }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + version: + - "1.11" + - nightly + os: + - ubuntu-latest + - windows-latest + - macos-latest + arch: + - x64 + steps: + - uses: actions/checkout@v4 + - uses: julia-actions/setup-julia@v2 + with: + version: ${{ matrix.version }} + arch: ${{ matrix.arch }} + - uses: julia-actions/cache@v2 + - uses: julia-actions/julia-buildpkg@v1 + - uses: julia-actions/julia-runtest@v1 + - uses: julia-actions/julia-processcoverage@v1 + if: matrix.version == '1.11' && matrix.os == 'ubuntu-latest' + - uses: codecov/codecov-action@v5 + if: matrix.version == '1.11' && matrix.os == 'ubuntu-latest' + with: + files: lcov.info + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + docs: + name: Documentation + runs-on: ubuntu-latest + permissions: + contents: write + statuses: write + steps: + - uses: actions/checkout@v4 + - uses: julia-actions/setup-julia@v2 + with: + version: "1.11" + - uses: julia-actions/cache@v2 + - name: Install dependencies + run: julia --project=docs -e 'using Pkg; Pkg.develop(PackageSpec(path=pwd())); Pkg.instantiate()' + - name: Build and deploy + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: julia --project=docs docs/make.jl diff --git a/.gitignore b/.gitignore index 8751010..93c081e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /compile/builddir/ compile/builddir/ -/compile/builddir/*.* \ No newline at end of file +/compile/builddir/*.* +docs/build/ +docs/Manifest.toml \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0365594 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024-2026 PSR / rafabench + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Project.toml b/Project.toml index b479ddf..2d61f6f 100644 --- a/Project.toml +++ b/Project.toml @@ -13,3 +13,9 @@ ArgParse = "1" MathOptInterface = "1" ProgressMeter = "1" julia = "1.11" + +[extras] +Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" + +[targets] +test = ["Test"] diff --git a/README.md b/README.md index dadab32..cd90de3 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ ## ModelCompare +[![CI](https://github.com/rafabench/ModelCompare/actions/workflows/CI.yml/badge.svg)](https://github.com/rafabench/ModelCompare/actions/workflows/CI.yml) +[![codecov](https://codecov.io/gh/rafabench/ModelCompare/branch/master/graph/badge.svg)](https://codecov.io/gh/rafabench/ModelCompare) +[![docs-dev](https://img.shields.io/badge/docs-dev-blue.svg)](https://rafabench.github.io/ModelCompare/dev/) + The purpose of this project is to compare two optimization models in two different files. ### Running @@ -9,9 +13,9 @@ You can compare them by running: ```julia compare_models( - file1 = "test/models/model1.lp", - file2 = "test/models/model2.lp", - outfile = "test/models/compare_lp.txt", + "test/models/model1.lp", + "test/models/model2.lp", + outfile = "test/models/compare_lp.txt", tol = 1e-3 ) ``` diff --git a/docs/Project.toml b/docs/Project.toml new file mode 100644 index 0000000..3a2fe41 --- /dev/null +++ b/docs/Project.toml @@ -0,0 +1,3 @@ +[deps] +Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" +ModelCompare = "1a876020-4912-11ea-1b46-d176d33439c0" diff --git a/docs/make.jl b/docs/make.jl new file mode 100644 index 0000000..7d191c4 --- /dev/null +++ b/docs/make.jl @@ -0,0 +1,16 @@ +using Documenter +using ModelCompare + +makedocs( + sitename = "ModelCompare.jl", + modules = [ModelCompare], + pages = [ + "Home" => "index.md", + "API Reference" => "api.md", + ], +) + +deploydocs( + repo = "github.com/rafabench/ModelCompare.git", + devbranch = "master", +) diff --git a/docs/src/api.md b/docs/src/api.md new file mode 100644 index 0000000..2967996 --- /dev/null +++ b/docs/src/api.md @@ -0,0 +1,51 @@ +# API Reference + +## Main Entry Point + +```@docs +compare_models +``` + +## Variables + +```@docs +VariablesDiff +compare_variables +``` + +## Bounds + +```@docs +BoundsDiff +compare_bounds +``` + +## Objective + +```@docs +ObjectiveDiff +compare_objective +``` + +## Expressions + +```@docs +ExpressionDiff +compare_expressions +``` + +## Constraints + +```@docs +ConstraintNamesDiff +ConstraintElementsDiff +compare_constraints +``` + +## Utilities + +```@docs +readmodel +partition +sort_model +``` diff --git a/docs/src/index.md b/docs/src/index.md new file mode 100644 index 0000000..5ccb305 --- /dev/null +++ b/docs/src/index.md @@ -0,0 +1,64 @@ +# ModelCompare.jl + +A Julia package for comparing two optimization models (LP/MPS format) and producing a detailed diff report covering variables, bounds, objective function, and constraints. + +## Installation + +```julia +using Pkg +Pkg.add(url="https://github.com/rafabench/ModelCompare") +``` + +## Quick Start + +```julia +using ModelCompare + +result = compare_models( + "model1.lp", + "model2.lp", + outfile = "comparison.txt", + tol = 1e-3, +) +``` + +The result is a `NamedTuple` with four fields: + +- `variables` — a [`VariablesDiff`](@ref) showing which variables are unique to each model vs shared +- `bounds` — a [`BoundsDiff`](@ref) showing variable bound differences +- `objective` — an [`ObjectiveDiff`](@ref) showing objective sense and coefficient differences +- `constraints` — a [`ConstraintElementsDiff`](@ref) showing constraint coefficient and bound differences + +## Output Sections + +### Variable Names + +Lists variables that belong only to Model 1, only to Model 2, or both. + +### Variable Bounds + +Shows bound differences for shared variables and bounds for variables unique to each model. + +### Objective Function + +Shows coefficient differences in the objective function. If the optimization senses differ (min vs max), that is reported as well. + +### Constraints + +Compares constraints **with matching names** across both models. Unmatched constraint names are not reported. For each matched constraint, coefficient and set (bound) differences are shown. + +## Sorting Models + +You can canonicalize a model file by sorting its variables and constraints alphabetically: + +```julia +sort_model("model.lp") # creates model.lp.sorted +``` + +## CLI Usage + +After compilation (see `compile/compile.jl`): + +```bash +ModelCompare --file1 model1.lp --file2 model2.lp -o result.txt -t 0.001 +``` diff --git a/src/ModelCompare.jl b/src/ModelCompare.jl index 3a4db79..e70caa1 100644 --- a/src/ModelCompare.jl +++ b/src/ModelCompare.jl @@ -1,6 +1,11 @@ module ModelCompare -export read_from_file, compare_variables, compare_expressions, compare_objective, compare_bounds, compare_constraints, compare_models +export readmodel, partition, + compare_variables, compare_expressions, compare_objective, + compare_bounds, compare_constraints, compare_models, + sort_model, + VariablesDiff, BoundsDiff, ExpressionDiff, ObjectiveDiff, + ConstraintNamesDiff, ConstraintElementsDiff using MathOptInterface using ArgParse @@ -20,6 +25,4 @@ include("args.jl") include("lp_write_moi.jl") include("sort.jl") -export compare_models, sort_model - end diff --git a/src/compare.jl b/src/compare.jl index d3a44eb..55368bf 100644 --- a/src/compare.jl +++ b/src/compare.jl @@ -78,6 +78,20 @@ function print_compare(outfile::String, end end +""" + compare_models(file1, file2; tol, outfile, verbose=true, one_by_one=true, separate_files=false) + +Compare two optimization model files (LP or MPS format) and write a detailed +diff report to `outfile`. Returns a `NamedTuple` with fields `variables`, +`bounds`, `objective`, and `constraints`. + +# Arguments +- `file1::String`: path to the first model file. +- `file2::String`: path to the second model file. +- `tol::Float64`: absolute tolerance for numerical comparisons. +- `outfile::String`: path to the output report file. +- `separate_files::Bool`: if `true`, write each section to a separate file. +""" function compare_models(file1, file2; tol :: Float64, outfile :: String, diff --git a/src/constraints.jl b/src/constraints.jl index af3ad3b..8d31f92 100644 --- a/src/constraints.jl +++ b/src/constraints.jl @@ -1,11 +1,30 @@ +""" + ConstraintNamesDiff + +Result of comparing constraint names between two models. + +# Fields +- `in_both::Vector{String}`: constraint names present in both models. +- `only_one::Vector{String}`: constraint names unique to the first model. +- `only_two::Vector{String}`: constraint names unique to the second model. +""" struct ConstraintNamesDiff in_both :: Vector{String} only_one :: Vector{String} only_two :: Vector{String} end -# Compare expression of constraints and objective function -# Then, compare bounds of constraints +""" + ConstraintElementsDiff + +Result of comparing constraint coefficients and bounds between two models. + +# Fields +- `equal::Vector{String}`: constraints that are identical (within tolerance). +- `both::Dict`: constraints present in both models with differences — maps name to `(ExpressionDiff, (set1, set2))`. +- `first::Dict`: constraints unique to the first model — maps name to `(coefficients, bounds)`. +- `second::Dict`: constraints unique to the second model — maps name to `(coefficients, bounds)`. +""" struct ConstraintElementsDiff equal::Vector{String} both::Dict{String, Tuple{ExpressionDiff, Tuple{Tuple{Float64, Float64}, Tuple{Float64, Float64}}}} @@ -13,6 +32,12 @@ struct ConstraintElementsDiff second::Dict{String, Tuple{Dict{String, Float64}, Tuple{Float64, Float64}}} end +""" + compare_constraints(model1, model2; tol) -> ConstraintElementsDiff + +Compare the constraints of two MOI models. Only constraints with matching names +are compared; unmatched constraint names are reported separately. +""" function compare_constraints(model1::MOI.ModelLike, model2::MOI.ModelLike; tol::Float64) condiff = ConstraintNamesDiff(partition(constraint_names(model1), constraint_names(model2))...) ctrindices1 = ctr_index_for_name(model1) diff --git a/src/expression.jl b/src/expression.jl index 7f53fb8..cd7b983 100644 --- a/src/expression.jl +++ b/src/expression.jl @@ -1,3 +1,14 @@ +""" + ExpressionDiff + +Result of comparing two linear expressions (objective or constraint). + +# Fields +- `equal::Vector{String}`: variable names with matching coefficients (within tolerance). +- `both::Dict{String, Tuple{Float64, Float64}}`: variables present in both expressions with differing coefficients `(coef1, coef2)`. +- `first::Dict{String, Float64}`: variables only in the first expression. +- `second::Dict{String, Float64}`: variables only in the second expression. +""" struct ExpressionDiff equal :: Vector{String} both :: Dict{String, Tuple{Float64, Float64}} @@ -5,6 +16,12 @@ struct ExpressionDiff second :: Dict{String, Float64} end +""" + compare_expressions(expr1, expr2, model1, model2; tol) -> ExpressionDiff + +Compare two `MOI.AbstractScalarFunction` expressions, returning an [`ExpressionDiff`](@ref) +that partitions variables into equal, differing, and unique-to-each-model categories. +""" function compare_expressions(expr1::MOI.AbstractScalarFunction, expr2::MOI.AbstractScalarFunction, model1::MOI.ModelLike, model2::MOI.ModelLike; tol::Float64) coefs1 = Dict(model1.var_to_name[t.variable] => t.coefficient for t in expr1.terms) coefs2 = Dict(model2.var_to_name[t.variable] => t.coefficient for t in expr2.terms) diff --git a/src/objective.jl b/src/objective.jl index efddee3..4c95493 100644 --- a/src/objective.jl +++ b/src/objective.jl @@ -1,8 +1,22 @@ +""" + ObjectiveDiff + +Result of comparing objective functions between two models. + +# Fields +- `sense::Tuple{MOI.OptimizationSense, MOI.OptimizationSense}`: optimization senses (min/max) of each model. +- `expression::ExpressionDiff`: coefficient differences in the objective expression. +""" struct ObjectiveDiff sense::Tuple{MOI.OptimizationSense, MOI.OptimizationSense} expression::ExpressionDiff end +""" + compare_objective(model1, model2; tol) -> ObjectiveDiff + +Compare the objective functions (sense and coefficients) of two MOI models. +""" function compare_objective(model1::MOI.ModelLike, model2::MOI.ModelLike; tol::Float64) sense1 = MOI.get(model1, MOI.ObjectiveSense()) sense2 = MOI.get(model2, MOI.ObjectiveSense()) diff --git a/src/sort.jl b/src/sort.jl index 8ed2e17..e2d5ca1 100644 --- a/src/sort.jl +++ b/src/sort.jl @@ -1,3 +1,9 @@ +""" + sort_model(file1::String) + +Canonicalize a model file by sorting its variables and constraints alphabetically. +Writes the sorted model to `file1 * ".sorted"` in LP format. +""" function sort_model(file1::String) src = readmodel(file1) diff --git a/src/utils.jl b/src/utils.jl index 7709632..74911de 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -1,3 +1,8 @@ +""" + readmodel(fname::String) + +Read an optimization model from an LP or MPS file and return an `MOI.ModelLike` object. +""" function readmodel(fname::String) m = MOIF.Model(filename = fname) MOI.read_from_file(m, fname) diff --git a/src/variables.jl b/src/variables.jl index 60cbdd1..ff4dca4 100644 --- a/src/variables.jl +++ b/src/variables.jl @@ -1,9 +1,24 @@ +""" + VariablesDiff + +Result of comparing variable names between two models. + +# Fields +- `in_both::Vector{String}`: variable names present in both models. +- `only_one::Vector{String}`: variable names unique to the first model. +- `only_two::Vector{String}`: variable names unique to the second model. +""" struct VariablesDiff in_both :: Vector{String} only_one :: Vector{String} only_two :: Vector{String} end +""" + compare_variables(model1, model2) -> VariablesDiff + +Compare the variable names of two MOI models and return a [`VariablesDiff`](@ref). +""" function compare_variables(model1::MOI.ModelLike, model2::MOI.ModelLike) return VariablesDiff(partition(variable_names(model1), variable_names(model2))...) end diff --git a/test/runtests.jl b/test/runtests.jl new file mode 100644 index 0000000..cd2c6c7 --- /dev/null +++ b/test/runtests.jl @@ -0,0 +1,375 @@ +using Test +using ModelCompare +using MathOptInterface +const MOI = MathOptInterface + +# Model file paths +const MODEL1_LP = joinpath(@__DIR__, "models", "model1.lp") +const MODEL2_LP = joinpath(@__DIR__, "models", "model2.lp") +const MODEL1_MPS = joinpath(@__DIR__, "models", "model1.mps") +const MODEL2_MPS = joinpath(@__DIR__, "models", "model2.mps") +const MODEL_TOL1 = joinpath(@__DIR__, "models", "model_tol_1.mps") +const MODEL_TOL2 = joinpath(@__DIR__, "models", "model_tol_2.mps") +const BIGLP1_LP = joinpath(@__DIR__, "models", "modelbiglp1.lp") +const BIGLP2_LP = joinpath(@__DIR__, "models", "modelbiglp2.lp") +const BIGLP1_MPS = joinpath(@__DIR__, "models", "modelbiglp1.mps") +const BIGLP2_MPS = joinpath(@__DIR__, "models", "modelbiglp2.mps") + +@testset "ModelCompare.jl" begin + + @testset "Utilities" begin + @testset "readmodel — LP" begin + m = ModelCompare.readmodel(MODEL1_LP) + @test m isa MOI.ModelLike + end + + @testset "readmodel — MPS" begin + m = ModelCompare.readmodel(MODEL1_MPS) + @test m isa MOI.ModelLike + end + + @testset "partition — basic" begin + inter, only_a, only_b = ModelCompare.partition([1, 2, 3], [2, 3, 4]) + @test sort(inter) == [2, 3] + @test only_a == [1] + @test only_b == [4] + end + + @testset "partition — empty" begin + inter, only_a, only_b = ModelCompare.partition(Int[], Int[]) + @test isempty(inter) + @test isempty(only_a) + @test isempty(only_b) + end + + @testset "partition — identical" begin + inter, only_a, only_b = ModelCompare.partition([1, 2], [1, 2]) + @test sort(inter) == [1, 2] + @test isempty(only_a) + @test isempty(only_b) + end + + @testset "partition — disjoint" begin + inter, only_a, only_b = ModelCompare.partition([1, 2], [3, 4]) + @test isempty(inter) + @test sort(only_a) == [1, 2] + @test sort(only_b) == [3, 4] + end + end + + @testset "compare_variables" begin + @testset "LP format" begin + m1 = ModelCompare.readmodel(MODEL1_LP) + m2 = ModelCompare.readmodel(MODEL2_LP) + vdiff = compare_variables(m1, m2) + @test vdiff isa ModelCompare.VariablesDiff + # Model 1 unique vars + @test "x" in vdiff.only_one + @test "w" in vdiff.only_one + @test "z_a" in vdiff.only_one + @test length(vdiff.only_one) == 3 + # Model 2 unique vars + @test "p" in vdiff.only_two + @test "t" in vdiff.only_two + @test "z_a_1_" in vdiff.only_two + @test "z_a_2_" in vdiff.only_two + @test "z_6_" in vdiff.only_two + @test "z_7_" in vdiff.only_two + @test "z_8_" in vdiff.only_two + @test "z_9_" in vdiff.only_two + @test "z_10_" in vdiff.only_two + @test length(vdiff.only_two) == 9 + # Common vars + @test "y_1_" in vdiff.in_both + @test "y_2_" in vdiff.in_both + @test "d" in vdiff.in_both + @test "z_1_" in vdiff.in_both + end + + @testset "MPS format" begin + m1 = ModelCompare.readmodel(MODEL1_MPS) + m2 = ModelCompare.readmodel(MODEL2_MPS) + vdiff = compare_variables(m1, m2) + @test vdiff isa ModelCompare.VariablesDiff + @test !isempty(vdiff.only_one) + @test !isempty(vdiff.only_two) + @test !isempty(vdiff.in_both) + end + + @testset "identical models" begin + m1 = ModelCompare.readmodel(MODEL1_LP) + m2 = ModelCompare.readmodel(MODEL1_LP) + vdiff = compare_variables(m1, m2) + @test isempty(vdiff.only_one) + @test isempty(vdiff.only_two) + @test !isempty(vdiff.in_both) + end + end + + @testset "compare_bounds" begin + @testset "LP format" begin + m1 = ModelCompare.readmodel(MODEL1_LP) + m2 = ModelCompare.readmodel(MODEL2_LP) + vdiff = compare_variables(m1, m2) + bdiff = compare_bounds(m1, m2, vdiff; tol = 0.0) + @test bdiff isa ModelCompare.BoundsDiff + @test !isempty(bdiff.first) + @test !isempty(bdiff.second) + # Model 1 unique variable bounds + @test haskey(bdiff.first, "w") + @test haskey(bdiff.first, "x") + @test haskey(bdiff.first, "z_a") + # Check specific bound values + @test bdiff.first["z_a"] == (25.0, 25.0) + end + + @testset "2-arg convenience method" begin + m1 = ModelCompare.readmodel(MODEL1_LP) + m2 = ModelCompare.readmodel(MODEL2_LP) + bdiff = compare_bounds(m1, m2; tol = 0.0) + @test bdiff isa ModelCompare.BoundsDiff + @test haskey(bdiff.first, "w") + end + + @testset "identical models — everything equal" begin + m1 = ModelCompare.readmodel(MODEL1_LP) + m2 = ModelCompare.readmodel(MODEL1_LP) + bdiff = compare_bounds(m1, m2; tol = 0.0) + @test isempty(bdiff.first) + @test isempty(bdiff.second) + @test isempty(bdiff.both) + @test !isempty(bdiff.equal) + end + + @testset "tolerance effects" begin + m1 = ModelCompare.readmodel(MODEL_TOL1) + m2 = ModelCompare.readmodel(MODEL_TOL2) + bdiff_tight = compare_bounds(m1, m2; tol = 1e-6) + bdiff_loose = compare_bounds(m1, m2; tol = 10.0) + @test length(bdiff_loose.equal) >= length(bdiff_tight.equal) + end + end + + @testset "compare_expressions" begin + @testset "objective expressions — LP" begin + m1 = ModelCompare.readmodel(MODEL1_LP) + m2 = ModelCompare.readmodel(MODEL2_LP) + attr1 = MOI.get(m1, MOI.ObjectiveFunctionType()) + attr2 = MOI.get(m2, MOI.ObjectiveFunctionType()) + obj1 = MOI.get(m1, MOI.ObjectiveFunction{attr1}()) + obj2 = MOI.get(m2, MOI.ObjectiveFunction{attr2}()) + ediff = compare_expressions(obj1, obj2, m1, m2; tol = 0.0) + @test ediff isa ModelCompare.ExpressionDiff + # Variables only in model1 objective: x, z_a + @test haskey(ediff.first, "x") + @test haskey(ediff.first, "z_a") + @test ediff.first["x"] == 2.0 + @test ediff.first["z_a"] == 1.0 + # Variables only in model2 objective: y_2_, z_3_, z_6_..z_10_, z_a_1_, z_a_2_, p + @test haskey(ediff.second, "p") + @test ediff.second["p"] == 3.0 + # Same variables with different coefficients + @test haskey(ediff.both, "y_1_") + @test ediff.both["y_1_"] == (3.0, 5.0) + @test haskey(ediff.both, "d") + @test ediff.both["d"] == (5.0, 1.0) + # Equal variables (same coefficient in both) + @test "z_1_" in ediff.equal + @test "z_4_" in ediff.equal + @test "z_5_" in ediff.equal + end + end + + @testset "compare_objective" begin + @testset "LP format" begin + m1 = ModelCompare.readmodel(MODEL1_LP) + m2 = ModelCompare.readmodel(MODEL2_LP) + odiff = compare_objective(m1, m2; tol = 0.0) + @test odiff isa ModelCompare.ObjectiveDiff + @test odiff.sense == (MOI.MAX_SENSE, MOI.MAX_SENSE) + @test !isempty(odiff.expression.first) + @test !isempty(odiff.expression.second) + @test !isempty(odiff.expression.both) + end + + @testset "MPS format" begin + m1 = ModelCompare.readmodel(MODEL1_MPS) + m2 = ModelCompare.readmodel(MODEL2_MPS) + odiff = compare_objective(m1, m2; tol = 0.0) + @test odiff isa ModelCompare.ObjectiveDiff + # MPS files use MIN_SENSE by default + @test odiff.sense[1] == MOI.MIN_SENSE + @test odiff.sense[2] == MOI.MIN_SENSE + end + + @testset "identical models" begin + m1 = ModelCompare.readmodel(MODEL1_LP) + m2 = ModelCompare.readmodel(MODEL1_LP) + odiff = compare_objective(m1, m2; tol = 0.0) + @test isempty(odiff.expression.first) + @test isempty(odiff.expression.second) + @test isempty(odiff.expression.both) + @test !isempty(odiff.expression.equal) + end + end + + @testset "compare_constraints" begin + @testset "LP format" begin + m1 = ModelCompare.readmodel(MODEL1_LP) + m2 = ModelCompare.readmodel(MODEL2_LP) + cdiff = compare_constraints(m1, m2; tol = 0.0) + @test cdiff isa ModelCompare.ConstraintElementsDiff + # Constraints in both: y_con, c1, z_con + @test !isempty(cdiff.both) || !isempty(cdiff.equal) + # model2 has unique constraint "zcon" + @test !isempty(cdiff.second) + @test haskey(cdiff.second, "zcon") + end + + @testset "identical models" begin + m1 = ModelCompare.readmodel(MODEL1_LP) + m2 = ModelCompare.readmodel(MODEL1_LP) + cdiff = compare_constraints(m1, m2; tol = 0.0) + @test isempty(cdiff.both) + @test isempty(cdiff.first) + @test isempty(cdiff.second) + @test !isempty(cdiff.equal) + end + + @testset "tolerance effects" begin + m1 = ModelCompare.readmodel(MODEL_TOL1) + m2 = ModelCompare.readmodel(MODEL_TOL2) + cdiff_tight = compare_constraints(m1, m2; tol = 1e-6) + cdiff_loose = compare_constraints(m1, m2; tol = 10.0) + @test length(cdiff_loose.equal) >= length(cdiff_tight.equal) + end + end + + @testset "compare_models (integration)" begin + @testset "LP end-to-end" begin + mktempdir() do dir + outfile = joinpath(dir, "compare_lp.txt") + result = compare_models(MODEL1_LP, MODEL2_LP; + outfile = outfile, tol = 0.0) + @test result isa NamedTuple + @test haskey(result, :variables) + @test haskey(result, :bounds) + @test haskey(result, :objective) + @test haskey(result, :constraints) + @test isfile(outfile) + content = read(outfile, String) + @test occursin("VARIABLE NAMES", content) + @test occursin("VARIABLE BOUNDS", content) + @test occursin("OBJECTIVE", content) + @test occursin("CONSTRAINTS", content) + end + end + + @testset "MPS end-to-end" begin + mktempdir() do dir + outfile = joinpath(dir, "compare_mps.txt") + result = compare_models(MODEL1_MPS, MODEL2_MPS; + outfile = outfile, tol = 0.0) + @test result isa NamedTuple + @test isfile(outfile) + end + end + + @testset "separate_files mode" begin + mktempdir() do dir + outfile = joinpath(dir, "compare.txt") + compare_models(MODEL1_LP, MODEL2_LP; + outfile = outfile, tol = 0.0, separate_files = true, verbose = false) + @test isfile(joinpath(dir, "compare_variables.txt")) + @test isfile(joinpath(dir, "compare_bounds.txt")) + @test isfile(joinpath(dir, "compare_objective.txt")) + @test isfile(joinpath(dir, "compare_constraints.txt")) + end + end + + @testset "tolerance parameter" begin + mktempdir() do dir + out_tight = joinpath(dir, "tight.txt") + out_loose = joinpath(dir, "loose.txt") + r_tight = compare_models(MODEL_TOL1, MODEL_TOL2; + outfile = out_tight, tol = 1e-6, verbose = false) + r_loose = compare_models(MODEL_TOL1, MODEL_TOL2; + outfile = out_loose, tol = 10.0, verbose = false) + @test length(r_loose.bounds.equal) >= length(r_tight.bounds.equal) + end + end + + @testset "big LP models" begin + mktempdir() do dir + outfile = joinpath(dir, "compare_big.txt") + result = compare_models(BIGLP1_LP, BIGLP2_LP; + outfile = outfile, tol = 0.0, verbose = false) + @test result isa NamedTuple + @test isfile(outfile) + end + end + end + + @testset "sort_model" begin + mktempdir() do dir + # Copy model1.lp to temp dir + src = MODEL1_LP + dst = joinpath(dir, "model1.lp") + cp(src, dst) + sort_model(dst) + sorted_file = dst * ".sorted" + @test isfile(sorted_file) + # Verify sorted file has content + content = read(sorted_file, String) + @test !isempty(content) + @test occursin("MINIMIZE", content) || occursin("MAXIMIZE", content) + end + end + + @testset "printdiff (output formatting)" begin + m1 = ModelCompare.readmodel(MODEL1_LP) + m2 = ModelCompare.readmodel(MODEL2_LP) + + @testset "VariablesDiff" begin + vdiff = compare_variables(m1, m2) + io = IOBuffer() + ModelCompare.printdiff(io, vdiff) + output = String(take!(io)) + @test occursin("VARIABLE NAMES", output) + @test occursin("Only MODEL 1", output) + @test occursin("Only MODEL 2", output) + end + + @testset "BoundsDiff" begin + bdiff = compare_bounds(m1, m2; tol = 0.0) + io = IOBuffer() + ModelCompare.printdiff(io, bdiff; one_by_one = true) + output = String(take!(io)) + @test occursin("VARIABLE BOUNDS", output) + end + + @testset "ObjectiveDiff" begin + odiff = compare_objective(m1, m2; tol = 0.0) + io = IOBuffer() + ModelCompare.printdiff(io, odiff; one_by_one = true) + output = String(take!(io)) + @test occursin("OBJECTIVE", output) + end + + @testset "ConstraintElementsDiff" begin + cdiff = compare_constraints(m1, m2; tol = 0.0) + io = IOBuffer() + ModelCompare.printdiff(io, cdiff; one_by_one = true) + output = String(take!(io)) + @test occursin("CONSTRAINTS", output) + end + end + + @testset "constraint_set_to_bound" begin + @test ModelCompare.constraint_set_to_bound(MOI.LessThan(5.0)) == (typemin(Float64), 5.0) + @test ModelCompare.constraint_set_to_bound(MOI.GreaterThan(3.0)) == (3.0, typemax(Float64)) + @test ModelCompare.constraint_set_to_bound(MOI.EqualTo(7.0)) == (7.0, 7.0) + @test ModelCompare.constraint_set_to_bound(MOI.Interval(2.0, 8.0)) == (2.0, 8.0) + end + +end From 62926898a7778b8300ae69764ee3e5f59933e222 Mon Sep 17 00:00:00 2001 From: Rafael Benchimol Klausner Date: Wed, 11 Mar 2026 22:10:24 -0300 Subject: [PATCH 2/2] Fix docs build: add compare_bounds docstring, allow missing_docs warning Co-Authored-By: Claude Opus 4.6 --- docs/make.jl | 1 + src/bounds.jl | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/docs/make.jl b/docs/make.jl index 7d191c4..b28ca8a 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -4,6 +4,7 @@ using ModelCompare makedocs( sitename = "ModelCompare.jl", modules = [ModelCompare], + warnonly = [:missing_docs], pages = [ "Home" => "index.md", "API Reference" => "api.md", diff --git a/src/bounds.jl b/src/bounds.jl index bcd1d9c..fb53cd7 100644 --- a/src/bounds.jl +++ b/src/bounds.jl @@ -10,6 +10,13 @@ struct BoundsDiff second :: Dict{String, Tuple{Float64, Float64}} end +""" + compare_bounds(model1, model2, vardiff; tol) -> BoundsDiff + compare_bounds(model1, model2; tol) -> BoundsDiff + +Compare variable bounds between two MOI models. The 2-argument form computes +the [`VariablesDiff`](@ref) automatically. Returns a [`BoundsDiff`](@ref). +""" function compare_bounds(model1::MOI.ModelLike, model2::MOI.ModelLike, vardiff::VariablesDiff; tol::Float64) indices1 = index_for_name(model1) indices2 = index_for_name(model2)