SDK: the Workflow class
Reference for qsolve.workflow: Workflow and every method you override, Outcome, Context (including emit and report_mitigation), and the exceptions WorkflowError, InvalidSolution, MethodUnavailable and QuantumNotAllowed.
Updated 13 November 2026 View as markdown
Module qsolve.workflow. Everything on this page is importable from the top-level package: from qsolve import Workflow, Outcome, Context, WorkflowError, InvalidSolution, MethodUnavailable, QuantumNotAllowed. For a guided walk-through read Writing the workflow; this page is the exact reference. Back to the SDK reference.
Workflow
class Workflow:
def __init__(self, input: dict[str, Any], params: dict[str, Any]) -> None
def size(self) -> int
def quantum(self, ctx: Context) -> Outcome
def reference(self, ctx: Context) -> Outcome
def evaluate(self, solution: Any) -> float
def answer(self, outcome: Outcome) -> dict[str, Any]
def estimate(self, target: str) -> Estimate | None
def summary(self, quantum: Outcome, reference: Outcome) -> str | None
You subclass Workflow and name the class in the manifest's entrypoint (package.module:ClassName). The SDK calls every method; you never call them. The base class implements only __init__, estimate and summary. The other five raise NotImplementedError until you override them.
In addition to these, every method of role classical that you declare in the manifest is a method named classical_<id>(self, ctx) -> Outcome, where <id> is the manifest method id with each dash turned into an underscore (simulated-annealing is classical_simulated_annealing). The base class does not define these; the runner looks them up by name.
| Manifest method | Class method | Required |
|---|---|---|
role quantum |
quantum |
yes (exactly one) |
role reference |
reference |
yes (exactly one) |
role classical, id x-y |
classical_x_y |
one per declared method |
A declared method whose class method is missing fails the run with method 'x-y' is declared in the manifest but the workflow has no classical_x_y method.
__init__(self, input, params)
| Parameter | Type | Meaning |
|---|---|---|
input |
dict[str, Any] |
The user's input, already validated against schemas/input.json. |
params |
dict[str, Any] |
Every manifest parameter by name: defaults plus the user's overrides, each checked against its type and bounds. Parameters you did not declare are never present. |
Returns nothing. The base implementation stores them as self.input and self.params; call super().__init__(input, params) first. Side effects: none in the base class. Called once per run, by the runner, before any method runs.
Raise WorkflowError("message") for anything the user got wrong that a schema cannot express. The platform shows the message to the user as written. A ValueError, KeyError, TypeError or IndexError raised here becomes WorkflowError("Could not set up the problem from this input: <original>"); any other exception is a crash.
def __init__(self, input, params):
super().__init__(input, params)
self.nodes = input["nodes"]
if len(set(self.nodes)) != len(self.nodes):
raise WorkflowError("node names must be unique")
Gotcha: do the expensive work in the method that needs it, not here. estimate_run also builds your class (to call size and estimate), so a slow constructor slows every price quote.
size(self) -> int
The number of qubits or variables. The runner compares it with the manifest envelope straight after construction: if it is above envelope.max_variables or envelope.max_qubits the run is refused with Problem has N variables; this workflow supports at most M (<envelope.notes>). No credits are spent. Raises NotImplementedError if you do not override it.
quantum(self, ctx) -> Outcome
The method with role quantum. It is the only method whose ctx can send circuits. Build circuits in Qiskit, train however you like, and run what you want measured with ctx.sampler.run(...), ctx.estimator.run(...) or ctx.backend.run(...). Local statevector simulation that does not go through ctx is not metered.
Exceptions: a WorkflowError raised here is the deliberate "this input cannot be solved" and reaches the user as written. MethodUnavailable makes the run fail with the quantum method or the reference produced no result: <why>. Any other exception is a crash: the platform reports it and bills what was used. The method must send at least one circuit through ctx, or validation fails (quantum method <id> sent no circuit to the backend).
reference(self, ctx) -> Outcome
The method with role reference: the classical answer the quantum result is judged against. Its ctx refuses circuits. Same exception rules as quantum; the reference must produce a value or the run fails.
classical_<id>(self, ctx) -> Outcome
One per classical method. Its ctx refuses circuits. Raise MethodUnavailable("why") when the method does not apply to an input: it is shown as unavailable with that reason and the run continues. Any other exception, WorkflowError included, is caught and the method is shown as unavailable with failed: <ExceptionType>: <message>, but a method in that state fails the validation case (see check_quality in Testing, linting and the sandbox). Only MethodUnavailable is a clean "not applicable".
evaluate(self, solution) -> float
| Parameter | Meaning |
|---|---|
solution |
A solution in whatever JSON-compatible form your methods put in Outcome.solution or in samples[i]["solution"]. |
Returns the objective of that solution; lower is better. The SDK calls it on every method's solution and on every entry of samples, to recompute the number each method claimed. Keep it exact and fast. Raise InvalidSolution("reason") for a solution that breaks a rule of the problem. Any other exception is turned into WorkflowError("evaluate() failed on a method's solution: <Type>: <msg>") and fails the run. A non-finite return value marks that method unavailable. See How Qsolve checks your answer.
def evaluate(self, solution):
x = np.asarray(solution, dtype=float)
if x.shape != (self.n,) or not np.isin(x, (0, 1)).all():
raise InvalidSolution("a solution is one 0 or 1 per variable")
return float(x @ self.Q @ x)
answer(self, outcome) -> dict[str, Any]
Turns an Outcome into the domain answer people read, shaped by schemas/output.json (Output schema reference). Called on the quantum method's outcome (it must succeed; the result is validated against the output schema, and a failure or mismatch fails the run with answer() failed on the quantum result: ... or Workflow produced an invalid answer: ...) and on every other method's outcome so answers can be shown side by side (a failure there is ignored and that method simply has no answer).
estimate(self, target) -> Estimate | None
| Parameter | Meaning |
|---|---|
target |
The target string, for example sim-cpu or ibm:ibm_fez. |
Optional. The base class returns None. Return an Estimate of what a run will send to the target; it becomes the price quote. With None, estimate_run substitutes one circuit and params["shots"] shots (or 0 if there is no shots parameter) and marks it by_default. The runner sets by_default itself, and adds the fixed run overhead to a runtime_s you give. Called by estimate_run and qsolve estimate; the estimate_honest gate compares it with what the first benchmark case really sent. Details in Metering and cost.
summary(self, quantum, reference) -> str | None
Optional. Return one sentence for the result page, or None for the default (<quantum title>: <value>; <reference title>: <value> (accuracy NN.NN%).). The runner truncates it to 500 characters. Called after all methods have run, with the quantum and reference Outcomes.
Outcome
@dataclass
class Outcome:
solution: Any
objective: float
stderr: float | None = None
samples: list[dict[str, Any]] | None = None
details: dict[str, Any] = field(default_factory=dict)
Every method returns one.
| Field | Type | Default | Meaning |
|---|---|---|---|
solution |
any JSON-compatible value | required | A solution in the form evaluate accepts. None for a result that cannot be re-evaluated (a measured energy, a number from a library): the objective is then accepted and labelled reported instead of recomputed. |
objective |
float |
required | The value this method reports for it. Lower is better. It must be finite: otherwise the method is unavailable (it reported a value that is not a finite number). If solution is given, the SDK recomputes it with evaluate; a difference of more than 1e-6 * max(1, abs(value)) is recorded as claimed and fails validation. |
stderr |
float | None |
None |
One standard deviation, when the value is a measurement. Must be finite and not negative, or the method is unavailable. |
samples |
list[dict] | None |
None |
For a sampling method, every outcome as {"solution": ..., "p": probability}, optionally with a "label" string. The SDK evaluates each, reports valid_fraction and p_reference, and shows the five most probable. Entries without a solution key or with a negative, non-finite or non-numeric p are dropped; the rest are renormalised to sum to 1. |
details |
dict[str, Any] |
{} |
Free-form diagnostics kept with the run (quantum method only, under provenance.solver.details). NumPy arrays and scalars are converted to plain JSON; details over 200,000 bytes serialised are replaced by a note. For the error_rate metric, details["n_test"] must hold the number of test cases. |
Maximisation: return the negative value as objective and turn it back in answer().
return Outcome(
solution=best["solution"],
objective=self.evaluate(best["solution"]),
samples=sorted(samples, key=lambda s: -s["p"])[:10],
details={"theta": theta.tolist()},
)
Gotcha: samples should hold all your outcomes, not a hand-picked top few, or valid_fraction and p_reference describe only that subset.
Context
@dataclass
class Context:
seed: int
target: str
backend: BackendV2
sampler: BaseSamplerV2
estimator: BaseEstimatorV2
emit: Callable[..., None]
report_mitigation: Callable[..., None]
Built by the runner and passed to every method; you never construct one except in tests. There are two flavours, made from the same inner backend: the quantum method's is live, and the one given to reference and every classical_<id> has a backend that refuses to run anything.
| Field | Type | Meaning |
|---|---|---|
seed |
int |
The run's seed (qsolve run --seed, default 0). Use it for every random number generator. |
target |
str |
The target string as requested, for example sim-cpu or fake:fez@1.6. |
backend |
BackendV2 |
A QsolveBackend. backend.run(circuits, shots=N) returns a finished job. |
sampler |
BaseSamplerV2 |
A QsolveSampler. |
estimator |
BaseEstimatorV2 |
A QsolveEstimator. |
emit |
Callable |
emit(type, **data), see below. |
report_mitigation |
Callable |
see below. |
Using backend, sampler or estimator from a classical method raises QuantumNotAllowed. It is raised at the point of the run call.
ctx.emit(type, **data)
This is qsolve.events.emit. Sends a progress event to whoever is listening (the run page on the platform, the on_event callback of run_workflow). With no listener it does nothing and costs nothing. It never raises and never affects the result.
| Parameter | Meaning |
|---|---|
type |
Event kind, a string. For example "train_step". The runner itself uses "stage". |
**data |
JSON-compatible keyword fields, for example step=3, value=-1.2. The event is {"type": type, "t": <unix time>, **data}. |
Gotchas: pass type positionally; a keyword type= clashes with the first parameter. A data field named t replaces the timestamp. The platform caps how many events it keeps.
ctx.emit("train_step", step=i, value=float(cost), best=float(best))
ctx.report_mitigation(circuits, distribution, methods=(), kept=None, steps=())
Declares the mitigated output of earlier circuits. Mitigation is your own post-processing, so Qsolve cannot see it; you report it, Qsolve recomputes its fidelity against the circuit's ideal output and labels it reported next to the raw figure it measured itself. The whole story, including what you may claim, is in Error mitigation and fidelity.
| Parameter | Type | Default | Meaning |
|---|---|---|---|
circuits |
list[int] |
required | Dispatch indices of the circuits this distribution mitigates, from pub_result.metadata["qsolve"][k]["dispatch"]. Must be non-empty and each must refer to a circuit already sent. |
distribution |
dict[str, float] |
required | The mitigated distribution, bit string to weight (Qiskit order, classical bit 0 on the right). Weights must be finite and not negative and sum to more than zero. Normalised by Qsolve. |
methods |
list[str] or tuple |
() |
Names of the mitigation methods, plain text. |
kept |
float | None |
None |
Share of shots that survived your checks, between 0 and 1 inclusive. None counts as 1. |
steps |
list[dict] or tuple |
() |
Your own fidelity ladder, for example {"name": "readout correction", "fidelity": 0.9}. Stored and shown as given. |
Returns None. Raises WorkflowError when circuits is empty or cites an index that was not sent (report_mitigation cites circuits [3], but 2 were sent so far ...), when the distribution is empty or has an invalid weight, or when kept is outside 0 to 1. Called by you, from the quantum method, after the circuits it covers were run. A report is only counted for circuits sent by the quantum method that share one ideal output.
Exceptions
| Exception | Base | Raised by | Meaning and effect |
|---|---|---|---|
WorkflowError |
Exception |
you, from __init__ or from quantum/reference; the SDK, for any problem with input, parameters, target or answer |
A problem with the user's input or the workflow, worded for the person who submitted it. The platform shows the message as is. qsolve run prints Error: <message> and exits 1. |
InvalidSolution |
Exception |
you, from evaluate |
A solution breaks a rule of the problem. The method that produced it is shown as unavailable with your message. |
MethodUnavailable |
Exception |
you, from any method | The method does not apply to this input; say why in the message (empty message shows not available for this input). |
QuantumNotAllowed |
WorkflowError |
the SDK | A classical method (role reference or classical) tried to use the quantum backend. The message is a classical method tried to run a circuit: only the quantum method may use the backend. |
InvalidSolution and MethodUnavailable are plain Exception subclasses, not WorkflowErrors. Catch WorkflowError and you also catch QuantumNotAllowed; do not swallow it inside a classical method, because that hides a violation the platform wants to see.
WorkflowError is also re-exported from qsolve.runner (it is in that module's __all__); it is the same class.
A complete minimal workflow
The test helper in the SDK's own tests uses this shape: a tiny QUBO solved by exhaustive search and by one sampling circuit.
import numpy as np
from qiskit import QuantumCircuit
from qsolve import Estimate, InvalidSolution, Outcome, Workflow, WorkflowError
class Tiny(Workflow):
def __init__(self, input, params):
super().__init__(input, params)
self.Q = np.array(input["Q"], dtype=float)
if self.Q.ndim != 2 or self.Q.shape[0] != self.Q.shape[1]:
raise WorkflowError("Q must be square")
self.n = len(self.Q)
def size(self): return self.n
def evaluate(self, solution):
x = np.asarray(solution, dtype=float)
if x.shape != (self.n,) or not np.isin(x, (0, 1)).all():
raise InvalidSolution("one 0 or 1 per variable")
return float(x @ self.Q @ x)
def answer(self, outcome):
return {"bits": [int(b) for b in outcome.solution], "objective": self.evaluate(outcome.solution)}
def estimate(self, target):
return Estimate(qubits=self.n, circuits_on_target=1, shots_on_target=int(self.params["shots"]))
def reference(self, ctx):
states = (np.arange(2**self.n)[:, None] >> np.arange(self.n)) & 1
e = np.einsum("si,ij,sj->s", states, self.Q, states)
return Outcome(solution=states[int(np.argmin(e))].tolist(), objective=float(e.min()))
def classical_greedy(self, ctx): # manifest method id: greedy, role: classical
return self.reference(ctx)
def quantum(self, ctx):
qc = QuantumCircuit(self.n)
qc.h(range(self.n)); qc.measure_all()
shots = int(self.params["shots"])
counts = ctx.sampler.run([(qc, None, shots)]).result()[0].data.meas.get_counts()
best = min(([int(c) for c in reversed(k)] for k in counts), key=self.evaluate)
return Outcome(solution=best, objective=self.evaluate(best))
The matching manifest, schemas and benchmark cases are in Your first workflow and The manifest.