Skip to content

Re-solving and model updates

Most research code solves the same model many times: a parameter sweep, a branching or diving heuristic, a Lagrangian loop, a master problem that grows. MIP++ models are live solver objects, so all of that is done by modifying in place and calling solve() again — never by rebuilding from scratch.

This page collects the modification operations and the guarantees around handles.

What stays valid across a solve

  • Variable and constraint handles. They identify entities of the model, not of a solve, and remain valid until the entity is removed.
  • Limits, tolerances and the sense set on the model.
  • Solution objects are snapshots and are not refreshed — fetch them again after each solve (Solutions).

Bounds

model.set_variable_lower_bound(x, lb);   // has_modifiable_variables_bounds
model.set_variable_upper_bound(x, ub);

double lb0 = model.get_variable_lower_bound(x);   // has_readable_variables_bounds

Changing bounds is the cheapest possible model update — the matrix is untouched — which makes it the right tool for:

  • fixing variables in a diving or rounding heuristic (lb = ub = value), then restoring;
  • manual branching, one child per bound tightening;
  • scenario loops where only capacities change.

Objective

set_objective replaces the objective, add_objective increments it, and set_objective_coefficient edits a single column — see Objectives. Switching the sense (set_maximization / set_minimization) needs no other change.

Constraint rows

On backends satisfying the corresponding concepts, a row can be edited in place:

model.set_constraint_rhs(row, new_b);                  // has_modifiable_constraint_rhs
model.set_constraint_sense(row, constraint_sense::greater_equal);
model.set_constraint_lhs(row, entries);                // range of (variable, coefficient)

and read back:

auto c    = model.get_constraint(row);        // a linear_constraint
auto lhs  = model.get_constraint_lhs(row);    // range of (variable, coefficient)
auto sens = model.get_constraint_sense(row);
double b  = model.get_constraint_rhs(row);

Right-hand-side updates are the standard way to run an ε-constraint or budget sweep: build the row once, then move b across the loop.

Adding and removing entities

New variables and constraints can be added to a solved model at any time; that is what makes cutting-plane and column-generation loops possible.

  • add_constraint / add_constraints — add rows (a cut, a lazy constraint added outside a callback, a new period).
  • add_column(entries, params) — add a column from (constraint, coefficient) pairs, the transpose of add_constraint. See Column generation.
  • remove_variable(v) / remove_variables(range) — on backends satisfying has_remove_variable (Clp, CPLEX, Gurobi, HiGHS).

Handles after a removal

Handles of the surviving entities stay valid across remove_variable — MIP++ keeps its own handle-to-column mapping, so you do not renumber anything. The handle of a removed variable, on the other hand, is dead and may later be recycled for a new variable: drop it from your own containers at the moment you remove it.

That guarantee is free until you use it

The mapping is not maintained eagerly. A model starts in identity mode: a variable handle's id is the solver's column index, add_constraint writes handle.id() straight into the index buffer handed to the C API, and neither id map is allocated.

The maps are built — and the indirection switched on — only by a remove_variable / remove_variables call that perforates the column numbering. On HiGHS and Gurobi, deleting the tail of the columns (the variables added most recently) does not even do that: the identity mapping survives it. Clp goes further and never remaps at all, zeroing the column in place and recycling its id.

So a model that never removes a variable — which is most models, including every cutting-plane and most column-generation codes — pays nothing for handle stability: no indirection per term, no vector, no branch beyond one predictable flag test. After the first perforating removal, each handle→column access costs a single array lookup, and the model stays in that mode for the rest of its life. The mechanics are in remapping_model_base.hpp.

Giving the solver a starting point

On backends satisfying has_mip_start, a known feasible (or partially assigned) solution can be handed to the solver from (variable, value) pairs:

model.add_mip_start({{x1, 1.0}, {x2, 0.0}});

model.add_mip_start(std::views::transform(items, [&](int i) {
    return std::pair{X(i), double(heuristic_selection[i])};
}));

Backends: Cbc, COPT, CPLEX, Gurobi, HiGHS, MOSEK, Xpress. This is the usual way to feed a constructive heuristic's solution into an exact run, and to make a "warm" second solve of a perturbed instance cheap.

For LPs, basis warm starts are specified but not yet implemented on any backend — see Solutions.

A benchmark loop

Putting the pieces together: one model, many parameter values, one row of results each.

auto model = milp_type(api);
build(model, instance);                       // once
if constexpr(has_time_limit<milp_type>) model.set_time_limit(600s);

for(double budget : budgets) {
    model.set_constraint_rhs(budget_row, budget);
    model.solve();
    auto rec = run_status(model);          // see Status, limits and tolerances
    if(rec.has_solution)
        report(budget, model.get_solution_value(), model.get_solution());
}

Every iteration reuses the solver's internal state; only the numbers that actually changed are pushed. Because model construction is not repeated, the measured time is the solve time — which is what the experiment is about.

Next

Branch-and-cut with lazy constraints — modifying the model from inside the search.