OMMX Python SDK 3.0.x#
Note
Python SDK 3.0.0 contains breaking API changes. A migration guide is available in the Python SDK v2 to v3 Migration Guide.
Unreleased#
Changes for the next release will be added here.
3.0.0 Beta 6#
Python package version: 3.0.0b6.
These notes cover changes since beta.5.
This release corrects imported MPS and QPLIB models, introduces violation values
for all special constraints, and unifies each constraintβs feasibility check as
violation <= atol. It also adds methods to tighten variable bounds using
linear constraints. Some APIs introduced in earlier v3 prereleases have changed.
Before upgrading, check the workflows you use:
If you use⦠|
Action for beta.6 |
|---|---|
Saved models imported from MPS or QPLIB |
Reimport affected models from their source files. For the built-in MIPLIB and QPLIB datasets, load them again to obtain the corrected distribution. Updating the SDK does not modify saved Artifacts. |
Constraint violation or feasibility queries |
Replace |
|
Construct a batch request and remove the |
Raw integer values for variable kinds |
Use enum members such as |
π Correct quadratic coefficients when importing QPLIB (#1208)#
QPLIB models now produce the intended quadratic objective and constraints.
Previously, quadratic contributions were doubled because the importer omitted
the formatβs factor of 1/2. This could change objective values and cause a
published feasible solution to be reported as infeasible.
If you used load_qplib() to create a saved model, reimport
it from the original .qplib file. ommx.dataset.qplib now loads the corrected
distribution published with OMMX 2.8.0, even if the older distribution is cached.
See the QPLIB tutorial.
π Preserve binary variables when importing MPS and MIPLIB (#1202, #1205)#
Some MPS files mark variables as integers without explicitly listing their
bounds. load_mps() and ommx.mps.load_file() now interpret
these variables as binary (0 or 1), following the Gurobi/HiGHS convention.
Previously, they were treated as general integers with no upper bound, which
could change the problem being solved. Explicit bounds in the file still take
precedence.
Reimport affected saved models from the original MPS file.
ommx.dataset.miplib2017 now loads the corrected MIPLIB distribution published
with OMMX 2.7.0, even if the older distribution is cached. See the
MIPLIB tutorial.
π Consistent tolerance for variable bounds and constraints (#1192)#
Checking whether a value is within its variable bounds now uses the same absolute-tolerance rule as checking an inequality constraint. This also applies when determining the allowed integer or binary values within a bound. Results close to a bound may differ from earlier prereleases. See bound evaluation for the exact rules.
π Use enums consistently for variable kinds (#1196)#
DecisionVariable now accepts and returns Kind
values consistently, restoring the behavior of v2. If your v3 prerelease code
passes a raw integer as the variable kind, replace it with an enum member such
as Kind.Binary or Kind.Integer. Existing convenience constructors such as
DecisionVariable.binary() and aliases such as DecisionVariable.BINARY
remain available. See the migration guide.
β Violation values for all special constraints and unified feasibility (#1213)#
All special constraints β Indicator, OneHot, and SOS1 β now have a nonnegative
violation value, so you can inspect how much each constraint is violated.
For every constraint type, including regular constraints, feasibility is now
determined by the same rule: violation <= atol. This rule applies to each
constraint individually.
Indicator constraints use the inner constraintβs violation when enabled and
zero when disabled. OneHot (exactly one member is 1 and the rest are 0) and
SOS1 (at most one member is nonzero) measure the smallest total absolute change
to member values needed to satisfy the constraint. Tolerance applies to that
total, so a group of small deviations can now be infeasible even if each was
previously accepted.
To migrate your code, use total_violation() in place of
total_violation_l1(); total_violation_l2() has been removed. The total sums
violations of regular, Indicator, OneHot, SOS1, and removed constraints.
solution.total_violation()
solution.constraint_violation(30, kind="one_hot") # existing OneHot constraint ID
solution.constraints_df(kind="sos1")[["feasible", "violation"]]
For individual constraints, replace EvaluatedConstraint.feasible with
is_feasible(atol=...) and call SampledConstraint.feasible(atol=...) as a
method. Pass solution.feasibility_atol or sample_set.feasibility_atol to use
the tolerance from the original evaluation. Solution and SampleSet feasibility
properties remain available.
SampleSet feasibility and best-feasible selection also check variable bounds
and kinds, matching the extracted Solution. These variable checks do not add to
total_violation(): a zero total alone does not prove that a solution is feasible.
When saving results with a custom tolerance or special constraints, use v2 serialization. Legacy v1 export recomputes feasibility at the SDK default tolerance for the regular constraints and variable values it retains. See the migration guide for details.
β Convert Big-M formulations to SOS1 constraints in batches (#1197, #1223, #1221)#
If your model expresses βat most one variable is nonzeroβ using binary
selectors and Big-M inequalities, promote_sos1_big_m()
can replace that formulation with an explicit SOS1 constraint after checking
that it describes the same mathematical problem.
The request now groups multiple formulations, keyed by the ID of each
constraint limiting the sum of selectors. For an existing formulation
x[2] + x[3] <= 1 with binary members and regular constraint ID 202:
from ommx import Sos1BigMPromotionRequest, Sos1BigMSelectorClaim
request = Sos1BigMPromotionRequest({
202: {
2: Sos1BigMSelectorClaim.reused(),
3: Sos1BigMSelectorClaim.reused(),
},
})
report = instance.promote_sos1_big_m(request)
print(report.promoted) # source constraint ID -> new SOS1 constraint ID
print(report.rejections) # source constraint ID -> reason for rejection
The default mode="best_effort" applies independent valid formulations and
reports rejected ones. Use mode="strict" to require every formulation to
succeed; a rejection raises Sos1BigMPromotionBatchRejectedError
and leaves the entire instance unchanged.
Remove the former atol argument. Conversion now checks mathematical
equivalence independently of evaluation tolerance. It also derives tighter
variable bounds from the supplied Big-M inequalities and applies them together
with each successful conversion. For example, 0 <= y <= 10 and y <= 3*z
with binary z allow the upper bound of y to become 3.
The objective and feasible assignments of the original variables are preserved,
but violation values and decisions near a numerical tolerance boundary can differ.
See the SOS1 conversion guide for requests with
separate selector variables and full migration examples.
Tighten variable bounds using linear constraints (#1220)#
You can now narrow variable bounds using information already present in your
constraints. For example, for integer variables, x + y <= 7 and y >= 2
imply x <= 5:
from ommx import DecisionVariable, Instance, Sense
x = DecisionVariable.integer(0, lower=0, upper=10)
y = DecisionVariable.integer(1, lower=2, upper=10)
instance = Instance.from_components(
decision_variables=[x, y], objective=x,
constraints={100: x + y <= 7}, sense=Sense.Minimize,
)
changed = instance.tighten_bounds_simultaneously_once()
assert changed[0].upper == 5
The method updates the instance and returns a dictionary of changed variable
IDs and their new bounds. To use selected constraints, call
instance.tighten_bounds_simultaneously_once_using_constraints({100}).
Each call makes one pass using the bounds at entry; call again to propagate
new bounds through other constraints.
Only supported linear constraints are used. Nonlinear constraints, composed
expressions, special constraints, and linear constraints with more than
max_terms variable terms (default: 32) are skipped. Numerical rounding can
affect feasibility near tolerance boundaries. See
Bound tightening for supported variable
types and tolerance behavior.
Read published QPLIB solutions (#1208)#
Load a published .sol file and evaluate it against the matching problem to
check its objective value and feasibility:
from ommx import Instance, State
instance = Instance.load_qplib("QPLIB_0018.qplib")
state = State.load_qplib_solution(
"QPLIB_0018.sol", num_variables=len(instance.decision_variables)
)
solution = instance.evaluate(state, atol=1e-8)
Variables omitted from the solution file receive zero. The reported objvar
is not treated as a variable; evaluation computes the objective from the model.
ommx.qplib.load_solution is also available. See the
QPLIB tutorial for supported files.
3.0.0 Beta 5#
π Inclusive atol boundaries (#1181)#
Absolute-tolerance comparisons now include the exact boundary consistently.
Equality residuals are feasible when \(|f(x)| \leq \mathtt{atol}\), while
inequality residuals are feasible when \(f(x) < 0\) or
\(|f(x)| \leq \mathtt{atol}\). The same rule is used by scalar and sample
evaluation, regular and Indicator constraints, Solution and SampleSet
validation, and v1/v2 deserialization.
Function zero-sensitive operations, Indicator activation, OneHot and SOS1
classification, discrete solver-state canonicalization, and fixed-value
consistency checks also include values exactly atol from their target.
Values just outside the boundary remain outside. APIs that own finite-value
validation continue to reject NaN and infinity before reporting domain or
consistency errors.
π Canonical solver states during evaluation (#1174, #1181)#
populate_state(), evaluate(), and
evaluate_samples() now canonicalize finite supplied
coordinates that are neither fixed nor dependent, as well as values derived for
dependent targets, according to each decision-variable kind and the callerβs
atol. For those coordinates, Binary values whose distance from 0 or 1 is
at most atol, and Integer or SemiInteger values whose distance from an integer
is at most atol, are stored as the corresponding exact discrete value,
including values exactly at the tolerance boundary. Kind and bound feasibility
continue to be checked separately under their existing rules. Continuous and
SemiContinuous values are never rounded. Other
finite values are preserved so a returned Solution can report
their feasibility, while non-finite state values remain rejected.
A caller-supplied fixed or dependent value remains a consistency assertion.
After validation, the returned state uses the Instance-owned fixed value
unchanged or the dependency-derived value canonicalized for its target kind.
Dependencies are evaluated from canonicalized inputs, so an explicitly supplied
dependent assertion computed from the raw solver vector can now be rejected if
it is not within atol of that derived value. Scalar and sample evaluation use
the same rule while preserving SampleID membership.
partial_evaluate() applies the same rule to its validated
input before special-constraint propagation, expression substitution, and
constant dependency evaluation. Evaluating the rewritten Instance therefore
uses the same canonical coordinates as directly evaluating the original
Instance. Existing Instance-owned fixed values remain unchanged, and values
outside partial evaluationβs existing kind and bound acceptance contract remain
rejected.
β Composed Function operations (#1158, #1178, #1181, #1184)#
Function can now represent composed expressions as well as
compact polynomials. Python users can construct absolute values, sign
functions, minima, maxima, divisions, and signed 32-bit integer powers directly:
from ommx import DecisionVariable, Function
x = Function(DecisionVariable.continuous(1))
y = Function(DecisionVariable.continuous(2))
z = Function(DecisionVariable.continuous(3))
f = abs(x - 2).maximum(y) / (z + 1)
g = f**2
h = f.powi(-2)
Use signum(), minimum(), and
maximum() for the named operations. f**n and
powi() are equivalent; floating-point or function-valued
exponents and reverse exponentiation are not supported. At evaluation,
abs(value) <= atol is classified as zero, including the boundary: signum
returns 0, while division by such a denominator and raising such a value to a
negative integer power raise ValueError. Composed expressions are serialized
as flat reverse Polish notation (RPN) instruction sequences in OMMX protobuf
payloads. All bundled adaptersβHiGHS, Python-MIP, PySCIPOpt, and OpenJijβ
currently declare polynomial-only input classes and therefore reject composed
expressions in the function positions they accept.
Zero-sensitive Signum, Div, and negative Powi nodes are retained through
construction, substitution, and partial evaluation so that a later evaluation
call supplies the atol that determines their result or domain error. In
particular, dividing a Function by a constant or coefficient remains a
composed expression. Function.evaluate_bound(..., atol=...) applies the same
zero classification to interval bounds. Indicator Big-M conversion,
special-constraint lowering, and integer-slack conversion also accept an
optional atol= for bounds they derive. This aligns zero-sensitive Function
body evaluation; algebraic lowering still assumes exact discrete values and
does not canonicalize approximate solver output near 0 or 1. Omitting atol
uses DEFAULT_ATOL. Integer-slack conversion classifies an inequality with the
same inclusive rule used by evaluation, \(f(x) < 0\) or
\(|f(x)| \leq \mathtt{atol}\), before coefficient normalization, so a small
positive value is not silently reclassified by scaling. Exact polynomial
normalization retains every finite nonzero coefficient and does not apply an
implicit tolerance-based cleanup. A future approximate-cleanup API would be
separate and explicit. Subtraction follows the same canonicalization as
addition with a negated right-hand side, so x - (-y) compacts to a polynomial
when possible and coefficient overflow is raised immediately as ValueError.
Because a Function is no longer necessarily a polynomial,
degree() and num_terms() return
None for composed expressions. Polynomial-term accessors and
content_factor() raise TypeError instead of treating a
composed expression as an empty polynomial. See the
Function user guide for operation ordering,
evaluation errors, and serialization details.
The instance-class API now names that polynomial requirement explicitly. This is a breaking rename from the API published in Python SDK 3.0.0 Beta 4:
Before |
After |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
PolynomialRequirement.any_degree() accepts a polynomial of any degree; it
does not admit a composed, non-polynomial Function.
π Checked SOS1 Big-M promotion (#1186)#
An SOS1 constraint allows at most one member to be non-zero. A Big-M formulation represents this condition with binary selectors, member-selector link constraints, and a selector-cardinality constraint.
A Sos1BigMPromotionRequest claims that the current Instance
contains such a formulation. This is an independent transformation request,
not a rollback or inverse of lowering. The promotion checker verifies that the
current variables, domains, and rows satisfy sufficient conditions that justify
replacing the claimed formulation rows with a first-class SOS1 constraint.
from ommx import Sos1BigMPromotionRequest, Sos1BigMSelectorClaim
request = Sos1BigMPromotionRequest({
102: {
0: Sos1BigMSelectorClaim.reused(),
1: Sos1BigMSelectorClaim.fresh(10, upper_link=100, lower_link=101),
},
})
report = instance.promote_sos1_big_m(request, mode="strict")
sos1_id = report.promoted[102]
The example uses the current strict-batch call shape for one request. If the
claimed formulation fails validation,
Sos1BigMPromotionBatchRejectedError is raised and the Instance
remains unchanged. The returned Sos1BigMPromotion is a batch
report whose promoted map identifies the new SOS1 constraint. Membership,
selector reconstruction, and removed rows can be queried from the Instance.
π Incremental constructors for interval-domain variables (#1185)#
Instance can now create every existing interval-domain decision
variable kind while assigning numeric IDs automatically. In addition to
new_binary(), use
new_integer(),
new_continuous(),
new_semi_integer(), or
new_semi_continuous():
from ommx import Instance
instance = Instance.minimize()
count = instance.new_integer("count", lower=0, upper=10)
amount = instance.new_continuous("amount", lower=0)
batch = instance.new_semi_integer("batch", lower=2, upper=10)
rate = instance.new_semi_continuous("rate", lower=0.5, upper=4)
Each method returns an AttachedDecisionVariable that can be used
directly in expressions. name remains the optional positional argument, while
subscripts, parameters, and description are keyword-only. The four new
methods also accept keyword-only lower and upper bounds. new_integer and
new_semi_integer additionally accept keyword-only atol; omitting it uses the
current default returned by get_default_atol(). For these two
methods, a finite lower endpoint is normalized to ceil(lower - atol) and a
finite upper endpoint to floor(upper + atol). If the normalized interval has
no integer, new_integer raises ValueError, while new_semi_integer retains
its zero alternative as [0, 0]. See the Instance user
guide for the incremental workflow.
Each constructor validates the complete variable definition before committing
the row and modeling label together to the Instance. An invalid
bound or tolerance, or a maximum existing decision-variable ID of 2**64 - 1
that prevents assignment of a larger automatic ID, leaves no partial variable
or label behind.
3.0.0 Beta 4#
β solve() and sample() now prepare inputs automatically (#1166)#
SolverAdapter.solve() and SamplerAdapter.sample() now apply the Adapterβs
recommended Preparation before execution and leave the supplied
Instance unchanged. Applications that customize Preparation
should prepare the Instance in place and call
solve_without_preparation() or sample_without_preparation() instead.
The breaking changes that require updates to existing code are:
OMMXOpenJijSAAdapter.sample(..., initial_state=...)andOMMXOpenJijSAAdapter.solve(..., initial_state=...)are no longer accepted. Prepare the Instance explicitly, then callOMMXOpenJijSAAdapter.sample_without_preparation(instance, initial_state=...)orOMMXOpenJijSAAdapter.solve_without_preparation(instance, initial_state=...).Custom Adapters must implement
solve_without_preparation()orsample_without_preparation()and move backend execution there. If Adapter-specific options remain available onsolve()orsample(), those methods prepare a copy and forward the options to the preparation-free method.
HiGHS and Python-MIP omit dual values when the Adapter input has an
output_objective. See the
Python SDK v2 to v3 Migration Guide for
the two calling workflows.
β Preserve input objectives across solver Preparation (#1167)#
to_qubo() and to_hubo() now leave the active Instance as the minimization
energy used by a solver, while Solution and SampleSet retain the objective
semantics exposed by the input instance:
from ommx import DecisionVariable, Instance, Sense
x = DecisionVariable.binary(0)
instance = Instance.from_components(
sense=Sense.Maximize,
objective=x,
decision_variables=[x],
constraints={0: x == 1},
)
instance.to_qubo(uniform_penalty_weight=2.0)
state = {0: 0.0}
assert instance.sense == Sense.Minimize
assert instance.objective.evaluate(state) == 2.0
assert instance.evaluate(state).sense == Sense.Maximize
assert instance.evaluate(state).objective == 0.0
assert instance.evaluate_samples({0: state}).sense == Sense.Maximize
assert instance.evaluate_samples({0: state}).objectives[0] == 0.0
This is a breaking correction from the latest stable Python SDK, whose drivers restored the active sense and used the penalized solver energy as the evaluated objective. The returned QUBO/HUBO coefficients keep the same meaning.
An Instance or ParametricInstance with an explicit output objective cannot
be represented losslessly by the v1 wire format. Its to_v1_bytes() method
therefore raises RuntimeError; use to_v2_bytes() for these models.
Penalty rewrites also map solver-reported optimality conservatively: when an
active-formulation proof does not transport, evaluated output remains
Optimality.Unspecified. Executable postconditions are documented on
to_qubo(), to_hubo(),
evaluate(), and
evaluate_samples(). See the
Python SDK v2 to v3 Migration Guide for
the explicit Preparation workflow.
β Adapter applicability is defined only by INPUT_CLASS (#1163)#
SolverAdapter.check_applicability() and require_applicable() now use
INPUT_CLASS membership as the complete applicability condition. The secondary
adapter-owned precondition layer has been removed, including
AdapterPreconditionViolation, ConstraintRef, _check_preconditions(), and
the preconditions_checked and precondition_violations report fields.
Both methods now return InstanceClassMembershipReport directly, and the
AdapterApplicabilityReport wrapper has been removed. Replace
report.is_applicable with report.is_member and report.input_membership
with report. For AdapterNotApplicableError, error.report is the membership
report itself and the Adapter identity is available as error.adapter.
Adapter implementations must express every accepted-model condition in
INPUT_CLASS. Converter-local representation checks and backend limits remain
in the solver-input construction path; their failures are conversion or backend
errors rather than AdapterNotApplicableError. In particular, OpenJij signed-ID
and finite-coefficient validation now occurs when sampler input is built. See
Adapter input classes and the
Adapter implementation tutorial for the
responsibility boundary.
Adapter input classes are required (#1160)#
Concrete SolverAdapter and SamplerAdapter implementations must declare
INPUT_CLASS as a non-optional ClassVar[InstanceClass]; None is not a valid
declaration. In-repository adapters now expose InstanceClass directly, so
callers can pass Adapter.INPUT_CLASS to prepare() without
an is not None guard. A missing declaration still produces a clear TypeError
when applicability is checked. See the Adapter implementation tutorial
for the complete contract.
3.0.0 Beta 3#
π Model and sample errors follow caller ownership (#1104, #1105, #1107)#
Function, polynomial, constraint, named-function, and Instance evaluation
APIs use the shared Rust-to-Python error boundary directly. Missing or unknown
caller-owned state, invalid caller-provided values, and recoverable
dependent-variable assertions raise ValueError. Direct function and
polynomial partial evaluation preserves CoefficientError, while coefficient
failures from Instance-owned dependency normalization and removed-constraint
restoration fall back to RuntimeError.
Decision-variable insertion and substitution, Function.content_factor, and
OneHot/SOS1 construction also use this boundary directly. Duplicate decision
variable or parameter IDs, invalid substitutions, unrepresentable content
factors, and empty structural constraints raise ValueError while retaining
their Rust SDK signal owner. Untyped defensive invariant failures continue to
fall back to RuntimeError.
Samples.append propagates duplicate sample IDs through the same boundary and
validates every incoming ID before mutation, so a failed append leaves the
collection unchanged. Instance.random_samples reports inconsistent state
group counts and an undersized inclusive sample-ID range as ValueError.
Full u64 ID ranges and valid positive partitions are generated without
integer overflow or a strategy panic.
π One preparation workflow across solver adapters (#1147, #1152, #1153, #1154)#
Different solvers accept different kinds of models. Beta 3 adds one explicit
workflow for adapting an Instance to the solver you want to use:
start from the adapterβs recommendation, adjust choices that depend on your
application, call prepare(), and pass that same instance to
the adapter. Direct adapter calls remain strict: they do not prepare or mutate
the supplied instance to make it applicable.
from ommx_highs_adapter import OMMXHighsAdapter
input_class = OMMXHighsAdapter.INPUT_CLASS
assert input_class is not None
policy = OMMXHighsAdapter.recommended_preparation_policy()
# Adjust the policy here if your application needs different choices.
instance.prepare(input_class, policy)
solution = OMMXHighsAdapter.solve(instance)
HiGHS, Python-MIP, PySCIPOpt, and OpenJij all support this workflow. Each recommendation covers the model changes commonly needed by that solver, while choices without a safe universal default stay under your control. For example, Beta 3 applies fixed penalties in the direction appropriate to minimization or maximization when preparing a constrained model for OpenJij; you still choose the magnitude.
prepare() updates the instance in place. When you evaluate returned solutions
and samples, OMMX can restore source-variable values and check constraints that
were removed during preparation. If a later preparation step fails, changes
from earlier completed steps remain. If you are migrating from v2 or Beta 2,
replace OpenJijβs model-conversion options and the prerelease
OpenJijPreparation* APIs with this common workflow. See
Sampling with OpenJij for a
complete example and the
Python SDK v2 to v3 Migration Guide for
the corresponding API replacements.
3.0.0 Beta 2#
β SolverAdapter.INPUT_CLASS and explicit OpenJij preparation (#1084, #1085, #1086, #1087, #1088)#
SolverAdapter now defines INPUT_CLASS, which represents
the set of Instance values an adapter can handle directly without
transformation. An operation that transforms a source instance into a member of
INPUT_CLASS is called Prepare. Preparation is currently available only for
OpenJij; a future update will standardize it as part of the SolverAdapter
workflow (#1111).
INPUT_CLASS is an InstanceClass: a finite union of
InstanceClassClause descriptions. A clause can constrain the
variable kinds in use, objective and constraint degrees, regular and Indicator
relations, OneHot and SOS1 presence, and optimization senseβfor example, to
accept only linear models. See InstanceClassClause for the full
set of conditions. Membership is evaluated on the exact input and
InstanceClassMembershipReport reports structured per-clause
mismatches.
OMMXHighsAdapter, OMMXPythonMIPAdapter, OMMXPySCIPOptAdapter, and
OMMXOpenJijSAAdapter each declare their concrete input class. Inputs outside
that class are rejected before backend construction with
AdapterNotApplicableError; use
check_applicability() to inspect structured mismatches. HiGHS and Python-MIP
accept linear models, PySCIPOpt accepts its supported quadratic, Indicator, and
SOS1 forms, and OpenJij accepts unconstrained binary minimization problems.
Adapters no longer lower special constraints implicitly. Use
active_special_constraint_kinds to inspect them and
lower_special_constraints() to lower selected kinds
explicitly; this operation is separate from input-class membership.
OpenJij no longer performs integer encoding, sense reversal, slack conversion,
special-constraint lowering, or penalty selection inside sample() or
solve(). Prepare a separate input explicitly and evaluate the result against
the source model when source semantics are required:
from ommx_openjij_adapter import (
OMMXOpenJijSAAdapter,
OpenJijPreparationConfig,
)
config = OpenJijPreparationConfig(
uniform_penalty_weight=20.0,
)
preparation = OMMXOpenJijSAAdapter.prepare(source, config=config)
prepared_samples = OMMXOpenJijSAAdapter.sample(preparation.input)
source_samples = preparation.evaluate_source(prepared_samples)
Finite penalties and approximate integer slack now require explicit opt-in.
Every prepared value is a new Instance, so applicability must be
checked on preparation.input, not inferred from the source. See
Adapter input classes and the
OpenJij tutorial for the
accepted model classes and preparation details.
When migrating from 2.6.1, catch AdapterNotApplicableError instead of an
adapter-specific exception for unsupported input. The canonical infeasibility
exception is ommx.InfeasibleDetected (also available through the existing
ommx.adapter alias). Replace response_to_samples() with
decode_to_samples() and sample_qubo_sa() with the explicit workflow above;
the replacement returns an evaluated SampleSet rather than raw Samples.
π Durable lifecycle reasons for Experiments and Runs (#1109)#
Failed and interrupted Experiments and Runs can now retain a concise reason in
the Experiment config. Python context managers record the exception type and
message automatically, and expose the durable value through
lifecycle_reason and
lifecycle_reason.
from ommx.experiment import Experiment
try:
with Experiment("example.com/team/experiment:latest") as experiment:
with experiment.run():
raise RuntimeError("solver process exited")
except RuntimeError:
pass
assert experiment.lifecycle_reason == "RuntimeError: solver process exited"
assert experiment.runs[0].lifecycle_reason == "RuntimeError: solver process exited"
The reason survives archive and registry transport. Exception reasons captured
by Python context managers collapse whitespace and are limited to 512 Unicode
characters, with longer values ending in an ellipsis. This bounds the durable
metadata but does not redact it. Lifecycle reasons are not adapter diagnostics;
do not include secrets, tracebacks, local variables, or environment values.
Existing Experiment artifacts without an outcome detail continue to load with
None.
π Rust SDK errors use consistent Python exceptions (#1087, #1090, #1096, #1097, #1099, #1100, #1101, #1102)#
Python bindings now translate OMMX-owned Rust SDK signal types at a shared PyO3 error boundary instead of selecting exception classes separately at each entry point. The mapping follows the owner and meaning of the failure:
invalid input, malformed OMMX protobuf or QPLIB data, and domain operations with invalid or unsatisfied preconditions raise
ValueError;missing variables, constraints, samples, named functions, Artifact layers, or Experiment and Run attachments raise
KeyError;unclassified SDK and infrastructure failures continue to fall back to
RuntimeError.
Python argument-extraction failures remain TypeError, and exceptions raised
by Python code pass through unchanged. Error messages retain OMMX field and
source context. This policy now applies consistently across model operations,
parsers, Artifact and Experiment APIs, attachments, registry operations, and
solver or sampler logging.
Remote load() and
load() failures now follow the same policy.
All inherit from RemoteArtifactError; catch
RemoteArtifactNotFoundError for a missing exact ref
without confusing it with authentication, authorization, transport, or invalid
Artifact failures.
Integer preparation operations expose three additional RuntimeError-compatible
specializations. log_encode() raises
LogEncodingError when exact encoding is unavailable,
ExactIntegerSlackError when exact slack conversion can be
replaced by an explicit approximate alternative, and
InfeasibleDetected when bounds prove infeasibility. Existing
except RuntimeError handlers continue to work; catch these concrete types
when recovery depends on the reason.
π VariableIDLike inputs for structural constraints (#1078)#
Structural-constraint construction now accepts VariableIDLike, defined as
int | DecisionVariable | AttachedDecisionVariable, wherever only a variableβs
identity is required. This applies to OneHotConstraint,
Sos1Constraint, IndicatorConstraint, and
Constraint.with_indicator(). The
constraints still store OMMX variable IDs internally, and their ID getters
continue to return integers.
from ommx import DecisionVariable, OneHotConstraint, Sos1Constraint
xs = [DecisionVariable.binary(i) for i in range(3)]
one_hot = OneHotConstraint(variables=xs)
sos1 = Sos1Constraint(variables=[x.id for x in xs])
indicator = (xs[0] <= 1).with_indicator(xs[1])
APIs that are intrinsically ID collections or mappings, such as
log_encode(), remain ID-based.
See Special constraints for the modeling workflow.
π Incremental Instance modeling (#1077)#
Instance can now own numeric ID assignment while a model is
built incrementally. Start with maximize() or
minimize(), create attached binary variables with
new_binary(), then set the objective and add constraints
directly. The existing from_components() workflow remains
available when components already have explicit IDs.
The ambiguous Instance.empty() compatibility alias is deprecated for static
type checkers; use Instance.minimize() instead.
from ommx import Instance
instance = Instance.maximize()
x = instance.new_binary("x")
y = instance.new_binary("y")
instance.objective = x + y
instance.add_constraint(x - y == 1, "c1")
new_binary and add_constraint accept the complete modeling label: name,
subscripts, parameters, and description. See the
Instance user guide for the complete workflow.
If the maximum decision-variable ID is already 2**64 - 1, new_binary
raises ValueError instead of propagating a Rust panic.
3.0.0 Beta 1#
β Legacy v1 ConstraintHints remain advisory (#1058)#
When Instance.from_v1_bytes or ParametricInstance.from_v1_bytes reads a legacy v1 payload, it now ignores ConstraintHints and preserves the referenced regular constraints and their context. Even a structurally plausible hint is not automatically promoted to a first-class one-hot or SOS1 constraint, so unverified metadata cannot change the feasible set or required adapter capabilities. Imported instances can also be serialized back to v1 because no special constraint is introduced implicitly.
Construct first-class special constraints from trusted modeling input rather than from a legacy hint alone. See the Python SDK v2 to v3 Migration Guide for details.
β Dedicated Experiment artifact type (#1033)#
Committed Experiment artifacts now write application/org.ommx.v1.experiment
as the OCI Manifest artifactType instead of the generic
application/org.ommx.v1.artifact type. Loading an Experiment validates this
root artifact type before decoding the Experiment config, so a generic Artifact
is not interpreted as an Experiment merely because its config descriptor uses
the Experiment config media type.
This intentionally does not preserve compatibility with Experiment artifacts
created by earlier 3.0 alpha builds that wrote the generic
application/org.ommx.v1.artifact artifactType. Those alpha artifacts must be
recreated with a build that writes the dedicated Experiment artifact type.
π Experiment Sampling records (#1055)#
log_sample() calls a
SamplerAdapter and records its complete
SampleSet as a separate Sampling
record. A successful sampling call remains finished even when the SampleSet
contains no feasible samples. Solver calls remain Solve
records whose output is Solution | None.
from ommx import SampleSet
from ommx.experiment import Experiment
from ommx_openjij_adapter import OMMXOpenJijSAAdapter
with Experiment() as experiment:
with experiment.run() as run:
sample_set = run.log_sample(OMMXOpenJijSAAdapter, instance, num_reads=100)
output = experiment.runs[0].samplings[0].output
assert isinstance(output, SampleSet)
Run.log_sample(..., store_diagnostics=True) uses the same adapter diagnostics
channel as Run.log_solve. See the Experiment management
tutorial for the Solve and Sampling recording model.
π Transparent attachment compression and streaming writes (#1054)#
Experiment and Run attachment
logging methods now accept compression="zstd". OMMX stores the compressed
layer with a +zstd media-type suffix and a reserved compression annotation,
while attachment_media_type, get_attachment, typed getters, codecs, and
file export expose the original media type and decompressed payload. Readers
only decompress marked layers, so logical media types ending in +zstd remain
unambiguous.
experiment.log_json("trace", trace_values, compression="zstd")
experiment.log_file("solver-log", log_path, compression="zstd")
log_file now streams the source file into the Local Registry instead of
buffering the whole file before the content-addressed write.
π Local Registry ref deletion and Experiment retention (#1053)#
ommx.artifact.remove_image() removes a named or anonymous image ref from the
Local Registry without deleting its content-addressed blobs and returns the
atomically removed Manifest digest for rollback, or None when the ref did not
exist. The CLI equivalent is ommx rm <ref>. Its output explains that
unreferenced data remains until a separate ommx gc --delete removes it after
the grace period.
Deletion output includes a copyable ommx restore-ref <ref> <manifest-digest>
command. The equivalent Python API is ommx.artifact.restore_image(). Restore
validates the complete Manifest closure still present in the CAS, is serialized
against deleting GC passes, and refuses to replace a ref that has since moved
to another digest.
ommx.artifact.prune_anonymous() now accepts experiments=True to include
anonymous Experiment refs and older_than="7d" for age-based retention. The
CLI exposes the same behavior through ommx prune-anonymous --experiments --older-than 7d. See Experiment cleanup
for the complete reachability and GC workflow.
from ommx.artifact import prune_anonymous, remove_image, restore_image
removed_digest = remove_image("example.com/team/experiment:obsolete")
assert removed_digest is not None
restore_image("example.com/team/experiment:obsolete", removed_digest)
prune_anonymous(delete=True, experiments=True, older_than="7d")
π Configurable Experiment autosave frequency (#1052)#
Experiment can now batch, rate-limit, or disable the
rolling draft checkpoints written after Runs close. The default remains one
checkpoint per closed Run. Autosave policies belong to the current unsealed
session and do not disable failed or interrupted checkpoints produced when an
Experiment context exits exceptionally.
from ommx.experiment import AutosavePolicy, Experiment
experiment = Experiment("example.com/team/sweep:latest")
experiment.set_autosave_policy(AutosavePolicy.every_n_runs(25))
Use AutosavePolicy.min_interval(seconds) for time-based rate limiting or
AutosavePolicy.disabled() when Run-close recovery checkpoints are not needed.
See Experiment Discovery, Recovery, and Cleanup
for the recovery and storage tradeoffs.
π Artifact and Experiment listing from the Local Registry (#1029)#
ommx.artifact.list_artifacts() now lists every OMMX Artifact ref from the
SQLite Local Registry. The returned ArtifactRef records include the image
name, Manifest and Config digests, update timestamp, artifactType, Manifest
annotations, and the complete OCI Manifest as a Python dict.
ommx.experiment.list_experiments() provides the Experiment-specific view. Its
ExperimentRef records additionally include status, run/solve counts, and the
complete Experiment Config. Both functions accept an optional prefix filter
matched against the full image reference string.
Internal Experiment checkpoint refs are hidden from list_artifacts() by
default. ommx.experiment.list_experiment_checkpoints() provides the recovery
view, filtering by the original requested image-name prefix and any combination
of draft, failed, and interrupted status. Pass
list_artifacts(..., include_internal=True) only when diagnosing the underlying
registry refs.
Manifest and Experiment Config JSON are cached in SQLite under their content
digests. A missing row is backfilled from the CAS on listing; subsequent
listings do not need to construct each Experiment. Existing version 1 Local
Registries are migrated to version 2 in place while preserving refs and the
registry ID. Invalid per-ref cache entries are repaired from the CAS when
possible and otherwise skipped with RuntimeWarning. Malformed individual ref
identities are also warned and skipped; strict=True turns these individual
failures into errors. SQLite schema, query, and cache-write failures remain hard
errors.
Experiments can also store caller-owned manifest annotations with
Experiment.set_annotation(...); OMMX-reserved annotation keys remain rejected.
from ommx.artifact import list_artifacts
from ommx.experiment import Experiment, list_experiment_checkpoints, list_experiments
with Experiment("example.com/team/experiments/demo:latest") as experiment:
experiment.set_annotation("com.example.problem", "demo")
refs = list_experiments("example.com/team/experiments")
assert refs[0].annotations["com.example.problem"] == "demo"
assert refs[0].config["status"] == "finished"
artifacts = list_artifacts("example.com/team")
assert artifacts[0].manifest["artifactType"].startswith("application/org.ommx")
recoverable = list_experiment_checkpoints(
"example.com/team/experiments",
statuses=["draft", "failed", "interrupted"],
)
Local Registry refs now store only their target manifest digest. Consequently,
AnonymousArtifactRef.size and AnonymousArtifactRef.media_type are removed;
descriptor fields are no longer part of the ref listing API.
π Non-finite float Run parameters (#1043)#
log_parameter() now accepts float("inf"),
-float("inf"), and float("nan"). These values round-trip through committed
Experiment artifacts and are restored by
run_parameters_df() with pandas nullable
dtypes. This keeps legitimate experiment observations such as unbounded ratios
or infeasibility summaries distinct from missing cells: logged NaN remains a
float NaN, while missing float cells are represented as pandas NA.
The run-parameter table layer is stored as MessagePack instead of JSON so it can preserve IEEE 754 non-finite values. Individual NaN payload bits are not part of the API guarantee.
π Unary integer encoding (#1010)#
unary_encode() is now available as a sampler-friendly
alternative to log_encode() for finite integer variables.
For an integer variable x in [lower, upper], unary encoding introduces
upper - lower binary variables and substitutes x = lower + sum(b).
Every binary assignment decodes to a value in the original integer range, so
no encoding-validity constraint or penalty is added. Since the number of
auxiliary variables grows linearly with the range width, prefer this encoding
for narrow ranges and keep using log encoding for wider variables. To avoid
accidental large allocations, Instance.unary_encode() rejects variables with
range width above max_range (default: 16); pass a larger max_range
explicitly when the auxiliary-variable cost is intentional.
Both Instance.unary_encode(..., atol=...) and Instance.log_encode(..., atol=...)
use the same ATol-aware integer-bound normalization as the rest of the SDK.
Instance.log_encode() rejects integer ranges that would require more than 53
auxiliary binary variables instead of accepting impractically large encoded
search spaces.
Both encoders also reject non-point integer ranges outside the unit-spaced
float integer interval, so every accepted encoded integer value remains
distinguishable after adding the encoding offset.
Passing an explicit fixed decision-variable ID is rejected before substitution,
so fixed values and dependent-variable assignments remain disjoint.
from ommx import DecisionVariable, Instance
x = DecisionVariable.integer(0, lower=2, upper=5)
instance = Instance.from_components(
sense=Instance.MAXIMIZE,
objective=x,
decision_variables=[x],
constraints={},
)
instance.unary_encode({0})
π Context-aware function formatting (#1004, #1011)#
Instance and ParametricInstance now provide
format_function() / format_function()
for rendering a function with decision-variable and parameter modeling labels.
The context-free Function text representation remains raw-ID
based.
Calling str() / repr() on Instance or
ParametricInstance now prints a compact summary with
context-aware objective, constraint, and named-function expressions. This makes
print(instance) useful for checking that encoded IDs still correspond to the
modeling labels imported from upstream modeling tools.
For notebook previews, use display_function() or
display_function(). They return
ommx.display.FunctionDisplay, which keeps truncation metadata and
renders escaped HTML in Jupyter.
from ommx import DecisionVariable, Instance
x = [DecisionVariable.binary(i, name="x", subscripts=[i]) for i in range(2)]
instance = Instance.from_components(
sense=Instance.MINIMIZE,
objective=x[0] + 2 * x[1],
decision_variables=x,
constraints={},
)
assert instance.format_function(instance.objective) == "x[0] + 2*x[1]"
preview = instance.display_function(instance.objective)
3.0.0 Alpha 8#
β Top-level ommx is the public Python SDK namespace (#979)#
SDK domain classes are now imported from top-level ommx, not from ommx.v1. The internal PyO3 extension remains ommx._ommx_rust, but users and adapters should treat top-level ommx as the public API surface.
from ommx import Instance, DecisionVariable, Function, Solution
ommx.v1 is no longer the Python SDK object namespace. It is reserved for protobuf wire-format concepts such as schema/package names and media types, and importing SDK domain classes from ommx.v1 now raises a migration error. See the Python SDK v2 to v3 Migration Guide for the broader import migration.
β Constraint metadata setter names (#975)#
Constraint metadata replacement now consistently uses the set_* prefix. Constraint.add_name, Constraint.add_description, and the same scalar replacing aliases on AttachedX handles are removed. Use set_name and set_description instead.
add_parameters now merges the provided entries into the existing parameter map, matching add_parameter and add_subscripts. Use set_parameters when replacing the whole map.
β Protobuf-backed annotations and read-only annotation views (#939)#
Annotations on Instance, ParametricInstance, Solution, and SampleSet are now stored in the protobuf payload instead of living only in Python-side wrapper state or Artifact descriptors. to_v1_bytes() / from_v1_bytes() and to_v2_bytes() / from_v2_bytes() therefore preserve titles, licenses, solver metadata, and user extension annotations. When reading older Artifacts, descriptor-only annotations are still merged in, with protobuf metadata taking precedence if both locations define the same OMMX key.
The annotations property is now a read-only types.MappingProxyType[str, str] projection. Mutating obj.annotations[...] or assigning obj.annotations = {...} now raises an error; update OMMX metadata through dedicated properties and update user annotations with add_user_annotation, add_user_annotations, or replace_annotations.
from ommx import Instance
instance = Instance.minimize()
instance.title = "portfolio"
instance.add_user_annotation("owner", "analytics")
restored = Instance.from_v1_bytes(instance.to_v1_bytes())
assert restored.title == "portfolio"
assert restored.get_user_annotation("owner") == "analytics"
Solution and SampleSet also expose process metadata through instance, solver, parameters, start, and end; those fields round-trip through both protobuf bytes and Artifacts.
π Instance.populate_state for complete solver states (#944)#
populate_state() is now exposed in the Python SDK. It validates a partial solver state against an Instance and returns a State containing every decision variable by filling fixed variables, irrelevant variables, and dependent variables owned by the Instance.
from ommx import DecisionVariable, Instance
x = {i: DecisionVariable.continuous(i) for i in [1, 2, 5, 10, 99]}
instance = Instance.from_components(
decision_variables=list(x.values()),
objective=x[1] + x[2],
constraints={},
sense=Instance.MINIMIZE,
)
instance.substitute({10: x[1] + x[2], 5: x[10] + 1})
instance = instance.partial_evaluate({99: 4.0})
state = instance.populate_state({1: 2.0, 2: 3.0})
assert state.entries == {1: 2.0, 2: 3.0, 5: 6.0, 10: 5.0, 99: 4.0}
β Decision variable role queries on Instance (#946)#
The Python SDK no longer exposes DecisionVariableUsage or DecisionVariableUsageEntry objects. Use used_decision_variables when adapters need the solver input variables, and use decision_variable_role(), decision_variable_roles(), fixed_decision_variables(), dependent_decision_variable_ids(), and irrelevant_decision_variable_ids() to query state roles directly from the owning Instance.
decision_variables_df() continues to include the state_role column, so DataFrame-based workflows can inspect used, fixed, dependent, and irrelevant roles without constructing a separate usage object.
β Fixed decision-variable values are owned by instances (#959)#
Fixed decision-variable values are now owned by Instance / ParametricInstance instead of detached DecisionVariable objects. A detached DecisionVariable remains a modeling snapshot for the variable definition and label, but it no longer carries owner-side fixed-value state, so DecisionVariable.substituted_value is no longer available.
Use fixed_decision_variables() to inspect all fixed values, or instance.attached_decision_variable(id).substituted_value when you need the value through a variable handle. decision_variables_df() continues to include the substituted_value column, populated from the owning instance.
π Coefficient arithmetic errors are reported through Python ValueError (#953)#
Python expression construction and comparisons now propagate coefficient arithmetic errors as ValueError instead of relying on infallible Rust operators. Operations that would create non-finite coefficients, such as overflow during addition or multiplication, now fail with messages like Coefficient must be finite. Arithmetic cancellation and underflow-to-zero remove the affected term instead of storing an invalid zero coefficient.
π Adapter diagnostics progress histories for HiGHS and PySCIPOpt (#945, #948)#
The HiGHS Adapter now records MIP progress snapshots from the HiGHS logging callback and records a termination report before decoding, so final status, MIP bounds, gap, feasibility summaries, runtime, and version metadata remain available even when decoding raises. The new HighsDiagnosticsAnalyzer can analyze either typed diagnostics collected during a direct solve or dictionaries loaded from an Experiment.
PySCIPOpt progress histories now include a synthetic TERMINATION row when diagnostics include a termination report, so progress_history_records and progress_history_df include the final solver state without duplicating the separate termination report.
See Adapter-specific Diagnostics for the direct and Experiment-based workflows.
π Versioned protobuf bytes APIs for top-level roots (#989)#
Instance, ParametricInstance, Solution, and SampleSet now expose explicit versioned protobuf bytes APIs. Use to_v1_bytes() / from_v1_bytes(...) for the legacy ommx.v1 protobuf roots, and to_v2_bytes() / from_v2_bytes(...) for the new ommx.v2 protobuf roots. Use the v2 methods when exchanging data that contains first-class indicator, one-hot, or SOS1 constraints.
The old unversioned to_bytes() / from_bytes(...) methods on these top-level roots are removed. Replace them with to_v1_bytes() / from_v1_bytes(...) when you need the legacy v1 wire format, or with the v2 methods for normalized v2 payloads.
The v1-only DTOs State, Samples, and Parameters also use to_v1_bytes() / from_v1_bytes(...) so Python byte APIs always name the protobuf version they target.
Artifact and Experiment solve payloads now store these top-level roots as ommx.v2 payloads, while still reading existing ommx.v1 payload layers for older Artifacts.
3.0.0 Alpha 7#
π Manual solver_input workflows in Experiment records (#934)#
open_solve() now opens a manual Solve scope for advanced solver features that are not covered by the Adapter API. Inside the scope, use solve.solver_input to operate the backend solver model directly, run the backend optimizer, then call solve.decode(...) so the decoded Solution becomes the Experiment Solve output. Manual adapter options can be recorded with solve.log_adapter_option(...), and store_diagnostics=True stores diagnostics recorded through solve.diagnostics until the scope exits. After the scope closes, terminal_state exposes the final outcome plus trace and diagnostics finalization state for advanced debugging.
See the Experiment management tutorial for the workflow example.
3.0.0 Alpha 6#
π Adapter-specific solve diagnostics (#913)#
Solver adapters now have an adapter-specific diagnostics channel for preserving backend solver information that does not belong in the common Solution result. Direct adapter calls can pass DiagnosticCollector to solve() through the reserved diagnostics keyword, while log_solve() owns that keyword and stores recorded diagnostics with each Experiment Solve when called with store_diagnostics=True. Experiment diagnostics are disabled by default so adapter-side collection overhead is opt-in.
The PySCIPOpt Adapter now emits SCIPProgressSnapshot diagnostics from SCIP BESTSOLFOUND and DUALBOUNDIMPROVED callbacks, plus SCIPTerminationReport after model.optimize(). The termination report includes SCIP status, primal/dual bounds, gap, incumbent objective value, node counts, LP/cut/solution counters, primal-dual integral, timing, and SCIP/PySCIPOpt version metadata. SCIPDiagnosticsAnalyzer can post-process the typed collector contents or dictionaries loaded from an Experiment into records or pandas DataFrames. With direct collection, the termination report is recorded before decoding back to an OMMX Solution, so it remains available to the caller even when decoding raises an adapter exception such as infeasible or unbounded detection.
See Adapter-specific Diagnostics for the full API workflow and the PySCIPOpt report field references.
3.0.0 Alpha 5#
See the GitHub Release above for full details. The following summarizes the main changes. This is a pre-release version. APIs may change before the final release.
π Run-scoped Experiment trace storage (#910, #916)#
Experiment, with_temp_local_registry(), and fork() now accept store_trace=True. When enabled, each with experiment.run() context captures the OpenTelemetry spans emitted inside that Run and stores one trace on the closed SealedRun. The stored trace is returned as TraceResult from trace, and is carried through commit, load, and fork.
See Tracing and Profiling for the full tracing workflow, renderers, and OpenTelemetry setup notes.
from ommx.experiment import Experiment
from ommx.tracing import render_text_tree
from ommx_highs_adapter import OMMXHighsAdapter
with Experiment.with_temp_local_registry(store_trace=True) as experiment:
with experiment.run() as run:
run.log_solve(OMMXHighsAdapter, instance)
loaded = Experiment.from_artifact(experiment.artifact)
trace = loaded.runs[0].trace
if trace is not None:
print(render_text_tree(trace))
The stored payload is OTLP protobuf, so TraceResult now owns the exported request, exposes flattened spans, and can round-trip with otlp_protobuf() / from_otlp_protobuf(). Text and Chrome trace renderers also use domain-oriented span names such as Run, solve, convert, call, and decode, and surface instrumentation scope while hiding debug-only source attributes.
β Experiment attachments are now name-indexed (#924)#
Experiment and Run attachments are now stored as name-indexed tables in the Experiment config. The public Python API is name-oriented: use attachment_names, attachment_media_type(name), get_attachment(name), the typed getters such as get_json(name) and get_instance(name), get_blob(name), get_with_codec(...), and write_attachment(...).
loaded = Experiment.from_artifact(experiment.artifact)
for name in loaded.attachment_names:
print(name, loaded.attachment_media_type(name))
value = loaded.get_attachment(name)
Descriptor-oriented attachment views from earlier 3.0 alphas, including Experiment.experiment_attachments and SealedRun.attachments, are removed. Registry-backed descriptors remain internal so attachment names, media types, file export names, and checkpoint metadata stay in the Experiment config instead of descriptor annotations.
π Experiment checkpoints and restore from interrupted sessions (#917)#
Experiment now publishes local checkpoints for partial experiment state. Closing a Run writes a best-effort draft checkpoint, and exiting an Experiment with an exception writes a failed or interrupted checkpoint instead of advancing the successful Experiment image reference. Closed Runs keep their attachments, solves, traces, and run parameters, including Runs closed as "failed" or "interrupted" after exceptions such as KeyboardInterrupt.
See Experiment Discovery, Recovery, and Cleanup for Experiment catalog filtering, Run close boundaries, checkpoint restoration, and Local Registry cleanup behavior.
Use restore_from_checkpoint() with the original Experiment image name to resume from the latest checkpoint:
from ommx.experiment import Experiment
image_name = "ghcr.io/example/team/experiment:notebook"
try:
with Experiment(image_name) as experiment:
with experiment.run() as run:
run.log_parameter("solver", "highs")
raise KeyboardInterrupt
except KeyboardInterrupt:
pass
experiment = Experiment.restore_from_checkpoint(image_name)
assert experiment.image_name == image_name
Successful commit() still publishes only the requested image reference and removes the local checkpoint when present. Checkpoint Artifact handles and checkpoint image names are intentionally not exposed in the Python API; users restore by remembering the original Experiment image name.
π Local Registry cleanup (#919)#
The ommx CLI now provides Local Registry maintenance commands for the SQLite-backed Artifact registry. Use ommx gc to report blobs that are unreachable from SQLite refs, including Experiment checkpoint refs. The command protects recently written unreachable blobs with a grace period so active Experiment writes are not deleted accidentally.
Destructive cleanup commands report by default and mutate the registry only when --delete is passed:
ommx prune-anonymous
ommx gc
ommx prune-anonymous --delete
ommx gc --delete
Normal reports show counts and sizes rather than raw digests. Pass --show-digests when low-level diagnostics are needed.
The same cleanup operations are also exposed from the Python SDK as
ommx.artifact.prune_anonymous() and ommx.artifact.gc(). These
functions are report-only by default, mutate the registry with delete=True,
and return structured report objects for notebook and script use.
π Typed attachment codecs for Experiments (#921)#
The new ommx.experiment.attachments.AttachmentCodec protocol lets packages that own Python payload types define how those values are stored as Experiment attachments. A codec class provides a media type plus encode / decode methods, and OMMX calls it through log_with_codec and get_with_codec on both Experiment-level and Run-level attachments.
See the Attachable Data Formats section of the Experiment management tutorial for a JijModeling Problem codec example.
from ommx.experiment import Experiment
class TextCodec:
media_type = "text/plain"
@staticmethod
def encode(value: str) -> bytes:
return value.encode()
@staticmethod
def decode(data: bytes) -> str:
return data.decode()
with Experiment.with_temp_local_registry() as experiment:
experiment.log_with_codec(TextCodec, "note", "created outside OMMX")
loaded = Experiment.from_artifact(experiment.artifact)
assert loaded.get_with_codec(TextCodec, "note") == "created outside OMMX"
The stored attachment media type is validated before decoding, so using the wrong codec for an attachment fails before the codecβs decode method is called.
π File attachments for Experiments (#922)#
Experiment and Run can now attach files that were produced outside OMMX. Use log_file to copy an existing file into the Experiment Artifact. OMMX stores the file bytes as an attachment blob, records the original basename for later export, and uses an explicitly provided media type or Rust SDK content-based inference with an application/octet-stream fallback.
Committed experiment and run views now also provide write_attachment to restore an attachment blob back to disk. For libraries that accept a binary file-like object, wrap the existing get_blob result with io.BytesIO.
import io
from pathlib import Path
from ommx.experiment import Experiment
with Experiment.with_temp_local_registry() as experiment:
experiment.log_file("input-spreadsheet", "input.xlsx")
loaded = Experiment.from_artifact(experiment.artifact)
spreadsheet_file = io.BytesIO(loaded.get_blob("input-spreadsheet"))
Path("restored").mkdir(parents=True, exist_ok=True)
loaded.write_attachment("input-spreadsheet", "restored/input.xlsx")
3.0.0 Alpha 4#
See the GitHub Release above for full details. The following summarizes the main changes. This is a pre-release version. APIs may change before the final release.
β SQLite-based Local Registry (#871, #872)#
In v3, local Artifact storage is organized around the SQLite-based Local Registry. Artifact blobs are stored in content-addressed storage, while image-name references and registry metadata are managed in SQLite. APIs that depended on the old disk OCI directory cache are removed; the user-facing flow is now to commit an Artifact into the Local Registry, then save / push / load that committed Artifact.
Alongside this storage model and the new Experiment API, the old ArtifactBuilder is reshaped as ArtifactDraft. An ArtifactDraft represents an uncommitted Artifact draft; after it is committed to the Local Registry, the resulting Artifact can be saved or pushed. .ommx archives are import/export exchange formats for the Local Registry. The main breaking changes are:
ArtifactBuilder.new_archiveβArtifactDraft.new+Artifact.save(new method).ArtifactBuilder.new_archive_unnamedβArtifactDraft.new_anonymous+Artifact.save(path). In v2, an unnamed archive literally had no image name and was read back asNone. In v3, an anonymous Artifact gets an automatically generated<registry-id8>.ommx.local/anonymous:<timestamp>-<nonce>image name from the Local Registry, so it can still be saved, loaded again, and cleaned up.Artifact.load_archiveraises a migration error pointing at the two replacement methods:Artifact.import_archive(imports the archive into the userβs persistent SQLite Local Registry β the v3 successor with registry-write semantics) andArtifact.inspect_archive(side-effect-free read of the manifest + layer descriptors, returns a newArchiveManifestview). v2βsload_archiveopened archives in place with no registry side effect, so the rename makes the semantic shift explicit instead of silently writing into the registry on upgrade.import_archiveaccepts v2 archives produced byArtifactBuilder.new_archive_unnamed(noorg.opencontainers.image.ref.nameannotation) by synthesizing an anonymous name on the fly;inspect_archivereads such archives back withArchiveManifest.image_name = None(no registry context for synthesis).CLI
ommx push <archive>andommx push <oci-dir>removed β load into the registry first, then push by image name.New CLI
ommx prune-anonymous [--delete]reports accumulated anonymous-commit entries by default and removes them only when--deleteis passed.ommx.get_image_dir(...)and the CLIommx image-dir <name>subcommand are removed. The return value was a v2 disk-cache path (<root>/<image_name>/<tag>/) that no longer corresponds to any v3 storage location β the SQLite Local Registry stores blobs content-addressed and refs in SQLite β so pointing users at it was actively misleading. Existing v2 caches still migrate viaommx import-legacy.
See the Python SDK v2 to v3 Migration Guide Β§13 for the full before/after code and migration checklist.
π Artifact-backed experiment management API: ommx.experiment (#882, #885, #886, #903)#
The new ommx.experiment module records experiment inputs, run conditions, and Solver/Sampler results as one OMMX Artifact. Use Experiment, Run, and Solve to store per-run comparison parameters, attachments, and solve input/output data in the Local Registry.
See the Experiment management tutorial for the basic workflow, sharing an Experiment, loading a committed Experiment, and creating derived experiments with fork.
π Run.log_solve records solve input/output and adapter options (#902)#
log_solve() is now available. Pass a subclass of ommx.adapter.SolverAdapter and an Instance; OMMX calls the adapterβs solve, then stores the input Instance, output Solution, adapter class name, and JSON-serializable keyword arguments as a Solve.
from ommx.experiment import Experiment
from ommx_highs_adapter import OMMXHighsAdapter
from ommx import Instance, Solution
with Experiment() as experiment:
with experiment.run() as run:
solution = run.log_solve(OMMXHighsAdapter, instance, verbose=False)
run.log_parameter("objective", solution.objective)
solve = experiment.runs[0].solves[0]
assert solve.adapter.endswith("OMMXHighsAdapter")
assert isinstance(solve.input, Instance)
output = solve.output
assert isinstance(output, Solution)
assert output.feasible
assert solve.adapter_options == {"verbose": False}
Adapter options are solve-scoped metadata, so they do not appear in run_parameters_df(), which is the table for comparing runs. Record values explicitly with log_parameter() when you want them in that DataFrame.
π Experiment fork and lineage (#905)#
fork() starts a new uncommitted Experiment from a committed one. The child inherits the parentβs attachments, Runs, Solves, Samplings, and Run parameters, while the parent remains unchanged. When the child is committed after adding new Runs or attachments, the parent manifest descriptor is recorded as the OCI subject.
from ommx.experiment import Experiment
from ommx_highs_adapter import OMMXHighsAdapter
loaded = Experiment.load("ghcr.io/jij-inc/ommx/tutorial/experiment:baseline")
with loaded.fork("ghcr.io/jij-inc/ommx/tutorial/experiment:capacity-64") as child:
with child.run() as run:
run.log_parameter("capacity", 64)
run.log_solve(OMMXHighsAdapter, instance, verbose=False)
Forking creates a new Artifact Manifest, but Instance / Solution / attachment payloads continue to reference content-addressed blobs in the Local Registry, so the data bodies are not duplicated. Saving or pushing the fork shares the complete forked Experiment, including Runs and Solves inherited from the parent.
π Instance.substitute / ParametricInstance.substitute (#891, #897)#
substitute() and substitute() are now available from Python. Pass a dictionary from decision-variable IDs to replacement Function expressions; OMMX rewrites those variables in the objective and active constraints in-place. This exposes the general substitution mechanism behind log_encode, so users can implement custom variable transformations such as unary or one-hot encodings.
from ommx import DecisionVariable, Instance
x = DecisionVariable.integer(0, lower=0, upper=3)
b = [DecisionVariable.binary(i) for i in (1, 2)]
instance = Instance.from_components(
decision_variables=[x, *b],
objective=x,
constraints={},
sense=Instance.MAXIMIZE,
)
instance.substitute({0: b[0] + 2 * b[1]})
assert str(instance.objective) == "Function(x1 + 2*x2)"
This API is an algebraic rewrite. It does not translate the substituted variableβs kind / lower / upper into constraints on the replacement expression. To preserve the optimization problem, use a domain-preserving encoding or add the required linking / bound constraints yourself. ParametricInstance.substitute may leave parameters in replacement expressions, so symbolic variable transformations can be applied before concrete values are supplied with with_parameters.
3.0.0 Alpha 3#
See the GitHub Release above for full details. The following summarizes the main changes. This is a pre-release version. APIs may change before the final release.
β *_df accessors are methods + include= filter + sidecar DataFrames (#846)#
Every *_df accessor on Instance / ParametricInstance / Solution / SampleSet is now a regular method instead of a #[getter] property. Existing call sites need parentheses:
# Before
df = solution.constraints_df
# After
df = solution.constraints_df()
The wide *_df methods take an include argument that gates the label / parameters column families. The default include=("label", "parameters") preserves the v2-equivalent wide shape:
solution.decision_variables_df() # core + label + parameters
solution.decision_variables_df(include=[]) # core only
solution.decision_variables_df(include=["label"]) # core + label
solution.decision_variables_df(include=["parameters"]) # core + parameters
Six new long-format / id-indexed sidecar accessors read directly from the SoA label/context stores. kind= selects the constraint family ("regular" / "indicator" / "one_hot" / "sos1", default "regular"):
constraint_context_df(kind=...)β id-indexed (name/subscripts/description)constraint_parameters_df(kind=...)β long format ({kind}_constraint_id/key/value)constraint_provenance_df(kind=...)β long format ({kind}_constraint_id/step/source_kind/source_id)constraint_removed_reasons_df(kind=...)β long format ({kind}_constraint_id/reason/key/value)variable_labels_df()β id-indexedvariable_parameters_df()β long format
Sidecar index names are kind-qualified (regular_constraint_id / indicator_constraint_id / one_hot_constraint_id / sos1_constraint_id / variable_id) so accidental cross-id-space df.join() mistakes surface in df.head() and friends. Long-format *_parameters_df / *_removed_reasons_df rows are sorted by (id, key), and empty long-format DataFrames keep their column schema instead of returning a column-less frame.
β removed_reason column gated by include= (#796, #847)#
In v2.5.1 Solution.constraints_df carried a removed_reason column unconditionally. The initial include= gate of that column landed in 3.0.0a2 (#796), and 3.0.0a3 finalizes it into the kind= / include= / removed= dispatch shape documented above (#847): the column is opted in by "removed_reason" in include= (a unit flag that controls both the reason name and removed_reason.{key} parameter columns). Rows whose constraint was not removed before evaluation get NA in those columns.
# Before (2.5.1)
df = solution.constraints_df # contains a 'removed_reason' column
# After (3.0.0a3 β `*_df` are now methods)
df = solution.constraints_df() # no removed_reason column
df = solution.constraints_df(include=("label", "parameters", "removed_reason"))
# β³ adds removed_reason / removed_reason.{key} (NA for active rows)
The same kind= / include= shape applies on SampleSet. On Instance and ParametricInstance, removed=True returns active + removed rows in one DataFrame and auto-sets "removed_reason" so removed rows are distinguishable.
β to_bytes / from_bytes removed from non-top-level types (#845)#
Bytes serialization is removed from the following component-level types:
These methods originally existed to ferry values across the Python β Rust boundary back when the Python SDK had its own protobuf-based wrapper layer and had to serialize on every hop. With the v3 transition to direct PyO3 re-exports the boundary disappears, so element-level bytes round-trips no longer serve a purpose, and keeping them aligned with the label/context storage redesign would only add maintenance cost. Versioned bytes APIs remain available on the container types (Instance, ParametricInstance, Solution, SampleSet) and on the cross-evaluate DTOs (State, Samples, Parameters) β use to_v1_bytes / from_v1_bytes or to_v2_bytes / from_v2_bytes where available when you need to persist or exchange data on disk or over the wire.
π Write-through label/context wrappers: AttachedConstraint / AttachedDecisionVariable (#849, #850, #852)#
Instance.add_constraint / instance.constraints[id] and the matching accessors on ParametricInstance now return write-through handles bound to the parent host instead of snapshot copies. Reads pull live data from the host and label/context setters write straight to its SoA stores, so two handles pointing at the same id observe the same state.
c = instance.add_constraint(x + y == 0) # AttachedConstraint
c.set_name("budget") # writes through to instance
assert instance.constraints[c.constraint_id].name == "budget"
Five write-through types ship: AttachedConstraint, AttachedIndicatorConstraint, AttachedOneHotConstraint, AttachedSos1Constraint, and AttachedDecisionVariable. Constraint and DecisionVariable are unchanged in shape β they remain the snapshot wrappers used for modeling input (operator overloading, Instance.from_components). Each AttachedX exposes .detach() to obtain an equivalent snapshot when you need to break the back-reference to the host.
As part of the same change, instance.decision_variables now returns list[AttachedDecisionVariable] (previously list[DecisionVariable] snapshots), aligning with instance.constraints and the special-constraint accessors.
π OpenTelemetry-based tracing and profiling (#816, #823, #826, #828, #829)#
The legacy log + pyo3-log β Python logging bridge is replaced by a tracing + pyo3-tracing-opentelemetry pipeline, so the Rust coreβs spans can now be consumed through the Python OTel SDK.
Two entry points ship under ommx.tracing:
%%ommx_traceβ a Jupyter cell magic that renders a per-cell span tree and a Chrome Trace JSON download linkcapture_trace/@tracedβ a context manager and decorator for the same workflow from regular Python scripts, tests, and CI
See Tracing and Profiling for the full walkthrough, configuring your own TracerProvider, and troubleshooting.
π Tracing spans in solver/sampler adapters (#833)#
Every OMMX adapter now emits three OpenTelemetry spans per solve/sample call, so the OTel tracing pipeline above can attribute wall-clock time to the three phases an adapter actually spends time in:
convertβ OMMXInstanceβ solver-native problem translationsolve/sampleβ the call into the underlying solver / sampler itselfdecodeβ decoding the solverβs response back toSolution/SampleSet(Rust-sideevaluatespans nest underneath)
Each adapter uses its own tracer name, so runs from different solvers are easy to distinguish in the tree view:
Adapter |
Tracer |
Spans |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
from ommx.tracing import capture_trace, render_text_tree
from ommx_pyscipopt_adapter import OMMXPySCIPOptAdapter
with capture_trace() as trace:
solution = OMMXPySCIPOptAdapter.solve(instance)
print(render_text_tree(trace)) # shows convert / solve / decode with durations
Spans are emitted through the standard OpenTelemetry API, so they are a no-op when no TracerProvider is installed β there is no runtime cost for users who do not opt in.
π Function.evaluate_bound is now available from Python (#831)#
Function.evaluate_bound is now exposed on Function. Given per-variable bounds, it returns a Bound that contains the range of the function value β useful when deriving feasibility bounds or doing simple presolve on the Python side.
from ommx import Function, Linear, Bound
f = Function(Linear(terms={1: 2}, constant=3)) # 2*x1 + 3
b = f.evaluate_bound({1: Bound(0.0, 2.0)})
# b.lower == 3.0, b.upper == 7.0
The bound is computed monomial-wise and summed, so it is a sound over-approximation of the true range but is not guaranteed to be tight when multiple terms share variables (the classic dependency problem in interval arithmetic). Variable IDs missing from bounds are treated as unbounded.
3.0.0 Alpha 2#
See the GitHub Release above for full details. The following summarizes the main changes. This is a pre-release version. APIs may change before the final release.
β Removal of the Constraint.id field (#806)#
The id field (along with the .id getter, set_id(), and id= constructor argument) is removed from Constraint and its variants (IndicatorConstraint / OneHotConstraint / Sos1Constraint / EvaluatedConstraint / SampledConstraint / RemovedConstraint). A constraintβs ID now exists only as the key of the dict[int, Constraint] passed to Instance.from_components.
# Before (2.5.1)
c = Constraint(function=x + y, equality=Constraint.EQUAL_TO_ZERO, id=5)
Instance.from_components(..., constraints=[c], ...)
# After (3.0.0a2)
c = Constraint(function=x + y, equality=Constraint.EQUAL_TO_ZERO)
Instance.from_components(..., constraints={5: c}, ...)
Global ID counters (next_constraint_id and friends) and per-constraint to_bytes / from_bytes are also removed. For full details and migration steps, see the Python SDK v2 to v3 Migration Guide.
π First-class special constraint types (#789, #790, #795, #796, #798)#
In addition to regular constraints, the following three special constraint types are now first-class citizens β they can be passed to Instance.from_components via indicator_constraints= / one_hot_constraints= / sos1_constraints=, and read back through constraints_df() / constraints_df() with kind= selecting the family.
IndicatorConstraintβ conditional constraint on a binary variable (new)OneHotConstraintβ replaces the previousConstraintHints.OneHotmetadataSos1Constraintβ replaces the previousConstraintHints.Sos1metadata
For concrete usage, evaluation-result access, and the Indicator relax / restore workflow, see Special Constraints.
Accordingly, the legacy ConstraintHints / OneHot / Sos1 classes, the Instance.constraint_hints property, and the PySCIPOpt Adapterβs use_sos1 flag are removed.
π numpy scalar support (#794)#
The Function constructor now accepts numpy.integer and numpy.floating values. In v2.5.1, Function(numpy.int64(3)) raised TypeError.
3.0.0 Alpha 1#
See the GitHub Release above for full details. The following summarizes the main changes. This is a pre-release version. APIs may change before the final release.
Complete Rust re-export of ommx and ommx.artifact types (#770, #771, #774, #775, #782)#
Python SDK 3.0.0 is fully based on Rust/PyO3.
In 2.0.0, the core implementation was rewritten in Rust while Python wrapper classes remained for compatibility. In 3.0.0, those Python wrappers are removed entirely β all types in ommx and ommx.artifact are now direct re-exports from Rust, and the protobuf Python runtime dependency is eliminated. The .raw attribute that previously provided access to the underlying PyO3 implementation has also been removed.
Migration to Sphinx and ReadTheDocs hosting (#780, #785)#
In v2, the Sphinx-based API Reference and Jupyter Book-based documentation were each hosted on GitHub Pages. In v3, documentation has been fully migrated to Sphinx and is now hosted on ReadTheDocs. GitHub Pages will continue to host the documentation as of v2.5.1, but all future updates will be on ReadTheDocs only.