Special Constraints#
In addition to regular constraints (Constraint — an equality or inequality over a Function), OMMX provides several constraint types frequently used in mathematical optimization as first-class citizens. This page introduces the following three special constraint types, their usage, and how to solve them with the PySCIPOpt Adapter.
IndicatorConstraint: a conditional constraint driven by a binary variableOneHotConstraint: exactly one of a set of binary variables equals 1Sos1Constraint: at most one of a set of variables is non-zero
The examples below use the PySCIPOpt Adapter, as in Solving optimization problems with OMMX Adapter. Install it first:
pip install ommx-pyscipopt-adapter
The PySCIPOpt Adapter passes Indicator and SOS1 constraints through to SCIP’s
addConsIndicator / addConsSOS1 (equality indicators are split into two
inequality indicators). Its solve() API copies the input and uses the
recommended Preparation policy to lower OneHot constraints to regular
equalities. Callers using solve_without_preparation() must perform that lowering on the
Instance first. See Adapter Input Classes and Explicit Constraint
Lowering.
IndicatorConstraint#
An indicator constraint enforces a constraint \(f(x) \leq 0\) (or \(f(x) = 0\)) only when a binary variable \(z = 1\). When \(z = 0\), the constraint is unconditionally satisfied.
Create an IndicatorConstraint from an existing Constraint by calling Constraint.with_indicator(). The indicator argument accepts a variable ID, a standalone DecisionVariable, or an AttachedDecisionVariable.
from ommx import DecisionVariable, Equality, Instance, Sense
z = DecisionVariable.binary(0, name="z")
x = DecisionVariable.continuous(1, lower=0, upper=10, name="x")
# z = 1 => x <= 5
ic = (x <= 5).with_indicator(z)
assert ic.indicator_variable_id == 0
assert ic.equality == Equality.LessThanOrEqualToZero
Add it to an instance by passing a dict[int, IndicatorConstraint] to the indicator_constraints= argument of Instance.from_components.
instance = Instance.from_components(
decision_variables=[z, x],
objective=x,
constraints={0: z == 1}, # fix z = 1
indicator_constraints={0: ic}, # z = 1 => x <= 5
sense=Sense.Maximize,
)
assert set(instance.indicator_constraints.keys()) == {0}
The PySCIPOpt Adapter accepts Indicator constraints and passes them directly to SCIP.
from ommx_pyscipopt_adapter import OMMXPySCIPOptAdapter
solution = OMMXPySCIPOptAdapter.solve(instance)
# With z = 1, the constraint x <= 5 is active, so the maximum value of x is 5
assert abs(solution.objective - 5.0) < 1e-6
OneHotConstraint#
A one-hot constraint over a set of binary variables \(\{x_1, \ldots, x_n\}\) requires \(\sum_i x_i = 1\) — i.e. exactly one of them is 1.
from ommx import OneHotConstraint
xs = [DecisionVariable.binary(i, name="x", subscripts=[i]) for i in range(3)]
oh = OneHotConstraint(variables=xs)
assert oh.variables == [0, 1, 2]
Each entry passed to variables may be a variable ID, a standalone DecisionVariable, or an AttachedDecisionVariable. This input is exposed as the VariableIDLike type alias because only the variable identity is consumed. When the constraint is inserted, the enclosing instance validates the referenced IDs and any constraint-specific requirement against its own decision variables. For OneHot, the referenced variables must exist and be binary. The constraint stores their IDs, which are available through oh.variables. Mathematically the constraint is equivalent to the linear equality \(x_0 + x_1 + x_2 - 1 = 0\), but holding it as a first-class constraint lets supporting solvers (many MIP solvers accept one-hot natively) handle it efficiently.
values = [5.0, 10.0, 3.0]
instance_oh = Instance.from_components(
decision_variables=xs,
objective=sum(v * x for v, x in zip(values, xs)),
constraints={},
one_hot_constraints={0: oh},
sense=Sense.Maximize,
)
assert set(instance_oh.one_hot_constraints.keys()) == {0}
Pass the original Instance to the easy solve() API. Its recommended
Preparation lowers the OneHot constraint to the regular equality
\(x_0 + x_1 + x_2 - 1 = 0\) only on the Adapter’s private copy.
solution = OMMXPySCIPOptAdapter.solve(instance_oh)
# Exactly one of the three is chosen, so x_1 with the largest value 10 is selected
assert abs(solution.objective - 10.0) < 1e-6
The caller-owned instance_oh remains unchanged:
assert set(instance_oh.one_hot_constraints.keys()) == {0}
assert instance_oh.constraints == {}
assert instance_oh.removed_one_hot_constraints == {}
Callers using solve_without_preparation() must instead lower OneHot in place
before solving. That explicit conversion removes the active OneHot constraint,
adds the equivalent regular constraint, and records the original in
removed_one_hot_constraints.
Sos1Constraint#
An SOS1 (Special Ordered Set type 1) constraint over a set of variables \(\{x_1, \ldots, x_n\}\) requires that at most one of them be non-zero. It differs from one-hot in the following ways:
One-hot requires \(\sum x_i = 1\), so exactly one variable is non-zero.
SOS1 permits up to one variable to be non-zero (zero variables non-zero is also allowed).
SOS1 variables are not necessarily binary — continuous variables work too.
from ommx import Sos1Constraint
ys = [DecisionVariable.continuous(i, lower=0, upper=10, name="y", subscripts=[i]) for i in range(3, 6)]
s1 = Sos1Constraint(variables=ys)
assert s1.variables == [3, 4, 5]
As with OneHotConstraint, each variables entry accepts VariableIDLike: a variable ID, a standalone decision variable, or an attached decision variable. The SOS1 constraint stores their IDs, which are available through s1.variables.
instance_s1 = Instance.from_components(
decision_variables=ys,
objective=sum(ys),
constraints={},
sos1_constraints={0: s1},
sense=Sense.Maximize,
)
assert set(instance_s1.sos1_constraints.keys()) == {0}
The PySCIPOpt Adapter accepts SOS1 constraints and passes them directly to SCIP.
solution = OMMXPySCIPOptAdapter.solve(instance_s1)
# Only one variable may be non-zero, so one is set to its upper bound 10 and the others to 0
assert abs(solution.objective - 10.0) < 1e-6
SOS1 Big-M formulations#
A Big-M formulation represents the SOS1 condition with regular constraints. For each member \(x_i\), introduce a binary selector \(y_i\) and finite coefficients \(M_i^+\) and \(M_i^-\) large enough to cover the member’s upper and lower domain values:
When \(y_i = 0\), the link constraints force \(x_i = 0\). The cardinality constraint then allows at most one member to be non-zero, so the Big-M rows imply SOS1. Conversely, when the coefficients cover the complete member domains, every SOS1-feasible member assignment can set the selector of its non-zero member to one and every other selector to zero, or set all selectors to zero when all members are zero. The two formulations therefore have the same feasible member assignments after fresh selectors are projected out. A binary member can be reused as its own selector, and a link whose side is already implied by the member’s domain can be omitted.
Promotion first prepares tighter bounds using only the claimed Big-M links.
For example, x <= 3z with binary z can reduce a stored upper bound of 100
to 3. All rows read the original domains; cardinality rows and unrelated
constraints do not participate. Only successful promotions commit their bounds.
Strict rejection leaves the entire Instance unchanged, including its bounds.
Promotion checks mathematical equivalence using the prepared member bounds.
Positive scaling of link rows is allowed, and tight Big-M values U and -L
are sufficient. Bounds are derived directly from the exact link-coefficient
ratios, without tolerance expansion. Integer limits are rounded inward, and
conversion to floating-point bounds rounds outward. Exact clipping retains
even sub-tolerance updates; planning and application do not read the default
tolerance. Any remaining Big-M shortfall is rejected. Equal per-row
violations and identical feasibility classification at a finite evaluation
tolerance are not guaranteed. A narrowed fresh selector is accepted only when
its domain permits the canonical indicator for every possible member value.
promote_sos1_big_m() takes one
Sos1BigMPromotionRequest for the entire batch and returns one
Sos1BigMPromotion report. The request maps each regular
cardinality constraint ID to the member-to-selector claims for that formulation.
For example, given matching existing formulation rows:
from ommx import Sos1BigMPromotionRequest, Sos1BigMSelectorClaim
request = Sos1BigMPromotionRequest({
102: {
0: Sos1BigMSelectorClaim.reused(),
1: Sos1BigMSelectorClaim.fresh(10, upper_link=100, lower_link=101),
},
202: {
2: Sos1BigMSelectorClaim.reused(),
3: Sos1BigMSelectorClaim.reused(),
},
})
report = instance.promote_sos1_big_m(request) # mode="best_effort"
print(report.promoted) # cardinality constraint ID -> SOS1 constraint ID
print(report.rejections) # cardinality constraint ID -> diagnostic string
The default mode="best_effort" applies every independent valid formulation.
The report has exactly one outcome per requested cardinality ID. Its
promoted and rejections dictionaries are snapshots of successful and
rejected outcomes from the same map: their keys are disjoint and together equal
the request’s keys. request_count is the number of formulations in the batch.
Members and retained formulation history can be queried from the mutated
Instance using the returned SOS1 IDs and the original regular IDs.
Choose mode="strict" when every formulation must be promoted. It returns
the same report type with no rejections on success. If any formulation is
rejected, it raises Sos1BigMPromotionBatchRejectedError before
mutation. The exception’s request_count is the batch size and rejections
maps every rejected cardinality constraint ID to its diagnostic. Formulations
absent from that map were accepted by planning, but none are applied when the
strict batch fails:
from ommx import Sos1BigMPromotionBatchRejectedError
try:
report = instance.promote_sos1_big_m(request, mode="strict")
except Sos1BigMPromotionBatchRejectedError as error:
for cardinality_id, message in error.rejections.items():
print(f"cardinality constraint {cardinality_id}: {message}")
# The instance is unchanged.
Both modes check all formulations against the same unchanged Instance and
reject overlapping row claims. Independent formulations may share SOS1
members. A cardinality ID can occur only once in the request map.
An empty batch returns an empty report. The Rust plan retains an exclusive
borrow until application, so neither rollback nor an Instance clone is needed.
The transformation validates the current rows independently; it does not
require them to originate from convert_sos1_to_constraints().
Independent ID spaces per constraint type#
In OMMX, each of the four constraint collections — regular / Indicator / OneHot / SOS1 — has an independent ID space. The four dicts passed to Instance.from_components are keyed independently, so using the same integer ID across different constraint types does not cause a collision.
For example, “regular constraint ID=1” and “Indicator constraint ID=1” coexist as distinct constraints.
z2 = DecisionVariable.binary(10, name="z2")
x2 = DecisionVariable.continuous(11, lower=0, upper=10, name="x2")
instance_mix = Instance.from_components(
decision_variables=[z2, x2] + xs + ys,
objective=x2,
constraints={1: z2 == 1}, # regular ID=1
indicator_constraints={1: (x2 <= 5).with_indicator(z2)}, # Indicator ID=1
one_hot_constraints={1: OneHotConstraint(variables=xs)}, # OneHot ID=1
sos1_constraints={1: Sos1Constraint(variables=ys)}, # SOS1 ID=1
sense=Sense.Maximize,
)
# Each of the four dicts holds its own ID=1 constraint independently
assert set(instance_mix.constraints.keys()) == {1}
assert set(instance_mix.indicator_constraints.keys()) == {1}
assert set(instance_mix.one_hot_constraints.keys()) == {1}
assert set(instance_mix.sos1_constraints.keys()) == {1}
When a special constraint is converted to a regular constraint (see Explicit Constraint Lowering), the generated regular constraint is allocated from the Constraint ID space. Only regular constraint IDs can collide after conversion.
Accessing evaluation results#
The Solution or SampleSet obtained after solving exposes a single constraints_df() method that dispatches on kind=:
Constraint type |
|
|---|---|
Regular |
|
Indicator |
|
OneHot |
|
SOS1 |
|
solution.constraints_df() # regular (default)
solution.constraints_df(kind="indicator") # indicator
sample_set.constraints_df(kind="one_hot") # one-hot
The DataFrame is indexed by the kind-qualified id column (regular_constraint_id, indicator_constraint_id, one_hot_constraint_id, sos1_constraint_id) — accidental cross-id-space df.join() mistakes surface in df.head() and friends.
The Indicator DataFrame includes an indicator_active column that disambiguates “the indicator was OFF (constraint trivially satisfied)” from “the indicator was ON and the constraint was actually satisfied”. Indicator constraints do not carry a dual variable — a dual value is not well-defined for a conditional constraint — so dual_variable is omitted.
Removed reason columns via include=#
removed_reason is no longer a default column of constraints_df(). Pass "removed_reason" in include= to fold the reason name and the removed_reason.{key} parameter columns back in (rows whose constraint was not removed before evaluation get NA in those columns):
df = solution.constraints_df(
include=("label", "parameters", "removed_reason"),
)
The same applies to Indicator, OneHot, and SOS1: pass the corresponding kind= together with "removed_reason" in include=. The long-format constraint_removed_reasons_df() sidecar (also kind=-dispatched) remains the right surface when you want one row per (constraint id, parameter key) pair for joins or aggregation.
Relax / Restore#
IndicatorConstraint supports the same relax / restore workflow as regular constraints.
Instance.relax_indicator_constraint(): relax (deactivate) an indicator constraint and record a reason string. The relaxed constraint is moved intoremoved_indicator_constraints.Instance.restore_indicator_constraint(): restore a previously relaxed indicator constraint. Fails if the indicator variable has already been substituted or fixed.
For OneHot and SOS1, movement into removed_one_hot_constraints / removed_sos1_constraints happens via the conversion APIs covered in Explicit Constraint Lowering.