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 merged after the most recent release will be appended here as they land, and promoted to a new version section when the next release is cut.
π 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.
β Input classes and explicit OpenJij preparation (#1085, #1086, #1087)#
OMMXHighsAdapter, OMMXPythonMIPAdapter, OMMXPySCIPOptAdapter, and
OMMXOpenJijSAAdapter now
declare INPUT_CLASS for the exact inputs accepted before direct backend
construction. The first three accept Binary, Integer, and Continuous variables used
by the active mathematical content and both optimization senses. HiGHS and
Python-MIP accept linear objectives and linear regular equality or inequality
constraints. PySCIPOpt accepts objectives and regular constraints of degree at
most two, Indicator equality or inequality bodies of degree at most one, and
SOS1 constraints. An input outside the declared class is rejected without
mutation through AdapterNotApplicableError, which carries
structured clause mismatches. Any explicitly prepared Instance
is a different input whose applicability must be checked again.
OpenJij accepts Binary, unconstrained minimization inputs with a polynomial
objective of any degree. Integer encoding, sense reversal, slack introduction,
special-constraint lowering, and finite penalties are no longer implicit in
the Adapter call. Prepare a separate input explicitly, pass that exact
Instance to the Adapter, and then evaluate its samples against
the source 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)
The OpenJij-specific preparation report separates source-class membership,
operations that completed, failures owned by the operation that encountered
them, and applicability of preparation.input; it is not a common composed
guarantee. Its separate config field records the normalized, immutable
preparation settings actually used. Approximate integer slack is disabled by
default and requires setting allow_approximate_integer_slack=True on
OpenJijPreparationConfig. Finite penalties remain explicitly selected through
uniform_penalty_weight or penalty_weights on that Config. Per-constraint
weight coverage is evaluated after slack preparation, against the regular
constraints that actually remain to be penalized. Common preparation policy and
guarantees are tracked in #1111.
The at-most-53-bit Integer encoding condition is operation availability checked
by the Integer-encoding phase, not source-class membership, the OpenJij input
class, or ommx.v2.Feature.
For HiGHS, Python-MIP, and PySCIPOpt, this is a breaking change to the public
exception contract from stable Python SDK 2.6.1. Unsupported objectives,
regular constraints, or used variable kinds
previously raised OMMXHighsAdapterError, OMMXPythonMIPAdapterError, or
OMMXPySCIPOptAdapterError; they now raise AdapterNotApplicableError before
backend construction. Code that caught an adapter-specific exception for
constructor-time input rejection must catch AdapterNotApplicableError
instead, or call check_applicability() before construction. Their stable
input boundaries are unchanged, and adapter-specific exceptions remain in use
for conversion and backend failures.
For OpenJij, this is also a breaking change from stable 2.6.1: constructor,
sample(), and solve() no longer accept preparation options or implicitly
rewrite a source model. Stable 2.6.1 selected a uniform penalty weight of 1.0
when none was supplied and automatically attempted discrete slack
approximation when exact conversion failed; v3 requires explicit finite weights
and an explicit approximation opt-in. The explicit evaluate_source() step
also fixes the old implicit pathβs result evaluation, which could report the
rewritten penalty objective, reversed sense, or transformed constraints instead
of the source objective, sense, and constraints.
The canonical infeasibility exception is now ommx.InfeasibleDetected, while
ommx.adapter.InfeasibleDetected remains an alias for the same object. It is a
RuntimeError subclass so existing handlers around the Rust-backed slack
operations continue to catch it. This changes the stable Adapter-side hierarchy:
an except RuntimeError around OpenJij preparation did not previously catch the
old direct Exception subclass, but does in v3. Catch
InfeasibleDetected explicitly when that distinction controls recovery.
The deprecated response_to_samples() and sample_qubo_sa() helpers are also
removed in 3.0.0. Use decode_to_samples() in place of response_to_samples().
For a directly applicable input, replace sample_qubo_sa() with
OMMXOpenJijSAAdapter.sample(); when preparation is required, use the explicit
prepare() / sample(preparation.input) / evaluate_source() flow above.
The replacement sampling API returns an evaluated SampleSet, rather than the
raw Samples returned by sample_qubo_sa().
Indicator, OneHot, and SOS1 constraints are not lowered implicitly for HiGHS or Python-MIP, and PySCIPOpt no longer lowers OneHot implicitly. These changes to first-class special-constraint handling affect Python SDK 3.0 prerelease behavior; they are not compatibility changes from stable 2.6.1.
π Rust SDK errors use consistent Python exceptions#
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. ValueError cases include invalid bounds or tolerances,
duplicate subscripts, parameterized-constraint extraction, and requesting a
best sample when no feasible sample exists. Artifact operations use the same
classification for invalid image references, malformed digests, unsupported or
incorrect layer media types, missing typed layers, and malformed OMMX payloads.
Experiment operations classify invalid image references, autosave values,
attachment media types, and JSON input as ValueError; registry, archive,
storage, and lifecycle failures remain on the RuntimeError fallback.
The remaining Instance, ParametricInstance, attached metadata, random
generator, Solution, Samples, and Artifact registry bindings now use the
same boundary. Binding-owned duplicate component IDs and missing penalty
weights raise ValueError. Solver and sampler adapter exceptions passed
through Run.log_solve and Run.log_sample preserve the original Python
exception object. Malformed payloads received by the private cross-extension
PyO3 bridge are internal protocol failures and raise RuntimeError.
The mapped signals currently include CoefficientError, AtolError,
BoundError, stable control-flow cases from DecisionVariableError,
SolutionError, and SampleSetError, and the ParseError and
QplibParseError parser signals. Missing Experiment or Run attachments retain
an AttachmentNotFound signal and raise KeyError. Invalid caller-provided
image references keep an ImageRefParseError, while a corrupted image ref
already persisted in the Local Registry keeps an
InvalidLocalRegistryImageRef and falls back to RuntimeError. Registry,
archive, and content-addressed storage failures also remain on that fallback.
Exceptions from Python-backed codecs, JSON callbacks, adapters, tracing hooks,
and data libraries pass through unchanged.
Integer preparation operations expose three additional RuntimeError-compatible
specializations. log_encode() raises
LogEncodingError only when an exact encoding is unavailable for
the requested variable. Exact integer-slack conversion raises
ExactIntegerSlackError only when callers may explicitly select an
approximate alternative, and both exact and approximate slack operations raise
InfeasibleDetected when bounds prove the model infeasible.
Allocation, substitution, and coefficient-arithmetic failures retain their
ordinary exception classification; they are not treated as availability
signals. Existing broad except RuntimeError handlers continue to work, while
callers that intentionally recover can catch the concrete exception.
The Python extension no longer depends directly on anyhow, and its PyO3
dependency no longer enables the blanket anyhow conversion feature.
pyo3-tracing-opentelemetry is upgraded to 0.3.1 so the feature stays disabled
through the tracing dependency as well. New exposed bindings can therefore no
longer rely on blanket conversion and must translate Rust SDK failures
explicitly through the shared boundary.
Zero coefficients remain normalized as a successful operation, and a failed
in-place numeric addition leaves the original object unchanged. MPS parsing and
file-open failures remain on the RuntimeError fallback until they have stable
OMMX-owned signals. Descriptor objects remain metadata-only; blob reads are
owned by the Artifact that supplies the registry context. Attachment codecs
validate their declared media type before reading a CAS blob and require
encoded values to be Python bytes. If a Run body and tracing cleanup both
fail, the original body exception is preserved and the Run is still closed as
failed or interrupted.
Related PRs: #1096, #1097, #1099, #1100, #1101, #1102, #1087.
π Instance classes and adapter applicability (#1084, #1088)#
The Python SDK now exposes InstanceClass as a set of OMMX
Instance values. Each InstanceClassClause is one
conjunctive structural description, and the containing class is their finite
union. Membership is evaluated from the exact input value and reported through
InstanceClassMembershipReport with structured per-clause
mismatches.
from ommx import DegreeBound, InstanceClass, InstanceClassClause, Kind, Sense
binary_linear = InstanceClass(
[
InstanceClassClause(
label="binary-linear",
allowed_variable_kinds={Kind.Binary},
objective_degree_bound=DegreeBound.at_most(1),
allowed_senses={Sense.Minimize},
)
]
)
report = binary_linear.check_membership(instance)
SolverAdapter subclasses declare INPUT_CLASS and use
check_applicability or require_applicable to layer adapter-owned
preconditions on top of input-class membership without mutating the callerβs
instance. Explicit preparation must be followed by a new membership check on
the prepared input. SpecialConstraintKind,
active_special_constraint_kinds, and
lower_special_constraints() provide separate inspection
and direct-selection lowering for special constraints. ommx.v2.Feature
remains an independent wire-reconstruction concept.
π Typed remote Artifact lookup errors (#1090)#
load() and
load() now report remote lookup failures
through OMMX-owned exceptions instead of a generic RuntimeError containing
the underlying OCI transport error. Catch
RemoteArtifactNotFoundError to handle a missing exact
ref without confusing it with authentication, authorization, registry
transport, or invalid-Artifact failures. All remote lookup exceptions inherit
from RemoteArtifactError.
from ommx.artifact import Artifact, RemoteArtifactNotFoundError
try:
artifact = Artifact.load("registry.example/team/model:latest")
except RemoteArtifactNotFoundError:
artifact = None
The sibling exception classes are
RemoteArtifactAuthenticationError,
RemoteArtifactAuthorizationError,
RemoteArtifactTransportError, and
InvalidRemoteArtifactError. Their messages retain the
original registry and transport context. Both load entry points use the same
PyO3 error-conversion boundary.
π 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.