Skip to content

Conflict tools? #116

@acostarelli

Description

@acostarelli

Would it make sense to add some user-helper functions for diagnosing conflicts more easily? Something that prints out conflicting constraints? I asked Claude what this might look non-IOM-specific:

module ConflictDebug

using JuMP

export print_conflict, find_conflict

"""
    find_conflict(model; verbose=true) -> Vector{ConstraintRef}

Return the constraints involved in the infeasibility of `model`.

Strategy:
  1. If the solver implements a conflict refiner (Gurobi/CPLEX/Xpress),
     use `compute_conflict!` to get the exact IIS.
  2. Otherwise fall back to `relax_with_penalty!` and report constraints
     whose slack is nonzero ("approximate" conflict — works on any solver).

You can force/override behaviour per solver by adding a method to
`supports_native_conflict(::MOI.AbstractOptimizer)` (see bottom of file).
"""
function find_conflict(model::Model; verbose::Bool = true, tol::Float64 = 1e-6)
    if termination_status(model) == OPTIMIZE_NOT_CALLED
        optimize!(model)
    end
    st = termination_status(model)
    if st  (INFEASIBLE, LOCALLY_INFEASIBLE, INFEASIBLE_OR_UNBOUNDED)
        @warn "Model is not infeasible (status = $st); conflict may be meaningless."
    end

    if _try_native_conflict(model)
        verbose && @info "Using native solver conflict refiner (exact IIS)."
        return _collect_native_conflict(model; verbose)
    else
        verbose && @info "Solver has no conflict refiner; using penalty relaxation (approximate)."
        return _collect_relaxed_conflict(model; verbose, tol)
    end
end

"""
    print_conflict(model)

Convenience wrapper: compute the conflict and pretty-print the offending
constraints (and, when available, a minimal infeasible sub-model).
"""
function print_conflict(model::Model; kwargs...)
    cons = find_conflict(model; kwargs...)
    if isempty(cons)
        println("No conflicting constraints identified.")
        return cons
    end
    println("\n=== Conflicting constraints ($(length(cons))) ===")
    for c in cons
        name = JuMP.name(c)
        label = isempty(name) ? string(c) : name
        println("", label, "  :  ", _show_constraint(c))
    end
    println("="^40)
    return cons
end

# ---------------------------------------------------------------------------
# Native IIS path
# ---------------------------------------------------------------------------

function _try_native_conflict(model::Model)::Bool
    supports_native_conflict(unsafe_backend(model)) || return false
    try
        compute_conflict!(model)
        return get_attribute(model, MOI.ConflictStatus()) == MOI.CONFLICT_FOUND
    catch err
        @debug "Native conflict computation failed; falling back." exception = err
        return false
    end
end

function _collect_native_conflict(model::Model; verbose::Bool)
    out = JuMP.ConstraintRef[]
    for (F, S) in list_of_constraint_types(model)
        for con in all_constraints(model, F, S)
            status = MOI.get(model, MOI.ConstraintConflictStatus(), con)
            if status == MOI.IN_CONFLICT
                push!(out, con)
            end
        end
    end
    return out
end

# ---------------------------------------------------------------------------
# Penalty-relaxation fallback (any solver)
# ---------------------------------------------------------------------------

function _collect_relaxed_conflict(model::Model; verbose::Bool, tol::Float64)
    # relax_with_penalty! mutates the model, so work on a copy.
    work, ref_map = copy_model(model)
    set_optimizer(work, solver_constructor(model))
    penalty_map = relax_with_penalty!(work)
    optimize!(work)

    if termination_status(work)  (OPTIMAL, LOCALLY_SOLVED, ALMOST_OPTIMAL)
        @warn "Relaxed model did not solve cleanly (status = $(termination_status(work)))."
    end

    out = JuMP.ConstraintRef[]
    for (con_in_copy, penalty_var) in penalty_map
        v = value(penalty_var)
        if abs(v) > tol
            # map the copied constraint back to the original model's ref
            orig = _reverse_lookup(ref_map, con_in_copy)
            push!(out, something(orig, con_in_copy))
            verbose && println("  violated by $(round(v; digits=6)):  ",
                               _show_constraint(con_in_copy))
        end
    end
    return out
end

# copy_model doesn't carry the optimizer; remember how to rebuild it.
# Users can override this if their constructor needs arguments/attributes.
solver_constructor(model::Model) = MOI.get(model, MOI.Name()) === nothing ?
    error("Set `ConflictDebug.solver_constructor(::Model)` to your optimizer constructor, " *
          "e.g. `() -> HiGHS.Optimizer()`.") :
    error("override solver_constructor")

function _reverse_lookup(ref_map, con)
    # ReferenceMap maps original -> copy; we need copy -> original.
    for (orig, copied) in ref_map.con_map
        copied == con && return orig
    end
    return nothing
end

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

function _show_constraint(c)
    try
        return string(constraint_object(c))
    catch
        return string(c)
    end
end

# ---------------------------------------------------------------------------
# Per-solver extension point
# ---------------------------------------------------------------------------

# Default: assume no refiner. Add a method returning `true` for solvers that
# implement MOI.compute_conflict!. These are loaded lazily so you don't need
# the package installed unless you use it.
supports_native_conflict(::MOI.AbstractOptimizer) = false

end # module

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions