Migrate from Python SDK v2 to v3#

This guide takes code written for OMMX 2.9.4 to the v3 API on main. Keep the v2 environment and set up a separate v3 environment using the installation guide. Before changing the code, save one model, a candidate, and the results your application actually uses. You will compare them in section 6.

Start by running a type checker on one of your existing scripts. Even if you have never used one, you can use its diagnostics to fix many incompatible calls immediately. Run Pyright for the first time, then use the old API index to look up a name in its diagnostics.

1. Update imports and object access#

Run a type checker on your first script#

From the project directory where you installed v3, run the command below. Replace solve.py with the path to a Python script that uses OMMX. uv provides Pyright for this command. You can start with an ordinary script, without adding type annotations or a configuration file.

uv run --no-sync --with pyright pyright solve.py

Pyright reads the type information shipped with OMMX and reports incompatible calls with their file and line numbers without executing the script. Fix any old ommx.v1 imports first so Pyright can identify the SDK objects. For example, if it flags instance.write_mps(path) as an unknown method, change it to instance.save_mps(path) and run the same command again. Work through the remaining diagnostics, then check the next script.

What Pyright flags

How to update the call

A list passed as constraints to Instance.from_components

Add each constraint to the model with add_constraint().

Unknown method instance.write_mps

Use save_mps().

Missing argument in solution.constraints_df()

Select the constraint family, for example kind=ConstraintKind.Regular.

Some changes keep the same type. For example, a value returned by extract_constraints() is still a float, but for a regular constraint it now measures violation. Use the same-candidate comparison to check reports that depend on its meaning.

Switch to the public imports#

In v2.9.4, Function, Constraint, DecisionVariable, Instance, ParametricInstance, Solution, and SampleSet are already available from ommx. You can switch these imports while still running v2. In v3, import the other modeling types from the same public namespace, including Linear, Parameter, Equality, and Sense.

If your v2 code imports ommx.v1.*_pb2 or accesses .raw, identify what it needs from the object. For example, read instance.sense directly. Start a model with minimize() or maximize(), then add variables and constraints. For serialized data, select a reader by format. The v1 protobuf schema name still identifies a wire format. The Python SDK no longer provides those generated protobuf modules or the from_raw, from_protobuf, and to_protobuf wrappers.

2. Build and edit a model#

Let the model allocate IDs, or preserve external IDs#

If your v2 loop numbered variables and constraints solely to build a model, let the v3 Instance allocate them. Create the model with its objective sense first, then use the returned variables in expressions. The returned constraint handle carries its assigned ID.

from ommx import Instance

model = Instance.maximize()
x = model.new_binary("x")
y = model.new_binary("y")
model.objective = x + y
limit = model.add_constraint(x + y <= 1, "limit")
assert model.constraints[limit.constraint_id].name == "limit"

new_integer, new_continuous, new_semi_integer, new_semi_continuous, and new_finite_domain also allocate variable IDs. On both Instance and ParametricInstance, add_constraint, add_indicator_constraint, add_one_hot_constraint, and add_sos1_constraint allocate constraint numbers.

If an external consumer requires the model itself to keep variable 0 or constraint 7, preserve those numbers. Keep the ID when constructing the variable. Replace v2’s constraints=[(x <= 1).set_id(7)] with constraints={7: x <= 1} in from_components(). A detached constraint has no ID. All constraint families share one number space per model, including removed constraints. Construction and loading reject collisions.

Find the row you intend to edit#

Look up a regular constraint as model.constraints[ConstraintID(7)]. The collection contains all families, so a bare integer cannot identify the family for lookup. In a family-specific view, model.constraints.of_kind(ConstraintKind.Regular)[7] also works. Iteration yields typed IDs. Use .items() for ID/row pairs.

On a model, an active row is an attached handle. Calling row.set_name("limit") updates the model. Call row.detach() to keep an independent copy. Replace v2’s add_name and add_description with set_name and set_description. Use set_parameters to replace the whole mapping and add_parameters to merge entries. Read fixed variable values from model.fixed_decision_variables(). The model owns that state even when a variable definition has been detached. See ConstraintCollection and DecisionVariable.

Check where a metadata edit lands#

In 2.9.4, editing the constraint returned by get_constraint_by_id only modified that Python snapshot. When translating this code, decide whether you intended to edit the model or just a copy for display. Both operations are explicit in v3.

from ommx import Instance

model = Instance.minimize()
x = model.new_binary("x")
live = model.add_constraint(x <= 1, "original")
local = live.detach()
local.set_name("display")
assert model.constraints[live.constraint_id].name == "original"
live.set_name("capacity")
assert model.constraints[live.constraint_id].name == "capacity"

A dictionary made with dict(model.constraints) still holds attached rows. Detach each active row you want to copy. On a detached constraint, a setter changes the receiver and returns a clone. To apply several edits to the same detached object, call its setters separately.

Replace a variable analysis query#

For one variable, use decision_variable_role(). For a report over many variables, use decision_variables_df() and its state_role and substituted_value columns. If your adapter only needs variables sent to the solver, use used_decision_variables. These queries answer different questions when fixed or dependent variables remain available for evaluating candidates.

Carry over the mathematical structure#

  • Constraint hints. Reading v1 bytes preserves the regular constraints and ignores advisory ConstraintHints. If the original model specifies a OneHot or SOS1 requirement, you can construct the corresponding v3 special constraint directly. Removing the regular rows on the strength of a hint alone can change the feasible region.

  • Expressions. Polynomial coefficients are available for expanded polynomials. A composed Function can return None from degree() or num_terms(). If you need an expanded square for coefficient export, multiply a polynomial by itself. Raising a Function to a power constructs a composed expression.

  • Parameters. Create a parameter as Parameter(3, name="capacity") and pass an ID-to-value mapping to with_parameters. A ParametricInstance exposes parameter objects via parameters and a table via parameters_df().

Check the type of any ID you exchange with other software. Variable and sample IDs are still integers. Constraint IDs stay typed in results and provenance and do not support int() conversion. For a custom transformation, use ConstraintIDBase and ConstraintView for heterogeneous rows and handle an unsupported family explicitly.

3. Pass a model to a solver or sampler#

For the adapter’s recommended preparation, pass your model to solve() or sample(). These methods prepare a copy. For a custom transformation policy, call instance.prepare(adapter.INPUT_CLASS, policy) on an instance you intend to change, then call solve_without_preparation() or sample_without_preparation() on that prepared input. The adapter catalogue links to each backend’s inputs and executable workflow.

Initial candidates and penalties. SCIP still accepts initial values by variable name and subscripts, as in 2.9.4. OpenJij initial states use the solver variable IDs of the exact prepared input. Supply them to its preparation-free API. When constraints require fixed penalties, choose the weights explicitly. Move the v2 penalty arguments to policy.fixed_penalty and integer slack settings to policy.integer_slack. The OpenJij adapter reference shows the full sequence.

Exporting coefficients yourself. to_qubo() and to_hubo() mutate the instance and expose the energy sent to the backend. In v3, evaluating a candidate with this instance returns the objective it exposed before preparation. Keep the backend energy separately if your application compares penalties or solver scores.

Move OpenJij penalty options into the preparation policy#

If your v2 call passed uniform_penalty_weight or relied on the implicit weight of 1, choose a weight for the original objective scale. Here selecting both options gains two points over the best allowed choice. A weight of four offsets that gain. Keep the source model so that another weight can be tried on a fresh copy. Install the OpenJij adapter to run this example.

from copy import copy

from ommx import FixedPenaltyPreparation, Instance, Sense
from ommx_openjij_adapter import OMMXOpenJijSAAdapter

source = Instance.maximize()
x, y = source.new_binary("x"), source.new_binary("y")
source.objective = 3 * x + 2 * y
source.add_constraint(x + y == 1)
prepared = copy(source)
policy = OMMXOpenJijSAAdapter.recommended_preparation_policy()
policy.fixed_penalty = (
    FixedPenaltyPreparation.uniform_penalty_method_with_fixed_weight(weight=4.0)
)
prepared.prepare(OMMXOpenJijSAAdapter.INPUT_CLASS, policy)
samples = OMMXOpenJijSAAdapter.sample_without_preparation(prepared, num_reads=2, seed=42)
assert samples.sense == Sense.Maximize

The sampling result reports the original objective. If your application also reads dual values, note that HiGHS and Python-MIP omit them when the prepared input has an explicit output objective.

Update downstream consumers#

The v2 helper sample_qubo_sa returned raw samples. The v3 adapter returns an evaluated SampleSet. For code that already has a native OpenJij response and only needs to decode it, replace response_to_samples with decode_to_samples. If you change a penalty, start again from the original model: preparing an already applicable input does not replace the penalty stored in its objective.

If you implement an adapter#

Declare the accepted input as INPUT_CLASS and implement solve_without_preparation() or sample_without_preparation(). Expose backend options through explicit typed arguments. An option keyed to prepared solver coordinates belongs on the preparation-free call unless the adapter can map it across transformations. Reject constraint families your backend cannot represent. Skipping a row can admit solutions that violate the model. See SolverAdapter and SamplerAdapter.

4. Evaluate candidates and read results#

Does your report need the signed value or the violation?#

For the candidate \(x=0\), the constraint \(x-1\leq0\) has signed value \(-1\) and violation \(0\). Both SDKs agree the candidate satisfies it. The meaning of the number returned by name has changed.

v2.9.4

from ommx import DecisionVariable, Instance

x = DecisionVariable.binary(0)
limit = (x <= 1).set_id(7).add_name("limit")
model = Instance.from_components(
    decision_variables=[x], objective=x, constraints=[limit], sense=Instance.MAXIMIZE,
)
result = model.evaluate({0: 0})
assert result.extract_constraints("limit")[()] == -1.0

v3 on main

from ommx import Instance

model = Instance.maximize()
x = model.new_binary("x")
model.objective = x
limit = model.add_constraint(x <= 1, "limit")
result = model.evaluate({x.id: 0})
assert result.extract_constraints("limit")[()] == 0.0
assert result.get_constraint_value(limit.constraint_id) == -1.0

extract_constraints() now searches all families and returns nonnegative violations keyed by subscript tuple. Duplicate subscripts for a name are ambiguous, even across families. If you relied on the sign or slack of a regular row, use get_constraint_value as above. For a violation by typed ID use constraint_violation. When reading a single evaluated row, use row.is_feasible(atol=result.feasibility_atol) to apply the result’s tolerance. Overall feasibility also checks variable domains. A zero total constraint violation alone is insufficient.

For a regular equality, the violation is the absolute residual. For an inequality it is the excess above the limit. Special constraints have family-specific metrics. total_violation() adds these values, including retained removed rows. Adding rows during transformation can therefore change the total. See total_violation() for definitions and tolerances. Update error handling for a missing required variable in evaluate() to catch ValueError. Partial assignments remain valid inputs to partial_evaluate().

Update tables and sample aggregation#

Call the *_df methods. In particular, replace solution.constraints_df with solution.constraints_df(kind=ConstraintKind.Regular). Constraint indexes contain family-specific ID objects. Update joins that expected integers. For a model, removed=True includes both active and retained removed rows. Results already include retained removed constraints and return snapshots on lookup. The constraint metadata tables for context, parameters, provenance, and removal reasons also require kind=.

For multiple samples, use samples.summary for objectives and overall feasibility, and samples.constraints_df(kind=...) for per-constraint results. samples.sample_ids() returns a set. Use samples.sample_ids_list when the consumer needs a list. See Solution and SampleSet for complete examples.

Migrate a join on constraint ID#

In v2 a report might join the id index of instance.constraints_df to a table keyed by integers. In v3 the regular-constraint index is named regular_constraint_id and contains ConstraintID objects. When building the model, record which assigned ID corresponds to each source constraint number (here, 7). Use that mapping to join the application’s table.

import pandas as pd
from ommx import ConstraintKind, Instance

model = Instance.maximize()
x = model.new_binary("x")
model.objective = x
limit = model.add_constraint(x <= 1, "limit")
id_map = {7: limit.constraint_id}

owners = pd.Series({7: "planning"}, name="owner")
rows = model.constraints_df(kind=ConstraintKind.Regular, include=[])
report = rows.join(owners.rename(index=id_map))
assert report.loc[limit.constraint_id, "owner"] == "planning"

Use include=["label", "parameters"] for a wide table with metadata. For a join with one row per key/value, use constraint_parameters_df(kind=...). Transformation history is in constraint_provenance_df(kind=...), with one row per provenance step. For a model, removed=True includes active rows as well: filter the non-null removed_reason values if your old report contained only removed rows.

Compare feasibility near the tolerance#

A threshold is applied to each constraint’s violation. For SOS1, leaving at most one nonzero value in (1, 0.00006, 0.00006) takes total absolute change 0.00012. At a tolerance of 0.0001 the row fails, even though each of the two small values is below that threshold. When reconciling reports, use the stored evaluation tolerance and compare the same constraint family. A new query tolerance does not rerun discrete-value canonicalization. See total_violation().

5. Bring saved models and Artifacts forward#

Read a serialized model or result#

The SDK version and the protobuf version are separate. A payload written by Instance.to_bytes() in SDK 2.9.4 uses the v1 wire format. Read it with Instance.from_v1_bytes(data) in v3. The same naming applies to ParametricInstance, Solution, and SampleSet. For new files use to_v2_bytes() and the matching from_v2_bytes() on the owning object. To persist a variable, expression, or constraint with its context, serialize the enclosing model or result.

Version 2.9.4 already preserves annotations in root payloads. In v3, set typed metadata through properties such as title and use add_user_annotation for extension keys. Update any direct edits to the annotations dictionary.

If a 2.x program must still consume your output, check that it supports the representation as well as the wire format. For example, 2.9.4’s schema includes finite-domain variables but its SDK rejects them when loading a model. An explicit output objective cannot be written with to_v1_bytes(). Use the v2 wire format to preserve native special constraints and a result’s feasibility tolerance.

If you export MPS to another tool, replace write_mps with save_mps(). Use the SDK byte format to restore model structure and metadata that MPS cannot represent.

Open or create a .ommx archive#

What your v2 code did

What to do in v3

Opened an archive with Artifact.load_archive(path)

To inspect its manifest and layer descriptors, use Artifact.inspect_archive(path). To read the layer contents, use Artifact.import_archive(path), which writes to the local registry.

Built a file with ArtifactBuilder.new_archive(path, name)

Create an ArtifactDraft.new(name), add the layers, commit, and call artifact.save(path) to export the file.

Used new_archive_unnamed(path)

Use ArtifactDraft.new_anonymous(). A committed anonymous Artifact has a generated image name. Update code that checked image_name is None.

Pushed a file with ommx push

Import it with ommx load, then push by image name.

See Artifact for the archive API. For cleanup, ommx prune-anonymous previews refs and ommx gc previews unreachable blobs. Each requires --delete to perform deletion. See Manage the Local Registry.

Replace an archive builder and reopen its output#

A v2 archive builder wrote a file when build() was called. In v3, commit() writes to the local registry and save() exports a file. This example creates an anonymous registry entry and uses a fresh directory for the archive.

from pathlib import Path
from tempfile import TemporaryDirectory

from ommx import Instance
from ommx.artifact import Artifact, ArtifactDraft

model = Instance.minimize()
model.title = "Archive migration"
draft = ArtifactDraft.new_anonymous()
draft.add_instance(model)
artifact = draft.commit()
with TemporaryDirectory() as directory:
    path = Path(directory) / "model.ommx"
    artifact.save(path)
    manifest = Artifact.inspect_archive(path)
    assert manifest.image_name == artifact.image_name
    restored = Artifact.import_archive(path).get_instance()
    assert restored.title == model.title

For an existing unnamed v2 archive without an image-name annotation, import_archive assigns an anonymous local name. Read the returned Artifact’s image_name if a later step needs to load it by name. Choose a fresh output path when exporting: save refuses to overwrite an existing file.

Read files from another SDK#

Use inspect_archive() to inventory an archive without writing to the local registry. The manifest describes layers, but reading a model from a layer requires import_archive(). For old archives with annotations on layer descriptors, protobuf metadata takes precedence if both sources specify the same OMMX key. Check titles and application annotations when migrating stored experiments.

6. Verify your application’s results#

  1. Evaluate the same candidate against the same original model in both environments. Compare objective sense, objective value, feasibility, and the tolerance used. For a transformed model, compare each result against the original requirements.

  2. Compare the numbers your application consumes. For example, a report that used the signed value \(-1\) in section 4 should still read \(-1\) after migration. A report intended to measure violation should read \(0\) for that candidate. Compare sample IDs and join keys if your application aggregates results in tables.

  3. If your workflow crosses a file or process boundary, read a real 2.9.4 payload or archive in v3. Check an external ID, a metadata field, and an evaluation result. If a 2.x consumer remains in service, exercise that reader on the outbound format as well.

Appendix. Find a replacement by its v2 name#

The site-wide general index also lists the detailed examples under their v2 API names.

Old API or use

v3 replacement

Section

.raw / from_raw / from_protobuf / to_protobuf

For example, instance.sense / Instance.minimize

1

add_name / add_description

set_name / set_description

Check where a metadata edit lands

ArtifactArchive / ArtifactDir / ArtifactBuilder

Artifact / ArtifactDraft

Replace an archive builder and reopen its output

Artifact.load_archive

Artifact.import_archive / Artifact.inspect_archive

Replace an archive builder and reopen its output

Constraint.id / set_id / next_constraint_id

add_constraint or, to preserve external constraint numbers, from_components

2

constraint_hints / OneHot / Sos1

OneHotConstraint / Sos1Constraint

2

constraints_df / removed_constraints_df

constraints_df(kind=...) / constraints_df(kind=..., removed=True)

Migrate a join on constraint ID

decision_variable_analysis()

decision_variable_roles() / fixed_decision_variables()

Check where a metadata edit lands

DecisionVariable.substituted_value

model.fixed_decision_variables()

2

evaluated.feasible / sampled.feasible

evaluated.is_feasible(atol=...) / sampled.feasible(atol=...)

4

extract_constraints(name)

extract_constraints for violations, get_constraint_value for signed regular values

4

get_constraint_by_id / get_removed_constraint_by_id

constraints[ConstraintID(n)] / removed_constraints[ConstraintID(n)]

2

Linear.from_object / Linear.equals_to

Arithmetic or Linear.single_term / almost_equal

2

Linear.terms / Quadratic.terms / Polynomial.terms

Linear.terms() / Quadratic.terms() / Polynomial.terms()

2

ommx.v1 / ommx.v1.*_pb2

ommx

1

Parameter.new / Parameters(entries=...)

Parameter(id, ...) / with_parameters({id: value})

2

ParametricInstance.parameters (DataFrame)

parameters_df()

2

response_to_samples / sample_qubo_sa

decode_to_samples / OMMXOpenJijSAAdapter.sample

Move OpenJij penalty options into the preparation policy

SampleSet.sample_ids / summary_with_constraints

sample_ids() / summary + constraints_df(kind=...)

4

Solution.total_violation_l1 / total_violation_l2

total_violation (sum of per-constraint violations). The L2 aggregate was removed.

4

to_bytes / from_bytes

to_v2_bytes / from_v2_bytes / from_v1_bytes

5

uniform_penalty_weight / penalty_weights / inequality_integer_slack_max_range

policy.fixed_penalty / policy.integer_slack

Move OpenJij penalty options into the preparation policy

used_decision_variable_ids() (Instance, Function)

Instance.required_ids() / Function.required_ids()

2

write_mps(path)

save_mps(path)

5

For changes between individual v3 prereleases, see the Python SDK 3.0 release notes.