QUBO solver
QUBOSolver validates and submits QUBO problems, supports blocking and nonblocking job workflows, and returns typed results.
Construct a platform problem
From a modeller
from qpiai_opt.opt_modeller import Binary, Model
from qpiai_opt.opt_solvers.qubo import QUBOProblem
x = Binary("x")
y = Binary("y")
model = Model("selection")
model.maximize(3 * x + 2 * y - 4 * x * y + 5)
problem = QUBOProblem.from_model(model)
This is the preferred bridge. It carries:
- the fully symmetric compiled matrix;
- source minimization/maximization sense;
- source constant;
- compiled variable names;
- compiled variable count.
Compilation happens immediately and retains an independent source-model snapshot for decoding, objective evaluation, feasibility, and transformations. Later edits to the original Model do not affect the compiled problem. Compile again to include those edits.
max_evaluations can be supplied to from_model(). Continuous source variables require the same explicit QUBO compile options:
problem = QUBOProblem.from_model(
model,
continuous_bits=10,
max_evaluations=50_000,
)
Direct symmetric matrix
problem = QUBOProblem(
num_variables=2,
qubo_dict={
(0, 0): 3.0,
(1, 1): 2.0,
(0, 1): -2.0,
(1, 0): -2.0,
},
variable_names=("x", "y"),
offset=5.0,
sense="MAX",
)
For matrix_format="symmetric" (default):
- every nonzero off-diagonal key needs its mirror;
- mirrored values must be equal;
- each mirrored value is half the direct polynomial coefficient;
- zero entries are removed after validation.
Missing or unequal mirrors raise pydantic.ValidationError.
Direct upper-triangular coefficients
problem = QUBOProblem(
num_variables=2,
qubo_dict={
(0, 0): 3.0,
(1, 1): 2.0,
(0, 1): -4.0,
},
matrix_format="upper",
variable_names=("x", "y"),
offset=5.0,
sense="MAX",
)
stored_format = problem.matrix_format
mirrored_coefficients = (
problem.qubo_dict[(0, 1)],
problem.qubo_dict[(1, 0)],
)
stored_format == "symmetric" and mirrored_coefficients == (-2.0, -2.0): the direct upper coefficient is split evenly across the symmetric matrix.
The model validates the upper triangle and immediately stores the platform form. Lower-triangle keys are invalid in upper input. If halving a nonzero coefficient underflows to zero, construction raises rather than losing the interaction.
QUBOProblem fields
| Field | Default | Contract |
|---|---|---|
num_variables |
required | Strict nonnegative integer; zero represents a constant problem locally. |
qubo_dict |
required | Finite (int, int) coefficient dictionary; indices lie in [0, num_variables). |
matrix_format |
"symmetric" |
"symmetric" or "upper" at input; normalized to symmetric storage. |
max_evaluations |
10000 |
Strict positive integer sent to the platform. |
problem_type |
"QUBO" |
Fixed QUBO marker. |
offset |
0.0 |
Finite source-objective constant retained by the SDK; not sent as a matrix term. |
sense |
"MIN" |
"MIN" minimizes the source objective; "MAX" maximizes it. Results are restored to this source sense. |
variable_names |
None |
One unique non-empty name per index; used for named results. |
Unknown fields are forbidden.
An empty normalized matrix is valid for local zero-energy solving when num_variables is supplied. The platform cannot store that representation, so QUBOSolver.submit(), high-level platform solving, and submit() reject it before creating a platform problem.
import os
from qpiai_opt.opt_solvers import Client, solve
client = Client(api_key=os.environ["QPIAI_OPT_API_KEY"])
zero_energy = QUBOProblem(num_variables=3, qubo_dict={})
local_result = solve(zero_energy, client=client)
zero_energy_solution = local_result.solution # [0, 0, 0]
# Raises CapabilityError before a platform request.
solve(zero_energy, mode="platform", client=client)
Client and solver construction
import os
from qpiai_opt.opt_solvers import Client
from qpiai_opt.opt_solvers.qubo import QUBOSolver
client = Client(api_key=os.environ["QPIAI_OPT_API_KEY"])
solver = QUBOSolver(client=client)
The solver can also create its own client:
import os
solver = QUBOSolver(
api_key=os.environ["QPIAI_OPT_API_KEY"],
experiment_name="Default Experiment",
)
Ordinary calls omit compute_resource_id; the platform automatically chooses an eligible QUBO resource. Advanced callers may pin a discovered resource ID on the solver or an individual call, where the per-call value takes precedence. A compute-resource ID is the opaque id on a record returned by Client.list_solvers():
resources = client.list_solvers(method="QUBO", active_only=True)
resource = resources[0]
pinned_submission = solver.submit(
problem,
compute_resource_id=resource.id,
)
This placement pins only that submission. Supplying the ID to QUBOSolver(..., compute_resource_id=resource.id) makes it the solver default.
available = solver.health()
health() -> bool delegates to Client.health(). It performs an authenticated account request with a five-second timeout, creates no problem or job, and returns False on request or API failure.
Blocking solve
Blocking solves read the current job state, then wait through Server-Sent Events (SSE) by default. If the stream cannot complete monitoring, the waiter falls back to polling; poll_interval controls that fallback interval in seconds.
result = solver.solve(
problem,
name="Selection QUBO",
experiment_name="Benchmarks",
timeout=3600,
request_timeout=60,
poll_interval=5,
)
Execution sequence:
- validate and serialize the problem;
- create a persisted platform problem;
- submit the job;
- wait for a terminal job state;
- retrieve inline or storage-backed output;
- validate and restore source metadata.
timeout is the waiting budget, starting after submission; it excludes problem creation, submission, and final result retrieval. request_timeout defaults to the client's timeout and supplies the connection/read timeout for those requests. Status reads use the client's timeout within the waiter's remaining budget. These HTTP timeouts bound connection and read inactivity, not total transfer duration; an in-flight request can overrun the waiting budget. A local timeout stops waiting without cancelling the platform job. Resume monitoring the same job ID rather than resubmitting. See request and wait timing.
Nonblocking lifecycle
This solver-instance submit() method is explicitly platform-only. Supplying its Client and calling it is the caller's choice to create one potentially paid platform job.
submission = solver.submit(
problem,
name="Selection QUBO",
timeout=60,
)
submission_fields = submission.model_dump(mode="json")
submit() returns after platform submission; it never waits for completion. Its timeout is an individual request bound. submission_fields contains the new job_id, problem_id, normalized lifecycle status, and platform message.
Check state:
status = solver.get_status(submission.job_id, timeout=60)
if status.status == "completed":
result = status.result
elif status.status == "failed":
failure_message = status.error
get_status() includes the validated result when completed.
Retrieve explicitly:
result = solver.get_result(submission.job_id, timeout=60)
solution = solver.get_solution(result=result)
get_result() raises RuntimeError unless the job is completed. get_solution(job_id=...) retrieves the result first; passing an existing result avoids another request.
Result interpretation
result_summary = {
"num_nodes": result.num_nodes,
"compute_time_s": result.compute_time_s,
"solution": result.solution,
"objective": result.objective,
"variable_assignment": result.variable_assignment,
"method": result.info().method,
"guarantee": result.info().guarantee,
}
| Field | Meaning |
|---|---|
problem_class |
Always "QUBO". |
execution_mode |
"local" or "platform". |
job_id |
Platform job identifier retained by blocking and retrieval workflows. |
problem_id |
Platform problem identifier associated with the job, when returned. |
solution |
Binary list in QUBO index order. |
variable_assignment |
Source-name dictionary when a source Model is retained; otherwise explicit variable names or generated x[index] names. |
objective |
Source objective evaluated from the returned bitstring after sense and offset restoration when metadata is available. |
num_nodes |
Non-negative QUBO node count; local results use the QUBO variable count. |
compute_time_s, solver_time_s |
Compute and algorithm durations in seconds, or None when unavailable. |
compute_time_ms, compute_time_us, solver_time_ms, solver_time_us |
Runtime in milliseconds and microseconds; omitted from default serialization. |
result.info() reports the provider, method, termination, source feasibility, optimality proof, guarantee, transformation, and known settings and is backed by the serializable solve_info field. For a source-feasible candidate, local exhaustive-enumeration reports termination="optimal" and guarantee="global_optimum"; simulated annealing and platform execution report "completed" and "best_found". A decoded candidate that violates its source model has no usable-solution guarantee. get_status() separately returns asynchronous job lifecycle state and embeds the result only after completion.
When source metadata is available, the SDK also checks the returned bitstring against the reported matrix energy. This comparison excludes the source offset and reverses the matrix sign for MAX, matching the minimizing service coordinates.
Metadata is cached only in the QUBOSolver instance that submitted the job. For retrieval in another process, reconstruct/pass the original problem:
result = solver.get_result(
submission.job_id,
problem=problem,
)
Without problem, the SDK cannot recover an offset, source sense, or variable names absent from the platform response. objective then contains the reported minimizing matrix value; info().source_objective and info().transformed remain None. Supply the original problem when retrieving source coordinates.
For a source-aware platform result, the reported minimizing matrix objective must agree with the returned bitstring under round(abs(reported - recomputed)) <= max(1, 1e-7 * max(1, abs(reported), abs(recomputed))). A non-finite difference or a rounded difference above this tolerance raises RuntimeError. Python rounding uses ties to even. When the tolerance is one, a difference of 1.4 is accepted and 1.5 is rejected; the tolerance increases with objective magnitude. The adapter then restores the source offset, sense, and decoded assignment.
For a modeller source, decode and verify independently:
decoded = model.decode_qubo_sample(result.solution)
source_feasible = model.is_feasible(decoded)
recomputed_objective = model.evaluate_objective(decoded)
source_feasible confirms every source constraint. Compare recomputed_objective with result.objective using a tolerance for non-integral coefficients.
Problem names and experiments
Problem names are labels and need not be unique. If omitted, the SDK generates a UTC timestamped name:
SDK QUBO problem - <UTC timestamp>
Per-call experiment ID/name overrides the solver/client fallback. Names are matched exactly within the authenticated organization and cached after resolution.
Submission failures
Problem/config/identifier validation occurs before the corresponding network side effect.
If the platform definitively rejects job submission with a non-transient 4xx, the solver attempts to delete the new problem record. For HTTP 408, 425, 429, server failures, or transport errors, acceptance is ambiguous. The record is retained and the exception note tells the caller which problem to inspect.
Do not blindly resubmit an ambiguous paid job:
jobs = client.list_problem_jobs(problem_id, page=1, page_size=20)
solve() raises JobFailedError, JobCancelledError, or TimeoutError when the waiter reaches those outcomes. HTTP and validation failures use APIError, AuthenticationError, Requests exceptions, or pydantic.ValidationError.
The result tables above define every solver-specific field; common result and error semantics are in Local solving and Automation. Construct a platform problem shows modeller-to-solver and independently supplied QUBO input, and blocking solve shows the explicit paid submission boundary.