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

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.