OMMX Python SDK 3.0.x

Contents

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 total_violation_l1() with total_violation() and update individual-constraint feasibility calls as described below. total_violation_l2() has been removed.

promote_sos1_big_m()

Construct a batch request and remove the atol argument. Choose whether to apply valid conversions individually or require the entire batch to succeed.

Raw integer values for variable kinds

Use enum members such as Kind.Binary. Constructors such as DecisionVariable.binary() remain available.

πŸ›  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.

A shared exception for Rust extension transfers (#1216)#

If an external Rust extension cannot transfer an OMMX object to the Python SDK, you can catch BridgeError. It covers incompatible transfer protocols and registration or transfer failures, including failures from independently built extensions. Transfer failures preserve the original Python exception in __cause__. A missing SDK or required bridge API raises ImportError.

3.0.0 Beta 5#

Static Badge

πŸ›  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

DegreeBound

PolynomialRequirement

DegreeBound.at_most(n)

PolynomialRequirement.at_most(n)

DegreeBound.unbounded()

PolynomialRequirement.any_degree()

bound.maximum

requirement.maximum_degree

bound.includes(degree)

requirement.accepts_degree(degree)

objective_degree_bound

objective_polynomial_requirement

regular_constraint_degree_bounds

regular_constraint_polynomial_requirements

indicator_constraint_degree_bounds

indicator_body_polynomial_requirements

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#

Static Badge

⚠ 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=...) and OMMXOpenJijSAAdapter.solve(..., initial_state=...) are no longer accepted. Prepare the Instance explicitly, then call OMMXOpenJijSAAdapter.sample_without_preparation(instance, initial_state=...) or OMMXOpenJijSAAdapter.solve_without_preparation(instance, initial_state=...).

  • Custom Adapters must implement solve_without_preparation() or sample_without_preparation() and move backend execution there. If Adapter-specific options remain available on solve() or sample(), 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#

Static Badge

πŸ›  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#

Static Badge

⚠ 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#

Static Badge

⚠ 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#

Static Badge

⚠ 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#

Static Badge

πŸ†• 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#

Static Badge

πŸ†• 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#

Static Badge

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#

Static Badge

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 as None. 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_archive raises 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) and Artifact.inspect_archive (side-effect-free read of the manifest + layer descriptors, returns a new ArchiveManifest view). v2’s load_archive opened 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_archive accepts v2 archives produced by ArtifactBuilder.new_archive_unnamed (no org.opencontainers.image.ref.name annotation) by synthesizing an anonymous name on the fly; inspect_archive reads such archives back with ArchiveManifest.image_name = None (no registry context for synthesis).

  • CLI ommx push <archive> and ommx 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 --delete is passed.

  • ommx.get_image_dir(...) and the CLI ommx 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 via ommx 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#

Static Badge

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-indexed

  • variable_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 link

  • capture_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 β€” OMMX Instance β†’ solver-native problem translation

  • solve / sample β€” the call into the underlying solver / sampler itself

  • decode β€” decoding the solver’s response back to Solution / SampleSet (Rust-side evaluate spans 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

ommx-pyscipopt-adapter

ommx.adapter.pyscipopt

convert / solve / decode

ommx-highs-adapter

ommx.adapter.highs

convert / solve / decode

ommx-python-mip-adapter

ommx.adapter.python_mip

convert / solve / decode

ommx-openjij-adapter

ommx.adapter.openjij

convert / sample / decode

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#

Static Badge

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.

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#

Static Badge

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.