ommx.Solution#

OMMX has several structures that represent the solution of mathematical models.

Data Structure

Description

State

Holds the solution value for the decision variable ID. The simplest representation of a solution.

Solution

A representation of the solution intended to be human-readable. In addition to the values of the decision variables and the evaluation values of the constraints, it also holds metadata for the decision variables and constraints added to the Instance.

Most solvers are software designed to solve mathematical models, so they return minimal information equivalent to ommx.State, but OMMX mainly handles ommx.Solution, which allows users to easily check the optimization results.

ommx.Solution is generated by passing ommx.State or equivalent dict[int, float] to the ommx.Instance.evaluate method. Let’s consider the simple optimization problem we saw in the previous section again:

\[ \begin{aligned} \max \quad & x + y \\ \text{subject to} \quad & x y = 0 \\ & x, y \in \{0, 1\} \end{aligned} \]

It is clear that this has a feasible solution \(x = 1, y = 0\).

from ommx import DecisionVariable, Instance, Sense

# Create a simple instance
x = DecisionVariable.binary(1, name='x')
y = DecisionVariable.binary(2, name='y')

instance = Instance.from_components(
    decision_variables=[x, y],
    objective=x + y,
    constraints={0: x * y == 0},
    sense=Sense.Maximize
)

# Create a solution
solution = instance.evaluate({1: 1, 2: 0})  # x=1, y=0

The generated ommx.Solution inherits most of the information from the ommx.Instance. Let’s first look at the decision variables.

solution.decision_variables_df()

In addition to the required attributes—ID, kind, lower, and upper—it also inherits metadata such as name. Additionally, the value assigned in evaluate is stored. Similarly, the evaluation value is added to the constraints as value.

solution.constraints_df(kind="regular")

The objective property contains the value of the objective function, and the feasible property contains whether the constraints are satisfied.

print(f"{solution.objective=}, {solution.feasible=}")

Since \(xy = 0\) when \(x = 1, y = 0\), all constraints are satisfied, so feasible is True. The value of the objective function is \(x + y = 1\).

What happens in the case of an infeasible solution, \(x = 1, y = 1\)?

solution11 = instance.evaluate({1: 1, 2: 1})  # x=1, y=1
print(f"{solution11.objective=}, {solution11.feasible=}")

feasible = False indicates that it is an infeasible solution.

Query constraints without pandas#

Use a typed ID to select one constraint from the live collection:

from ommx import ConstraintID

rows = solution.constraints
rows[ConstraintID(0)].violation()

Instance, ParametricInstance, Solution, and SampleSet expose all constraint families through .constraints. The property keeps a reference to its owner; it does not collect constraint rows. Indexing fetches the selected constraint from the owner’s current state. Integer keys raise TypeError because the family cannot be inferred. A missing typed ID raises KeyError.

kind

ID type

Solution value

SampleSet value

"regular"

ConstraintID

EvaluatedConstraint

SampledConstraint

"indicator"

IndicatorConstraintID

EvaluatedIndicatorConstraint

SampledIndicatorConstraint

"one_hot"

OneHotConstraintID

EvaluatedOneHotConstraint

SampledOneHotConstraint

"sos1"

Sos1ConstraintID

EvaluatedSos1Constraint

SampledSos1Constraint

Model collections contain active constraints and return Attached* handles; their context setters update the owner. .removed_constraints contains all removed model families and returns snapshots. Result collections include both active and retained removed constraints. Each result access returns an owned snapshot with labels, provenance, removal reasons, and evaluation diagnostics. Changing that snapshot does not update the result; accessing the same ID again reads the current owner. Use owner methods such as Solution.set_dual_variable to update the result.

Attached handles compare equal when they refer to the same owner object and typed ID. Removed and result snapshots compare their stored data, including metadata and removal reasons. Thus rows == dict(rows) and membership in rows.values() or rows.items() use the same row equality. Handles from two different model owners are unequal even when their models contain identical data.

rows.get(id) returns None for a missing typed ID, or the supplied default with rows.get(id, default). Like indexing, it preserves the concrete row type for a concrete ID; the return type also includes the default’s type.

Collection membership cannot be changed by assignment or deletion. keys(), values(), and items() are live views. iter(rows) snapshots typed IDs immediately; iterators over the standard Python views snapshot them on the first next(). IDs follow family order (regular, indicator, one-hot, SOS1), then numeric order. Values are fetched as they are visited. If a snapshotted key is subsequently removed, fetching its value raises KeyError. Use dict(rows) for a materialized mapping.

Use of_kind to narrow a collection to one family:

sos1 = instance.constraints.of_kind("sos1")
row = sos1[5]  # AttachedSos1Constraint; 5 is promoted to Sos1ConstraintID(5)
for cid, constraint in sos1.items():
    print(cid, constraint.name)  # cid is a Sos1ConstraintID

of_kind accepts the same strings as constraints_df: "regular", "indicator", "one_hot", and "sos1". It returns a live ConstraintFamilyView, without collecting rows. The selected family fixes the ID type, so indexing, get, and membership accept integers as well as that family’s typed IDs; IDs of another family raise TypeError. Iteration still returns typed IDs, and the literal kind selects the concrete key and row types for static type checking. The view retains its owner and follows the same row lifecycle and iterator snapshot rules as the original collection. This also applies to instance.removed_constraints.of_kind("sos1"), ParametricInstance, and result collections on Solution and SampleSet. An unknown kind raises ValueError. Filtering is for inspecting a selected family; it does not establish that a solver or transformation supports every constraint in the model.

constraints_df(*, kind=...) materializes one family as a DataFrame and requires kind on every owner. Its typed index matches that family’s collection keys. On models it selects active rows by default; removed=True includes both .constraints and .removed_constraints. Results always include retained removed rows. Sampled rows expose violations and feasible(atol=...) keyed by sample ID.

The metadata methods constraint_context_df, constraint_parameters_df, constraint_provenance_df, and constraint_removed_reasons_df also require keyword-only kind on every owner. For sample objectives and overall feasibility, use samples.summary; inspect per-constraint results through samples.constraints or samples.constraints_df(kind=...).

Inputs that already identify a family still promote integers: for example, constraints={1: constraint} and instance.constraints.of_kind("regular")[1]. They reject IDs of another family. An input mapping containing both 1 and ConstraintID(1) raises ValueError because the keys normalize to the same identity. Mixed-family collections, constraint_violation(id), and DataFrame indexes require typed IDs. IDs do not support int() or integer-index conversion. Variable and sample IDs remain integers.

IDs are immutable: copy.copy(id) and copy.deepcopy(id) return the ID itself. Deep-copying a container with IDs as keys or values preserves their families. Pickle round-trips also preserve the ID family and value, including IDs stored in DataFrame indexes or columns.

Supporting future constraint families#

Adding a special-constraint family is an additive API extension. The SDK does not promise that the currently known families exhaust all future possibilities. Collections, including .removed_constraints, implement Mapping[ConstraintIDBase, ConstraintView]. Iteration returns ConstraintIDBase; values share the metadata interface ConstraintView. Indexing with a known concrete ID still returns its precise row type, such as AttachedConstraint for ConstraintID on a model.

A solver adapter or transformation must explicitly reject families it cannot handle. Silently skipping a constraint can relax the optimization problem. For example, a consumer that translates only regular constraints needs this guard even if its earlier applicability check normally rules out other kinds:

for cid in instance.constraints:
    if not isinstance(cid, ConstraintID):
        raise NotImplementedError(f"Unsupported constraint family: {type(cid).__name__}")
    constraint = instance.constraints[cid]  # AttachedConstraint
    # Translate this regular constraint into the target model.

Handle additional concrete ID types explicitly when supported, and keep the rejecting fallback. A new family reaching an older consumer should fail; that is the extension contract working. Metadata-only inspection can use the common interface without interpreting the family’s mathematical meaning.

Extract constraints by name#

Use extract_constraints(name) to obtain a dictionary keyed by subscript tuples, just as extract_decision_variables groups evaluated variable values by name. All four owners expose constraint_names: set[str] to discover the names in this query scope. ParametricInstance also exposes decision_variable_names, matching the other owners; parameter names are excluded. Unnamed rows are omitted, while explicitly empty names are included. Each access returns a fresh set, with duplicate names listed once even if their subscripts would be ambiguous for extraction.

All constraint families participate. Models return attached constraint objects; results return nonnegative violations.

named = Instance.minimize()
x_named = named.new_binary("x")
capacity = named.add_constraint(x_named - 2 <= 0, "capacity", subscripts=[0])

assert named.constraint_names == {"capacity"}
assert named.extract_constraints("capacity")[(0,)] == capacity
assert named.as_parametric_instance().extract_constraints("capacity")[(0,)].name == "capacity"

named_solution = named.evaluate({x_named.id: 0})
assert named_solution.extract_constraints("capacity") == {(0,): 0.0}
named_samples = named.evaluate_samples({7: {x_named.id: 0}})
assert named_samples.extract_constraints("capacity", 7) == {(0,): 0.0}

Owner

Returned values

Scope

Instance / ParametricInstance

ConstraintView (concrete Attached handles)

Active constraints

Solution

float violations

All retained constraints, including removed rows

SampleSet

float violations for the requested sample ID

All retained constraints, including removed rows

The dictionary captures the matching subscript keys at extraction time. Model handles still refer to their owner and support the same updates as .constraints. Names and subscripts identify matches; parameters and descriptions are ignored. A scalar uses () as its key. An unknown name raises KeyError; duplicate subscripts raise ValueError, even across different constraint families. Transformations can copy labels onto generated constraints: if a result retains both the source and generated rows with the same label, use typed-ID lookup in .constraints to distinguish them. SampleSet also rejects unknown sample IDs.

For regular constraints, extraction returns the violation rather than the signed function value: the feasible inequality above has function value -2 but violation 0. Use solution.get_constraint_value(id) or the regular row’s evaluated_value when the signed value is needed. Indicator violations account for activation; one-hot and SOS1 use their own violation metrics.