Introduction
XQuad is a hardware-agnostic toolchain for expressing and running quadratic optimisation problems – QUBO, Ising, and integer formulations – on quantum annealers and classical solvers. A problem is written once against the XQVM instruction set and runs unchanged on any backend the toolchain supports.
Think of it as LLVM for quantum computing: write a problem once, compile it to XQVM bytecode, and run it on any supported backend.
What XQuad solves
Combinatorial optimisation problems – travelling salesman, graph colouring, knapsack, set cover, and their relatives – can be expressed as quadratic binary models and handed to a quantum annealer or a classical sampler. XQuad gives that pipeline a single intermediate representation, XQVM bytecode, and a reference virtual machine, so the same compiled program runs on a D-Wave QPU, a local simulated-annealing sampler, or the Quip network without a rewrite. See Quadratic Models for what a QUBO/Ising model actually is, and Backends for the solvers XQuad targets.
The real components
The toolchain is dual-language: a Rust core (VM, assembler, bytecode)
with Python interfaces (constraint-programming DSL, solver adapters, FFI
bindings, and the xquad CLI).
Two Rust crates, published to crates.io:
| Crate | Role |
|---|---|
xqvm | Bytecode definitions, opcode table, instruction builder, binary codec, an incremental instruction-stream reader, the VM interpreter, and a disassembler |
xqasm | Text assembler: .xqasm source to bytecode |
A third crate, xqffi,
is a PyO3 bridge that exposes xqvm and xqasm to Python. It ships as a
Python wheel rather than a crates.io library.
Four Python distributions, published to PyPI:
| Package | Role |
|---|---|
xqcp | High-level constraint-programming DSL that compiles to XQVM assembly |
xqsa | Solver adapters for XQMX models: local simulated annealing, D-Wave, and the Quip network |
xqffi | The PyO3 FFI bindings crate above, packaged as a wheel |
xquad | Umbrella package re-exporting xqffi, xqcp, and xqsa under one namespace, with an interactive Program / Session / RunResult API |
spec/xqvm/ defines the VM, and the committed conformance vectors hold
xqvm to it: each vector runs in CI against the outcome the spec
prescribes, and a mismatch fails the build. See Conformance for what a vector does
and does not cover.
Architecture at a glance
XQVM is a stack-based interpreter with a 256-slot register file. The value
stack holds i64 integers; registers hold typed values (RegVal): integers,
integer vectors, QUBO/Ising/integer models (XqmxModel), model vectors, and
candidate solutions (XqmxSample). A dedicated loop stack drives RANGE and
ITER iteration.
The instruction set comprises 93 instructions, declared once in the
opcodes! table at
xqvm/src/bytecode/types/table.rs.
The Opcode enum, the Instruction enum, mnemonic strings, and operand
arity are all derived from that single table.
Every .xqb file opens with a fixed 15-byte XQBC header – magic bytes,
format version, calldata/output-slot counts, instruction-stream length, and
a CRC-32 checksum – followed by the raw instruction stream: an opcode byte
followed by its operands in big-endian byte order. See
Bytecode Format for the full field layout.
Where to go next
- Getting Started – install the toolchain and run your first program.
- Concepts – what a QUBO/Ising model is, the ways to use XQuad, and the backends it targets.
- Modelling with XQCP – write a problem with the constraint-programming DSL.
- Running Programs and Solving – execute a compiled program and hand it to a solver.
- XQVM Reference – the machine model, assembly language, instruction set, and bytecode format.
- Embedding – using the Rust crates directly,
no_stdsupport, and conformance to the specification. - Examples – worked problems including a Travelling Salesman Problem.
License
Licensed under the GNU Affero General Public License v3.0 or later.
Install XQuad
This page installs XQuad, confirms the install works, and gets the runnable examples this book uses. It also covers what a GPU or QPU solver needs beyond the base install. First Problem picks up from here with a real run.
Install
The root README’s Install section
is the canonical install reference for both ecosystems this project ships
into – prebuilt wheel platforms, the Rust toolchain required to build the
xqffi extension from source, and installing individual Python packages
instead of the umbrella.
Install XQuad into a project environment first: a virtual environment,
or a uv project.
python -m venv .venv && source .venv/bin/activate
pip install xquad
# or, in a uv project:
uv add xquad
For the xquad command alone, outside any project, install it as a
uv tool, with the quip extra for the xquad quip commands:
uv tool install xquad # the xquad command
uv tool install 'xquad[quip]' # with the xquad quip commands
On macOS, a user install (pip install --user, or pip falling back to
one) puts the xquad command in ~/Library/Python/3.x/bin, which is
not on PATH; python -m xquad runs the same command without it.
Verify the Install
xquad --version prints the installed version, confirming the command
is on PATH and runs. If your shell reports xquad: command not found,
that Python environment’s script directory is not on PATH; python -m xquad --version runs the same command without it. Importing the
umbrella package confirms the Python API:
$ python -c "import xquad; print('ok')"
ok
Get the Examples
Every uv run python examples/... command in this book, including on
the next page, runs against a checkout of this repository – the
examples are not part of any published package, so pip install above
does not put them on disk. Get one and set up the
Python workspace once:
git clone https://gitlab.com/quip.network/xquad.git
cd xquad
make deps-py
make deps-py needs uv
and a Rust toolchain, which builds the xqffi
extension from source. It
syncs the Python workspace and wires up cross-package imports, so
uv run python examples/<name>/runner.py then works from the repository
root – the same command every example and cookbook page in this book
uses.
GPU and QPU Install Prerequisites
The base install runs CPU simulated annealing only, through xqsa’s
cpu backend. Every other solver – a local GPU or a real D-Wave
QPU – needs an extra on top, and the extra needs hardware or credentials
this page cannot install for you. xqsa/pyproject.toml defines the
extras, and xquad/pyproject.toml forwards to the ones it re-exports:
cuda–cupy-cuda12x>=13.0,nvidia-cuda-nvrtc-cu12, andnvidia-cuda-runtime-cu12, for a local NVIDIA GPU.dwave–dwave-system>=1.0, for the real D-Wave QPU.metal–pyobjc-framework-Metal>=11.0, markedsys_platform == 'darwin', for a local Apple GPU. The marker meanspip install xquad[metal]succeeds on Linux and installs nothing.quip–substrate-interface>=1.7.4,<2plusquip-signer>=0.3.0,<0.4, for the Quip network solver, which needs aQUIP_RPC_URLand a configured signer; see Quip Network for what that solver does.
xqcp and xqffi define no optional dependencies at all.
The only extra this page can fully specify is [dwave]: it needs a D-Wave
Leap account and a DWAVE_API_TOKEN, and dwave ping confirms both. A
local GPU ([cuda] or [metal]) is another option; see Local
Solvers for those two extras
and their driver checks.
pip install "xquad[cuda]", xquad[metal], xquad[dwave], and
xquad[quip] each forward to the matching xqsa extra, and extras are
composable: pip install "xquad[cuda,dwave]".
A missing extra does not break the base install: import xqsa never
fails just because an optional extra is absent. Only constructing the
solver class that needs it does, raising ImportError with a
pip install xqsa[...] hint naming the extra to add.
Per-solver parameters, driver-level troubleshooting, and what a QPU result contains that a CPU one does not are covered in Solving Overview, Local Solvers, and D-Wave QPU. This section only covers what to install before you get there.
Where to Go From Here
- First Problem – run a complete problem end to end and get a real answer back.
- What Happened – the explanation of what that run just did.
- XQVM Reference – a minimal hand-written
.xqasmprogram, if you want to see the machine underneath before running anything larger. - Toolchain Map – the pieces XQuad is built from, and how they hand off to each other.
First Problem
This page runs one problem from nothing to a real answer. It does not explain how the pieces work – What Happened does that once you have seen a result to explain.
Max-Cut
Max-Cut splits a graph’s nodes into two groups to maximise the total
weight of edges crossing between them.
Quadratic Models
derives its formulation by hand on a three-node graph; examples/maxcut/
is the same problem, runnable, on a graph you pick the size of.
Run It
With XQuad installed and the examples checked out, per Install XQuad:
$ uv run python examples/maxcut/runner.py --seed 42
{
"_note": "canonical CI golden",
"_seed": 42,
"cut_weight": 354,
"energy": -354,
"n": 5,
"partition": [
0,
1,
0,
0,
1
],
"valid": 1
}
No flags beyond --seed are required: --n defaults to 5, and
--solver defaults to cpu, the baseline that needs no hardware or
credentials.
Reading the Result
partition is the answer: one 0 or 1 per node, five nodes here,
naming which side of the cut each node is on. cut_weight is the
total weight of edges connecting a 0-side node to a 1-side node –
the quantity Max-Cut maximises. energy is -354, the negative of
cut_weight, because the model minimises -1 times the cut weight to
turn maximising into the minimising every XQuad model does; see
Quadratic Models
for why that sign flip is exact. valid: 1 confirms every value in
partition is 0 or 1 – the only thing this problem’s verifier
checks, since Max-Cut declares no constraint beyond that.
Next: What Happened names each step this run took, in order, and links to where each one is covered in full.
What Happened
First Problem ran examples/maxcut/runner.py and
printed a partition, a cut weight, and an energy. This page is an index:
it names the five moves that run made, in order, and points at the page
that covers each one in full, rather than explaining them again here.
Toolchain Map has its own diagram of the same
pipeline, if a picture is what you want first.
Five Moves, One Command
- A problem became three programs.
build_probleminexamples/maxcut/runner.pydescribes Max-Cut once, in Python, with thexqcpDSL;problem.compile()turned it into an encoder, a verifier, and a decoder, sharing no state. Three Programs covers why there are three and not one, and walks this exact run step by step. - The encoder ran and built a model. Quadratic Models covers the Hamiltonian it built, specialised to this graph’s weights.
- A solver minimised it.
cpu, the default backend, ran simulated annealing over the model and returned a sample. Backends covers the other four solvers this same model could have gone to instead, unchanged. - The verifier checked the sample and computed its energy
independently.
energy: -354was not relayed from the solver; see Quadratic Models: Energy for that computation. - The decoder turned the sample into
partition. See Outputs and Decoding for what a decoder can read and what it hands back.
Where to Go From Here
- Toolchain Map – every piece named above, in one table, with what each hands to the next.
- Modelling – write your own
Probleminstead of reading someone else’s; builds on a second running example, Knapsack, from the first input declaration onward. - Running Programs – drive an already-compiled
program from Python yourself, the way
runner.pydoes internally. - Solving Overview – the five solvers behind step 3, and what each needs installed before you can reach it.
- Using the Examples – turn Max-Cut, or any of the other thirteen examples, into a problem of your own.
Toolchain Map
XQuad turns one problem description into bytecode that runs unchanged on any backend it supports. This page names the pieces that make that true, and what each one consumes and produces.
flowchart LR
P["DSL (xqcp) or hand-written .xqasm"] --> ASM[Assembler]
ASM --> B[XQVM bytecode]
B --> V["VM: xqvm"]
H[Host program] -->|calldata| V
V -->|model-building program| M[XQMX model]
M --> H
H --> SLV[xqsa solver]
SLV -->|sample| H
H -->|sample as calldata| V
V -->|verifier / decoder programs| H
| Component | Role | Hands off |
|---|---|---|
DSL (xqcp) or .xqasm source | Describes the problem: a program whose job is to build a model, in Python or in assembly text | .xqasm source |
Assembler (xqasm) | Parses .xqasm text and resolves labels | XQVM bytecode (.xqb) |
| Bytecode | The portable artifact: the same bytes run on the xqvm interpreter, wherever it is embedded | Input to the VM |
VM (xqvm) | Executes bytecode against calldata the host program supplies | An XqmxModel, when the program’s job is building one; decoded output, when its job is reading a sample back |
Model (XqmxModel) | The Hamiltonian a model-building program assembled: the model’s energy function, covered in Quadratic Models | Input to a solver |
Solver (xqsa) | Minimises the model’s Hamiltonian | An XqmxSample: the best assignment found |
| Host program | The script or CLI session driving every stage above | Reads the model out of the VM and hands it to the solver, then feeds the sample back in as calldata (the numbered input slots a host fills before each VM run) for the next run |
Following the Pipeline
A hand-written program can skip most of this. Save the following as
add.xqasm:
; push two integers and add them
PUSH 10
PUSH 32
ADD
HALT
$ xquad vm asm add.xqasm -o add.xqb
assembled 4 instructions (21 bytes) -> add.xqb
$ xquad vm run add.xqb
stack (bottom to top):
42
Here the pipeline is just two rows: the assembler and the VM. The xquad
CLI plays the host program’s role, reading add.xqb from disk and
printing the result, and that already runs the real toolchain end to end.
XQVM Reference walks through this same add.xqasm
again, from the machine’s side: what each instruction does to the stack as
it runs.
A problem worth handing to a solver uses every row in the table above.
xqcp compiles a Problem into three separate .xqasm programs, not one
– see Three Programs for what those three are and
why. The first one builds a model; xqsa solves it; the other two check
and decode the answer. A Python host program – xquad.program,
xqffi.vm, or a full xqcp pipeline – drives it, injecting calldata
before each VM run and reading outputs back after. See
Ways to Use XQuad for which surface plays that host-program
role in each of the six ways to work with XQuad.
Where to Go From Here
- Ways to Use XQuad – the six surfaces that build a model and drive execution, and how to pick one.
- Quadratic Models – what the model in the middle of this pipeline actually is, and why constraints become part of it.
- Three Programs – why
xqcpcompiles a problem into three separate programs instead of one. - Backends – the solvers that sit in the gap between encoder and verifier.
Past this chapter: Modelling builds a model with the DSL, Running Programs and Solving Overview execute it and hand it to a solver, and XQVM Reference is the machine this whole pipeline compiles down to.
Ways to Use XQuad
Six separate surfaces get a problem into XQuad and a result back out. Pick the wrong one and you find out several pages later. This page is the map.
| Surface | What it is | Who it is for |
|---|---|---|
xqcp DSL | Python constraint-programming layer; compiles a problem to XQVM assembly | Modelling a new combinatorial problem without hand-writing assembly |
xquad Program/Session API | Program / Session / RunResult: load a program once, run it repeatedly with fresh calldata | Driving an existing compiled program from Python – a REPL, a notebook, a script |
xqffi.vm FFI | Vm: a thin wrapper over the Rust interpreter | Python code building its own convenience layer on top of the raw Rust VM |
xquad.vm VM wrapper | VM: runs .xqasm source text or bytecode on the Rust interpreter, keeping calldata, output slots and limits across runs | Scripts that run several programs in a row with the same settings; the surface every example runner under examples/ uses |
xquad CLI | xquad vm asm / run / dis / check | Assembling, running, inspecting, or verifying one .xqasm/.xqb file, no Python involved |
| Rust embedding | xqvm::InstructionBuilder, building a Program directly, no text assembly step | Embedding XQVM in a Rust host: no_std target, WASM runtime, on-chain pallet |
xqcp – The DSL
You write a Problem in Python: declare inputs, define the model, add
objective terms and constraints, declare outputs. problem.compile()
returns CompiledPrograms(encoder, verifier, decoder) – three .xqasm
programs, ready to run. See Three Programs for what
those three are and why there are three. Every example under examples/
is built this way; examples/maxcut/runner.py is a complete pipeline you
can read start to finish.
Stop here if you have a combinatorial problem to model and no existing
.xqasm source. Keep reading if you already have compiled bytecode and
need to run or inspect it. Full coverage: Modelling.
xquad.program – Program and Session
Program.from_source(src) or Program.load(bytecode) loads a program
once; program.session(output_slots=n) gives you a Session you call
.run() on repeatedly, each time with fresh calldata via
session.set_calldata(...). Each run returns a RunResult with
dict-keyed outputs (an unset slot reads as None), the residual stack,
and the step count. Program.from_source plus Session.run()
round-trips 40 + 2 through calldata and an output slot
to {0: 42}, and running the same session again returns {0: 42} again:
each run() starts clean.
Stop here if you are driving an already-compiled program from Python and want calldata handling and output decoding done for you. Keep reading if you are choosing between this and the raw FFI below. Full coverage: Running Programs.
xqffi.vm – The Raw FFI
xqffi.vm.Vm is a thin wrapper over the Rust interpreter: construct a
Vm, call set_calldata/set_output_slots, call .run(bytecode), read
.outputs(). No Program/Session layer on top, and unlike Session,
a Vm holds its stack and registers across runs: call .run() twice on
the same Vm without an intervening .reset() and the second run’s
results sit on top of the first’s. reset() clears the stack, registers,
and loop state; calldata and outputs both survive a reset() untouched,
and only a fresh set_output_slots(n) call replaces the output slots.
Session sidesteps the question entirely: Session.run() builds a
fresh Vm per call, so no state survives between runs. That is the real
reason to prefer it for anything but a single one-off program.
Reach for Vm directly only if you are building your own convenience
layer on top, or the Program/Session assumptions do not fit – most
Python users want xquad.program or xquad.vm instead. Full coverage:
Running Programs.
xquad.vm – The Source-Level VM
xquad.vm.VM wraps xqffi.vm.Vm. run(source) assembles .xqasm text
and runs it; run_bytecode(bytecode) runs pre-assembled bytes. Calldata,
output slots, and the step and memory limits persist across runs until
reset(), while every run starts from a fresh stack and register file.
Outputs come back as the xqffi types unchanged: int, list[int],
XqmxModel, XqmxSample, or None. Every example runner under
examples/ imports VM from here, not xqffi.vm directly.
Stop here if you run .xqasm source from a script and want the settings
to carry from one run to the next. Keep reading if you need the raw FFI
underneath instead. Full coverage: Running
Programs.
xquad CLI
xquad vm asm, xquad vm run, xquad vm dis, xquad vm check – four
subcommands, entirely in the shell. Assemble a .xqasm file to bytecode, run
a .xqb file (or a .xqasm file with --text) against calldata,
disassemble bytecode back to a readable listing, or verify a compiled
program before running it.
xquad vm asm add.xqasm -o add.xqb followed by xquad vm run add.xqb,
xquad vm dis add.xqb, and xquad vm check add.xqb assembles, runs,
disassembles, and verifies the four-instruction add.xqasm from
Toolchain Map.
Stop here if you are working from a shell or from CI. Keep reading if you need to drive many runs programmatically rather than one at a time. Full coverage: CLI.
Rust Embedding
xqvm::InstructionBuilder builds a Program directly from Rust, with no
.xqasm text step at all:
#![allow(unused)]
fn main() {
use xqvm::{InstructionBuilder, Vm};
let mut builder = InstructionBuilder::new();
builder.emit_push(10).emit_push(32).emit_add().emit_halt();
let program = builder.build().unwrap();
let mut vm = Vm::new();
vm.run(&program).unwrap();
assert_eq!(vm.stack(), &[42]);
}
The xqvm crate is no_std + alloc-compatible, so this is the surface
for embedding XQVM where Python, or even a filesystem, is not available.
Stop here if you are embedding the VM inside another Rust program. Everyone else wants one of the five surfaces above. Full coverage: Builder API.
The Orthogonal Choice: Where It Solves
Whichever surface builds your model, you still choose separately where
that model gets solved: local CPU or GPU simulated annealing, a D-Wave
QPU, or the Quip network. xqsa.build_solver(name, seed=...) selects a
backend by name regardless of which surface above produced the model, and
every example runner exposes this as a --solver flag. See
Backends.
Quadratic Models
Every backend XQuad targets agrees on one thing: a single number, computed from a candidate assignment, that the solver tries to make as small as possible. That number is the model’s energy, and a quadratic model is the recipe for computing it:
$$H(x) = \sum_i \text{linear}[i] \cdot x_i + \sum_{i \le j} \text{quadratic}[i,j] \cdot x_i \cdot x_j$$
x is a vector of variables. linear[i] scales with variable i’s own
value, and quadratic[i,j] scales with the product of i and j’s values
– a cost, or a saving if negative. Read those as “the cost of turning i
on” and “the extra cost of turning i and j on together” when x is
binary; Three Domains below covers two domains where
nothing is “on.” No term touches three variables at once – that is what
“quadratic” means here. The sum runs over \(i \le j\), not \(i < j\):
the diagonal is legal, and quadratic[i,i] scales with \(x_i^2\). What
that means depends on the domain – on binary variables \(x^2 = x\), so
it acts as a linear bias written through the quadratic table; on spin
variables \(x^2 = 1\), so it is a constant offset. The VM stores and
evaluates such a term without interpreting it. H is called the model’s Hamiltonian, borrowing
the term physicists use for a system’s total energy, because the annealing
hardware XQuad can target literally is a physical system settling toward a
low-energy state.
XQVM builds this structure directly: BQMX/SQMX/XQMX allocate a model
register, and SETLINE/SETQUAD/ADDLINE/ADDQUAD write the two
coefficient maps one term at a time. See
Allocators and
Coefficient Access for the
instructions.
Why Optimisation Problems Become One of These
A combinatorial problem – which cities to visit in what order, or which
items fit in the knapsack – usually starts life as a set of decisions
plus rules those decisions must obey. A quadratic model has no separate
mechanism for rules: it is one function, and a solver’s only operation is
minimising it. Every constraint has to become part of the same H, or the
solver never sees it. The
Penalties section
below is that mechanism, and the concept this page most wants you to leave
with.
A Worked Example: Max-Cut
Take Max-Cut: split a graph’s nodes into two groups to maximise the total weight of edges that cross between them. \(x_i \in \{0, 1\}\) marks which group node \(i\) is in. For each weighted edge \((i, j, w)\):
$$\text{linear}[i] \mathrel{+}= -w, \qquad \text{linear}[j] \mathrel{+}= -w, \qquad \text{quadratic}[i,j] \mathrel{+}= 2w$$
That edge’s contribution to \(H\) is \(-w(x_i + x_j - 2x_ix_j)\), and
\(x_i + x_j - 2x_ix_j\) is the binary XOR of \(x_i\) and \(x_j\): it
is \(1\) when \(x_i \neq x_j\) and \(0\) when they match. That edge
contributes \(-w\) exactly when it crosses the partition, and \(0\)
otherwise. Summed over every edge, \(H\) is the negative of the
cut weight, so minimising \(H\) maximises the cut. examples/maxcut/runner.py
builds exactly this model.
For three nodes and edges \((0,1,3)\), \((1,2,5)\), \((0,2,1)\), the rule above gives:
$$\text{linear} = \{0: -4,\ 1: -8,\ 2: -6\}, \qquad \text{quadratic} = \{(0,1): 6,\ (1,2): 10,\ (0,2): 2\}$$
Two assignments, worked by hand:
- \(x = (1, 1, 0)\): \(H = (-4)(1) + (-8)(1) + (-6)(0) + 6(1)(1) + 10(1)(0) + 2(1)(0) = -12 + 6 = -6\). Nodes 0 and 1 share a group, so only the two edges crossing to node 2 count: \(5 + 1 = 6\), and \(-6\) is the negative of that, matching.
- \(x = (1, 0, 1)\): \(H = (-4)(1) + (-8)(0) + (-6)(1) + 6(1)(0) + 10(0)(1) + 2(1)(1) = -10 + 2 = -8\). Every edge except \((0,2)\) crosses: \(3 + 5 = 8\), matching. This is the lowest of the four distinct cuts – a partition and its complement always have the same energy, so the eight assignments give four cuts – so it is the Max-Cut optimum for this graph.
Three Domains
x_i is not always a 0/1 bit. XQVM supports three domains, and the choice
affects one thing above all: whether a backend can solve the result.
| Domain | Values | Model allocator | Sample allocator |
|---|---|---|---|
| Binary | \(\{0, 1\}\) | BQMX | BSMX |
| Spin | \(\{-1, 1\}\) | SQMX | SSMX |
| Integer(\(k\)) | \(\{0, \ldots, k{-}1\}\), \(k \ge 2\) | XQMX | XSMX |
Binary is QUBO – Quadratic Unconstrained Binary Optimisation – the domain most combinatorial formulations target directly: a variable is either selected or not. The Max-Cut model above is binary.
Spin is the Ising model: each variable is a magnetic moment pointing up
or down, \(-1\) or \(+1\), which is the domain quantum annealing
hardware minimises natively. A spin variable’s linear coefficient rewards
one orientation and penalises the other by the same amount, rather than
switching a cost on or off. Binary and spin describe the same choices:
substituting \(x_i = (s_i + 1)/2\) (or \(s_i = 2x_i - 1\)) turns any
binary Hamiltonian into a spin Hamiltonian over the same variables, with
rescaled coefficients and one additive constant that does not change which
assignment is optimal. Build a model in one domain and you can rebuild it
in the other; between these two the choice is free, since every current
xqsa solver accepts both, so pick whichever domain the problem is
natural in.
Integer generalises past two states to give a variable \(k\) integer values directly, suited to a quantity with a natural ordering or magnitude – a position in a short list – without one-hot encoding it into several binary variables first. Unlike binary and spin, it is not a relabelling of the other two: it cannot encode an unordered categorical choice, because a quadratic form over integer variables cannot express that two values merely differ without also expressing by how much.
Integer is the domain with a real consequence: every current xqsa
solver rejects it. An integer XqmxModel is a real thing you can build in
XQVM bytecode today, but there is no backend yet that can solve one. See
Backends.
A model and a sample share a domain and a variable count, but not a shape:
a model is two sparse coefficient maps, linear and quadratic, while a
sample is one value per variable and nothing else.
Allocators covers both families and
their default values in full.
Energy
Given a model and a candidate assignment (a sample), ENERGY computes
\(H(x)\) for that specific x and pushes the result – the same
formula as the model’s Hamiltonian above, evaluated at one point.
“Energy” and “Hamiltonian value at x” mean the same thing throughout
XQuad’s documentation. A solver’s job is to search for the x that
minimises this value; XQVM’s job is only to compute it, which is what lets
a program check a solver’s answer independently rather than trust it. See
Energy Evaluation.
Penalties: Folding a Constraint into the Objective
The constraint instructions below expand assuming binary variables, where
\(x^2 = x\). That identity does not hold for spin variables, where
\(x^2 = 1\) always: apply these instructions to a BQMX model, and
convert a spin model to binary first if you need one of them (see
Three Domains).
Take the simplest useful constraint: exactly one of a set of variables should be 1 (a one-hot choice – pick exactly one city to visit first, one colour for this node). Written as a penalty term:
$$H \mathrel{+}= P \cdot \left(\sum_i x_i - 1\right)^2$$
The squared term is zero exactly when the constraint holds (the sum is 1) and strictly positive for every assignment that violates it – pick zero variables and the sum is 0, pick two and the sum is 2, either way \((\sum x_i - 1)^2 \ge 1\). Adding this to \(H\) means violating the constraint always costs at least \(P\) units of energy on top of whatever the rest of the objective says. A solver minimising the combined \(H\) has a direct incentive to satisfy the constraint, without any code path in the solver that knows constraints exist – it is minimising one number, the same way it always does.
This is the general shape behind most of XQVM’s high-level constraint
instructions: ONEHOTR/ONEHOTC are exactly the sum above over a grid
row or column, and EXCLUDE, IMPLIES, EQUALITY, ATLEAST, and
ATLEASTW are the same idea applied to different rules: mutual exclusion,
implication, weighted equality, at-least-k with unit weights, and
at-least-k with arbitrary weights. The instruction reference also
documents REDUCE, which uses the same technique – a term that is zero
exactly when a condition holds – but to enforce an algebraic identity
between variables rather than a rule from the problem; that is a different
enough job to read about on its own. The full expansion for each
instruction – which linear and quadratic coefficients change by how much
– is reference material, not a concept, and lives on
High-Level Constraints and in the
normative
HLF specification.
This page is not repeating that table; the mechanism above is what to carry
forward from it.
Choosing a Penalty Weight
\(P\) is not a detail to default and forget – it is the real engineering decision in penalty-based modelling, and it fails in both directions.
Too low, and the solver buys its way out of the constraint: \(P\) must
exceed the largest objective improvement any violating assignment can buy
over the best feasible one, or the minimiser takes that trade, because
nothing in \(H\) told it not to. Suppose the Max-Cut model above also
carried a one-hot constraint over nodes 0 and 2 only, with weight \(P\).
The unconstrained optimum \(x = (1, 0, 1)\) worked out above violates
it; the best assignments that satisfy it are \(x = (1, 1, 0)\), worked
out above, and \(x = (0, 0, 1)\), both at \(H = -6\). Violating the constraint lowers the
energy from \(-6\) to \(-8\), an improvement of \(2\), so
penalty = 1 costs less than it saves and the minimiser still prefers
the infeasible optimum. penalty = 2 ties the two, and \(P\) must
exceed \(2\) before satisfying the constraint wins outright.
Too high does not corrupt the model itself. For any two assignments that
both satisfy the constraint, the penalty term is 0 on both, so their
energy difference is exactly the objective’s difference, independent of
\(P\); ENERGY computes that difference with exact i64 arithmetic, so
nothing in \(H\) degrades. What a large \(P\) costs happens outside the
model. On a fixed-precision device, an objective difference much smaller
than \(P\) can fall below the device’s resolution once the combined
\(H\) is scaled to fit it, so the device stops representing that
difference at all. And a large \(P\) raises the energy barrier between
feasible regions, so a heuristic such as simulated annealing gets trapped
in whichever feasible region it reaches first and stops exploring for a
better one elsewhere.
Getting this right in practice – how to size \(P\) relative to your
objective, and how to tell from a solver’s output which failure mode you
hit – belongs to
Constraints.
What to take from this page is that the weight is a parameter you choose,
not one XQVM chooses for you: every constraint instruction pops penalty
off the stack as an ordinary operand.
Three Programs
An optimisation problem in XQuad is three independent programs sharing one
instruction set: an encoder, a verifier, and a decoder. They communicate
only through calldata in and outputs out. xqvm’s
Three-Program Architecture
defines this shape, so it is not something xqcp adds on top. Nothing in
the instruction set enforces it – see Beyond xqcp – but
every tool in the toolchain assumes it.
xqcp generates all three for you from one Problem definition:
problem.compile() returns CompiledPrograms(encoder, verifier, decoder),
three independent .xqasm sources, each its own complete program with its
own fixed inputs and outputs. A hand-written program can follow the same
shape with no DSL involved: xqvm/examples/tsp/main.rs builds and runs a
three-program TSP pipeline directly against the Rust xqvm crate.
Why Three, Not One
XQVM has no instruction that calls a solver. Its 93 instructions cover control flow, stack and register I/O, arithmetic, comparison, vectors, grid operations, the constraint and energy family – nothing that reaches outside the VM to an annealer or a sampler. Solving happens in host code, between two runs of the VM, using whichever backend Backends describes. A model-building program cannot also be the program that checks and decodes the answer, because the answer does not exist yet when that program halts.
flowchart LR
Q[Problem definition] -->|compile| ENC[Encoder]
Q -->|compile| VER[Verifier]
Q -->|compile| DEC[Decoder]
ENC -->|XQMX model| H[Host program]
H -->|model| SLV[xqsa solver]
SLV -->|XQMX sample| H
H -->|model, sample| VER
H -->|sample| DEC
VER -->|energy, valid| H
DEC -->|decoded result| H
What Each Program Does
Encoder. Reads the problem’s runtime inputs from calldata, allocates the XQMX model, and emits every objective term and constraint penalty the DSL recorded. Its one output is the model, on slot 0. This is “a program whose job is to construct a model” – running it does not solve anything, it only builds the thing a solver will minimise.
Verifier. Takes the encoder’s own inputs, then the model and the
sample, and checks whether the sample satisfies every constraint the
encoder applied, then computes the sample’s energy with the ENERGY
opcode. It outputs (energy, valid). It needs the encoder’s inputs
because it replays the encoder to rebuild the constraint data, which lives
in registers at VM runtime rather than in the model. This is how a
sample’s feasibility gets checked independently of whatever backend
produced it.
Decoder. Takes a sample and N, and extracts the answer in the
problem’s own terms – a tour, a partition, a set of selected items –
into one or more output vectors. It does not know or care whether the
sample it was given is valid; that is the verifier’s job, not the
decoder’s.
Independent, Not Sequential
The three programs share no state. Communication between them happens only through calldata in and outputs out – there is no hidden channel, and no program reads another program’s internals. The verifier and the decoder are not a pipeline: both read the same sample straight from the solver, at the same point, side by side. A host program can decode a sample the verifier just rejected, which is useful for inspecting what a bad solution actually looks like.
A Concrete Run
examples/maxcut/runner.py is exactly this shape. Max-Cut declares no
constraints, so its verifier has none to check: the loop under
; === Validity checks === in the compiled verifier only confirms each
sample value is 0 or 1. The ENERGY recomputation still runs and is real
independent verification – the energy the verifier reports is computed
fresh from the model and the sample, not relayed from the solver.
Its run() function makes four calls in sequence, three of them through
the VM:
vm.run(programs.encoder)with calldata[n, flat_edges]and one output slot, producing anXQMXmodel.solver.solve(model), entirely outside the VM, producing a sample.vm.run(programs.verifier)with calldata[n, flat_edges, model, sample]and two output slots, producing(energy, valid).vm.run(programs.decoder)with calldata[sample, n]and one output slot, producing the decoded partition.
Running uv run python examples/maxcut/runner.py --n 5 --seed 42 prints:
$ uv run python examples/maxcut/runner.py --n 5 --seed 42
{
"_note": "canonical CI golden",
"_seed": 42,
"cut_weight": 354,
"energy": -354,
"n": 5,
"partition": [
0,
1,
0,
0,
1
],
"valid": 1
}
The encoder, verifier, and decoder are deterministic – same bytecode and
calldata in, same output out. The solve sitting between them is only
pinned to the seed and the dwave-samplers version, since SA is
sensitive to BQM construction order, so a different version of that
library can return a different valid sample for the same seed. See below
for what make example-smoke checks instead of the exact output.
_note and _seed are the runner’s own bookkeeping, not part of the
result: _note’s value, "canonical CI golden", describes what the
runner calls this invocation, not a guarantee that a test pins against
it. energy is the negative of cut_weight because the encoder
minimises -weight per crossing edge to make the objective function,
matching the derivation in
Quadratic Models.
valid: 1 here only confirms every sample value is in {0, 1}, which is
all this problem’s verifier checks. make example-smoke is what actually
guards this example: it runs it once and checks valid == 1, and does
not compare cut_weight, energy, or partition against a stored
answer.
These numbers depend on the dwave-samplers version behind cpu;
a different version can return a different valid sample with a different
cut weight.
The three VM calls above, and the solve between them, are the host program driving all three compiled programs and the solver together – see Ways to Use XQuad for the surfaces that can play that role.
Beyond xqcp
A hand-assembled .xqasm program is free to read inputs, build a model,
and produce output in one file, the way Toolchain Map’s
minimal add.xqasm example does – the interpreter does not enforce the
three-program split. The split earns its cost once an external solver sits
in the loop and a sample needs independent checking, which is exactly when
reaching for xqcp, or hand-writing the same three-program shape, starts
to pay off. See Modelling: Compiling for how
the compiler produces the three programs, and
Running Programs and
Solving Overview for executing each stage.
Backends
“Hardware-agnostic” means one concrete thing: build a model once, then choose where it solves, from five backends behind a single interface. Nothing about the model, the encoder, or the verifier changes with that choice.
The Solver Set
| Name | Class | Where it solves | Install |
|---|---|---|---|
cpu (default) | SolverCPU | Locally, on the CPU: simulated annealing | base xqsa |
dwave | SolverDWave | D-Wave Leap cloud: physical quantum annealing hardware | xqsa[dwave] |
cuda | SolverGPU(engine="cuda") | Locally, on an NVIDIA GPU: simulated annealing | xqsa[cuda] |
metal | SolverGPU(engine="metal") | Locally, on an Apple GPU: simulated annealing or block Gibbs sampling | xqsa[metal] |
quip | SolverQuip | The Quip network: submitted to a decentralised compute market, solved by a miner you do not control | xqsa[quip] |
build_solver(name, seed=...) constructs any of the five from this name,
so a caller can stay backend-agnostic. seed reaches the three classical
simulated-annealing backends and is ignored by dwave (physical
hardware has no seed) and quip (env-configured, no local randomness).
What Actually Distinguishes Them
Where the computation happens. cpu, cuda, and
metal all run the identical algorithm family – simulated annealing
– just on different local processors; picking between them is a
performance question, not a correctness one. dwave hands the problem
to real annealing hardware over the network. quip does not solve
anything itself: it posts the model as a job to the network’s mempool, a
miner solves it on hardware of its own choosing, and the adapter decodes
whatever comes back.
Domain support. Every backend above accepts a binary or spin model and
rejects anything else: Solver._validate_model() raises ValueError for
any domain other than BINARY or SPIN. XQVM’s third domain, integer
(XQMX), has no backend yet. See Quadratic Models
for what the three domains are.
The result contract is identical regardless of backend. Every solve()
call returns a SolverResult(sample, energy, timing, metadata). energy
is not simply relayed from the device: XQuad’s own verifier program
recomputes it independently with the ENERGY opcode, so you never have to
take a backend’s word for its own energy – run the verifier and recompute
it. See Three Programs.
Before You Choose
cpu needs no hardware and no credentials, so it is the default:
the reproducible baseline every other backend is compared against. The
other four need something this book cannot check for you –
a GPU for cuda or metal, D-Wave Leap credentials for
dwave, network configuration for quip – and setting each of those
up belongs to the pages below.
Depth Lives in Solving
This page is orientation, not a manual. Backend-specific setup, parameter tuning, and result interpretation belong to Solving Overview, Local Solvers, D-Wave QPU, Quip Network, and Energy and Precision.
Modelling Lifecycle
XQCP is a Python-embedded DSL that turns a problem description into three
XQVM assembly programs. You declare a Problem’s runtime inputs, allocate
the quadratic model those inputs fill, add objective terms and
constraints, declare what you want back, then call compile(). XQCP
records every call you make against Problem as you make it and only
turns that recording into .xqasm when compile() runs – see
Compiling for how the recording becomes three programs.
A problem definition follows one call sequence, in order:
Problem(name) -> input()* -> define_model() -> body* -> output()* -> compile()
input() calls come first: Inputs and Model Shape
covers why, and what a declared input becomes at run time. define_model()
runs exactly once and makes problem.model available for the rest of the
body: objective terms, constraints, and loops, in any order, covered
across Expressions, Objectives,
Constraints, and Control Flow. Your
first output() call marks the start of the decoder section, covered in
Outputs and Decoding.
Why XQCP, Not Assembly
Ways to Use XQuad names six surfaces for
getting a problem into XQuad; XQCP is one of them. Reach for it when you
are modelling a combinatorial problem from scratch and would otherwise
hand-write the same loop-over-variables, add-a-coefficient pattern in
.xqasm three times over, once per program – Problem records that
pattern once, and the compiler emits all three. Write .xqasm directly
instead when a program is small enough that the three-program duplication
costs nothing to write by hand. Reach for
xqvm::InstructionBuilder
instead when a Rust host is generating programs and adding a Python step
to emit them buys nothing.
One Problem, Once
Every page in this chapter builds on the same running example:
examples/knapsack/runner.py. It takes item weights, item values, and a
capacity, and chooses the subset of items maximising total value without
exceeding the capacity. build_problem is the whole of it:
def build_problem(n: int, weights: list[int], values: list[int], capacity: int) -> Problem:
problem = Problem("Knapsack")
num_items = problem.input("num_items", type=Types.Int)
weights_in = problem.input("weights", type=Types.Vec)
values_in = problem.input("values", type=Types.Vec)
capacity_in = problem.input("capacity", type=Types.Int)
problem.define_model(size=num_items, domain=Domain.BINARY)
# Objective: minimise -sum(v_i * x_i)
with problem.range(0, num_items) as i:
vi = problem.stow("vi", values_in.get(i))
problem.model.linear[i].add(-vi)
# Constraint: sum(w_i * x_i) <= W
indices = problem.vec()
coeffs = problem.vec()
with problem.range(0, num_items) as i:
indices.push(i)
coeffs.push(weights_in.get(i))
problem.slack(indices, coeffs, num_items, capacity_in)
problem.model.apply_equality(indices, coeffs, capacity_in, 100)
selected = problem.output("selected", type=Types.Vec)
with problem.range(0, num_items) as i:
selected.append(problem.sample.getline(i))
return problem
One binary variable per item: x_i = 1 means item i is selected.
Four inputs, one 1D binary model sized to num_items, a range loop
building the objective, a second range loop paired with slack and
apply_equality encoding the capacity constraint, and a decoder loop
reading the solved sample back into a selected vector. Running it end
to end:
$ uv run python examples/knapsack/runner.py --seed 42
{
"_note": "canonical CI golden",
"_seed": 42,
"capacity": 18,
"energy": -32446,
"n": 5,
"selection": [
1,
1,
1,
0,
1
],
"total_value": 46,
"total_weight": 12,
"valid": 1,
"values": [
5,
4,
18,
3,
19
],
"weights": [
2,
1,
5,
4,
4
]
}
_note and _seed are the runner’s own bookkeeping, the same as in
Three Programs. energy
is the full model’s Hamiltonian, capacity constraint included, so it is
not simply -total_value here the way Max-Cut’s energy is -cut_weight
– Objectives isolates just the objective term’s
contribution, and Constraints covers what the rest of
energy is paying for.
This run is not the optimum: the five weights sum to 16, under the
capacity of 18, so every item fits and the true best value is 49, not
the 46 printed above. A heuristic solver returns a good sample, not a
proven best one. valid: 1 reports only the verifier’s binary-domain
check here, not the capacity constraint; see
Compiling for
what a verifier actually checks.
Inputs and Model Shape starts with the four
problem.input() calls and the define_model() line above; the rest of
the chapter works through the remaining lines in the order they appear.
Where to Go From Here
- Inputs and Model Shape – runtime inputs and the 1D/2D model they fill.
- Expressions – the operators and functions that build a coefficient or a loop bound.
- Objectives – turning “minimise this” into
linear/quadraticcoefficients, sign included. - Constraints – folding a rule into the objective as a penalty.
- Control Flow –
range,iter, andbranch. - Outputs and Decoding – reading a solved sample back into your problem’s own terms.
- Compiling – what
problem.compile()actually produces.
Inputs and Model Shape
A Problem definition starts with two declarations: the runtime inputs it
reads, and the model those inputs fill. Both happen before anything else –
spec/xqcp/SPEC.md fixes the order as
Problem(name) -> input()* -> define_model() -> body* -> output()* -> compile(),
and input() calls after define_model() raise RuntimeError.
Every code sample in this chapter, and the rest of Modelling and Solving, assumes the same import:
from xquad import cp
It is shown once, here, and omitted everywhere after.
Inputs
problem.input(name, type=cp.Types.Int) or type=cp.Types.Vec declares one
runtime value and returns an InputRef you use for the rest of the
problem body. examples/knapsack/runner.py declares four:
num_items = problem.input("num_items", type=cp.Types.Int)
weights_in = problem.input("weights", type=cp.Types.Vec)
values_in = problem.input("values", type=cp.Types.Vec)
capacity_in = problem.input("capacity", type=cp.Types.Int)
Types.Int is a scalar integer, and Types.Vec is a vector of integers.
Every runtime input is one of these two types.
Declaration Order Is Calldata Order
Compiling the fragment above emits this for each input:
PUSH 0
INPUT r0
PUSH 1
INPUT r1
INPUT pops a calldata slot index and clones calldata[slot] into the
target register. It does not check the calldata value’s type against
anything (Register I/O covers
INPUT in full). XQCP’s register allocator hands out registers in call
order starting at r0, and because input() must run before
define_model(), the first input() call is allocated r0, the second
r1, each next call the next register, with nothing else allocated
between them. That is why the slot index XQCP pushes before each INPUT
matches the input’s position in your problem.input() call sequence:
your Nth input() call reads calldata[N-1].
The host has to supply calldata in that same order.
examples/knapsack/runner.py calls vm.set_calldata([n, weights, values, capacity]),
matching num_items, weights_in, values_in, capacity_in above one for
one. Nothing checks this correspondence: INPUT clones whatever sits at
that slot, regardless of the input’s declared type. Swap the position of
weights and values on either side and both are Types.Vec, so no
RegisterType fault stops you – the encoder runs to completion and reads
weights where it wanted values. See Calldata and Outputs
for how a host supplies calldata to any XQVM program, XQCP-generated or
hand-written.
Reading a Vec Input
A Types.Vec input supports two operations no Types.Int input has:
values_in.get(i) # VECGET: element at index i
values_in.veclen() # VECLEN: the vector's length
Both return an expression, not a value – see Expressions
for what you can build with the result. Calling either on a Types.Int
input raises TypeError immediately, at problem-definition time, not at
compile time:
>>> num_items.get(0)
TypeError: Cannot index into int input 'num_items'
The Model
problem.define_model(size, domain, rows=None, cols=None) allocates the
quadratic model the encoder builds and runs exactly once per problem.
Before it runs, problem.model and problem.sample raise RuntimeError;
after, both are available for the rest of the chapter. size is the
total variable count, as an int or an expression built from your
inputs – knapsack sizes its model directly off an input:
problem.define_model(size=num_items, domain=cp.Domain.BINARY)
domain is a Domain member; see
Quadratic Models for what
each domain means and how to choose between them, since that choice does
not belong to this page. Binary is the domain the running examples in this
book use. It must be xqcp.Domain, which xquad.cp re-exports:
xquad.types.Domain is the VM’s own domain type under the same name,
and define_model() raises TypeError if handed it.
Integer Variables
An integer variable takes one of k values rather than two. Give the
width directly:
problem.define_model(size=num_assets, domain=cp.Domain.INTEGER, k=4)
That allocates num_assets variables over {0, 1, 2, 3}. k is an
expression like size, so it may come from calldata.
Where the values you are modelling are not zero-based, give bounds instead:
problem.define_model(size=num_assets, domain=cp.Domain.INTEGER, lo=-5, hi=5)
You then write coefficients over x in [-5, 5] while the model holds
y = x - lo in {0, ..., 10}, and sample.value(i) shifts back on the
way out. XQCP rewrites each quadratic write for you: w * x_i * x_j
expands to w*y_i*y_j + w*lo*y_i + w*lo*y_j + w*lo^2 once x = y + lo is
substituted, so the write records w*lo against the linear coefficient of
both indices as well. The w*lo^2 constant is dropped, because XQMX has
no offset field. Energies shift by the same amount for every assignment,
so the minimum is still in the same place; the number is not the
objective’s true value. Linear writes need no correction.
Two consequences worth knowing before you reach for the ranged form.
Setting a coefficient is refused on it, on quadratic[i, j] = w and
linear[i] = w alike, because setting replaces a coefficient while the
corrections can only accumulate: two writes to the same pair would
disagree, and a linear set would drop whatever corrections earlier
quadratic writes had left on that index. Use .add(), which loses
nothing, since a coefficient starts at zero. And a runtime lo competes
with an output loop bound for the decoder’s single calldata scalar, so
compile() raises naming both. Literal bounds sidestep it.
Constraints are binary-only. On a spin or integer model every
apply_* method is refused and only coefficient writes are supported,
because each expansion in the VM is derived under x^2 = x. Write the
penalty out by hand, as
Portfolio Rebalance does for its
budget.
Categorical Variables
A categorical variable takes one of k unordered cases. There is no VM
domain for that, so XQCP records the standard encoding:
problem.define_model(size=num_nodes, domain=cp.Domain.CATEGORICAL, k=num_colors, penalty=200)
That is a num_nodes x num_colors binary grid with one ONEHOTR per
row, built through the same calls you would have written yourself. The
model is binary afterwards, so constraints work on it as usual and
coefficient access is (variable, case). Read the answer back with
sample.case(v), which gives the case a variable took or -1 if its row
came back empty.
1D and 2D Models
A model is either flat (1D, indexed 0..size) or a grid (2D, indexed by
(row, col)). Every model in this chapter is 1D: knapsack’s x_i is one
binary decision per item, with no row/column structure to it. Pass rows
and cols to get a grid instead:
problem.define_model(size=rows * cols, domain=cp.Domain.BINARY, rows=rows, cols=cols)
rows and cols are both-or-neither: define_model() raises
ValueError when given exactly one, rather than building a 1D model that
fails later.
Once a model has a shape, coefficient access accepts a (row, col) tuple
in place of a flat index, and XQCP flattens it for you. Compiling
n = problem.input("n", type=cp.Types.Int)
problem.define_model(size=n * n, domain=cp.Domain.BINARY, rows=n, cols=n)
problem.model.linear[(1, 2)] = 99
emits the block below, once n has claimed r0 and define_model() has
claimed r1 for cols and r2 for the model:
PUSH 1
PUSH 2
LOAD r1
IDXGRID
PUSH 99
SETLINE r2
r1 is the cols register define_model() allocated; this block does not
depend on n’s runtime value at all, only on how many calls came before it.
IDXGRID computes row * cols + col, so at run time, with n = 3, the
coordinate (1, 2) flattens to 1 * 3 + 2 = 5, and every
coordinate-accepting call on a 2D model (coefficient access,
apply_exclude, apply_implies) goes through the same flattening. size
still has to equal rows * cols yourself; XQCP does not derive one from
the other. It does optimise the size expression itself: size=n*n emits a
single SQR instead of LOAD, LOAD, MUL whenever both multiplicands
are the same register, 2D model or not. Grid allocation itself –
RESIZE and the rest of what BQMX r2 plus a shape actually builds – is
Allocators and
Grid Operations territory, not this page’s.
Symbolic Reference Types Introduced Here
spec/xqcp/TYPES.md is the normative reference for every symbolic type
XQCP hands back. The two this page introduces:
| Type | Created by | Register type |
|---|---|---|
InputRef | problem.input(name, type) | int or vec |
ModelRef | problem.define_model(...) | xqmx |
SampleRef – problem.sample, the model’s read-only counterpart used in
the decoder – is created together with ModelRef but belongs to
Outputs and Decoding, where it is actually
used. Expressions covers LoopVar and RegLoad, the
two symbolic types this page’s fragments do not need yet; the full type
table, including register-allocation and error-condition details past
what this page restates, is in
spec/xqcp/TYPES.md.
With inputs declared and a model allocated, the next thing every problem body needs is a way to compute with them – see Expressions.
Expressions
Every symbolic value XQCP hands you – an InputRef from problem.input(),
a LoopVar from problem.range()/problem.iter(), a RegLoad from
problem.stow(), a coefficient read back from model.linear[i] – builds
the same kind of expression tree when you combine it with an operator or
an xq_* function. A plain Python int used anywhere one of these is
expected gets coerced to a literal automatically; anything else raises
TypeError. This page groups the resulting vocabulary by what you are
trying to compute, not by class hierarchy.
Every xq_* function below lives in the same namespace as Problem and
Types:
from xquad import cp
Arithmetic: Computing a Value
+ - * // % and unary - work exactly as Python’s operators do on
integers, and compile to ADD SUB MUL DIV MOD NEG. Knapsack’s objective
loop uses three of them in three lines:
with problem.range(0, num_items) as i:
vi = problem.stow("vi", values_in.get(i))
problem.model.linear[i].add(-vi)
-vi is unary negation on a RegLoad; problem.range and problem.iter
yield LoopVars (i here) with the same arithmetic, used as loop-bound
math in Max-Cut’s offset = e * 3. // is integer division, matching
XQVM’s DIV, not Python’s floating-point / – XQCP has no /, since
every value in this system is an i64.
Adding or subtracting exactly the literal 1 is special-cased: a + 1
compiles to LOAD, INC and a - 1 to LOAD, DEC, skipping the
PUSH 1 a general add or subtract would need. Nothing about how you write
it changes – a + 1 and a - 1 are the natural way to write these, and
the compiler does the substitution for you.
Comparisons: Producing 0 or 1
== < > <= >= compile to EQ LT GT LTE GTE, each pushing 1 for true
and 0 for false rather than a Python bool. That makes a comparison’s
result usable anywhere an integer is: as a coefficient directly, as the
condition argument to problem.branch() (see
Control Flow), or combined further with arithmetic.
Python has no != here. There is no XQVM opcode for it, so != on
an XQCP expression raises immediately, at problem-definition time:
TypeError: '!=' is not supported on XQCP expressions; use xq_not(a == b) instead
_ExprOps overloads __ne__ purely to raise that. Left to Python’s
default, __ne__ would call __eq__ and invert the result, and
inverting a CompareOp object with not only asks whether the object
itself is falsy – which it never is – so a != b would evaluate to the
constant False regardless of a and b. Write xq_not(a == b)
instead:
model.linear[0] = cp.xq_not(a == b)
compiles to LOAD, LOAD, EQ, NOT, and gives 1 when a and b
differ, 0 when they match.
Bitwise: Bit-Level Values
& | ^ ~ << >> compile to BAND BOR BXOR BNOT SHL SHR, useful wherever a
problem packs several small values into one integer, or – as in
knapsack’s slack step – builds a binary-weighted sum out of individual
bits. These are ordinary Python operators, not xq_* functions, because
Python lets you overload them.
Index Math: One Number From Several
Two functions turn a pair (or triple) of coordinates into a single flat index:
cp.xq_triu(i, j) # IDXTRIU: upper-triangular packed index for (i, j)
cp.xq_grid(row, col, cols) # IDXGRID: row * cols + col, grid flat index
xq_grid is what a 2D model’s tuple-coordinate coefficient access uses
internally – see Inputs and Model Shape
for that path; call it directly when you need the flat index as a value
in its own right rather than as a coordinate to model.linear[...].
xq_triu(i, j) swaps i and j when i > j, then computes
j * (j - 1) // 2 + i on the swapped pair, so the packed index does not
depend on argument order, per the swap rule
spec/xqvm/ISA.md
gives for IDXTRIU. xq_triu(2, 5) returns 12
(5 * 4 // 2 + 2; no swap needed, since 2 <= 5).
Logical and Other Free Functions
Python’s and, or, and not keywords cannot be overloaded. XQCP gives
you functions in their place. The first four work on the 0/1 convention
comparisons use, not on Python truthiness:
cp.xq_not(x) # NOT: 0 -> 1, non-zero -> 0
cp.xq_and(a, b) # AND
cp.xq_or(a, b) # OR
cp.xq_xor(a, b) # XOR
cp.xq_bnot(x) # BNOT: bitwise complement, the same opcode as ~ above,
# kept as a function for symmetry -- not the same as xq_not
Writing a and b, a or b, not a, or using an expression as an if
condition instead of calling one of these functions raises immediately,
the same way != does above:
TypeError: XQCP expressions cannot be used in a boolean context ('and', 'or', 'not', 'if'); use xq_and(a, b), xq_or(a, b) or xq_not(a) instead
Python’s and/or/not/if all decide on the truthiness of the
object, not on the value it represents, so XQCP has no way to give them
the right answer – only to refuse before a Python habit produces the
wrong operand or the constant False in place of a real expression.
Five more free functions round out the arithmetic vocabulary that has no Python operator:
cp.xq_sqr(x) # SQR: x * x
cp.xq_abs(x) # ABS: absolute value
cp.xq_min(a, b) # MIN
cp.xq_max(a, b) # MAX
cp.xq_bitlen(x) # BITLEN: floor(log2(x)) + 1, and 0 for x <= 0
Naming an Expression: stow
problem.stow(name, expr) evaluates an expression once and stores it in a
register, returning a RegLoad you can reuse without re-emitting the
computation. Knapsack’s vi = problem.stow("vi", values_in.get(i)) is
this: values_in.get(i) is a VECGET, emitted once per loop iteration
and bound to vi, and every later use of vi in that iteration is a
cheap LOAD instead of repeating the VECGET. Passing an existing
RegLoad back into stow overwrites that register instead of allocating
a new one – useful for an accumulator threaded through a loop.
The operators and free functions above are the DSL’s whole vocabulary;
spec/xqcp/TYPES.md lists nothing this page omits. With that in hand,
the next question is what you do with a value once you have one:
Objectives covers turning an expression into a term the
solver minimises.
Objectives
An objective is the part of a model’s Hamiltonian you write directly,
as opposed to the part a constraint instruction adds on your behalf –
see Quadratic Models for what the
Hamiltonian \(H\) is and why a solver minimising it is the whole
mechanism. In XQCP terms, an objective is whatever you assign or add to
model.linear[i] and model.quadratic[i, j] before any constraint
method runs.
Solvers Minimise. Say So.
Every backend XQuad targets minimises. If your problem is a maximisation, you have to negate it, and getting this backwards produces a model that runs, verifies, and confidently returns the worst answer instead of the best one – nothing checks that you meant to maximise.
Knapsack maximises total value. examples/knapsack/runner.py writes the
objective as:
# Objective: minimise -sum(v_i * x_i)
with problem.range(0, num_items) as i:
vi = problem.stow("vi", values_in.get(i))
problem.model.linear[i].add(-vi)
x_i = 1 means item i is selected, and -vi is the sign flip: instead
of rewarding selection with +v_i, the objective penalises it with
-v_i, so minimising H picks large values, not small ones. Isolating
just this loop – no capacity constraint – and running the encoder with
num_items=5, values=[5, 4, 18, 3, 19], then computing ENERGY against
the sample [1, 1, 1, 0, 1] (items 0, 1, 2, 4 selected, values
5 + 4 + 18 + 19 = 46) gives:
objective-only energy: -46
expected: -(sum of selected values) = -46
Energy is exactly the negative of total value, for any selection, because
that is what linear[i] = -v_i means: drop the - and the same solver
would minimise total value instead, selecting nothing.
Assign or Accumulate
model.linear[i] and model.quadratic[i, j] are CoefficientRef
proxies, not values. Three things you can do with one:
| Syntax | Opcode | Effect |
|---|---|---|
model.linear[i] = w | SETLINE | Coefficient becomes w, replacing whatever was there |
model.linear[i].add(w) | ADDLINE | Coefficient becomes current + w |
x = model.linear[i] | GETLINE | Read the current coefficient as an expression |
The same three exist for model.quadratic[i, j], via SETQUAD/ADDQUAD/
GETQUAD. The distinction matters the moment more than one term touches
the same coefficient – knapsack’s .add(-vi) runs once per item, each
touching a different linear[i], so assign and accumulate would agree
there. They stop agreeing as soon as two terms share a coefficient: two
.add() calls against the same linear[0] with weights 5 and 7
leave it at 12; two = assignments with the same weights leave it at
7, the second overwriting the first outright. Reach for .add() any
time a coefficient might accumulate contributions from more than one
place, which for an objective built inside a loop – the usual shape –
is the common case; reach for = when you know this is the
coefficient’s only source.
Quadratic Terms
A quadratic coefficient couples two variables, and the same accumulate
pattern applies. Max-Cut’s objective is the DSL rendition of the Max-Cut
coefficient rule
Quadratic Models
derives by hand – for each weighted edge (i, j, w):
problem.model.linear[i].add(-w)
problem.model.linear[j].add(-w)
problem.model.quadratic[i, j].add(w * 2)
three .add() calls, one per term of
\(\text{linear}[i] \mathrel{+}= -w,\ \text{linear}[j] \mathrel{+}= -w,\ \text{quadratic}[i,j] \mathrel{+}= 2w\).
.add() is what makes this loop correct: a node touched by several edges
accumulates a -w contribution from each one, and = would let the last
edge silently overwrite every edge before it. Running
examples/maxcut/runner.py --n 5 --seed 42 confirms the sign on the
result – energy: -354 against cut_weight: 354 – matching the same
“minimise the negative” pattern knapsack uses, for the same reason.
Coefficient access on a 2D model accepts (row, col) tuples in place of
flat indices, for both linear and quadratic coordinates independently
– see Inputs and Model Shape for
the flattening XQCP applies; nothing about objective assignment changes
once a coordinate is a tuple instead of an int.
Beyond Quadratic: model.reduce()
Everything above stops at degree two: one or two variables per term.
model.reduce(var_a, var_b, p_aux) -> RegLoad is the only way past that.
It performs a Rosenberg degree reduction: allocate one fresh auxiliary
variable w at model.size, grow model.size by one, and add
enforcement terms so w behaves as var_a AND var_b:
p_aux * (x_a*x_b - 2*x_a*w - 2*x_b*w + 3*w), which is 0 when
w == x_a AND x_b and strictly positive otherwise. It returns w as a
RegLoad, an ordinary variable index you can use in a further quadratic
term, or feed into a second reduce() call to reach one degree higher
still:
w = problem.model.reduce(ti, tj, p_aux)
problem.model.quadratic[w, tk].add(coeff) # coeff * x_i * x_j * x_k
examples/cubic_opt/runner.py builds a cubic term this way, one
reduce() call per term inside a problem.range() loop; max3sat and
portfolio_opt use the same one-call pattern for their own cubic terms.
examples/quartic_opt/runner.py chains two reduce() calls to reach a
quartic term – the reason reduce() returns a RegLoad rather than
nothing.
p_aux is a penalty in its own right, separate from any constraint
penalty in the same model: it has to be large enough that violating the
w == x_a AND x_b relationship is never worth it, the same reasoning
Constraints applies to
constraint penalties. Each reduce() call that actually executes grows
model.size by one, so the final variable count is size plus the
number of reduce() calls executed – loops included. cubic_opt
allocates one auxiliary variable per cubic term inside its loop, not one
for the whole model.
reduce() hangs off model, like every constraint form, but it is not
one: XQCP keeps it out of the DSL’s constraint bookkeeping deliberately,
since it is a structural transformation of the model, not a domain rule
about a solution. That is also why it lives on this page and not
Constraints.
How Large an Objective Gets
An objective’s magnitude is bounded by what its coefficients can sum to.
Knapsack’s linear coefficients are -values, so its objective ranges from
0 (nothing selected) down to -49 for values = [5, 4, 18, 3, 19]
(everything selected, which for this instance fits the capacity: the
weights [2, 1, 5, 4, 4] sum to 16, under the capacity of 18) – a
range fixed entirely by the problem’s own numbers, before any constraint
enters the picture. Constraints picks a penalty weight relative
to a range like this one; this page stops at producing the range, not
sizing anything against it.
With an objective in place, the next step most problems need is a rule the objective alone cannot express – see Constraints for folding one into the same Hamiltonian.
Constraints
A constraint is a rule a solution must obey. XQCP has no separate mechanism
for rules – every constraint call turns into a penalty term added to the
model’s Hamiltonian, the same H Quadratic Models
describes. This page covers the constraint forms the DSL exposes, when to
reach for each, and how to size the penalty weight in practice. For the
linear/quadratic coefficient deltas each opcode produces, see
High-Level Constraints; this page does
not repeat that table.
Every constraint method hangs off problem.model, the ModelRef that
define_model() makes available, and every one takes a penalty argument.
The Constraint Forms
| DSL call | Enforces | Reach for it when |
|---|---|---|
model.apply_onehot_row(row, penalty) | Exactly one variable in grid row row is 1 | A 2D model needs “exactly one choice per row” – one city per tour position. Requires a grid; row must name one the grid declares |
model.apply_onehot_col(col, penalty) | Exactly one variable in grid column col is 1 | The column-wise mirror – one position per city. Same grid requirement |
model.apply_exclude(a, b, penalty) | a and b are not both 1 | Two choices conflict and picking both is meaningless or invalid |
model.apply_implies(a, b, penalty) | If a is 1, b is 1 | One choice requires another – selecting a route requires its start node open |
model.apply_equality(indices, coeffs, target, penalty) | sum(coeffs[k] * x[indices[k]]) == target | A weighted sum equals an exact value – one-hot is a special case of this (all-1 coefficients, target 1); exclude is not, despite the resemblance |
model.apply_atleast(indices, k, penalty) | At least k of indices are 1 | A minimum count, unweighted – cover at least k elements |
model.apply_atleastw(indices, coeffs, k, penalty) | sum(coeffs[j] * x[indices[j]]) >= k | A minimum weighted sum – cover at least k units of capacity |
model.apply_inequality(indices, coeffs, target, capacity, penalty) | sum(coeffs[k] * x[indices[k]]) <= capacity | A capacity-style bound in one call – composes problem.slack() and apply_equality(), shown separately below. The third parameter is named target in the source but is not a target value: it is where slack variables begin, normally the count of real variables. capacity is the bound. Pass it positionally |
onehot_row/onehot_col, exclude and implies take coordinates directly;
on a 2D model those coordinates can be (row, col) tuples, flattened
automatically. equality, atleast, atleastw and inequality instead
take two vector registers built with problem.vec() and .push():
indices names which variables participate, coeffs (where present)
weights them.
A minimal \(2 \times 2\) grid problem exercises the row/column/pairwise forms together:
problem.define_model(size=n * n, domain=Domain.BINARY, rows=n, cols=n)
with problem.range(0, n) as r:
problem.model.apply_onehot_row(r, penalty=50)
with problem.range(0, n) as c:
problem.model.apply_onehot_col(c, penalty=50)
problem.model.apply_exclude((0, 0), (1, 1), penalty=50)
problem.model.apply_implies((0, 1), (1, 0), penalty=50)
rows and cols on define_model are what make the two one-hot forms
legal. Omit them and every apply_onehot_row/apply_onehot_col call
raises InvalidGridDimensions at run time –
xquad vm check cannot catch it, because grid extents are runtime values.
The product rows * cols must also fit inside size; a grid cannot
describe cells the model never declared. atleast, atleastw and
inequality append slack variables past the grid and only ever grow
size, so they never invalidate a grid that was legal when it was set.
Compiling and running this for n = 2 produces a model whose linear and
quadratic maps match the row/column penalties plus the two pairwise terms:
linear: {0: -100, 1: -50, 2: -100, 3: -100}
quadratic: {(0, 1): 100, (0, 2): 100, (0, 3): 50, (1, 2): -50, (1, 3): 100, (2, 3): 100}
atleast and atleastw allocate slack variables and grow model.size
directly, as part of their own expansion – ATLEAST’s own expansion in
HLF.md
builds the slack terms itself rather than composing with the SLACK
instruction below. That is a real difference, not an implementation
detail: SLACK takes no model operand and only appends to the
indices/coeffs vectors it is given, so it never touches model.size
by itself. A problem.slack() call grows the model only later and
indirectly, when a subsequent apply_equality() widens it to
max(indices) + 1 – a problem.slack() result that apply_equality()
never consumes leaves the model unchanged. Each atleast/atleastw call,
by contrast, allocates floor(log2(max_excess)) + 1 slack variables,
where max_excess is N - k for atleast and sum(coeffs) - k for
atleastw, and zero slack variables when max_excess is zero or less.
A 3-item
apply_atleast(idx, 2, penalty=30) allocates one slack variable, since
max_excess = 3 - 2 = 1. apply_atleastw(idx, coeffs, 3, penalty=30) on
the same three indices then allocates zero more for coeffs = [1, 1, 1],
one more for coeffs = [1, 1, 2], or two more for coeffs = [1, 2, 3].
Turning an Inequality into an Equality
None of the forms above is <= or >= on a plain sum – atleast/atleastw
cover >=, but a capacity constraint like knapsack’s (“total weight at most
W”) needs the other direction. problem.slack() bridges the gap: it
appends binary-weighted slack variables to an indices/coeffs pair so that
an EQUALITY constraint over the combined vector is satisfiable exactly when
the original inequality holds. See SLACK
for the bit-width formula.
examples/knapsack/runner.py builds its capacity constraint this way:
indices = problem.vec()
coeffs = problem.vec()
with problem.range(0, num_items) as i:
indices.push(i)
coeffs.push(weights_in.get(i))
problem.slack(indices, coeffs, num_items, capacity_in)
problem.model.apply_equality(indices, coeffs, capacity_in, 100)
indices/coeffs start as the item weights, one entry per item. slack
appends slack variables starting at index num_items (the model’s current
size) sized to absorb up to capacity_in of unused capacity. apply_equality
then constrains sum(weight_i * x_i) + sum(slack bits) == capacity. If the
items sum to less than capacity, some slack combination fills the gap
exactly. If they sum to more, no slack combination can, because slack only
adds. The compiled encoder shows the two instructions back to back:
LOAD r0
LOAD r3
SLACK r7 r8
LOAD r3
PUSH 0x64
EQUALITY r4 r7 r8
model.apply_inequality(indices, coeffs, num_items, capacity_in, 100)
composes exactly these two calls into one and produces the same model.
Reach for the two-call form when you need indices/coeffs for something
else afterward, or want the two steps visible; reach for apply_inequality
otherwise.
Why the Reported Energy Is Not Just -total_value
EQUALITY’s expansion adds penalty * a_k * (a_k - 2b) and
2 * penalty * a_k * a_m terms but drops the constant penalty * b^2,
since it shifts every assignment’s energy equally and does not change which
one is optimal – see HLF.md.
Dropping a constant from the model does not drop it from ENERGY’s result:
a feasible sample still carries -penalty * b^2 as a fixed offset, because
the terms that remain evaluate to exactly that once the constraint holds.
Running examples/knapsack/runner.py --seed 42 selects items worth 46 in
total against a capacity of 18, at penalty = 100, and reports
energy = -32446. That is -46 - 100 * 18^2 = -46 - 32400 = -32446: the
objective contribution and the constant the equality constraint leaves
behind, added together. Objectives isolates the -46 on
its own; this is where the other -32400 comes from.
Choosing a Penalty Weight
Quadratic Models
gives a safe upper bound: penalty must exceed the largest objective
improvement any violating assignment can buy over the best feasible one.
It is not the tightest bound – an equality-shaped violation (equality,
one-hot, atleast, atleastw) with integer excess d actually pays
penalty * d^2, and exclude/implies pay penalty outright, the
d = 1 case. The rest of this section finds that tighter threshold by
accounting for d directly.
Enumerate the Failure, Not the Intuition
Take a 4-item knapsack: weights [3, 4, 5, 2], values [4, 5, 6, 3],
capacity 7. The best feasible selection is items {0, 1} or {2, 3},
tied at value 9 and weight 7. Every selection that exceeds capacity is a
candidate the solver might prefer instead, if the penalty is too cheap.
Seven of the sixteen selections exceed it:
| Items | Weight | Value | Excess over capacity | Value gap over 9 | Rejection threshold |
|---|---|---|---|---|---|
{0, 2} | 8 | 10 | 1 | 1 | 1.00 |
{1, 2} | 9 | 11 | 2 | 2 | 0.50 |
{0, 1, 3} | 9 | 12 | 2 | 3 | 0.75 |
{0, 2, 3} | 10 | 13 | 3 | 4 | 0.44 |
{1, 2, 3} | 11 | 14 | 4 | 5 | 0.31 |
{0, 1, 2} | 12 | 15 | 5 | 6 | 0.24 |
{0, 1, 2, 3} | 14 | 18 | 7 | 9 | 0.18 |
The last column is value gap / excess^2, because EQUALITY’s penalty term
is penalty * (sum - target)^2: a selection exceeding capacity by d pays
penalty * d^2, not penalty. The largest entry in that column, not the
largest value gap, sets the threshold: {0, 2} needs penalty > 1, the
largest threshold in the table, while {0, 1, 2, 3} – the biggest value
gain – needs only penalty > 0.18, because its excess of 7 is squared
away. Reasoning from the value gap alone (9, from the worst
offender by value) is safe, but it oversizes the weight here: the raw gap
suggests penalty > 9, nine times the penalty > 1 this problem actually
needs.
Compiling this exact problem and brute-forcing every assignment of the real
QUBO – 4 item variables plus the 3 slack bits SLACK allocates for a
capacity of 7 – confirms the table: at penalty = 1, the true optimum is
a three-way tie at H = -58 between the two feasible sets and the
infeasible {0, 2}; at penalty = 2, only the two feasible sets remain,
both at H = -107. penalty = 1 is the exact boundary the table predicts,
not an approximation of it.
A tie at the boundary is not a tie a sampler breaks the same way every
seed. Sampling this exact model at penalty = 1 with SolverCPU
(200 reads) across eight seeds returns the infeasible
{0, 2} on two of the eight, at the identical energy, -58, as every
feasible seed; the other six split across the two feasible ties. A single
feasible sample at a borderline weight is not evidence the weight is
safe – the tie means either outcome is a true optimum, and only the rate
across repeated solves, each with a different solver seed, separates a
weight sitting on the boundary from one below it. A penalty that is
actually below its threshold, rather than sitting on it, behaves
differently: building a second 4-item instance (weights [5, 4, 2, 5],
values [7, 4, 3, 9], capacity 6) whose tightest threshold works out to
penalty > 3, the identical penalty = 1 is now strictly below it, and
all eight seeds return the infeasible pick, every time, because there the
infeasible assignment is the unique optimum rather than one member of a
tie.
When You Cannot Enumerate
Most problems are too large to brute-force. Enumerate a small instance of
the same shape if you can, the way the table above does, since the
qualitative lesson – the tightest violator, not the most valuable one, sets
the threshold – carries over. When even that is impractical, a safe but
loose fallback exists for a purely linear objective: since each variable
contributes to it at most once, no two full assignments can differ by more
than the sum of the absolute values of every linear coefficient, and any
nonzero integer excess is at least 1, so penalty greater than that sum
always rejects every violation. A quadratic objective needs the same sum
extended to include the absolute value of every quadratic coefficient too,
since a quadratic[i,j] term can also flip between two assignments. For the
table above that sum is 4 + 5 + 6 + 3 = 18, eighteen times the actual
boundary of 1 – a real bound, not the largest single coefficient, and
worth using only until you can afford to enumerate a representative case.
The seed-42 knapsack run printed in
One Problem, Once has values
[5, 4, 18, 3, 19], and knapsack’s objective is -values (see
Objectives), so the same fallback sum is
5 + 4 + 18 + 3 + 19 = 49. The runner’s own penalty = 100 clears that sum
outright, so it is safe by this loose bound alone, without needing the
per-instance enumeration this page otherwise recommends.
Too High Does Not Corrupt the Model
Raising penalty from 2 to 1000 on the same problem leaves the gap
between the best and second-best true optimum at exactly 1 in both
cases, for the reason
Quadratic Models
gives: the penalty term is zero on every feasible assignment, so it cannot
change their relative order.
What a large penalty costs instead happens outside the model, on the
hardware that has to represent it – see
Energy and Precision for that
trade-off, and
Selection Under Budget for a worked
recipe built on this page.
Control Flow
XQCP gives you four constructs for anything beyond a flat sequence of
calls: problem.range(), problem.iter(), problem.branch(), and
problem.stow(). Each is a Python API you call while building a Problem,
and each records something into the action list that compile() later
turns into real XQVM instructions. That gap between the two is the first
thing to get straight, because it decides what each construct actually
buys you.
A Python Loop and a Compiled Loop Are Different Things
xqcp is a Python program that runs once, when you call problem.compile(),
to produce an .xqasm string. An ordinary Python for loop inside your
problem definition runs during that one recording pass – it does not exist
in the output at all. Three plain calls to add(1) inside for i in range(3)
record three separate add_linear actions, one per concrete index, and the
compiled encoder shows exactly that: no loop instruction, three ADDLINE
blocks back to back.
for i in range(3):
problem.model.linear[i].add(1)
PUSH 0
PUSH 1
ADDLINE r1
PUSH 1
PUSH 1
ADDLINE r1
PUSH 2
PUSH 1
ADDLINE r1
problem.range() is different: it records one RANGE loop that the VM
executes at run time, over a count that need not be known until calldata
arrives. with problem.range(0, num_items) as i compiles to a single
RANGE/NEXT pair regardless of how large num_items turns out to be,
because num_items is a runtime input, not a Python integer the recorder
can unroll:
with problem.range(0, num_items) as i:
vi = problem.stow("vi", values_in.get(i))
problem.model.linear[i].add(-vi)
PUSH 0
LOAD r0
RANGE
LVAL r5
LOAD r5
VECGET r2
STOW r6
LOAD r5
LOAD r6
NEG
ADDLINE r4
NEXT
This is examples/knapsack/runner.py’s objective loop, unaltered. Use a
plain Python loop only when the count is fixed at problem-definition time
and small; use problem.range() whenever the count depends on an input,
which is the common case, since num_items in this example is not known
until calldata is set.
range – Counted Loops
problem.range(start, end) is a context manager yielding a LoopVar. It
records a range_start action on entry and a range_end action on exit,
and compiles to RANGE/NEXT around whatever the body records. The bounds
can be any expression, not just literals or inputs – problem.range(i + 1, n)
inside another range(0, n) builds the upper-triangular pattern a pairwise
comparison needs, one RANGE nested inside another:
with problem.range(0, n) as i:
with problem.range(i + 1, n) as j:
problem.model.quadratic[i, j].add(1)
For n = 3 this adds quadratic[i, j] += 1 for every pair with i < j:
(0, 1), (0, 2), (1, 2), each exactly once, matching what running the
compiled encoder against n = 3 produces. RANGE is documented at the
instruction level in Loops, where it pops count and
start, not start and an exclusive end. problem.range(start, end)
takes the exclusive end a Python range would; the compiler emits the
subtraction that turns it into a count, so a non-zero start shows a SUB
before RANGE – compiling problem.range(2, 5) emits
PUSH 2 / PUSH 5 / PUSH 2 / SUB / RANGE. A start of 0 hides this,
because end and count coincide there, so every RANGE listing so far
in this chapter shows no SUB.
iter – Looping Over a Vector’s Elements
problem.iter(vec, start, end) loops over a slice of a vector’s actual
elements rather than a count, compiling to ITER/NEXT. It always yields a
pair, (idx, val): the element’s absolute position in the vector and its
value. Unpack whichever you need and discard the other with _:
with problem.iter(weights, 0, n) as (idx, val):
problem.model.linear[idx].add(val)
Running this against weights = [7, 8, 9] produces linear = {0: 7, 1: 8, 2: 9}
– idx supplies the coordinate, val the weight added there. The compiled
form pairs LIDX/LVAL inside the loop body with exactly this reading:
PUSH 0
LOAD r0
ITER r1
LIDX r3
LVAL r4
LOAD r3
LOAD r4
ADDLINE r2
NEXT
Reach for iter when the loop body needs the element’s value directly and
would otherwise call vec.get(idx) on every iteration; reach for range
when the body needs an index into something other than the vector being
walked, the way knapsack’s objective loop above indexes values_in
by position rather than walking it as a slice.
stow – Carrying State Across Iterations
Expressions covers what
problem.stow(name, expr) does and why knapsack’s objective loop uses it
once per iteration. Inside range and iter, stow has a second job:
passing an existing RegLoad back into it, instead of a name, reuses that
RegLoad’s register rather than allocating a new one, which is how a loop
carries an accumulator from one iteration to the next:
acc = problem.stow("acc", 0)
with problem.range(0, n) as i:
problem.stow(acc, acc + i)
problem.model.linear[0].add(acc)
The compiled loop body reads and writes the same register, r2, on every
iteration, and running it for n = 4 leaves linear[0] = 6 – 0 + 1 + 2 + 3:
PUSH 0
STOW r2
PUSH 0
LOAD r0
RANGE
LVAL r3
LOAD r2
LOAD r3
ADD
STOW r2
NEXT
branch – Conditional Terms
problem.branch(cond1, body1, cond2, body2, ..., default) compiles to a
chain of JUMPI/JUMP/TARGET with first-match semantics. branch tests
conditions in order and runs the first true arm’s body; no later arm runs,
even one whose condition is also true. The final argument is mandatory and
has no condition – pass a callable for a default action, or None to do
nothing when no arm matches.
with problem.range(0, n) as i:
w = problem.stow("w", weights.get(i))
problem.branch(
w > 5, lambda: problem.model.linear[i].add(w * 10),
w > 0, lambda: problem.model.linear[i].add(w),
None,
)
LOAD r4
PUSH 5
GT
NOT
JUMPI .1
LOAD r3
LOAD r4
PUSH 10
MUL
ADDLINE r2
JUMP .0
TARGET .1
LOAD r4
PUSH 0
GT
NOT
JUMPI .2
LOAD r3
LOAD r4
ADDLINE r2
JUMP .0
TARGET .2
TARGET .0
Each condition compiles to itself negated, then a JUMPI past its arm: w > 5
becomes NOT then JUMPI .1, so the arm runs when the condition is true and
is skipped straight to TARGET .1 when it is false. An arm that runs ends
with JUMP .0, past every remaining arm, which is what makes the semantics
first-match rather than last-match. The default arm has no condition and no
skip logic – it just falls through to TARGET .0 if reached.
Each body is a zero-argument callable, called once while recording, under
the same recording pass a range body runs under, so it can reference i
and w from the enclosing scope. Running this for weights = [7, 3, 0] adds
70 at index 0 (7 > 5), 3 at index 1 (3 > 5 is false, 3 > 0 is
true), and nothing at index 2 (neither condition holds, and the default is
None): linear = {0: 70, 1: 3}, with index 2 absent rather than zero.
Nesting and Ordering
range and iter nest to arbitrary depth, matching the nesting
Loops documents at the instruction level: LVAL,
LIDX and NEXT always act on the innermost active loop. branch arms can
themselves contain range, iter, or further branch calls, since a body
callable can record anything a top-level problem definition can.
The encoder splits body actions into an objective block and a constraint
block by scanning for constraint calls – see Compiling for
that partition. A range or iter body is recorded flatly, so a nested
constraint call is still visible to the scan. A branch arm is captured
separately: a constraint call made only inside one still constrains the
model, but prints under ; === Objective === instead of
; === Constraints ===. Call constraint methods directly inside the
enclosing range instead, the way
examples/set_cover/runner.py
does, if you want the section comment to match.
Outputs and Decoding
A solver hands back a sample: one raw value per variable. Decoding turns that assignment into the answer your problem is actually about – a chosen item set, for knapsack. This page covers a decoder program’s outputs, its read access to the sample, and when decoding outside the VM makes more sense.
Declaring an Output
problem.output(name, type=Types.Vec, length=None) declares one decoder
output and returns an OutputRef. type must be Types.Vec – every
pipeline output is a vector, and problem.output() raises TypeError for
anything else. An output is filled one of two ways, chosen by length=.
Appending
Without length=, an output starts empty and .append(value) grows it by
one element:
selected = problem.output("selected", type=Types.Vec)
with problem.range(0, num_items) as i:
selected.append(problem.sample.getline(i))
This is examples/knapsack/runner.py’s decoder in full: one output,
selected, filled by reading the sample’s per-item assignment directly.
problem.sample.getline(i) reads sample[i]: 1 if the solver selected
item i, 0 otherwise. selected ends up the same shape as the input
item list, a 0/1 flag per item rather than a list of chosen indices.
Compiled, this is the decoder assembly Compiling shows in
full: VECI r2 ; selected, a RANGE loop reading GETLINE r0 and
pushing with VECPUSH r2, then PUSH 0 / OUTPUT r2.
Writing and Reading by Index
With length=, the decoder starts the output as length zeros, and you
write and read it by index: out[i] = value compiles to VECSET, and
out[i] to VECGET. Use it when a value’s position is computed rather
than reached in order. On the grid examples/tsp/runner.py builds, one
row per city and one column per position, rowfind gives the position a
city was placed at, so the tour can be written city by city:
tour = problem.output("tour", type=Types.Vec, length=n)
with problem.range(0, n) as city:
tour[problem.sample.rowfind(row=city, value=1)] = city
length is anything the decoder can evaluate before its first loop: a
literal, the scalar on calldata slot 1, arithmetic over those, or a sample
read. Slots you never write stay 0. An out[i] read returns an
expression, so one output can feed another:
flags = problem.output("flags", type=Types.Vec, length=n)
scores = problem.output("scores", type=Types.Vec)
with problem.range(0, n) as i:
flags[i] = problem.sample.getline(i)
with problem.range(0, n) as i:
scores.append(flags[i] * 10 + i)
Record-Time Checks
The two modes do not mix, and both are checked when you write the
problem, not when the decoder runs. out[i] = value or out[i] on an
output without length= raises
TypeError: Writing to output 'selected' by index needs a declared length; declare it with problem.output('selected', length=n), or fill it with selected.append(value)
(Reading from for a read), and .append() on an output with length=
raises
TypeError: Output 'tour' was declared with length= and is filled by index; write tour[i] = value instead of tour.append(value)
A literal length= below 1 raises ValueError, and so does a literal
index outside [0, length) when length= is a literal too. A length=
that reads another output, such as length=counts[0], raises ValueError
as well: every length is evaluated before any output is filled, so it
would read 0. Any other
index is bounds-checked by the VM when the decoder runs, which faults with
IndexOutOfBounds. A length that evaluates to 0 or less at decode
time leaves the output empty, so the first index write faults the same
way.
problem.output() must be called at the top level. Inside a
problem.range() or problem.iter() block, or inside a branch() arm,
it raises RuntimeError at once:
RuntimeError: problem.output('o') is called inside a range() or iter() block; declare every output at the top level, outside every loop and branch() arm
Reading a Sample
problem.sample is available once define_model() has run, and exposes
seven read methods built on the Grid Operations
and Coefficient Access
instructions a Sample register supports:
| Call | Reads |
|---|---|
sample.getline(i) | Variable i’s assignment (1D or flat) |
sample.value(i) | Variable i in the domain you declared it over |
sample.case(v) | Case variable v took, or -1 (categorical) |
sample.rowfind(row, value) | Column of the first match for value in row (2D) |
sample.colfind(col, value) | Row of the first match for value in col (2D) |
sample.rowsum(row) | Sum of every value in row (2D) |
sample.colsum(col) | Sum of every value in col (2D) |
getline and value differ only on a model declared with lo=/hi=,
where the stored variable is y = x - lo and value(i) adds lo back
to give the x you wrote coefficients over. Everywhere else they are the
same call. getline is the only raw reader and value the only shifted
one, so nothing shifts unless you ask.
case(v) is rowfind(v, 1) under a name that says what it means on a
categorical model: the case variable v took, or -1 where the solver
left that row empty. It reads the same on any 2D binary grid, whether or
not Domain.CATEGORICAL built it.
Knapsack’s flat model only needs getline. A 2D grid model – one variable
per (row, col) pair, the way a one-hot assignment problem is usually
shaped – more often needs colfind: “which row has a 1 in this column”
reads directly as “which choice was made for this slot.” On a
\(2 \times 2\) grid sample with rows [0, 1] and [1, 0],
sample.colfind(col=0, value=1) returns 1 (row 1 has the 1 in column
0) and sample.colfind(col=1, value=1) returns 0, each call compiling to
the column pushed, then the value to match, then COLFIND r{sample}.
What a Decoder Block May Reference
Everything recorded after the first problem.output() call goes to the
decoder and to nothing else, and the decoder is a separate program with a
separate register file. It runs on two pieces of calldata: the sample on
slot 0, and one scalar on slot 1. Anything a decoder block names that is
not reachable from those two is refused at compile() rather than
compiled into a read of whatever register happens to hold something.
A block may reference the sample through the read methods above, its own
loop variables, one scalar, and out[i] on an output declared with
length=. That scalar does not have to be N:
examples/graph_coloring/runner.py stows a total variable count before
declaring its output and passes that on slot 1. The emitted decoder names
which one it resolved, so INPUT r1 ; total_vars tells you what the
caller has to supply. Naming a second one raises, because the decoder is
handed exactly one and cannot say which of the two you meant:
RuntimeError: xqcp: a decoder block references two scalars, 'n' and 'acc', but the decoder is handed exactly one on calldata slot 1
What a block may not reference is anything the decoder was never given: a
vector input or a problem.vec() allocation, a model coefficient
(problem.model.linear[i] – the decoder holds the sample, not the model),
or a loop variable from a loop that has closed. problem.iter() is refused
for the same reason: ITER walks a vector register, and no input vector
reaches the decoder. Walk the indices with problem.range() and read each one from
problem.sample.
Filling Outputs in Any Order
The decoder allocates every output before it runs anything else and ships
every output to its slot after everything else has run. Between those, it
walks every action recorded after the first problem.output() once, in
the order you recorded them. An output can therefore be filled from any
point after its declaration: two outputs from one loop, or an earlier
output after a later one has been declared.
chosen = problem.output("chosen", type=Types.Vec)
weights = problem.output("weights", type=Types.Vec)
with problem.range(0, num_items) as i:
chosen.append(problem.sample.getline(i))
weights.append(problem.sample.getline(i) * 2)
Conditional Outputs
problem.branch() works after the first problem.output(), with the same
first-match arms it has in the encoder. Conditions read the sample, loop
variables and the scalar, like any other decoder expression. This keeps
only the indices of the selected items instead of a flag per item:
picked = problem.output("picked", type=Types.Vec)
with problem.range(0, num_items) as i:
problem.branch(
problem.sample.getline(i) == 1, lambda: picked.append(i),
None,
)
With items 0, 3 and 4 selected out of five, picked decodes to
[0, 3, 4]. An arm takes everything else a decoder block takes:
.append(), out[i] = value, a problem.range() loop, and a nested
problem.branch().
The Decoder Program vs. Decoding in the Host
Once a solver returns a sample, two different things can decode it, and they are not the same operation.
The decoder program is XQVM bytecode: vm.run(programs.decoder) with
the sample and N on calldata, exactly like running the encoder or the
verifier. It only ever sees the sample through the instructions above, so
it runs on any surface that can execute XQVM bytecode –
Ways to Use XQuad lists six – with no
Python or Rust object model required at the call site.
Decoding in the host means reading the sample object directly in
whatever language is driving the pipeline, without running the decoder
program at all. In Python, a solved sample is an XqmxSample, so
sample.get_linear(i) for each item index reads
the same assignments sample.getline(i) does inside the decoder, just from
host code instead of from bytecode:
vm.set_calldata([sample, n])
vm.set_output_slots(1)
vm.run(programs.decoder)
result = vm.outputs()[0]
decoder_selection = list(result) # via programs.decoder
host_selection = [sample.get_linear(i) for i in range(n)] # direct read
VM imports from xquad.vm, alongside the
xquad.cp/xquad.types imports Inputs and Model Shape
opens this chapter with. set_output_slots has to run before vm.run,
since the slot count defaults to 0. OUTPUT against a slot that was
never allocated raises OutputIndex while the decoder runs.
vm.outputs()[0] comes back as a plain list of int. Both reads of the
seed-42 knapsack sample (weights = [2, 1, 5, 4, 4],
values = [5, 4, 18, 3, 19], capacity = 18) produce the same list:
[1, 1, 1, 0, 1].
Host decoding is less code for a one-off script already holding the sample
in memory. The decoder program is the version worth keeping once decoding
needs to happen the same way regardless of which language or environment is
driving the run, or needs to be inspected and verified as an artifact in
its own right the way the encoder and verifier already are.
Where This Page Stops
Decoding turns a sample into an answer; it says nothing about whether that
answer is any good. valid and energy come from the verifier, not the
decoder – see
Compiling for
what a verifier’s validity check does and does not cover. Choosing a solver
and judging solution quality belong to
Solving Overview and
Running Programs, not here.
Compiling
problem.compile() walks the action list problem.define_model(), your
objective terms, your constraints and problem.output() recorded, and
returns CompiledPrograms(encoder, verifier, decoder) – three .xqasm
strings. Three Programs covers why the
split exists and what each program is for; this page covers what
compile() actually emits and how to read it when something is wrong.
Three Independent Passes
compile() runs three separate compiler functions over the same action
list, each keeping a different subset. Nothing computed for one program
carries into another; there is no shared intermediate representation.
Compiling examples/knapsack/runner.py’s build_problem() – the exact
code from Constraints,
unaltered – produces this encoder:
; === Inputs ===
PUSH 0
INPUT r0
PUSH 1
INPUT r1
PUSH 2
INPUT r2
PUSH 3
INPUT r3
; === Allocations ===
LOAD r0
BQMX r4
; === Objective ===
PUSH 0
LOAD r0
RANGE
LVAL r5
LOAD r5
VECGET r2
STOW r6
LOAD r5
LOAD r6
NEG
ADDLINE r4
NEXT
VEC r7
VEC r8
PUSH 0
LOAD r0
RANGE
LVAL r9
LOAD r9
VECPUSH r7
LOAD r9
VECGET r1
VECPUSH r8
NEXT
LOAD r0
LOAD r3
SLACK r7 r8
; === Constraints ===
LOAD r3
PUSH 0x64
EQUALITY r4 r7 r8
; === Output ===
PUSH 0
OUTPUT r4
HALT
PUSH 0x64 is 100, the penalty passed to apply_equality – the compiler
renders a handful of common penalty values in hex for readability; every
other constant here prints in decimal.
This is the encoder for the exact problem shape examples/knapsack/runner.py
builds, independent of the item count: register numbers and instruction
count come from how many input(), stow(), vec() and define_model()
calls the Python code makes, not from --n at the command line.
What compile_encoder Does
Per spec/xqcp/COMPILER.md,
the encoder partitions the action list into inputs, the model allocation,
and a body that splits again into objective and constraint blocks: a
top-level block is a constraint block if it contains any of
onehot_row/onehot_col/exclude/implies/equality/atleast/atleastw/inequality,
otherwise it is objective. ; === Objective === and ; === Constraints ===
are these two blocks, in that order, regardless of the order you wrote them
in Python. Knapsack’s objective loop and the SLACK call both come from
code written before apply_equality, and neither one is itself a
constraint action, so the encoder above places both in the objective
section (see Control Flow for the
one case, a constraint call inside a branch arm, where this partition
surprises).
The output section is fixed: PUSH 0 / OUTPUT r{model} / HALT, since an
encoder’s only output, always on slot 0, is the model it built.
Register allocation is one incrementing counter shared across the whole
program. spec/xqcp/COMPILER.md
fixes the order. Inputs go first. A 2D model’s cols register comes ahead
of the model register; a 1D model has no cols register, so its model
register follows the inputs directly. Loop variables, stowed values,
vec() registers and output registers fill in afterward, each claiming a
register in the order it is called. Knapsack’s model is 1D: its four
inputs claim r0-r3, the model claims r4, and everything after
follows call order – r5/r6 for the objective loop’s LoopVar and
stow, r7/r8 for the two vec() calls.
Inputs and Model Shape shows the
2D case, where the cols register lands before the model register: one
input at r0, cols at r1, model at r2. Going past r255 raises
RuntimeError at compile time, before any assembly is generated.
What compile_verifier and compile_decoder Do
The verifier replays the encoder. A constraint’s operands – the index and
coefficient vectors an apply_equality was handed – are register handles,
not data: those vectors are built by VECPUSH instructions that run inside
the encoder at VM runtime, often nested in loops. A separate program with
its own register file cannot inherit them. So the verifier re-executes the
encoder’s inputs, loops, stows, branches and vector construction, drops
every model mutation, and emits a check of the sample in place of each
constraint, at the same point in the stream. Its register and vector state
at each constraint site is then identical to the encoder’s.
That is why the verifier keeps the encoder’s register numbers instead of
starting a fixed layout of its own, and why it takes the encoder’s
calldata. It claims eight registers above the encoder’s high-water mark:
the sample, the valid flag, energy, the model’s declared size, a
weighted-sum accumulator, an ITER position, an element, and a counter
tracking the index each REDUCE would have allocated. Going past r255
raises RuntimeError at compile time.
Knapsack’s encoder stops at r11, so its verifier’s sample lands on r12
and its valid flag on r13:
; === Inputs ===
PUSH 0
INPUT r0
PUSH 1
INPUT r1
PUSH 2
INPUT r2
PUSH 3
INPUT r3
PUSH 4
INPUT r4
PUSH 5
INPUT r12
; === Model shape ===
LOAD r0
STOW r15
; === Validity checks ===
PUSH 1
STOW r13
; Check every declared variable is in the model's domain
PUSH 0
LOAD r15
RANGE
LVAL r17
LOAD r17
GETLINE r12
COPY
PUSH 0
EQ
SWAP
PUSH 1
EQ
OR
LOAD r13
AND
STOW r13
NEXT
; === Objective (replayed for state, not for energy) ===
PUSH 0
LOAD r0
RANGE
LVAL r5
LOAD r5
VECGET r2
STOW r6
NEXT
VEC r7
VEC r8
PUSH 0
LOAD r0
RANGE
LVAL r9
LOAD r9
VECPUSH r7
LOAD r9
VECGET r1
VECPUSH r8
NEXT
; === Constraints ===
PUSH 0
STOW r16
PUSH 0
VECLEN r7
ITER r7
LIDX r17
LVAL r18
LOAD r17
VECGET r8
LOAD r18
GETLINE r12
MUL
LOAD r16
ADD
STOW r16
NEXT
LOAD r16
LOAD r3
LTE
LOAD r13
AND
STOW r13
; === Energy ===
ENERGY r4 r12
STOW r14
; === Output ===
PUSH 0
OUTPUT r14
PUSH 1
OUTPUT r13
HALT
Three things in that listing are worth reading closely. The model is
INPUT into r4, the register the encoder allocated it to, so replayed
references resolve without a renumbering pass; BQMX is skipped, but the
size expression is replayed and stowed because the domain check needs the
bound. The objective loop stowing r6 has no effect on the outcome and
runs anyway – a later constraint could read that register, and the replay
does not try to work out which stows matter. And the capacity check reads
LTE, not EQ: knapsack builds its constraint with slack() followed by
apply_equality, and a slack-extended equality is an inequality over the
real variables. The SLACK instruction is not replayed, so r7 and r8
hold the four item entries and no slack entries.
The domain check runs to the model’s declared size, r15, replayed from
define_model. Slack and REDUCE auxiliary variables live past that size
and are not domain-checked. A SPIN model is checked against -1 and +1
instead of 0 and 1; the binary-only constraint kinds – onehot_row,
onehot_col, exclude, implies – raise at compile time on a spin model
rather than emit a check that does not mean anything there.
The decoder puts the sample on r0 and N on r1, allocates every
output vector with VECI under one ; === Decode === header, walks
everything recorded after the first problem.output() call, then ships
every output with PUSH {slot} / OUTPUT r{out} under one
; === Output === header. Inside the decoder, every scalar reference – an input the encoder read, or a
value problem.stow() put in a register – resolves to LOAD r1, because
one scalar is all the decoder is handed:
; === Inputs ===
PUSH 0
INPUT r0
PUSH 1
INPUT r1 ; num_items
; === Decode ===
VECI r2 ; selected
PUSH 0
LOAD r1
RANGE
LVAL r10
LOAD r10
GETLINE r0
VECPUSH r2
NEXT
; === Output ===
PUSH 0
OUTPUT r2
HALT
num_items in with problem.range(0, num_items) as i is an InputRef in
the encoder, so in the decoder it becomes LOAD r1, the same register slot
1 was read into two lines above – the decoder has no independent notion of
num_items, only of the one scalar it is passed. Which scalar that is, is
the program’s choice rather than a fixed N, and the header comment names
it. Referencing a second, distinct scalar is rejected at compile(); see
Outputs and Decoding
for the full list of what a decoder block may and may not name.
The Calldata and Output Contract
Each program’s calldata order and output slots are fixed by what it was compiled from, and a host has to match them exactly:
| Program | Calldata (in order) | Outputs |
|---|---|---|
| Encoder | One entry per problem.input() call, in call order | Slot 0: the model |
| Verifier | One entry per problem.input() call, in call order, then the model, then the sample | Slot 0: energy, slot 1: valid |
| Decoder | Sample, N | One slot per problem.output() call, in declaration order |
examples/knapsack/runner.py’s run() function drives exactly this
contract: vm.set_calldata([n, weights, values, capacity]) and
vm.set_output_slots(1) before the encoder, matching the four
problem.input() calls in build_problem() in order and the encoder’s
one output slot; vm.set_calldata([n, weights, values, capacity, model, sample])
and vm.set_output_slots(2) before the verifier; vm.set_calldata([sample, n])
and vm.set_output_slots(1) before the decoder. The output slot count
defaults to 0; running a program that executes OUTPUT against a slot
that was never allocated raises OutputIndex (see
Limits and Errors).
Problem.verifier_calldata() returns that order as a list of names –
["num_items", "weights", "values", "capacity", "model", "sample"] for
knapsack – so a host can zip its own values against it rather than
rebuilding the order from the problem definition.
Inspecting the Emitted Assembly
programs.encoder, programs.verifier and programs.decoder are plain
Python strings – printing one is the fastest way to see what a problem
definition actually compiled to, which is how every listing on this page
was produced. From there, the xquad CLI static-inspects a single program
without needing a Python host at all: xquad vm asm and xquad vm dis
round-trip a .xqasm file to bytecode and back to a readable listing, and
xquad vm check runs the same structural, jump-target, loop-nesting,
register type-state and stack-depth checks problem.compile() already runs
automatically through xqffi when that package is installed. See
CLI for the full command reference; nothing about
compiling changes it.
xquad vm check and xquad vm dis work on any of the three programs as they
stand, but xquad vm run does not, because none of the three programs’ inputs
are all plain integers – the encoder above needs two vectors on calldata
positions 1 and 2, and the CLI’s --calldata flag only accepts a
comma-separated list of i64s. Handing it integers where a program expects
a vector does not fail to parse; it fails at run time, once the program
tries to use the value:
$ xquad vm run --text knapsack.encoder.xqasm --calldata 2,10,20,5
Error: run failed: RegisterType { reg: 2, expected: "vec<int>", got: "int" }
Running any of these three programs against real calldata needs a host that
can construct vectors, models and samples – the Python VM/Program
surfaces Ways to Use XQuad describes, covered
in full in Running Programs. The CLI’s role here is
static: assemble, disassemble, and verify a program’s structure before
handing it to a host that can supply calldata rich enough to run it.
Running Programs
This page covers driving an already-compiled .xqasm or .xqb program from
Python: loading it, supplying calldata, running it, and reading outputs back.
Ways to Use XQuad names the surfaces; this page
is the full coverage for three of them: xquad.program, and the two VM
wrappers underneath and alongside it. For writing a problem with the xqcp
DSL, see
Modelling. For what xquad vm check checks and how
to fix a rejected program, see Verification.
All three surfaces run the same Rust xqvm interpreter, and every example
on this page runs as shown.
Program and Session
xquad.program.Program loads a program once; Program.session() gives you
a Session you call .run() on repeatedly, each time with fresh calldata.
Each run returns a RunResult with dict-keyed outputs, the residual stack,
and the step count:
from xquad.program import Program
src = """
PUSH 0
INPUT r0
PUSH 1
INPUT r1
LOAD r0
LOAD r1
ADD
STOW r2
PUSH 0
OUTPUT r2
HALT
"""
program = Program.from_source(src)
assert program.instruction_count == 11
assert program.source is not None # retained for debugging
session = program.session(output_slots=4)
session.set_calldata([40, 2])
result = session.run()
assert dict(result.outputs) == {0: 42, 1: None, 2: None, 3: None}
assert result.stack == []
assert result.steps == 11
# Re-run with different calldata -- sessions carry no hidden state.
session.set_calldata([100, 200])
assert dict(session.run().outputs) == {0: 300, 1: None, 2: None, 3: None}
RunResult.outputs is a dict keyed by slot index; a slot the program never
wrote reads as None rather than raising. Program is immutable and
reusable; Session carries the mutable calldata and output-slot count.
Deriving a second Session from the same Program gives you two runners
that share no state.
Step limits
Session.set_step_limit(n) caps how many instructions a run may execute
before run() raises. The limit is exact: 0 permits no instructions at
all. A fresh Session starts at xquad.vm.DEFAULT_STEP_LIMIT, which is
10,000,000. None is the only unbounded spelling, and a caller has to
write it – unbounded execution is asked for, never inherited.
A run that faults raises the xqffi.vm.XqvmError subclass named for the
fault – here StepLimitExceeded – with the fault’s description as its
message. XqvmError is a RuntimeError.
from xqffi.vm import StepLimitExceeded
session = program.session(output_slots=1)
session.set_calldata([1, 2])
assert "step_limit=10000000" in repr(session)
session.set_step_limit(3)
try:
session.run()
except StepLimitExceeded as e:
print(e)
# step charge of 1 exceeds the step limit of 3 (3 steps already charged)
# 0 is not a sentinel for "unlimited" -- it executes nothing, so this
# 11-step program fails at its first instruction.
session.set_step_limit(0)
try:
session.run()
except StepLimitExceeded:
pass
# None removes the bound. A program that never halts will not return,
# and it does not take SIGALRM: the GIL is held inside the Rust run.
session.set_step_limit(None)
assert "step_limit=unlimited" in repr(session)
assert dict(session.run().outputs) == {0: 3}
A budget that exactly covers the program succeeds. This one needs 11
steps, so set_step_limit(11) runs it and set_step_limit(10) does not.
Loading bytecode directly
Program.load(bytes) parses raw wire-format bytes – a .xqb blob produced
by xquad vm asm or by program.bytecode(). No source is retained:
blob = program.bytecode()
reloaded = Program.load(blob)
assert reloaded.source is None
assert reloaded.instruction_count == program.instruction_count
Program.load never validates its input; a malformed blob decodes
successfully as a Program object and only fails once a Session tries to
run it. A one-byte blob is too short even for the wire-format header:
p = Program.load(b"\x43")
s = p.session()
try:
s.run()
except RuntimeError as e:
print(e)
# decode error: XQBC header is truncated
Program.from_source validates at load time instead: an unassemblable
string raises ValueError immediately, before a Session exists.
Calldata types
A calldata list may mix any of int, list[int], xqffi.vm.XqmxModel,
xqffi.vm.XqmxSample, and None (unset). Session.set_calldata checks
every element’s type as soon as you call it, not at run time:
from xqffi.vm import (
Domain,
IndexOutOfBounds,
InvalidGridDimensions,
XqmxModel,
XqmxSample,
)
model = XqmxModel.binary(size=4) # or XqmxModel(Domain.BINARY, size=4)
model.set_linear(0, -1)
model.set_quad(0, 1, 2)
sample = XqmxSample.spin(values=[-1, 1, -1, 1])
session.set_calldata([model, sample, [1, 2, 3], 42])
# session.run() now sees four typed input slots.
try:
XqmxModel.binary(size=4, rows=2, cols=3)
except InvalidGridDimensions as e:
print(e)
# a 2 x 3 grid does not fit 4 variables; rows and cols must both be 0,
# or both positive with rows * cols <= size
try:
session.set_calldata([object()])
except TypeError as e:
print(e)
# unsupported calldata element type: object; expected int, list[int],
# XqmxModel, XqmxSample, or None
The constructors also refuse a size over
xqffi.vm.MAX_ALLOCATION_SIZE, the largest allocation bytecode can
make, with InvalidAllocation, and XqmxSample refuses a value outside
its domain with ValueError. The coefficient accessors (set_linear,
get_linear, set_quad, get_quad) raise IndexOutOfBounds for a
variable index outside 0..size, a negative index included.
InvalidAllocation, InvalidGridDimensions and IndexOutOfBounds are
XqvmError faults, so RuntimeError subclasses:
try:
model.set_linear(4, 1)
except IndexOutOfBounds as e:
print(e)
# index 4 out of bounds (len 4)
Inspecting a model or sample
XqmxModel and XqmxSample are pyo3 objects; there is no dict-conversion
helper in this package today. Read a model’s coefficients through its own
accessors:
model = XqmxModel.binary(size=4)
model.set_linear(0, -1)
model.set_linear(2, 3)
model.add_quad(0, 1, 2)
model.domain # Domain.BINARY
model.domain.name # "binary"
model.size # 4
list(model.linear_items()) # [(0, -1), (2, 3)]
list(model.quadratic_items()) # [((0, 1), 2)]
model.get_linear(0) # -1
model.get_quad(0, 1) # 2
repr(model) # 'XqmxModel(domain=binary, size=4)'
model.energy(XqmxSample.binary([1, 1, 0, 0])) # 1, as ENERGY computes it
add_linear and add_quad accumulate into a coefficient and raise
xqffi.vm.ArithmeticOverflow, leaving the model unchanged, if the result
leaves the signed 64-bit range. A sample’s values are checked against its
domain when it is constructed; XqmxSample.binary([2]) raises
ValueError.
__repr__ is intentionally minimal. To inspect every coefficient or
serialise a model, iterate linear_items() and quadratic_items().
The other two VM surfaces
Two lower-level wrappers sit underneath and alongside xquad.program,
both worth knowing about directly.
xqffi.vm.Vm is the thinnest possible wrapper over the Rust
interpreter: construct it, call set_calldata / set_output_slots, call
.run(bytecode), read .outputs() as a positional list. .reset() clears the stack, registers, loop stack, and
step counter, but calldata and output slots are untouched by it – both
persist across runs until you call set_calldata() or set_output_slots()
again. It is what Session.run() builds internally:
from xqffi.vm import Vm
vm_src = "PUSH 7\nPUSH 5\nADD\nSTOW r0\nPUSH 0\nOUTPUT r0\nHALT\n"
bytecode = Program.from_source(vm_src).bytecode()
vm = Vm()
vm.set_output_slots(1)
vm.run(bytecode)
assert vm.outputs() == [12]
Ways to Use XQuad
covers when to reach for it directly instead of Session.
xquad.vm.VM is a separate wrapper that every example runner under
examples/ actually uses (examples/maxcut/runner.py,
examples/knapsack/runner.py). It wraps xqffi.vm.Vm and runs .xqasm
source text directly, assembling it on each run(); run_bytecode()
takes pre-assembled bytes instead:
from xquad.vm import VM
src = "PUSH 7\nPUSH 5\nADD\nSTOW r0\nPUSH 0\nOUTPUT r0\nHALT\n"
v = VM()
v.set_output_slots(1)
v.run(src)
assert v.outputs() == [12]
assert v.stack() == []
VM keeps calldata, output slots, and the step and memory limits across
runs until reset(), but resets the machine before every run, so no stack
or register state leaks from one run into the next. VM.outputs() returns
the same XqmxModel / XqmxSample objects as xqffi.vm.Vm.outputs() and
Session.run().outputs. Pick VM when you run .xqasm source from a
script and want the settings to carry across runs; pick Session for the
dict-keyed, slot-sparse ergonomics shown above; pick xqffi.vm.Vm only if
you are building your own layer on top of the raw Rust FFI.
VM.set_calldata() stores a copy of the list it is given, holding the
same objects, and checks nothing itself, because the values check
themselves. An XqmxSample raises
ValueError when it is built with a value outside its domain, and again
when such a value is written to it, so no out-of-domain sample can reach
run(). The element types are checked at run(): anything other than an
int, a list[int], an XqmxModel, an XqmxSample or None raises
TypeError there. A program that writes an out-of-domain value with
SETLINE or ADDLINE faults with SampleOutOfDomain, an XqvmError.
A complete run across three programs
A compiled problem is three independent programs – see Three
Programs for why. Driving all three from
xquad.program looks like this, using the exact build_problem function
examples/knapsack/runner.py defines (see Compiling
for what compile() emits):
from examples.knapsack.runner import build_problem
from xquad.program import Program
from xqffi.vm import XqmxSample
n = 4
weights = [2, 3, 4, 5]
values = [3, 4, 5, 8]
capacity = 8
problem = build_problem(n, weights, values, capacity)
programs = problem.compile()
# Encoder: inputs in, model out.
encoder = Program.from_source(programs.encoder)
enc_session = encoder.session(output_slots=1)
enc_session.set_calldata([n, weights, values, capacity])
model = enc_session.run().outputs[0]
# A hand-picked sample -- items 1, 2, 3 selected, no solver involved.
sample = XqmxSample.binary(values=[0, 1, 1, 1, 0, 0, 0, 0])
# Verifier: the encoder's own inputs, then model and sample; (energy, valid) out.
verifier = Program.from_source(programs.verifier)
ver_session = verifier.session(output_slots=2)
ver_session.set_calldata([n, weights, values, capacity, model, sample])
ver_result = ver_session.run()
energy, valid = ver_result.outputs[0], ver_result.outputs[1]
# Decoder: sample + N in, decoded selection out.
decoder = Program.from_source(programs.decoder)
dec_session = decoder.session(output_slots=1)
dec_session.set_calldata([sample, n])
selected = dec_session.run().outputs[0]
assert (energy, valid, selected) == (-4817, 0, [0, 1, 1, 1])
Items 1, 2 and 3 weigh 3 + 4 + 5 = 12 against a capacity of 8, so the
sample violates the capacity constraint and valid comes back 0.
energy is still computed: ENERGY recomputes the objective whatever the
sample’s feasibility, so the two outputs answer different questions. Swap
in [1, 1, 0, 0, 0, 0, 0, 0] – items 0 and 1, weighing 5 – and the same
run gives (-5507, 1).
Verification
says what valid covers.
The verifier takes the encoder’s calldata because it replays the encoder to
rebuild the constraint data, then appends the model and the sample. Each
program’s own .set_calldata order and .set_output_slots count are fixed
by what it was compiled from – Compiling
has the full table. A real pipeline replaces the hand-picked sample above
with xqsa.build_solver(...).solve(model).sample; see Solving
Overview.
What this page does not cover
- Tracing. Step-by-step execution inspection is not part of
Sessiontoday. - Keyword calldata (
session.set_calldata(n=4, ...)).Sessiontakes a positional list only; there is no input-slot labelling to key against. - A
_repr_html_for notebooks.__repr__is the only representationProgram,Session,RunResult,XqmxModelandXqmxSampleprovide. - A
to_numpy()helper. Convert vialinear_items()/quadratic_items()andnumpy.asarray(...)on your own side; this package does not carry a numpy dependency.
Verification
xquad vm check rejects a program before it runs, printing the first
structural or semantic problem the bytecode verifier finds. This page takes
you from that message to a working program. Verifier
is the reference half, covering what each phase checks and what every
error means. This page does not repeat that; it shows each error actually
firing and what to change.
xquad vm check --text file.xqasm assembles the source in memory and
verifies the result; xquad vm check file.xqb verifies pre-assembled
bytecode. Both forms and their exit codes are covered in
CLI: check; this page assumes you already know how
to invoke it.
Reading an error
Every example below is the smallest program that reproduces its error,
followed by the exact xquad vm check output it produces.
Structural errors
BadOpcode – a byte in the instruction stream does not map to any of
the 93 known instructions. This cannot come from a .xqasm file the
assembler accepted; it means a .xqb file was hand-edited or corrupted:
Error: check failed: BadOpcode: unknown opcode 0x0d at byte 0x0000
Fix: reassemble from source rather than editing bytecode by hand, and check
the .xqb file was not truncated or modified in transit.
TruncatedInstruction – the byte stream ends partway through an
instruction’s operand bytes. A PUSH1 opcode byte with no value byte
after it triggers it:
Error: check failed: TruncatedInstruction: truncated instruction at byte 0x0000
Fix: same as BadOpcode – the .xqb file is not a complete, valid
encoding. Reassemble it.
Jump-target and loop-nesting errors
UndefinedJumpTarget – a jump instruction’s label id is at or past the
program’s target count. Text assembly cannot produce this: the assembler
resolves every .N label reference against the labels actually defined in
the source and refuses to emit a jump to one that does not exist –
xqasm::undefined_label, at assemble time, before the verifier ever runs.
UndefinedJumpTarget is a defense for bytecode assembled some other way,
or corrupted after assembly: a jump instruction’s label-id byte, changed to
a value the program’s target count does not cover, produces:
Error: check failed: UndefinedJumpTarget: jump at byte 0x0003 references undefined target label 5 (program has 1 targets)
Fix: this is not something valid .xqasm can trigger, so seeing it means
the .xqb file in hand is not what the assembler produced. Reassemble from
source.
NoActiveLoop – NEXT, LVAL, or LIDX appears with no RANGE or
ITER open at that point:
NEXT
HALT
Error: check failed: NoActiveLoop: loop instruction at byte 0x0000 executed outside any active loop
Fix: every NEXT, LVAL, and LIDX needs an enclosing RANGE/ITER on
every path that reaches it – check you have not placed one outside the
loop body, or past a jump that skips the loop opener.
UnmatchedLoop – a RANGE or ITER opens with no matching NEXT
before the program ends:
PUSH 0
PUSH 3
RANGE
HALT
Error: check failed: UnmatchedLoop: unmatched loop: RANGE/ITER at byte 0x0004 has no corresponding NEXT (1 loop(s) still open at end of program)
Fix: add the missing NEXT. If this program came from xqcp, it usually
means a with problem.range(...) block (see
Control Flow) whose body raised or returned
before the DSL closed it correctly – check the generated .xqasm for the
loop in question.
Register errors
ReadUnsetRegister – a register is read before anything writes it, or
before every incoming control-flow path writes it (must-init analysis,
phase 3, catches the second case even when phase 2’s type check alone would
not):
LOAD r0
HALT
Error: check failed: ReadUnsetRegister: register r0 read at byte 0x0000 before being written
Fix: write the register before reading it. If the read follows a branch,
confirm both arms write it – a register set on only one side of a
conditional is ReadUnsetRegister at the first read after the join, even
though nothing about the type looked wrong on either arm individually.
DROP also resets a register to unset; a LOAD right after DROP on the
same register fails the same way.
RegisterTypeMismatch – a register holds a RegVal variant other
than the one the instruction needs. Here BQMX writes a Model, and
LOAD only handles Int:
PUSH 4
BQMX r0
LOAD r0
HALT
Error: check failed: RegisterTypeMismatch: register r0 at byte 0x0004: expected int, got model
Fix: LOAD is for scalar integers. To move a Model out of a register for
inspection or as another instruction’s operand, pass the register directly
to an instruction that accepts Model (GETLINE, ENERGY, and others);
do not route it through LOAD.
Stack-depth errors
StackUnderflow – a reachable instruction would pop more items than
the analysis can prove are on the stack:
ADD
HALT
Error: check failed: StackUnderflow: stack underflow at byte 0x0000
Fix: push the operands an instruction needs before it runs. This same check also catches loop-related shortfalls; see the “does not guarantee” section below for its blind spot.
StackOverflowRisk – the analysis converges on a depth past the
8,192-item limit for some reachable block. A straight-line run of 8,200
PUSH instructions with no loop involved:
Error: check failed: StackOverflowRisk: potential stack overflow at byte 0x0000 (depth 8200)
Fix: this fires on a single basic block’s static depth, so the usual cause
is building up a large flat structure with individual PUSH/VECPUSH
calls rather than a loop. Reducing the item count, or restructuring the
build as a loop the analysis can bound per iteration, both work; see the
next section for why a loop is not automatically safe either.
LoopStackImbalance – a loop body’s net stack effect is non-zero: the
depth the analysis computes at NEXT differs from the depth at the
matching RANGE/ITER. An unmatched PUSH inside the loop body:
PUSH 0
PUSH 3
RANGE
PUSH 99
NEXT
HALT
Error: check failed: LoopStackImbalance: loop starting at byte 0x0004 has non-zero stack effect (entry depth 0, exit depth 1)
Fix: every value a loop body pushes needs a matching pop (directly, or via
STOW into a register) before NEXT, so each iteration leaves the stack
exactly as it found it. SCLR inside a loop body is a special case: it
resets the tracked depth to zero unconditionally, so this error fires
when the loop’s entry depth was already non-zero, not when the body’s
depth before the reset was non-zero. If the entry depth was zero, the
same reset can instead mask a real imbalance earlier in the body, and
the mismatch surfaces later, at the next downstream join point, rather
than here.
StackDepthMismatch – two control-flow paths reach the same join
point with different stack depths, so the depth at that point is not
well-defined. A conditional that pushes on only one arm:
PUSH 1
JUMPI .0
PUSH 2
.0: HALT
Error: check failed: StackDepthMismatch: stack depth mismatch at join point byte 0x0006: one path has depth 0, another has depth 1
Fix: make every arm of a branch leave the same stack depth before it rejoins. If only one arm pushes a value, move that push above the branch so both arms inherit it.
What passing verification does not guarantee
A pass means every phase’s static checks succeeded. It does not mean the program runs to completion. Verifier: what passing verification guarantees states the precise scope. Treat a pass as “structurally sound,” not as “will run to completion.”
A whole class of faults is outside the verifier’s reach: they depend on
values, and the verifier tracks types and depths rather than values. An
allocator size, a grid extent, a loop bound, a calldata index and every
arithmetic operand are ordinary popped stack values, so
InvalidAllocation, InvalidGridDimensions, InvalidIntegerK,
ArithmeticOverflow, IndexOutOfBounds, SampleOutOfDomain,
LoopStackOverflow, StepLimitExceeded and MemoryLimitExceeded are all
runtime faults with no static counterpart. SampleOutOfDomain is the
clearest case of why: the value written into a sample can arrive from
calldata, so no amount of static analysis can know it. A verified program
can still raise any of them, and for an embedder that is the point: the
budgets and the range checks are what bound a program the verifier has
already passed. See Limits and
Errors.
The generated verifier program is not the bytecode verifier
xquad vm check and the verifier program are two different things that
share a name by coincidence of vocabulary.
xquad vm check runs the bytecode verifier described above and in
Verifier: a static analysis over any .xqasm or
.xqb file, checking that the program is well-formed before anything
executes. It has no idea what problem, if any, the program encodes.
A compiled problem’s verifier program is one of the three .xqasm
outputs problem.compile() returns – see Three
Programs and
Compiling.
It is an ordinary program like any other: xquad vm check can check it is
well-formed, the same as it checks the encoder or the decoder. What the
verifier program itself does at runtime – taking a model and a candidate
sample as calldata and producing (energy, valid) – is domain logic the
DSL emitted, unrelated to the bytecode verifier’s job. Running the
verifier program does not verify a program in the bytecode-verifier sense,
and running xquad vm check against the verifier program’s .xqasm text
does not check whether a sample is a good answer.
What the generated verifier’s valid flag covers
valid is the conjunction of one check per constraint the problem
declared, plus a domain check over every declared variable. A sample that
violates an onehot_row, onehot_col, equality, inequality,
atleast, atleastw, exclude, implies, or the Rosenberg auxiliary a
reduce() allocated, comes back valid = 0.
Compiling
shows how each check is emitted.
The checks are over the variables the problem declared, not over the
encoding. slack() extends an equality’s index vector with the encoder’s
own variables; the verifier checks that constraint as sum <= bound over
the real variables and leaves the slack bits unconstrained. Checking the
expanded form instead would report valid = 0 for a feasible sample whose
slack bits a solver happened to leave inconsistent, which on a settlement
path means refusing to pay for a correct answer.
Two things valid = 1 does not claim. The domain check runs to the
model’s declared size, so slack and REDUCE auxiliaries allocated past
that size are not domain-checked. And valid says nothing about
optimality: ENERGY recomputes the objective independently of where the
sample came from, and a feasible sample can still be a poor one.
Solving Overview
Backends names the five solvers and what distinguishes them. This chapter covers each one in enough depth to run it: installation, parameters, and what its result actually contains.
xqsa is the package that does this. It defines one base class,
Solver, and four classes built on it – SolverCPU, SolverGPU,
SolverDWave, and SolverQuip – which cover the five backends:
SolverGPU runs on either a CUDA or a Metal GPU, picked by its engine.
A model built from xqcp or by hand in XQVM bytecode does not know or
care which one samples it.
The Solver Interface
Every backend is called through the same single method:
class Solver:
def solve(self, model: XqmxModel, **kwargs: Any) -> SolverResult: ...
model is an XqmxModel over the binary or spin domain. **kwargs
override that solver’s constructor defaults for this call only –
solver = SolverCPU(num_reads=100) then solver.solve(model, num_reads=500) runs 500 reads without building a new solver. On every
backend except quip, a keyword the solver does not take raises
TypeError instead of being ignored, so a typo such as num_raeds=500
fails loudly. solve() raises on failure
(hardware unreachable, no embedding found, connection lost); it never
returns a partial or empty result.
solve() is the same code for every backend except quip: it validates
the model, runs the backend, recomputes the energy and assembles the
result. A backend only supplies the part that turns a model into a
sample, so the result has the same shape whichever one ran.
The return value is a frozen dataclass:
@dataclass(frozen=True)
class SolverResult:
sample: XqmxSample
energy: int
timing: float
metadata: dict[str, Any] = field(default_factory=dict)
sample is the solution, an XqmxSample with one value per variable. energy is the
authoritative Hamiltonian energy. timing is wall-clock seconds spent
solving. metadata holds what the run used, covered below.
energy is never the backend’s own reported value taken on faith. Every
solve() recomputes it with the same integer formula the XQVM ENERGY
opcode uses, over the model and the returned sample. See
Energy and Precision for what that buys you
and what it costs on hardware with limited precision.
metadata has exactly three keys on cpu, cuda, metal, and
dwave:
| Key | Holds |
|---|---|
seed | The seed the run used; None when unseeded, and always None on dwave (physical hardware has no seed) |
reads | How many samples the backend took |
params | Everything else: the selectors that chose the backend (algorithm, engine, mode), the parameters the run used, and diagnostics such as the raw pre-recompute energy |
result = SolverCPU(seed=42).solve(model)
result.metadata["params"]["algorithm"]
# 'sa'
Backend-specific detail lives under params, so code that reads seed
and reads works on any of the four; see
D-Wave QPU for what dwave
puts there. quip is the exception: it returns a different set of keys
entirely, with no seed, reads, or params; see
Quip Network.
Picking a Backend by Name
build_solver constructs any of the five from a short string, so a
caller – an example runner, a CLI flag, a script – can stay
backend-agnostic:
from xqsa import SOLVERS, DEFAULT_SOLVER, build_solver
print(sorted(SOLVERS))
# ['cpu', 'cuda', 'dwave', 'metal', 'quip']
print(DEFAULT_SOLVER)
# cpu
solver = build_solver("cpu", seed=42)
result = solver.solve(model)
seed reaches the three classical simulated-annealing backends
(cpu, cuda, metal) and is ignored for dwave
(physical hardware has no seed) and quip (configured from the
environment; the miner’s own randomness is out of the caller’s control).
The registry otherwise uses each backend’s own constructor defaults,
with one exception: its cuda and metal entries raise num_reads
to 200 and num_sweeps to 2000, rather than the 100 and 1000 the bare
constructor defaults to. They build
SolverGPU(engine="cuda", num_reads=200, num_sweeps=2000) and the same
with engine="metal".
Local Solvers
has the constructor defaults for every backend.
Swapping Backends Without Changing the Model
This is the claim the whole toolchain rests on, so here it is checked
rather than asserted. examples/maxcut/runner.py builds one Max-Cut
model with xqcp, runs the encoder on the Rust XQVM, and samples the
result with whichever solver --solver names, built through
build_solver. Run it against two different backends – cpu
(CPU simulated annealing, 100 reads and 1000 sweeps) and metal (an
Apple Silicon GPU kernel, a different process on different hardware, and
– per the registry’s override above – 200 reads and 2000 sweeps) –
and nothing about the model or the encoder changes:
uv run python examples/maxcut/runner.py --n 6 --seed 42 --solver cpu
uv run python examples/maxcut/runner.py --n 6 --seed 42 --solver metal
Both print the same result: "energy": -571, "cut_weight": 571,
"valid": 1, the identical partition. The encoder produced one QUBO;
two unrelated solvers minimised it and agreed. That agreement is not
guaranteed in general – a harder model can leave different backends in
different local optima – but the model they were handed, and the
verifier that checked what came back, never changed.
The Domain Every Backend Rejects
XQVM has three domains: binary, spin, and integer. Every current
xqsa solver’s _validate_model() accepts binary and spin and raises
ValueError on integer:
from xqsa import SolverCPU
from xquad.types import XqmxModel
model = XqmxModel.integer(size=3, k=4)
SolverCPU().solve(model)
# ValueError: Unsupported domain for solving: integer
An integer XqmxModel is a real thing you can build in XQVM bytecode
today – see Three Domains
– but nothing in this chapter can solve one. spec/xqsa/DOMAINS.md
calls this “reserved”: a future solver may relax the check, but none of
the current five does. Re-encoding the problem in binary or spin is the
only route around it today.
Choosing Among the Rest
| You want | Read |
|---|---|
| A baseline that runs anywhere, no hardware or credentials required | Local Solvers, cpu |
| A local GPU for a bigger or faster run | Local Solvers, cuda / metal |
| Real quantum annealing hardware | D-Wave QPU |
| A decentralised, miner-solved compute market | Quip Network |
What the numbers in SolverResult actually mean | Energy and Precision |
That baseline requirement – runs anywhere, needs nothing – is exactly
DEFAULT_SOLVER: every other backend is worth comparing against
cpu before you trust its answer.
Local Solvers
Three of the five backends run entirely on the machine calling them,
using only local hardware and no account. cpu runs on the CPU;
cuda and metal run custom simulated-annealing kernels on an
NVIDIA or Apple Silicon GPU. The metal examples below ran for
real, on an Apple Silicon Mac; no NVIDIA GPU was available while
writing this chapter, so the cuda material comes from reading
xqsa/inprocess/cuda.py
rather than from execution.
SolverCPU: The Baseline
SolverCPU wraps dwave-samplers’ SimulatedAnnealingSampler.
It needs only a CPU and no credentials, and that is exactly what makes
cpu the xqsa.DEFAULT_SOLVER: every other backend in this chapter is worth
comparing against it before you trust a faster or more exotic answer.
It ships in the base package – pip install xqsa is enough.
from xqsa import SolverCPU
from xquad.types import XqmxModel
model = XqmxModel.binary(size=4)
model.set_linear(0, -1)
model.set_quad(0, 1, 2)
solver = SolverCPU(seed=42)
result = solver.solve(model)
print(result.energy, result.metadata)
Constructor parameters, all overridable per call via solve(**kwargs):
| Parameter | Default | Meaning |
|---|---|---|
num_reads | 100 | Independent annealing runs; the best is kept |
num_sweeps | 1000 | Sweeps per run |
num_sweeps_per_beta | 1 | Sweeps held at each temperature level before cooling; num_sweeps must divide evenly |
beta_range | None | Inverse-temperature (start, end); None lets dwave-samplers pick |
seed | None | RNG seed; None means non-deterministic |
SolverCPU also takes algorithm, which is "sa" and has no other
value yet. A misspelled parameter raises TypeError, whether you pass
it to the constructor or to solve().
GPU Backends: SolverGPU
SolverGPU bypasses the D-Wave SDK entirely. It converts the model to
a dense array and runs parallel-replica simulated annealing directly on
the GPU, in custom CUDA or Metal kernels. engine picks which, and it
is required: SolverGPU(engine="cuda") or SolverGPU(engine="metal"),
which are what the cuda and metal names build. Picking between the
CPU backend and either GPU engine is a performance question, not a
correctness one – see
Backends;
all three run the identical annealing algorithm family, just on
different processors.
Install the extra that matches your hardware:
pip install xqsa[cuda] # NVIDIA GPU, CUDA 12.x driver
pip install xqsa[metal] # Apple Silicon Mac with a Metal device
from xqsa import SolverGPU
from xquad.types import XqmxModel
model = XqmxModel.binary(size=4)
model.set_linear(0, -1)
model.set_quad(0, 1, 2)
solver = SolverGPU(engine="metal", algorithm="sa", num_reads=200, num_sweeps=2000, seed=42)
result = solver.solve(model)
print(result.energy)
# -1
Wall-clock result.timing is not shown here: it varies by roughly 2x
between runs on identical hardware, so a single pasted number would
read as a performance claim it cannot support. The Metal engine also
supports algorithm="gibbs" (block Gibbs sampling over a greedy graph
coloring of the coupling graph). Both algorithms produce correct
output on real Metal hardware:
xqsa/tests/test_gpu_validation.py
checks each against models with a known exact ground state, plus a
spin-glass model matched within 2% of the CPU reference energy, all six
of which pass.
The CUDA engine supports only algorithm="sa" – CUDA has no Gibbs
kernel. Any other algorithm raises ValueError saying it is not
available in-process; further algorithms arrive with the solver binaries.
See
xqsa/inprocess/cuda.py
for the implementation the rest of this section describes.
| Parameter | engine="cuda" default | engine="metal" default | Meaning |
|---|---|---|---|
algorithm | "sa" (only option) | "sa"; also "gibbs" | Kernel to dispatch; constructor-only |
num_reads | 100 | 100 | Parallel replicas, one per GPU threadgroup/block |
num_sweeps | 1000 | 1000 | Sweeps per replica |
num_sweeps_per_beta | 1 | 1 | Sweeps held per temperature level before cooling |
beta_schedule_type | "linear" | "geometric" | Shape of the cooling schedule – note the two engines default to different shapes |
beta_range | None | None | Inverse-temperature (start, end); None auto-computes it from the model’s coefficients |
seed | None | None | Base seed for the on-device RNG |
Every parameter except algorithm is overridable per call via
solve(**kwargs). These are the constructor’s own defaults.
xqsa.build_solver, the
backend-agnostic constructor covered in
Solving Overview, raises
num_reads to 200 and num_sweeps to 2000 for both GPU engines
instead of using these defaults.
The Metal engine computes coefficients in float32; the CUDA engine computes them
in float64, end to end in its kernels. result.energy is always
recomputed afterward in exact integer arithmetic on both, so either
GPU run and a CPU run report the same authoritative energy regardless
of which precision the search itself used. See
Energy and Precision for what the float32
downcast costs the Metal engine and when it matters.
Driver Prerequisites
| Extra | Hardware | Prerequisite | Verify |
|---|---|---|---|
[cuda] | NVIDIA GPU | CUDA 12.x driver | nvidia-smi |
[metal] | Apple Silicon | A Metal device | built in on every Apple Silicon Mac |
After installing, confirm the solver class constructs before building a model around it:
python -c "from xqsa import SolverGPU; SolverGPU(engine='cuda'); print('ok')"
python -c "from xqsa import SolverGPU; SolverGPU(engine='metal'); print('ok')"
Both raise ImportError with a pip install xqsa[...] hint if the
extra is missing, and RuntimeError if the extra is installed but no
matching GPU is detected – import xqsa itself never fails on a
missing extra, only constructing the solver that needs it does.
D-Wave QPU
SolverDWave submits the model to a physical D-Wave Advantage
quantum annealer over the D-Wave Leap cloud API. This page is derived
from
xqsa/inprocess/dwave.py
and spec/xqsa/*; no D-Wave Leap account was available while writing
it, so nothing on this page was run.
Credentials
Requires the [dwave] extra:
pip install xqsa[dwave]
SolverDWave() resolves an API token from the token= constructor
argument first, then the DWAVE_API_TOKEN environment variable; with
neither set, construction raises ValueError. An optional endpoint=
or DWAVE_API_ENDPOINT overrides the default Leap API URL.
import os
os.environ["DWAVE_API_TOKEN"] = "your-leap-token"
from xqsa import SolverDWave
from xquad.types import XqmxModel
model = XqmxModel.binary(4)
model.set_linear(0, -1)
model.set_quad(0, 1, 2)
solver = SolverDWave() # auto-selects a Pegasus-topology Advantage system
result = solver.solve(model)
print(result.metadata["params"]["solver"]) # e.g. "Advantage_system5.4"
print(result.metadata["params"]["qpu_timing"]) # QPU timing breakdown from Leap
Pass solver="Advantage_system5.4" to target a specific system instead
of the default Pegasus-topology filter. SolverDWave also takes
mode, which is "qpu" and has no other value yet; hybrid modes are
not available.
What Embedding Means
A D-Wave Advantage chip is not fully connected: each physical qubit
couples only to a fixed, small set of neighbours defined by its
Pegasus topology. Your model’s coupling graph is almost never a
subgraph of that hardware graph directly. SolverDWave wraps the
sampler in dwave.system.EmbeddingComposite, which finds a
minor embedding: each logical variable maps to a chain of one or more
physical qubits, wired together so the chain acts as a single variable
by strongly coupling its members (chain_strength).
Problem size on real hardware is not simply “how many variables.”
A densely coupled model needs longer chains to embed, chains
compete for the chip’s limited qubits, and a model that does not embed
at all raises rather than silently degrading. EmbeddingComposite
handles the search automatically; there is no separate embedding step
to call in xqsa. Contrast this with Quip Network,
where SolverQuip requires an exact subgraph match onto a fixed
topology and refuses to do this chain-based embedding at all.
Parameters
| Parameter | Constructor default | Meaning |
|---|---|---|
token | None (env fallback) | Leap API token |
endpoint | None (env fallback) | Leap API endpoint override |
solver | None (Pegasus filter) | Specific Advantage system name |
num_reads | 100 | Annealing runs read back from the chip |
annealing_time | 20 | Microseconds per anneal |
chain_strength | None (not passed) | Coupling that holds each chain together |
auto_scale | None (not passed) | Whether Ocean rescales coefficients to the chip’s range |
Everything except token, endpoint, and solver is overridable per
call: solver.solve(model, annealing_time=100, chain_strength=2.0).
chain_strength and auto_scale are the only Ocean sampling
parameters xqsa forwards to the sampler, and only when set. Any other
keyword raises TypeError instead of reaching the sampler unchecked.
How a QPU Result Differs
The result shape is the same SolverResult every backend returns, but
two things are specific to this backend. result.energy is still
recomputed by xqsa in exact integer arithmetic from the returned
sample – the chip’s own reported energy is never trusted directly –
but the sample search itself ran on physical hardware subject to analog
noise and the embedding above, not an exact digital simulation. And
result.metadata["params"] carries QPU-specific detail that the local
backends do not report at all.
metadata has the same three keys as every backend except quip:
| Key | On this backend |
|---|---|
seed | Always None; there is no seed on physical hardware |
reads | num_reads for the call |
params | mode, solver, qpu_timing, annealing_time, raw_energy, plus chain_strength and auto_scale when forwarded |
params["solver"] names the physical Advantage system that ran the
job. params["qpu_timing"] is the timing breakdown Leap returns – a
dict when present, with keys that come from Leap and are not fixed by
xqsa, or None when the sampler response carries no timing info at
all. params["raw_energy"] is the chip’s own reported energy,
informational only.
result.timing covers the whole call, including converting the model
to a BQM and the sample back, not only the round trip to Leap. See
Solving Overview for the shape every
backend shares.
Quip Network
SolverQuip is the one backend in this chapter that does not solve
anything itself. It proposes the model as a job to the Quip Network’s
QuantumComputeMempool pallet, waits for a miner – hardware it does
not control, running an algorithm it does not choose – to solve it,
and decodes whatever comes back as an XQMX sample.
This page is derived from
xqsa/quip/,
spec/xqsa/SOLVERS.md, and an internal contributor testing guide. The
mechanism it describes has been exercised end to end against aglais,
the public Quip test network. Your First Quip Job
walks one job through it; Gaps lists what the figures there
cannot promise.
Installation and Configuration
Requires the [quip] extra:
pip install "xquad[quip]"
The umbrella xquad package forwards [quip] to the matching xqsa
extra, as it does [cuda], [dwave], and [metal]; with xqsa alone,
pip install "xqsa[quip]" is the same install. The extra brings in
substrate-interface (the chain RPC client), certifi (the CA
bundle wss:// falls back to on a macOS Python with none), and
quip-signer, a
native extension providing the chain’s hybrid signature scheme
(sr25519 plus FN-DSA-512), which substrate-interface alone cannot
produce. quip-signer resolves a prebuilt wheel on Linux and builds
from source with a local Rust toolchain on macOS and Windows. Both
guards are lazy: import xqsa and every other solver in this chapter
work without the extra installed; only constructing SolverQuip needs
it.
Both the chain RPC connection over wss:// and the faucet request
verify the certificate chain, host name and validity dates, and by
default neither checks revocation (no CRL, no OCSP). To check
revocation lists, pass an ssl.SSLContext as ssl_context to
SolverQuip, SolverQuip.for_network or fund_from_faucet. Both
clients then use it in place of the default CA-bundle context,
reconnects and autofund included; it is ignored for ws:// and
http:// URLs, and SolverQuip refuses it with an https:// RPC URL,
whose transport cannot use it. The context replaces the default
entirely, so it must carry its own trusted roots: on a macOS Python whose
trust store is empty, load certifi’s bundle as below. A
standard-library context checks only revocation lists you fetch and
load yourself. With VERIFY_CRL_CHECK_LEAF, as below, it needs the list
from each server certificate’s issuer; with VERIFY_CRL_CHECK_CHAIN,
one for every certificate in each chain. One context serves both
servers, so with autofund on, load the lists for the faucet’s issuer
as well as the RPC node’s. A handshake missing a list it needs fails.
import ssl
import certifi
from xqsa.quip import SolverQuip
ctx = ssl.create_default_context(cafile=certifi.where())
ctx.load_verify_locations(cafile="issuer-crl.pem") # a PEM CRL you fetched
ctx.verify_flags |= ssl.VERIFY_CRL_CHECK_LEAF
solver = SolverQuip.for_network("aglais", ssl_context=ctx)
A standard-library context cannot check OCSP, which needs a different
TLS stack that fails closed when the check does not finish. Code that
opens its own chain connection with connect in xqsa.quip.metadata
passes a context through ws_options["sslopt"]["context"] instead.
Configuration resolves from constructor arguments first, then environment variables:
| Argument | Environment variable | Meaning |
|---|---|---|
url | QUIP_RPC_URL | Websocket RPC endpoint |
seed | QUIP_SIGNER_SEED | 32-byte hex master seed |
keystore | QUIP_KEYSTORE | Keystore file path (loaded, or created on first use). Unset, the account preference or ~/.quip/keystore.json applies; see Accounts |
reward | QUIP_REWARD | Reward in planck; falls back to the chain’s MinReward |
faucet | QUIP_FAUCET_URL | Faucet base URL used to top up a short account when autofund allows it. The variable is read only when the RPC URL also comes from QUIP_RPC_URL, so an explicit url never picks up a faucet for another chain |
autoconfirm | QUIP_AUTOCONFIRM | Whether to submit without asking. True (the default) submits; False asks on the terminal (stdin, and stderr or stdout) and raises QuipCancelledError without one, a notebook included; a callable judges the job’s JobQuote. The variable takes 1/true/yes/on or 0/false/no/off |
access | none | Who may answer: "Open" or AccessOpen() (the default), or AccessAllow(accounts=[...], types=[...]) |
resolution | none | The payout rule: "SingleBest" (the default), ResolutionSingleBest(), ResolutionTopNEqual(n) or ResolutionTopNWeighted(n) |
delivery | none | How results are delivered; only "OnChainOnly" (the default) is accepted |
deadline_blocks | none | Blocks an order stays open; defaults to 100 |
block_wait | none | Blocks an order keeps accepting answers after the first one arrives; defaults to 10 |
autofund | QUIP_AUTOFUND | Whether to draw one drip from faucet when the account cannot cover the quoted job; a shortfall larger than one drip, or an account already holding more than one drip, raises instead. Same forms and default as autoconfirm |
inclusion_window_blocks | none | Blocks a signed proposal stays valid for, from the finalized block it was signed at: 64 to 2048, rounded up to a power of two. Defaults to 64, DEFAULT_INCLUSION_WINDOW_BLOCKS. A client setting, not an order option; see Settling a send |
ssl_context | none | An ssl.SSLContext used for both the wss:// RPC connection and the faucet request in place of the default CA-bundle context; ignored for ws:// and http:// URLs, refused with an https:// RPC URL. See the revocation note above |
The signer comes from the first of these that is set: seed,
keystore, QUIP_SIGNER_SEED, QUIP_KEYSTORE, the project account
preference, the user account preference, then ~/.quip/keystore.json,
created on first use. An explicit argument wins over the environment, so
a keystore argument wins over an exported QUIP_SIGNER_SEED.
spec_id and
topology both default to chain state (DefaultIsingSpecId and
DefaultTopology). spec_id is a constructor-only override with no
environment variable. topology also reads QUIP_TOPOLOGY, accepts
"native", and can be overridden per order; see The Topology
Constraint.
The order options among these (reward, access, resolution,
deadline_blocks, block_wait and topology) are client defaults.
Every order create_order() builds inherits them, and create_order()
or JobOrder.set() override them for that order. The quality floors
(max_energy, min_solutions and min_diversity) exist per order
only, never as client defaults. Job Orders covers each
option, including the payout rules and
access rules. delivery accepts only
"OnChainOnly": the client has no subscriber for the ResultReady
event the pallet’s Callback delivery emits when the order expires.
Before connecting, the constructor raises ValueError for a delivery
other than "OnChainOnly", and for an access or resolution that is
not one of the rules above. After connecting, it checks the default
access against the chain’s allowlist limits. The remaining options are
checked against the chain’s limits when an order is created.
Accounts
A keystore is an account: the file holds its 32-byte master seed in plain text, so treat it as the account itself and keep it out of version control. Each account level holds keystore files and an account preference:
| Level | Keystore files | Preference |
|---|---|---|
| Project | .quip/keystores/<name>.json | .quip/config.toml |
| User | ~/.quip/keystores/<name>.json, plus ~/.quip/keystore.json as the account default | ~/.quip/config.toml |
The project level is the nearest .quip/ directory found walking up
from the working directory, stopping below the home directory, so
~/.quip/ is never a project. A .quip/ owned by another user or
writable by everyone is skipped, so a shared directory such as /tmp
cannot choose the signer. A preference is the single line
account = "<name>". A project preference may name a project or a user
keystore, and a name present at both levels resolves to the project one;
a user preference applies in every directory, so it names a user
keystore. Account names are lowercase letters, digits, _ and -, at
most 64 characters, starting with a letter or digit.
xqsa.quip.accounts manages them. Each call takes the level explicitly:
from xqsa.quip import create_account, resolve_accounts, use_account
create_account("work", level="user")
use_account("work", level="project")
print(resolve_accounts().in_effect)
create_account writes a keystore from a fresh seed, or from seed=,
and refuses to replace one unless overwrite=True; with no name it
writes default. At project level with no .quip/ found, it makes one
in the working directory, and anything created under a project .quip/
also writes a .quip/.gitignore of * when there is none.
use_account and clear_account set and remove a preference.
resolve_accounts lists every keystore with its account and walks the
signer order above, reporting the step in effect. Each step is a
SignerSource, whose account_id is the 32-byte account of the
keystore it points at, read from the account the keystore caches, so no
seed is loaded; it is None for the seed steps and for a missing or
unusable keystore. resolve_accounts never writes a file
or reads a seed, so an unusable keystore or a broken preference is
listed with the problem rather than raised. SolverQuip raises
QuipSigningError only when such a step is the one it would sign with.
For reads that need no signer, pass read_only=True:
solver = SolverQuip.for_network("aglais", read_only=True)
print(solver.balance(account=address)) # SS58 text, 0x hex or bytes
A read-only client resolves no signer, so it never creates a keystore,
and refuses seed and keystore. get_receipt, and list_orders,
balance, reserved and order_slots given account=, work on it.
Everything that signs, account, and those four without an account
raise QuipSigningError.
A signing client reads its own account the same way:
solver = SolverQuip.for_network("aglais")
print(solver.account)
print(solver.balance(), solver.reserved(), solver.order_slots())
5FXRydTJYE4cPGBH9CSf3dyGvaJNnYATEZJMyFqMiLPHv9oc
0 0 (0, 32)
account is the signer’s account in SS58. balance() is the free
balance in planck, which an order’s reward and fee are paid from.
reserved() is the reserved balance: the chain reserves an order’s
reward when it is proposed and releases it when a claim or a reclaim
closes the order, and it includes anything else the runtime reserves.
order_slots() returns (used, limit): the orders not yet closed, each
holding a slot until a claim or a reclaim closes it, and the runtime’s
MaxOrdersPerProposer, None when the runtime sets no cap. Each takes
account= to read another account. xquad quip account show prints
all four; see Accounts.
(Captured on 2026-10-26 with a fresh, unfunded account.)
Named networks
SolverQuip.for_network(name, **kwargs) builds a solver from a named
preset in xqsa.quip.networks instead of an explicit url:
SolverQuip.for_network("aglais")
Two presets are registered: aglais, the public test network, and
devnet, the local Docker stack contributors run for offline testing.
The RPC and faucet coordinates behind each preset live in
xqsa.quip.networks, move with the deployment, and are corrected in
patch releases. The test network’s own page,
aglais.quip.network, publishes the
current endpoints and the faucet; check the preset against it if a
connection fails. The preset also names its network’s faucet, if it
has one, which the solver uses unless faucet= names another;
QUIP_FAUCET_URL does not override a preset.
A preset may also pin its network’s genesis hash and may have no
faucet. With a pinned genesis, for_network() refuses a node that
serves another chain with a QuipConnectionError naming both hashes.
With no faucet, an account that cannot cover a quote is refused with a
QuipSubmissionError that says <network> has no faucet; fund the account another way. Neither current preset does either: both name a
faucet, and both leave the genesis open, because a test network
relaunches with a new genesis. The Quip MainNet preset will pin its
genesis and have no faucet.
Runtime compatibility
SolverQuip reads the runtime’s specVersion once, when it connects; a
client that stays connected across an upgrade keeps the version it read
until it reconnects. Runtime 119 renamed the order’s energy-ceiling
field from min_energy_milli to max_energy_milli. xquad 0.4.5 and
later send the ceiling as max_energy_milli only, so they need runtime
119 or later: on an older runtime the proposal fails to encode. xquad
0.4.2 and earlier cannot propose on 119.
ResolutionTopNWeighted needs runtime 119 or later. On an older runtime
every order that uses it is refused before signing, because older
runtimes weight the split by absolute energy, so a worse answer can take
the larger share.
Moving a network to runtime 119 starts it afresh instead of upgrading it
in place. Orders placed before the move do not carry over, so an order
id recorded before it does not name the same order after it. aglais
has run runtime 119 since its relaunch on 2026-10-15.
The signed-extension order comes from the chain’s metadata, not from
a list in the client. At its first connect, SolverQuip checks that it
can encode the extrinsic envelope and every signed extension the
runtime declares, and every signature is checked against the runtime
the same way. A runtime it cannot read or sign for raises
QuipMetadataError: at connect, or at signing when the runtime
changed under a live client, before anything is sent. The remedy is
upgrading xquad; retrying the same node gives the same answer.
Every reconnect must reach the chain the first connection reached: a
node that serves another genesis is refused with a
QuipConnectionError naming both hashes. A reconnect checks the genesis
only, and fails with QuipMetadataError only for a node whose metadata
this client cannot read. A submit() that is settling a send stops at
once on either fault and leaves the order sending (see Settling a
send).
Each connection logs the network, runtime and genesis at INFO on the
xqsa.quip logger, with reconnected to in place of connected to on
a reconnect (captured on 2026-10-26):
connected to Quip aglais at wss://bootnode-1.aglais.quip.network:20049/rpc: specVersion 119, transactionVersion 7, genesis 0x542a9915acf840b8940d35ca6de242f669a0c6e3b40f9a8d8c14b9bf7b8ebecb
Job Lifecycle
Submitting an order calls propose_job, which reserves the full reward
and emits a JobProposed event. Miners discover work only from that
live event stream – there is no storage backfill, so a job proposed
before a miner subscribed stays invisible to it. A miner that accepts
solves it on its own hardware with its own algorithm and submits a
solution.
The chain marks an order Expired at its effective expiry, or a few
blocks later when many orders expire at once, and only a claim or a
reclaim closes it. SolverQuip computes finality itself from block
height instead of waiting for an OrderClosed event:
effective_expiry = min(created_at + deadline_blocks, first_solution_at + block_wait)
JobOrder.wait() and solve() poll until the chain head reaches that
height. solve(model, **options) is
create_order(model, **options).submit().wait() followed by decoding
the answer into a SolverResult (see Metadata); unlike
create_order(), it warns about an unknown option and drops it instead
of raising. The order surface – drafts, quotes, receipts, reclaiming,
and reading answers – is on Job Orders.
Only solve(), and the deprecated query(), reclaim on their own. When
an order finalizes with zero solutions, they reclaim the reserved reward
through reclaim_order and raise QuipJobFailedError, whose message
says whether the reward was reclaimed or why it was not.
JobOrder.wait(), JobOrder.best() and a receipt’s wait() never
reclaim; call reclaim() on the order or its receipt.
The Topology Constraint
Real annealing hardware – and the miner fleet behind this network –
has a fixed set of physical couplings. SolverQuip skips the chains
and minor embedding that D-Wave QPU’s
EmbeddingComposite builds, and instead requires subgraph
placement: an injective variable-to-node mapping where every one of
the model’s couplings maps onto a real hardware edge. A model whose
coupling graph is not a subgraph of the target topology raises
PlacementError, carrying the couplings that could not be placed.
Placement is deterministic, computed from the model alone, so nothing
about it needs to be persisted or looked up later.
The target topology is resolved once, at construction, from three
sources in order: the topology= argument, the QUIP_TOPOLOGY
environment variable, then the chain’s QuantumPow.DefaultTopology.
Because the same topology shape hashes differently per deployment –
each network’s allowed-value specifications fold into the hash – a hash
from one Quip deployment is not portable to another. That is why nothing
is pinned in the codebase: every source is deployment-local, and a
topology that resolves from none of them raises rather than selecting a
hash no chain would accept. QUIP_TOPOLOGY is the operator’s override
for whichever chain they are pointed at, and targets a registered
non-default topology without threading a constructor argument through.
solve() does not check the resolved hash against
QuantumPow.MineableTopologies. That set is the chain’s active mining
set: it gates submit_proof, and so block production, not the compute
mempool. An order carries its nodes, edges and coefficients inline and
no topology hash at all, so the chain cannot perceive which topology an
order was built against, and the solver fleet has no topology field to
filter on. A topology that is registered but not mineable is proposed,
matched and solved like any other.
Native topology mode
Passing topology="native" builds the topology from the model’s own
variables and couplings instead of reading one from the chain. It is
accepted on the constructor, as an order option on create_order() and
JobOrder.set(), per call on solve() and the deprecated quote() and
query(), and through QUIP_TOPOLOGY=native, matched without regard to
case or surrounding whitespace:
result = solver.solve(model, topology="native")
The variables, after any BINARY-to-SPIN fold, are relabelled densely to
0..n-1, so placement is the identity and cannot raise
PlacementError. No chain topology is read, and the native topology
carries no allowed-value sets, so the allowed-value warning stays
silent; the mempool never enforces those sets. mapping= has no
hardware graph to target in this mode and raises ValueError. A
per-call value overrides the constructor’s in either direction. A native
order is as large as the model, so one over the mempool’s MaxNodes
(5,000) or MaxEdges (50,000) raises EncodingError before it is
quoted. The solver reads both bounds from the chain for each native
order; a failed read raises QuipConnectionError.
Native mode narrows who can answer. A miner that cannot embed an
arbitrary graph, such as a QPU-backed one, cannot solve these orders;
the simulated-annealing miner fleet can. Native mode is never chosen
automatically on PlacementError: a call that raises today would
otherwise start proposing jobs and spending funds.
Coefficient Encoding
The normative rules for this section are
Coefficient encoding and allowed values
– the same URL EncodingError, the rounding warning, and the
allowed-value warning below all carry.
XQMX coefficients are natural-scale integers. The pallet stores
couplings and fields as milli-scale i32 values, read back on-chain as
value / 1000. Confirmed directly from
xqsa/quip/codec.py:
from xqsa.quip.codec import MILLI_SCALE, MAX_NATURAL_COEFFICIENT
print(MILLI_SCALE, MAX_NATURAL_COEFFICIENT)
# 1000 2147483
SolverQuip computes spin coefficients from the model, multiplies
each by MILLI_SCALE, and rounds to the nearest integer milli value,
ties to even. A coefficient finer than 1/1000 is therefore rounded,
not refused. Each rounding moves a coefficient by at most 0.0005.
SolverQuip reports the largest such error once per solver instance as
a warnings.warn when the model is placed (by create_order(), a
set() that places it again, or solve()), and on every result as
metadata["quantization_error"]. A coefficient whose rounded milli
value would overflow i32 still raises EncodingError. An energy
ceiling (max_energy) on a rounded model warns as well, because the
chain applies the ceiling to the rounded model; Energy and
Precision explains the ceiling’s units.
Rounding changes only the model the miners search. result.energy is
recomputed on your original model, so a returned sample is never scored
against the rounded coefficients. The winning submission is still
chosen by the chain’s energy on the rounded model; only the vectors
inside it are re-scored on yours. quantization_error bounds each
coefficient, not the energy: a sample’s energy can move by up to
quantization_error times the number of nonzero terms. A model whose
coefficients all round to zero raises EncodingError rather than
proposing an empty order. A BINARY model with ordinary
three-decimal weights is the common case: the basis change below
divides each quadratic weight by 4, so 1.5 on x_0 and 0.001 on
x_0 x_1 give a spin field of 0.75025, which encodes as 750.
MAX_NATURAL_COEFFICIENT is the exact ceiling only for a SPIN
model, whose h/j coefficients pass through to the pallet unchanged.
A BINARY model goes through a basis change first (s = 2x - 1, via
dimod), which folds every quadratic coefficient into the linear field
of both variables it touches: j_ij = b_ij / 4, and
h_i = a_i / 2 + 1/4 * (sum of every quadratic coefficient incident on i). _to_milli() only ever sees these post-conversion values, so for
a BINARY model the natural-scale ceiling moves in both directions from
what the constant alone suggests:
- An isolated coefficient survives past
MAX_NATURAL_COEFFICIENT: an isolated quadratic term up to about8_589_934(roughly 4x), an isolated linear term up to about4_294_967(roughly 2x). - A high-degree variable can overflow well below it, because
h_iaggregates a quarter of every incident quadratic term. Ten quadratic terms of1_000_000all touching variable0giveh_0 = 2_500_000– nowhere nearMAX_NATURAL_COEFFICIENT– but its milli value,2_500_000_000, exceedsi32and raisesEncodingError.
The error in that case reads h[0] = 2500000.0 overflows the encodable range, naming a linear field the caller never set directly; the actual
cause is the aggregate of ten quadratic terms landing on variable 0
during the basis change. This is a range shift, not a precision loss –
quarters and halves of integers are exact in floating point, and exact
at 1/1000 milli scale too (0.25 becomes 250) – the stored value
is correct, only the range it has to fit in moved.
Rescaling a model does not change its optimum (see Choosing a Penalty Weight), so an oversized coefficient is fixable by scaling the whole model down, not a hard ceiling on what you can express.
Each topology also declares an allowed-value set for its coefficients
(the default plain-Ising spec allows {-1, 0, +1} for fields and
{-1, +1} for couplings), but the pallet does not enforce it, so
SolverQuip submits coefficients as-is. A miner matches jobs by an
exact hash over the topology’s structure, not over the coefficient
values, and real hardware rescales monotonically in any case. An
out-of-spec coefficient produces a one-time warnings.warn, not a
rejection.
Cost Model
A job costs two amounts, both in planck, the chain’s smallest denomination:
- The reward: the order’s
reward, else the client’sreward, elseQUIP_REWARD, else the chain’sMinReward. A reward belowMinRewardraisesQuipOrderOptionErrorbefore anything is signed.propose_jobreserves the reward from the caller’s account. A solved order’s reward is allocated to its winning solvers on chain and paid when one of them claims it; a receipt’ssettlement()shows the split. An order that finalizes with no solutions can have its reward reclaimed, andsolve()reclaims it itself, so a failed job costs the fee, not the reward. - The transaction fee for
propose_job, burned either way.
create_order(model).quote() prices an order without proposing it. It
signs the propose_job extrinsic the order would submit, asks the chain
for its fee through payment_queryInfo, and reads the account balance.
Nothing is submitted and no nonce is consumed. submit() later sends
the same signed extrinsic, re-signing only if the account’s nonce moved
in between or half its inclusion window has passed (see Settling a
send). quote() returns a JobQuote:
| Field | Meaning |
|---|---|
network | The preset name, or None for an explicit url |
account | The paying account, in SS58 form |
quoted_at_block | The block the quote was read at |
inclusion_window_blocks | Blocks the signed proposal stays valid for; see Settling a send |
num_variables | The model’s variables |
num_spins, num_couplings | The spins and couplings the model was placed onto |
placement | "native", or the topology hash the model was placed on |
reward_planck | The reward the job reserves |
fee_planck | The transaction fee |
fee_exact | True when the fee is the chain’s answer; False when that query failed and a fixed headroom stands in for it |
balance_planck | The account’s free balance |
token_symbol, token_decimals | The chain’s token, used to render amounts |
total_planck | reward_planck + fee_planck: what the account must hold |
shortfall_planck | How far the balance falls short of the total, or 0 |
affordable | Whether the balance covers the total |
str(quote) renders a 72-column box: the network, account and block;
the problem and where it was placed; the reward, fee and total in token
units; then the balance, the shortfall and whether the account can
afford the job. submit() and solve() build the same quote before
proposing, log it at INFO on the xqsa.quip logger (also printing it
to stderr when stderr is a terminal), and pass it to the autoconfirm
and autofund gates. A field the solver could not read is left out of
the box.
What each part of the price measures:
- The fee is metered. It comes from benchmarked weights plus the encoded length, so it grows with the model’s nodes and edges. It is small next to the reward. Fees are burned, not paid to the block author, and do not respond to congestion: the runtime’s fee multiplier is constant.
- The reward is arbitrary.
MinRewardis a round runtime constant that gates submissions. Nothing derives it from the cost of solving, yet it is nearly all of what a job costs. - Solver compute is not metered. The runtime declares
type VM = NoOpVm, so nothing on-chain executes or measures the miner’s work, and that work never enters the price. - The token is subsidised. On
aglais, AGLS comes free from the faucet and has no external price.autofunddraws from the faucet for a short account, andfund_from_faucet(dest, url=...)requests one drip directly.
This page does not print a current figure, because every one of these
amounts is live chain state; JobOrder.quote() reads it. For a dated figure
from an observed job, see Your First Quip Job.
Metadata
SolverResult.metadata for quip carries nine keys, eight read from
the order and one from the encoding:
| Key | Type | Meaning |
|---|---|---|
order_id | int | The on-chain order id; get_receipt(order_id) rebuilds the order’s receipt in any session. |
solver | str | The solver account whose vector is returned; always present. |
best_energy_milli | int | The leader’s ranked energy on chain, in milli units. It stays the leader’s even when a stored-energy mismatch makes solve() return another solver’s vector; only when the ranking cannot be read is it the chosen solver’s stored best. |
energy_matches_chain | bool | Whether any stored vector of the chosen solver reaches the chain’s energy. False means a fallback returned a different vector, not necessarily an encoding fault. |
leader_read | bool | Whether the chain’s ranking of the order was read. |
leader | bool | Whether the sample is the leader’s: the solver the chain pays. |
num_submissions | int | How many solvers have a stored answer; a solver’s resubmission overwrites its earlier one. |
num_solutions | int | How many vectors the chain stores for the returned sample’s solver. |
quantization_error | float | The largest absolute error, in natural units, from rounding a spin coefficient to the milli grid. 0.0 when every coefficient was already on it; at most 0.0005. |
best_energy_milli is milli-scale (see Coefficient
Encoding) and is not directly comparable to
result.energy, the authoritative natural-scale energy recomputed on
your model; diffing the two without converting scale first will make a
correct result look wrong.
metadata["solver"] here names the solver account that answered, not a
piece of hardware – D-Wave QPU
records the physical Advantage system that ran the job under
metadata["params"]["solver"], so the same name refers to a different
kind of thing depending on the backend.
solve() returns the leader’s best vector, the answer the chain pays
for, rather than the best vector any solver stored. leader,
leader_read and energy_matches_chain say whether that rule held or a
fallback chose another vector; Reading the
answer describes the rule and both
fallbacks.
Failure Taxonomy
Sixteen exception classes cover this backend – one base class and
fifteen concrete failures, spanning local encoding checks, order checks,
the consent and funding gates, and network-dependent lifecycle failures.
Fifteen are exported from xqsa.quip and xquad.sa.quip; the
deprecated QuipUnconfirmedError still imports from both, with a
warning, but import * does not bring it in:
| Exception | Raised when |
|---|---|
QuipError | Base class for every error below |
EncodingError | The model is not an XqmxModel, its domain is unsupported, or it has no nonzero linear or quadratic term. Also covers a coefficient whose milli value overflows i32 (see Coefficient Encoding), and a native order over the mempool’s MaxNodes or MaxEdges |
PlacementError | The model’s coupling graph is not a subgraph of the target topology. Raised in default mode only; see Native topology mode |
QuipSigningError | Extrinsic assembly, keystore handling, or submission fails |
QuipConnectionError | The node is unreachable; a configured Ising spec or topology is not registered on-chain; a mempool constant cannot be read (MinReward, MaxDeadlineBlocks, MaxBlockWait, MaxSolutions and MaxBidMiners at construction, MaxNodes and MaxEdges per native order); the chain defines no MinReward and no reward was given; an order id names no order; a balance, nonce or block read fails; or the node serves another chain than the preset pins, named by both genesis hashes. A reconnect to another chain than the first connection reached raises it too, but that happens only while a send is settling, so it reaches the caller as the __cause__ of QuipSubmitIncompleteError. Most come from the constructor or for_network(), which read the limits once |
QuipMetadataError | This client cannot read or sign for the node’s runtime: its metadata, an extrinsic envelope or signed extension it cannot encode, or blocks or events it cannot decode while settling a send. Raised at connect, on a reconnect whose metadata it cannot read, and when quote() or submit() signs; while a send is settling it is the __cause__ of QuipSubmitIncompleteError instead. The remedy is upgrading xquad, not retrying |
QuipSubmissionError | An extrinsic cannot be submitted or the chain rejects it. Raised when propose_job fails on chain (the fee was paid, no order was placed, and the order moves to failed), and when the account’s nonce moves twice while submitting because another client is sending from the account. Also raised before proposing when the account cannot cover the quote and autofund cannot help, with a hint for each case: a named network with no faucet says to fund the account another way, a raw url= with none says how to name one, a shortfall larger than one drip suggests checking reward=, and a balance already above one drip is one the faucet will not top up. Also raised when a sent proposal certainly never reached the chain: the order stays a draft |
QuipOrderOptionError | A QuipSubmissionError: an order option fails a check before signing, such as a reward below MinReward, a deadline or block wait out of range, a bad payout or access rule, or a quality floor out of range. Carries the option’s name as option. Nothing is spent |
QuipReclaimRefusedError | A QuipSubmissionError: reclaim() refuses before signing because another account proposed the order, it is already closed, it is still open, or it was answered; or the chain refuses the reclaim and charges the fee |
QuipSubmitIncompleteError | A QuipSubmissionError: a sent propose_job could not be settled by the end of its window; or settling stopped at once, because its block or events could not be decoded, a reconnect reached another chain or a node whose metadata this client cannot read, or the node discarded the state it needs. The order is sending; calling submit() again resumes it and never places a second order. Carries order, extrinsic_hash, nonce and window_end, with the cause as __cause__; see Settling a send |
QuipUnconfirmedError | Deprecated, and no longer raised since v0.4.5; importing it from xqsa.quip warns. A QuipSubmissionError that carried extrinsic_hash and block_hash for a send whose outcome was unknown; QuipSubmitIncompleteError replaces it |
QuipCancelledError | The autoconfirm or autofund gate declines the job, at the prompt, by callable, or for want of a terminal; carries the declined JobQuote as quote. Nothing has been proposed or funded |
QuipFaucetError | The faucet refuses a drip or cannot be reached; carries the HTTP status (None for a transport failure) and the parsed JSON body |
QuipTopologyError | Retained for compatibility; nothing raises it. It reported a topology absent from MineableTopologies, which does not gate the mempool |
QuipTimeoutError | An order does not reach finality before the configured timeout. Carries order_id and, when a wait raised it, the order’s receipt, so the caller can call exc.receipt.wait() again; another session rebuilds the receipt with get_receipt(order_id) |
QuipJobFailedError | A final order has no usable solution; carries order_id. From solve(), the reward of an unanswered order was reclaimed first, and the message says whether the reclaim succeeded; it also names any quality floors or allowlist that may explain why nobody answered. JobOrder.best() raises it without reclaiming |
The EncodingError emptiness check tests whether linear_items() and
quadratic_items() are both empty. Both list
nonzero terms only – an XqmxModel drops a term the moment its
coefficient returns to 0 – so “has no terms” and “carries only zero
terms” are the same condition, and an all-zero model never reaches the
chain.
EncodingError and PlacementError can be raised entirely locally,
before anything touches the network – they come from
xqsa.quip.codec, the same module the coefficient encoding above uses.
The one exception is the native size check, which reads MaxNodes and
MaxEdges from the chain first; a failed read raises
QuipConnectionError. QuipOrderOptionError and QuipCancelledError
are also raised before anything is signed or proposed. The rest depend
on chain state or the round trip completing.
Deprecated APIs
Each of these warns with a DeprecationWarning and is removed in
v0.6.0:
| Deprecated | Since | Use instead | Notes |
|---|---|---|---|
SolverQuip.quote(model, ...) | v0.4.3 | solver.create_order(model, ...).quote() | |
SolverQuip.status(order_id) | v0.4.3 | solver.get_receipt(order_id).status() | The keys differ: state and chain_status replace status and is_final |
SolverQuip.query(order_id, model, ...) | v0.4.3 | solver.get_receipt(order_id) to follow an order; JobOrder.wait() and best() in the session that submitted it | Still reclaims an unanswered order; see below |
mode= on the constructor, for_network(), quote() and solve() | v0.4.3 | access= | |
A raw chain dict for access or resolution on solve() or quote() | v0.4.3 | AccessOpen(), AccessAllow(...), or a Resolution rule | |
An unknown keyword on solve() or quote(), including delivery= and spec_id= | v0.4.3 | None; the option is dropped | |
xqsa.quip_codec, quip_signing, quip_metadata, quip_faucet, quip_networks | v0.4.3 | xqsa.quip.codec, .signing, .metadata, .faucet, .networks | Warns on the first import only |
The 27 Quip names on xqsa and xquad.sa, such as xqsa.SolverQuip | v0.4.3 | The same names on xqsa.quip or xquad.sa.quip | They left both __all__ lists, so from xqsa import * no longer brings them in |
QuipUnconfirmedError | v0.4.5 | QuipSubmitIncompleteError | No longer raised; warns on import from xqsa.quip |
Loading a pickled unconfirmed order | v0.4.5 | None | Orders pickled by 0.4.3 or 0.4.4 in that state still load, warn, and are never sent again; look up the extrinsic the warning names |
xqsa.quip.signing.SIGNED_EXTENSIONS | v0.4.5 | None | The signed-extension order now comes from the runtime metadata |
The top-level commands xquad asm, dism, run and verify | v0.4.5 | xquad vm asm, dis, run and check | Print a warning line to stderr rather than a DeprecationWarning; see xquad vm |
solve() is not deprecated, and neither is a typed resolution= on
solve(). The strict paths refuse what the deprecated ones accept:
create_order() and JobOrder.set() raise TypeError for mode=, a
raw chain dict, or an unknown option.
query() has no full replacement yet. A receipt follows an order on
chain but cannot decode its answers onto your model, so from a session
other than the one that submitted the order,
query(order_id, model, ...) is still the only way to get a decoded
SolverResult.
Python shows a DeprecationWarning by default only when the deprecated
call is made from __main__: a script sees it, but a library calling
the same API does not. Run with -W default::DeprecationWarning to see
every one; each points at the calling line.
Gaps
Your First Quip Job now walks a funded job end to end with the numbers one run produced. What stays open is that those numbers move:
MinRewardis a runtime constant, not storage governance can set. It changes only with a runtime upgrade, with no extrinsic and no event.SolverQuipreads it live, so the toolchain follows a change; prose does not.- The fee weights are benchmark output. Re-running the runtime’s benchmarks moves the fee for the same model.
- Congestion is not a cause. The fee multiplier is constant, so
for a given model and the default reward, only an upgrade moves
either figure.
JobOrder.quote()is the only reading to trust at submission time. - Timing and fleet behaviour are unmeasured. How long a job takes and how many miners answer it depend on the fleet at that moment; the tutorial reports one run, not a distribution.
Your First Quip Job
This page submits one job to aglais, the public Quip test network,
from an empty account: define a four-city Travelling Salesman Problem in
XQCP, run its encoder on the XQVM locally, and hand the resulting model
to SolverQuip as a job order. Every output block below was captured
from the code on this page, run against aglais on 2026-10-15 at
runtime specVersion 119. Quip Network is the
reference for the mechanism and Job Orders for the
order surface; this page is the walkthrough.
Install
pip install "xquad[quip]"
The extra pulls in the chain client and quip-signer, the native
extension that signs extrinsics. quip-signer ships a prebuilt wheel on
Linux and builds from source with a local Rust toolchain on macOS and
Windows.
Set Up an Account
xquad quip account show is the setup check: it prints the account in
effect, where it comes from, and its balance. On a first run there is
none. At a terminal it prints the guidance, then offers to create an
account; the answer defaults to skip, and nothing is written until the
last answer:
xquad quip --network aglais account show
No Quip account is set up.
No keystores found. Create one with `xquad quip account new`, or bring a seed with `xquad quip account import`.
Resolution order (the first match signs):
1 QUIP_SIGNER_SEED unset
2 QUIP_KEYSTORE unset
3 project preference unset
4 user preference unset
* 5 default ~/.quip/keystore.json
Setting the account:
xquad quip account use <name> prefer <name> in this project, else for your user
xquad quip account use <name> --global prefer <name> for your user
xquad quip account use --unset [--global] clear the preference at that level
--keystore PATH, QUIP_KEYSTORE, QUIP_SIGNER_SEED override the preferences
Create one now? (new, import, skip) [skip]: skip
Answering new creates the account there and then. Off a terminal, or
with --json, the command prints the same guidance as an error and exits
1 without asking or creating anything.
Create the account, then check it again:
xquad quip account new
xquad quip --network aglais account show
created default at the user level: ~/.quip/keystore.json
5GGt9yK5EbpdQNv3fCnpZLo93DgL663VsT7VeS5ueJqNoaBD
it is the user default, which signs when nothing else is set
Account 5GGt9yK5EbpdQNv3fCnpZLo93DgL663VsT7VeS5ueJqNoaBD
Source default keystore ~/.quip/keystore.json
Balance 0.000000000000 AGLS
Reserved 0.000000000000 AGLS
Orders 0 of 32 slots used
The keystore holds the account’s signing key. Keep the file: it is the
account. It starts empty; Submit funds it from the faucet.
These two blocks were captured on 2026-10-24, with a fresh account.
The receipts further down came from a different, funded account, so
their Account line is not the one shown here.
Build the Model
The model is the TSP example at n = 4, with a
fixed distance matrix instead of a random one so the numbers below are
reproducible. The encoder runs on your machine; nothing touches the
network yet.
from xquad import cp
from xquad.vm import VM
n = 4
distances = [12, 30, 25, 18, 40, 22] # xq_triu order: 0-1, 0-2, 1-2, 0-3, 1-3, 2-3
problem = cp.Problem("TSP")
num_cities = problem.input("num_cities", type=cp.Types.Int)
distance_matrix = problem.input("distance_matrix", type=cp.Types.Vec)
problem.define_model(size=num_cities * num_cities, domain=cp.Domain.BINARY, rows=num_cities, cols=num_cities)
with problem.range(0, num_cities - 1) as city_i:
with problem.range(city_i + 1, num_cities) as city_j:
dist = problem.stow("dist", distance_matrix.get(cp.xq_triu(city_i, city_j)))
with problem.range(0, num_cities) as position:
next_position = (position + 1) % num_cities
problem.model.quadratic[(city_i, position), (city_j, next_position)].add(dist)
problem.model.quadratic[(city_j, position), (city_i, next_position)].add(dist)
with problem.range(0, num_cities) as city:
problem.model.apply_onehot_row(city, penalty=100)
with problem.range(0, num_cities) as position:
problem.model.apply_onehot_col(position, penalty=100)
tour = problem.output("tour", type=cp.Types.Vec)
with problem.range(0, num_cities) as position:
tour.append(problem.sample.colfind(col=position, value=1))
programs = problem.compile()
vm = VM()
vm.set_calldata([n, distances])
vm.set_output_slots(1)
vm.run(programs.encoder)
model = vm.outputs()[0]
model is a 16-variable BINARY XQMX: one variable per city and tour
position.
Connect and Quote
from xquad import sa
solver = sa.quip.SolverQuip.for_network("aglais", topology="native")
order = solver.create_order(model)
print(order)
print(order.quote())
╭─ Job order (draft) ──────────────────────────────────────────────────╮
│ Network aglais │
├──────────────────────────────────────────────────────────────────────┤
│ Model 16 variables, 96 couplings, binary │
│ Placed native, 16 spins │
├──────────────────────────────────────────────────────────────────────┤
│ Payout single best │
│ Access open │
│ Floors none │
│ Deadline 100 blocks │
│ Block wait 10 blocks │
│ Reward 1.000000000000 AGLS │
├──────────────────────────────────────────────────────────────────────┤
│ Quote not quoted │
│ Receipt not submitted │
╰──────────────────────────────────────────────────────────────────────╯
╭─ Job quote ──────────────────────────────────────────────────────────╮
│ Network aglais │
│ Account 5DL9B7rhMw1cg7kXzqmdj5QZ1V67vBvfYPq2bTEgZfSXiBSJ │
│ Quoted at block 1896 │
├──────────────────────────────────────────────────────────────────────┤
│ Problem 16 variables -> 16 spins, 96 couplings │
│ Placed on native │
├──────────────────────────────────────────────────────────────────────┤
│ Reward 1.000000000000 AGLS refunded if unanswered │
│ Fee 0.002011933394 AGLS exact │
│ Total 1.002011933394 AGLS │
╞══════════════════════════════════════════════════════════════════════╡
│ Balance 0.000000000000 AGLS │
│ Shortfall 1.002011933394 AGLS │
│ Affordable no │
╰──────────────────────────────────────────────────────────────────────╯
Two arguments do the work:
"aglais"selects the preset carrying the network’s RPC endpoint and faucet; see Named networks.topology="native"submits the order over the model’s own coupling graph. In the default mode, the placement search finds no embedding of this model in the network’s topology within its step budget and raisesPlacementError; native mode skips placement. See Native topology mode.
create_order() places the model and returns a draft JobOrder with
the client’s defaults: winner takes all, any solver may answer, no
quality floors, a 100-block deadline and a 10-block wait after the first
answer. Nothing has been signed or sent. Job Orders
covers every option a draft takes.
No signer is passed, so SolverQuip signs with the account
xquad quip account show reported. To use a different keystore, pass
it:
solver = sa.quip.SolverQuip.for_network("aglais", keystore="~/.quip/work.json", topology="native")
quote() signs the order’s propose_job extrinsic and asks the chain
what it would cost, without submitting anything. The returned JobQuote
carries each amount in planck, the chain’s smallest unit, as
reward_planck, fee_planck, total_planck, balance_planck and
shortfall_planck; fee_exact says the fee came from the chain rather
than a fallback estimate. The signature is kept, and submit() sends
those same bytes.
What the figure means
As of 2026-10-15, at specVersion 119, a job costs 1 AGLS in reward
plus about 0.0020 AGLS in fee: about 1.0020 AGLS in total. The reward is
the chain’s MinReward, a runtime constant. It is charged to gate
submissions and maps to no measured compute: the runtime declares
type VM = NoOpVm,
so the chain meters none of the solver’s work. The fee is metered,
burned, and does not rise with congestion. AGLS on aglais comes free
from the faucet. Both figures move when the runtime is upgraded, so read
them from quote() rather than from this page.
Submit
order.submit()
╭─ Job quote ──────────────────────────────────────────────────────────╮
│ Network aglais │
│ Account 5DL9B7rhMw1cg7kXzqmdj5QZ1V67vBvfYPq2bTEgZfSXiBSJ │
│ Quoted at block 1896 │
├──────────────────────────────────────────────────────────────────────┤
│ Problem 16 variables -> 16 spins, 96 couplings │
│ Placed on native │
├──────────────────────────────────────────────────────────────────────┤
│ Reward 1.000000000000 AGLS refunded if unanswered │
│ Fee 0.002011933394 AGLS exact │
│ Total 1.002011933394 AGLS │
╞══════════════════════════════════════════════════════════════════════╡
│ Balance 0.000000000000 AGLS │
│ Shortfall 1.002011933394 AGLS │
│ Affordable no │
╰──────────────────────────────────────────────────────────────────────╯
submit() quotes once more and shows that quote: it always goes to the
xqsa.quip logger at INFO, and it is also printed when stderr is a
terminal, as here. It then runs two gates with their defaults.
autoconfirm=True accepts the price. The account is short, so
autofund=True draws one drip of 10 AGLS from the faucet. Finally it
sends the signed propose_job, which reserves the reward, and returns
the order, now submitted, once the block holding it is finalized, a
few blocks after it is included. The order is read-only from here: set(),
quote() and submit() refuse it.
Read the Result
order.wait()
solution = order.best()
print(solution.energy)
print(solution.solver, solution.leader, solution.energy_milli)
vm = VM()
vm.set_calldata([solution.sample, n])
vm.set_output_slots(1)
vm.run(programs.decoder)
print(list(vm.outputs()[0]))
-696
5GqG8c8zWHX33ruGJX4FDokxCTFrASVi268einKJY1XnHNYn True -1790000
[0, 1, 3, 2]
wait() blocks until the order is final by block height. Here the first
answer arrived six blocks after the order was included, and the order
was final ten blocks later, at the end of its 10-block wait; the script
returned about 122 seconds after it started.
- The decoded tour visits cities 0, 1, 3, 2 and returns to 0. Its length is 12 + 40 + 22 + 30 = 104. It is a valid tour but not the shortest: of the three distinct tours on four cities, 0, 1, 2, 3 is shortest, at 77, with energy -723. The network pays for the best answer it receives, not for the optimum, and here the two answers that arrived missed it. The one-call form below came back with the shortest tour.
solution.energyis that length plus -800, the constant the eight one-hot penalties contribute at any valid assignment. It is recomputed on your model.best()returns the leader’s best vector: the answer from the solver the chain ranks first, and pays.solution.leaderisTruewhen that rule held; Reading the answer covers the cases where it falls back to another solver’s vector.energy_milliis the vector’s energy on the chain’s milli scale, in the SPIN basis, and is not comparable tosolution.energydirectly.
The order’s receipt shows what the chain recorded, including the fee and the payout:
print(order.receipt())
╭─ Job order 20 receipt ───────────────────────────────────────────────╮
│ Network aglais │
│ Proposer 5DL9B7rhMw1cg7kXzqmdj5QZ1V67vBvfYPq2bTEgZfSXiBSJ │
│ Submitted block 1898, 2026-10-15 04:22:24 UTC │
├──────────────────────────────────────────────────────────────────────┤
│ Problem 16 spins, 96 couplings │
│ Spec 0x8f46f3a31321d1d093314fc769c42cbe7a83d71a0b69e6571a0f68 │
│ e2a04067f0 │
├──────────────────────────────────────────────────────────────────────┤
│ Payout single best │
│ Access open │
│ Floors none │
│ Deadline 100 blocks │
│ Block wait 10 blocks │
│ Reward 1.000000000000 AGLS │
├──────────────────────────────────────────────────────────────────────┤
│ Status final │
│ Answers 2 │
│ Leader 5GqG8c8zWHX33ruGJX4FDokxCTFrASVi268einKJY1XnHNYn │
│ Energy -1790 │
╞══════════════════════════════════════════════════════════════════════╡
│ Fee 0.002011933394 AGLS │
│ Winner 1.000000000000 AGLS, awaiting claim │
╰──────────────────────────────────────────────────────────────────────╯
Two solvers answered; the leader wins the whole reward, which it receives once a claim is made. The account now holds the drip less one job’s total.
The one-call form
solve() does all of the above in one call: it creates the order,
submits it, waits, and returns the leader’s answer as a SolverResult,
whose metadata names the order:
result = solver.solve(model)
print(result.energy)
print(result.metadata)
-723
{'order_id': 23, 'solver': '5GqG8c8zWHX33ruGJX4FDokxCTFrASVi268einKJY1XnHNYn', 'best_energy_milli': -1817000, 'energy_matches_chain': True, 'leader_read': True, 'leader': True, 'num_submissions': 1, 'num_solutions': 1, 'quantization_error': 0.0}
solve() prints the quote box the same way submit() does.
Metadata explains each key.
Confirm Before Paying
autoconfirm=False asks on the terminal before anything is proposed.
Answering anything but y raises QuipCancelledError, which carries
the quote:
from xquad import sa
solver = sa.quip.SolverQuip.for_network("aglais", topology="native", autoconfirm=False)
try:
result = solver.solve(model)
except sa.quip.QuipCancelledError as exc:
print("cancelled:", exc)
print(exc.quote.total_planck)
╭─ Job quote ──────────────────────────────────────────────────────────╮
│ Network aglais │
│ Account 5DL9B7rhMw1cg7kXzqmdj5QZ1V67vBvfYPq2bTEgZfSXiBSJ │
│ Quoted at block 1952 │
├──────────────────────────────────────────────────────────────────────┤
│ Problem 16 variables -> 16 spins, 96 couplings │
│ Placed on native │
├──────────────────────────────────────────────────────────────────────┤
│ Reward 1.000000000000 AGLS refunded if unanswered │
│ Fee 0.002011933394 AGLS exact │
│ Total 1.002011933394 AGLS │
╞══════════════════════════════════════════════════════════════════════╡
│ Balance 4.989940332991 AGLS │
│ Shortfall 0.000000000000 AGLS │
│ Affordable yes │
╰──────────────────────────────────────────────────────────────────────╯
[quip] Submit this job for 1.002011933394 AGLS? [y/N] n
cancelled: autoconfirm declined: answered 'n' at the prompt
1002011933394
The same gate runs in order.submit(). With no terminal to ask on, a
notebook included, autoconfirm=False raises QuipCancelledError
without prompting. A declined job costs nothing: the gate runs before
funding, so it draws no drip, and nothing reaches the chain.
To decide in code instead, pass a callable that receives the JobQuote
and returns whether to submit. This one accepts any job up to 2 AGLS (a
token here has 12 decimals):
solver = sa.quip.SolverQuip.for_network(
"aglais",
topology="native",
autoconfirm=lambda quote: quote.total_planck <= 2 * 10**12,
)
result = solver.solve(model)
print(result.metadata["order_id"], result.energy)
╭─ Job quote ──────────────────────────────────────────────────────────╮
│ Network aglais │
│ Account 5DL9B7rhMw1cg7kXzqmdj5QZ1V67vBvfYPq2bTEgZfSXiBSJ │
│ Quoted at block 1936 │
├──────────────────────────────────────────────────────────────────────┤
│ Problem 16 variables -> 16 spins, 96 couplings │
│ Placed on native │
├──────────────────────────────────────────────────────────────────────┤
│ Reward 1.000000000000 AGLS refunded if unanswered │
│ Fee 0.002011933394 AGLS exact │
│ Total 1.002011933394 AGLS │
╞══════════════════════════════════════════════════════════════════════╡
│ Balance 5.991952266385 AGLS │
│ Shortfall 0.000000000000 AGLS │
│ Affordable yes │
╰──────────────────────────────────────────────────────────────────────╯
24 -723
What a Failed Job Costs
An order that becomes final with no answers costs its transaction fee,
which is burned either way. Its reward stays reserved until the order is
reclaimed. solve() reclaims it for you and raises
QuipJobFailedError, carrying the order_id, with a message that says
whether the reclaim succeeded:
QuipJobFailedError: order 25 finalized with no solutions; only allowlisted solvers could answer (accounts 5DL9..iBSJ) and none did; the reserved reward was reclaimed
That order was restricted to an account that is not a registered solver, so nobody could answer it. Its total cost was the proposal fee plus the reclaim fee, about 0.004 AGLS. A faucet drip drawn for a job stays in the account either way.
On an order you submitted yourself, wait() and best() never reclaim.
best() raises QuipJobFailedError and tells you to reclaim; do it
once the order is final:
refunded = order.reclaim()
A job that outlasts the solver’s timeout raises QuipTimeoutError
instead, carrying order_id and the order’s receipt. The job is still
live on chain, so wait on the receipt again:
from xquad import sa
try:
order.wait()
except sa.quip.QuipTimeoutError as exc:
exc.receipt.wait()
Coming Back Later
The order lives on chain, not in your session. A new process lists the account’s orders and rebuilds any one’s receipt from its id:
solver = sa.quip.SolverQuip.for_network("aglais")
print(solver.list_orders())
receipt = solver.get_receipt(20)
print(receipt.status())
[24, 23, 21, 20]
{'state': 'finalized', 'order_id': 20, 'chain_status': 'Expired', 'created_at': 1898, 'first_solution_at': 1904, 'effective_expiry': 1914, 'current_block': 2219, 'solution_count': 2}
chain_status is Expired: the chain marked the order expired at its
effective expiry. It turns Closed once someone claims it, and
print(receipt) then shows the Winner row as claimed. A closed
order drops out of list_orders(), which lists only orders not yet
closed, but its receipt still rebuilds from the id. Orders 21 and 22
were placed for the examples on Job Orders. Orders 22
and 25 are already missing: they went unanswered and were reclaimed. A
receipt also lists the solvers that answered and their raw spin vectors,
shows the settlement, and reclaims an unanswered order with
receipt.reclaim().
From a shell, xquad quip --network aglais orders lists the same
orders, one line each, and xquad quip --network aglais status 20
prints the receipt box; see Orders.
A receipt does not hold your model, so it cannot decode an answer into a
tour. Decoding needs the JobOrder from the session that submitted it.
From a new session, the deprecated
solver.query(order_id, model, topology="native") still returns a
decoded SolverResult, with the same topology the order was proposed
with; it warns and is removed in v0.6.0 (see Deprecated
APIs).
What Native Mode Gives Up
A miner that cannot embed an arbitrary graph, such as a QPU-backed one,
cannot answer a native order. The simulated-annealing miners on aglais
answered every open job on this page.
Job Orders
SolverQuip.solve() proposes a job, waits for it and decodes the answer
in one call. create_order() splits that call into steps: it returns a
draft JobOrder that you can inspect, change, price and submit, and a
submitted order leaves a receipt that follows it on chain from any
session. Every option is checked against the chain’s limits before
anything is signed, so a refused option costs nothing.
This page covers the order surface. Quip Network
covers installation, configuration, encoding, cost and the error
classes, and Your First Quip Job walks one order
through end to end. The examples import from xquad.sa.quip;
xqsa.quip exports the same names. Every output on this page was
captured on aglais at specVersion 119 on 2026-10-15, from the
16-variable TSP model the tutorial builds, unless it says otherwise.
Orders
from xquad import sa
solver = sa.quip.SolverQuip.for_network("aglais", topology="native")
order = solver.create_order(model, deadline_blocks=200)
print(order)
print(order.quote())
order.submit().wait()
print(order.receipt())
create_order(model, **options) places the model and returns a draft.
It takes ten options. The first six default to the client’s values (see
Installation and
Configuration);
mapping and the three quality floors exist per order only:
| Option | Default | Meaning |
|---|---|---|
reward | The client’s reward, else QUIP_REWARD, else MinReward | Planck reserved at proposal and paid to the winners |
access | AccessOpen() | Who may answer; see Access |
resolution | "SingleBest" | How the reward is split; see Payout rules |
deadline_blocks | 100 | Blocks the order stays open, from 10 to MaxDeadlineBlocks |
block_wait | 10 | Blocks the order keeps accepting answers after the first, from 0 to MaxBlockWait |
topology | The client’s topology | A registered topology hash, or "native" |
mapping | None | An explicit variable-to-node placement; not allowed with "native" |
max_energy, min_solutions, min_diversity | None | See Quality floors |
Any other keyword raises TypeError, including mode= (deprecated) and
delivery= (client-only):
TypeError: unknown order option 'mode'; expected one of access, block_wait, deadline_blocks, mapping, max_energy, min_diversity, min_solutions, resolution, reward, topology
reward, deadline_blocks and block_wait must be integers
(TypeError: reward must be an int, not str). A raw chain dict for
access or resolution raises
TypeError: access: raw chain values are not accepted.
States
An order is in one of five states, or the deprecated unconfirmed.
status()["state"] reports it, and the order box shows it in its
title.
| State | Meaning |
|---|---|
draft | Built by create_order(); nothing is on chain. Only a draft accepts set(), quote() and a fresh submit(); a sending order is settled first, and submit() resumes it |
sending | submit() sent the signed propose_job but has not settled it: the proposal may still land. The order keeps its signed bytes and nonce, pickling included, and is never signed again while they can land; see Settling a send |
submitted | Proposed, and the block holding it is finalized; the order has an id and a receipt |
finalized | The chain head reached the order’s effective expiry (see Job Lifecycle); wait() and status() move a submitted order here |
failed | propose_job was included in a finalized block but its dispatch failed: the fee was paid and no order was placed |
unconfirmed | Deprecated, and no longer produced since v0.4.5. An order pickled in this state by xquad 0.4.3 or 0.4.4 loads with a DeprecationWarning and is never sent again; see Deprecated APIs |
Calling set(), quote() or submit() on any order other than a draft
or a sending one raises QuipSubmissionError:
QuipSubmissionError: order 20 is finalized; set() works only on a draft order
Changing a draft
set(**options) takes the same options as create_order(). It re-runs
every check over the whole configuration and leaves the order unchanged
if any check refuses, so it also repeats the warnings for options you
did not touch. It places the model again only when topology or
mapping changes. Any change drops the cached signature, so the next
quote() or submit() signs afresh. options() returns all ten
options as a dict, and each is also a read-only property
(order.reward, order.access, order.max_energy and so on).
Quote and submit
quote() signs the order’s propose_job extrinsic once and caches it,
then prices it: the fee from the chain’s payment_queryInfo, the
account balance and the head block, all re-read on every call. Nothing
is sent and no nonce is consumed. It returns a JobQuote; Cost
Model lists its fields.
submit() sends the cached extrinsic, signing first if the order was
never quoted. Before sending, it quotes again and runs the autoconfirm
and autofund gates, as solve() does. Once the proposal is sent, it
ends in one of three states:
submitted: the order is in a finalized block, andsubmit()returns the order.failed: the dispatch failed, andsubmit()raisesQuipSubmissionError("propose_job 0x... failed on chain in block 0x...: <error>. The fee was paid and no order was placed.").sending: the send could not be settled, andsubmit()raisesQuipSubmitIncompleteError; see Settling a send.
A proposal the node refuses, or one proven never to have reached the
chain, leaves the order a draft, and submit() raises
QuipSubmissionError; submitting it again signs afresh.
A signature carries the account’s nonce. When another order from the
same account is sent between quote() and submit(), the nonce has
moved, so submit() signs again. Two drafts quoted together and then
submitted show it:
a = solver.create_order(model, resolution=sa.quip.ResolutionTopNEqual(2))
b = solver.create_order(model, access=sa.quip.AccessAllow(accounts=[me]), deadline_blocks=10, block_wait=0)
a.quote(), b.quote()
a.submit()
b.submit()
proposed Quip job: order_id=21 spec_id=0x8f46...67f0 reward=1000000000000 planck (block 0x2114...c0fa)
account nonce moved from 1 to 2 since signing; signing again
proposed Quip job: order_id=22 spec_id=0x8f46...67f0 reward=1000000000000 planck (block 0x2a8b...1752)
(Log lines from the xqsa.quip logger at INFO, hashes shortened.) If
the nonce moves again while submit() signs, it refuses rather than
race the other client:
QuipSubmissionError: account nonce moved twice while submitting; another client is sending from this account. Nothing was sent.
submit() logs the quote box at INFO on the xqsa.quip logger, and
also prints it to stderr when stderr is a terminal.
Settling a send
Every proposal is signed mortal: the chain accepts it only within
inclusion_window_blocks of the finalized block it was signed at, and
never after. The window is a client setting, not an order option.
SolverQuip and for_network() take 64 to 2048 blocks, rounded up to
the next power of two, so inclusion_window_blocks=100 gives 128; a
value outside that range raises QuipOrderOptionError
(inclusion_window_blocks=4096 must be from 64 to 2048), and one that
is not an integer raises TypeError, both before connecting. The
default, DEFAULT_INCLUSION_WINDOW_BLOCKS, is 64 blocks, about 6.4
minutes. A longer window rides out more congestion or a longer outage,
but a send that never lands takes longer to be proven dead. The quote
box shows the window in its Window row (included within 64 blocks),
and a draft’s status()["inclusion_window_blocks"] reports it.
quote() signs once, but submit() signs again when the account nonce
has moved or half of the cached bytes’ window has passed, so a quote
left sitting never sends bytes that could expire before they land. It
refuses to sign at all while finality lags the best block by half the
window or more:
QuipSubmissionError: finality lags 40 blocks behind the best block, at least half the 64-block inclusion window; nothing was sent, try again once it catches up.
submit() reads the outcome from finalized state alone. The order
becomes submitted, with its order id, or failed once the block
holding the proposal is finalized, so submit() returns a few blocks
after the proposal is included, not at inclusion. A failed dispatch is
read from that block’s events, as System.ExtrinsicFailed: <error>.
While the proposal may still land, submit() sends the same bytes
again, so a node that lost them learns them again; the bytes carry one
hash and one nonce, so they land once at most. A lost connection is
reopened with backoff. Settling reads past blocks’ state, so the node
must keep full state history (--state-pruning=archive); the named
network presets do.
When submit() cannot settle the send by the end of its window, because
no node could be reached or finality stalled, it raises
QuipSubmitIncompleteError and leaves the order sending. It stops at
once, with the same error, when the block that spent the nonce or its
events cannot be decoded, when a reconnect reaches another chain or a
node whose runtime metadata this client cannot read, or when the node
has discarded the state it needs. The error is a QuipSubmissionError,
so existing handlers still catch it. It carries order (None after
pickling, since the order holds a live connection), extrinsic_hash,
nonce and window_end, the last block that can include the proposal;
the specific cause is its __cause__. The message ends with what to do.
This example is illustrative, built from the message in
xqsa/quip/client.py, not captured:
QuipSubmitIncompleteError: propose_job 0x5e0c...81d4 (nonce 3) was sent but is not settled (<the last error>). Its window ends at block 4160. The order is sending: calling submit() again resumes it; it never places a second order.
Calling submit() on a sending order resumes it. It settles the
bytes already sent, up to the end of their window, and signs nothing. If
they landed, the order is submitted (or failed, raised as above). If
they are proven never on chain, the order is a draft again and is
quoted, gated, signed and sent as a new one. Either way it never places
a second order:
from xquad import sa
try:
order.submit()
except sa.quip.QuipSubmitIncompleteError as exc:
print(exc.extrinsic_hash, exc.nonce, exc.window_end)
exc.order.submit() # resumes; never places a second order
A KeyboardInterrupt, or any other exception, after the bytes may have
reached the node also leaves the order sending. submit() prints the
hash, nonce and window end to stderr and re-raises the exception
unchanged:
[quip] submit() stopped after propose_job 0x5e0c...81d4 (nonce 3) may have reached the node; its window ends at block 4160. The order is sending: calling submit() again resumes it; it never places a second order.
Before that point the order stays a draft. While an order is sending,
every other method settles it first: status() makes one read without
waiting and reports the state it settles to, or, while it cannot,
state, extrinsic_hash, nonce and window_end. The order box shows
the hash, nonce and window end in its Receipt row. A sending order
pickles with its signed bytes, but copy() and deepcopy() refuse it,
since two live orders must not share one nonce.
Each send spends one account nonce, and the chain includes an account’s
transactions in nonce order. A send signed while an earlier one from the
same account is still waiting in the node’s pool takes the next nonce,
so it cannot land before the earlier one. If the earlier send’s window
ends with it never on chain, its nonce stays unspent: the order settles
back to a draft, and its submit() raises
QuipSubmissionError: propose_job 0x... never reached the chain. The
later send cannot land while that nonce is unspent. Submitting the
earlier draft again signs with that nonce, and once it lands, the later
send can still land within its own window. If nothing spends the nonce
before the later window ends, the later order settles back to a draft
the same way.
Checks before signing
create_order() and set() check each option against the chain’s
limits, which the client reads once when it connects. A refusal raises
QuipOrderOptionError, a QuipSubmissionError whose option attribute
names the option. A warning is a UserWarning; the order is still
built. The limits on aglais are MinReward 1 AGLS,
MaxDeadlineBlocks 1000 and MaxBlockWait 100.
| Option | Refused | Warns |
|---|---|---|
reward | reward=999999999999 planck is below the chain's MinReward of 1000000000000 | |
deadline_blocks | deadline_blocks=1001 must be at least 10 and at most MaxDeadlineBlocks (1000) | deadline_blocks=50: orders shorter than 100 blocks often go unanswered |
block_wait | block_wait=101 must be at least 0 and at most MaxBlockWait (100) | block_wait=100 is not below deadline_blocks=100; the deadline ends the order before the wait for better answers can |
The chain itself accepts a deadline of 0, which expires on arrival. The
short-deadline warning reflects how often aglais answered: an order
with a 1-block deadline was answered 0 times out of 11, 30 blocks 28
times out of 38, and 100 blocks 108 times out of 108. The other options’
checks are in their sections below.
The order box
print(order) renders a 72-column box. Its sections are the network;
the model and where it was placed; the terms (payout, access, floors,
deadline, block wait and reward); and a summary of the latest quote and
the receipt:
╭─ Job order (draft) ──────────────────────────────────────────────────╮
│ Network aglais │
├──────────────────────────────────────────────────────────────────────┤
│ Model 16 variables, 96 couplings, binary │
│ Placed native, 16 spins │
├──────────────────────────────────────────────────────────────────────┤
│ Payout single best │
│ Access open │
│ Floors none │
│ Deadline 100 blocks │
│ Block wait 10 blocks │
│ Reward 1.000000000000 AGLS │
├──────────────────────────────────────────────────────────────────────┤
│ Quote not quoted │
│ Receipt not submitted │
╰──────────────────────────────────────────────────────────────────────╯
On a draft, the Quote row shows the last quote taken and whether the
account can afford it. Once the order has an id, a Paid row replaces
it: the fee the chain charged, read from the order’s
TransactionFeePaid event, the reward, and the quote’s total beside
them for comparison. The quoted fee is an estimate at the head block, so
the two can differ. When the fee cannot be read, the row says
fee unknown. A failed order paid the fee but placed nothing, so its
Paid row shows the fee and the quote with no reward. A sending
order’s outcome is unknown, so a Quoted row shows the quote alone.
Payout rules
The resolution option decides how the reward is split:
"SingleBest"orResolutionSingleBest(), the default: the leader, the solver the chain ranks first, takes the whole reward.ResolutionTopNEqual(n): the bestnranked solvers split it evenly.ResolutionTopNWeighted(n): the bestnsplit it by rank. Withkranked solvers, rankr(0 is best) gets weightk - r; energies decide only the order.
from xquad import sa
order = solver.create_order(model, resolution=sa.quip.ResolutionTopNEqual(2))
n runs from 1 to 32. The rule’s own constructor checks it, so a bad
n raises ValueError
(ResolutionTopNEqual: n must be from 1 to 32, got 33) or TypeError
before any order exists. n=1 warns
ResolutionTopNEqual(1) pays exactly like ResolutionSingleBest(). Any
other value for resolution is refused: with ValueError on the
constructor, and with QuipOrderOptionError on an order:
resolution='TopNEqual' is not a payout rule; pass 'SingleBest' or one of ResolutionSingleBest(), ResolutionTopNEqual(n), ResolutionTopNWeighted(n)
ResolutionTopNWeighted needs runtime 119 or later (see Runtime
compatibility). On a network
below runtime 119, create_order(), set(), solve() and quote()
refuse it before signing (captured on runtime 118):
QuipOrderOptionError: ResolutionTopNWeighted needs runtime 119 or later, and the chain runs runtime 118; older runtimes weight by absolute energy, so a worse answer can take the larger share
A top-N rule with n above 1 and block_wait=0 warns, because the
order closes on its first answer:
resolution=top 3, equal split: with block_wait=0 only the first answer is accepted, so there can be only one winner.
Settlement
The chain ranks solvers by energy, lowest first, with the earlier
submitter first on a tie. Winners are the first n ranked solvers, so
an order with fewer answers than n has fewer winners. A receipt’s
settlement() computes each winner’s share:
TopNEqual: each winner getsreward // k, and rank 0 also gets the remainder. A reward of 10 over 3 winners splits[4, 3, 3].TopNWeighted: withtotal = k * (k + 1) / 2, rankrgets(reward // total) * w + (reward % total) * w // totalfor its weightw, and rank 0 also gets the remainder. A reward of 10 over 3 winners splits[6, 3, 1].
A top-2 order answered by three solvers, two of them tied at the best energy:
print(order.settlement())
{'claimed': False, 'winners': [{'solver': '5Gn1BACWJGtxCnkHpbonhLZJw3ZYF3LzwXEoWjub74E9RPv9', 'energy_milli': -1790000, 'allocation_planck': 500000000000}, {'solver': '5FNGHjGLzoo8CXv4iiX7z71xJycYpTGZUaA37281kZhWoWun', 'energy_milli': -1790000, 'allocation_planck': 500000000000}]}
An allocation is not a payment. One claim_reward extrinsic pays every
winner; until then the receipt shows each as awaiting claim, and
claimed turns True once the chain closes the order. Below runtime
119, settlement() of a TopNWeighted order raises
NotImplementedError, and the receipt prints that message as its
Reward row. The order box and the receipt show the rule as
single best, top 2, equal split or top 2, weighted by rank.
Access
The access option decides who may answer. "Open" or AccessOpen(),
the default, admits every registered solver.
AccessAllow(accounts=[...], types=[...]) admits a solver whose account
is listed or whose declared type is listed:
from xquad import sa
order = solver.create_order(model, access=sa.quip.AccessAllow(types=["QpuDwave"]))
Solver types are self-declared when a solver registers, and the protocol
does not verify them, so a type entry admits any solver that claims it.
On aglais, an order allowlisted to Cpu was answered by our own
solver and three others.
The allowlist is bounded:
- It must name at least one account or type:
AccessAllow: name at least one account or solver type; the chain refuses an empty list(ValueError, from the constructor). An empty list would fail on chain after the fee was paid. - At most 8 types (
ValueError). - At most
MaxBidMinersaccounts, 16 onaglais:AccessAllow lists 17 accounts; the chain's MaxBidMiners is 16(QuipOrderOptionError). - Type names come from the runtime’s
MinerType, onaglaisCpu,Gpu,QpuDwave,QpuIbm,QpuIonq,QpuPasqalandAsic:AccessAllow: unknown solver type 'Fpga'; the chain has Cpu, Gpu, QpuDwave, QpuIbm, QpuIonq, QpuPasqal, Asic(QuipOrderOptionError).
Over either cap the runtime fails with no readable error, which is why
the client refuses first. Duplicate entries are dropped, and accounts,
given as SS58, 0x hex or bytes, are normalised to 0x hex. A bad
value for access raises ValueError on the constructor and
QuipOrderOptionError on an order:
access='Bid' is not an access rule; pass 'Open' or one of AccessOpen(), AccessAllow(accounts=[...], types=[...]).
An allowlisted account that is not a registered solver is never refused, but it warns, naming the account in SS58:
UserWarning: access: 5DL9B7rhMw1cg7kXzqmdj5QZ1V67vBvfYPq2bTEgZfSXiBSJ is not a registered solver, so it cannot answer until it registers
The constructor warns for its default access, and create_order() and
set() warn when they change it. When nobody on the allowlist answers,
the no-answer error says so:
order 22 has no answers; only allowlisted solvers could answer (accounts 5DL9..iBSJ) and none did; once it is final, reclaim its reward with order.reclaim().
A solver that is not on the list and answers anyway is refused on chain
with System.ExtrinsicFailed: QuantumComputeMempool.NotEligibleSolver,
followed by the error’s description when the metadata carries one.
Network coordinators submit to allowlisted orders regardless and pay for
each refused submission, so a block explorer can show many refused
submissions against one allowlisted order: 4 to 142 per order on
aglais.
Quality floors
Three per-order options set a minimum quality an answer must reach. None of them is a client default.
max_energy: an energy ceiling. The chain drops each submitted vector above it.min_solutions: how many vectors each answer must carry. The chain stores exactly that many from each submission.min_diversity: how different those vectors must be, as a fraction of the placed spins.0means no floor.
max_energy is in model units, the same units as result.energy. For a
BINARY model the chain’s energy differs from the model’s by a constant
offset, which the client subtracts before it sends the ceiling; An
Energy Ceiling on the Quip
Network
works through the conversion. A ceiling that moves shows both values in
the order box’s Floors row, here for max_energy=-2 on the model
-2*x0 + x0*x1:
│ Floors energy <= -2 (chain -1.25) │
Refusals (QuipOrderOptionError unless marked) and warnings:
| Option | Refused | Warns |
|---|---|---|
max_energy | A bool or a non-number (TypeError); not finite; too large for a float; outside i64 once converted to milli; below the lowest energy the placed model can reach: max_energy=-1000000.0 is below -5388, the lowest energy the placed model can reach, so no answer could meet it | At or above the highest energy: max_energy=1000000.0 is at or above 7576, the highest energy the placed model can reach, so it filters nothing. On a model whose coefficients were rounded: max_energy=-1: the model's coefficients were rounded to the chain's milli grid, and the chain applies the ceiling to the rounded model, so answers near it can land on either side |
min_solutions | Not an int (TypeError); outside 1 to MaxSolutions (20 on aglais): min_solutions=21 must be at least 1 and at most MaxSolutions (20) | Exactly 1: min_solutions=1: the chain stores only the first vector of each answer, but ranks the answer by its best vector |
min_diversity | Not a number (TypeError); outside 0 to 0.5; not a whole number of thousandths; above what the placed spins allow: min_diversity=0.4 is above 1/3, the most diversity 3 placed spins allow, since a vector and its full flip count as one; positive with min_solutions=1: min_diversity=0.3 needs min_solutions of at least 2 or unset; one solution has no diversity (the chain's InvalidDiversityConfig) |
What the chain does with each floor:
- The ceiling drops each submitted vector above it. A submission is
refused (
InsufficientEnergy) only when none of its vectors is left. - With
min_solutions=k, the chain stores exactly thekmost diverse vectors of each submission. That subset can leave out a solver’s best vector, which is one reasonbest()can fall back (see Reading the answer). - Diversity is the average pairwise Hamming distance over the placed
spins, in thousandths, with a vector and its full flip counted as the
same vector. That is why
nspins cap it atfloor(n/2)/n: a 3-spin model caps at 0.333.
Floors narrow the answers a solver can give, and a small model may have
too few distinct vectors to meet min_solutions or min_diversity at
all, so an order with floors can go unanswered. The no-answer error then
names them: best() in model units, solve() in chain units. The order
box shows floors in model units; the receipt shows them in chain units;
both show none when no floor is set.
Receipts
A submitted order has a receipt, a JobOrderReceipt. It holds only the
order id and the network’s genesis hash, needs no model, and reads the
chain on every call. order.receipt() returns it; on a draft it raises
QuipSubmissionError: this order is a draft; submit() it first. Any
session rebuilds a receipt from the order id:
solver = sa.quip.SolverQuip.for_network("aglais")
print(solver.list_orders())
receipt = solver.get_receipt(20)
print(receipt.status())
[24, 23, 21, 20]
{'state': 'finalized', 'order_id': 20, 'chain_status': 'Expired', 'created_at': 1898, 'first_solution_at': 1904, 'effective_expiry': 1914, 'current_block': 2219, 'solution_count': 2}
get_receipt() raises QuipConnectionError for an id that names no
order
(order 1000000000 not found on-chain (QuantumComputeMempool.JobOrders)).
list_orders(state=None, account=None) returns the ids of an account’s
orders that are not yet closed, newest first: the chain drops an order
from the account’s list once a claim or a reclaim closes it. state is
"submitted", "finalized", "unanswered" (final with no answers, so
reclaim() refunds it) or None, and any other value raises
ValueError; account is SS58, 0x hex or bytes, and defaults to the
signer. It returns at most the newest 32 ids, after the state filter,
and warns when there are more:
showing 32 of N orders on this account. This 32-order page limit was imposed for the old protocol cap of 32 orders per account; please flag it to the xquad team so it can be lifted.
receipt.summary() returns the order’s outcome as one flat dict, the
fields the receipt box shows under its status rows; xquad quip orders
and status --short print its order id, state and leader as one line
(see Orders):
print(solver.get_receipt(20).summary())
{'order_id': 20, 'state': 'final', 'open_until': None, 'answers': 2, 'leader': '5GqG8c8zWHX33ruGJX4FDokxCTFrASVi268einKJY1XnHNYn'}
Its state is open, final (answered, the reward awaiting a claim),
reclaimable (final with no answers, so reclaim() refunds it) or
closed (claimed or reclaimed). open_until is the block an open order
closes at, None otherwise; leader is None with no answers.
Captured on 2026-10-26.
The receipt and the in-session JobOrder share these methods:
| Method | Returns |
|---|---|
status() | state (submitted or finalized, which sticks once reached), order_id, chain_status (the chain’s OrderStatus), created_at, first_solution_at, effective_expiry, current_block and solution_count |
wait() | The receipt (or order) once it is final, polling every poll_interval; raises QuipTimeoutError after the client’s timeout |
solvers() | One entry per solver that answered: solver, ranked_energy_milli, energy_milli, submitted_at, num_solutions. Ranked solvers come first, then the rest by stored energy and then by submission time. energy_milli is the solver’s stored best, which a later resubmission can overwrite |
raw_solutions(solver=None) | {solver: {"energy_milli": ..., "spins": ...}} over the placed chain nodes, not decoded onto the model; KeyError for a solver that did not answer |
settlement() | {"claimed": bool, "winners": [...]}; see Settlement |
reclaim() | The planck refunded; see below |
chain_status and state answer different questions. state is
finalized once the chain head passes the order’s effective expiry,
while chain_status is the chain’s own record: Opened until the
effective expiry, Expired from then, and Closed once a claim or a
reclaim closes the order. The chain expires a bounded number of orders
per block, so when many expire at once an order can read Opened for a
few blocks past its expiry. The deprecated SolverQuip.status() returns
the old keys, status and is_final, in place of state and
chain_status.
wait() raises QuipTimeoutError with the receipt attached, so the
caller can wait again instead of rebuilding it:
from xquad import sa
try:
order.wait()
except sa.quip.QuipTimeoutError as exc:
receipt = exc.receipt
receipt.wait()
QuipTimeoutError drops the receipt when it is pickled, since the
receipt holds a live connection; another process rebuilds it with
get_receipt(exc.order_id).
The receipt box
print(receipt) renders a box with five sections: the network, proposer
and submission block and time; the problem; the terms, with floors in
chain units; the outcome (status, answer count, leader and its energy);
and, under a double rule, the money. The fee is what the chain charged
the proposing extrinsic, tip included, read from the block that placed
the order; when it cannot be read the row says unknown. The Reward
row is reserved, leader so far, reserved, no answers yet,
reclaimed, or unanswered: reclaim with get_receipt(N).reclaim(); an
answered, final order shows one Winner row per winner,
awaiting claim or claimed:
╭─ Job order 20 receipt ───────────────────────────────────────────────╮
│ Network aglais │
│ Proposer 5DL9B7rhMw1cg7kXzqmdj5QZ1V67vBvfYPq2bTEgZfSXiBSJ │
│ Submitted block 1898, 2026-10-15 04:22:24 UTC │
├──────────────────────────────────────────────────────────────────────┤
│ Problem 16 spins, 96 couplings │
│ Spec 0x8f46f3a31321d1d093314fc769c42cbe7a83d71a0b69e6571a0f68 │
│ e2a04067f0 │
├──────────────────────────────────────────────────────────────────────┤
│ Payout single best │
│ Access open │
│ Floors none │
│ Deadline 100 blocks │
│ Block wait 10 blocks │
│ Reward 1.000000000000 AGLS │
├──────────────────────────────────────────────────────────────────────┤
│ Status final │
│ Answers 2 │
│ Leader 5GqG8c8zWHX33ruGJX4FDokxCTFrASVi268einKJY1XnHNYn │
│ Energy -1790 │
╞══════════════════════════════════════════════════════════════════════╡
│ Fee 0.002011933394 AGLS │
│ Winner 1.000000000000 AGLS, awaiting claim │
╰──────────────────────────────────────────────────────────────────────╯
Reclaiming
reclaim() returns an unanswered order’s reserved reward and returns
the planck refunded. It reads the head block, then refuses before
signing, with QuipReclaimRefusedError, in this order:
order N was proposed by <account>, not by this accountorder N is already closed (reclaimed or settled)order N is open until block B (about M min); reclaim after it closesorder N was answered by K solvers; its reward awaits a claim and cannot be reclaimed
If the chain refuses the reclaim, it raises QuipReclaimRefusedError
naming the block and the chain’s error, and the fee is charged. If the
outcome is unknown, it raises
QuipSubmissionError("the reclaim of order N (<extrinsic>) was sent but its outcome is unknown");
a retry is safe, since a reclaim that landed makes the next one refuse
as already closed. A reclaim costs its own transaction fee, about 0.0019
AGLS on aglais.
Only solve(), and the deprecated query(), reclaim on their own: when
an order finalizes with no answers, they reclaim it and raise
QuipJobFailedError, saying whether the reclaim succeeded:
QuipJobFailedError: order 25 finalized with no solutions; only allowlisted solvers could answer (accounts 5DL9..iBSJ) and none did; the reserved reward was reclaimed
JobOrder.wait(), JobOrder.best() and a receipt’s wait() never
reclaim.
Reading the answer
An in-session JobOrder holds the model it was built from, so it
decodes answers onto it:
solution = order.best()
print(solution.energy)
print(solution.solver, solution.leader, solution.energy_milli)
-696
5GqG8c8zWHX33ruGJX4FDokxCTFrASVi268einKJY1XnHNYn True -1790000
best() returns a Solution (importable from xquad.sa.quip, or
xqsa.quip, only) with five fields:
| Field | Meaning |
|---|---|
sample | The vector decoded onto the model, as an XQMX sample |
energy | Its energy recomputed on your model; authoritative |
energy_milli | Its energy on the chain’s milli scale, over the coefficients the order carried |
solver | The account that answered with it |
leader | Whether the chain ranks that account first |
solutions(solver=None) returns every stored answer as a Solution,
best first by recomputed energy with the leader’s vectors first on a
tie. With solver, it returns that solver’s vectors alone, and raises
KeyError for a solver that did not answer. An unanswered order returns
an empty list.
best() returns the leader’s best vector, the answer the chain pays
for, not necessarily the best vector any solver stored. It falls back to
the best stored vector overall, the leader first on a tie, in two cases:
- The ranking cannot be read, or names a solver with no stored answer.
metadata["leader_read"]is thenFalseon asolve()result. - None of the leader’s stored vectors recomputes to the energy the chain
ranked it at.
energy_matches_chainis thenFalse, and the client logs both energies atWARNING. A worse resubmission can overwrite a solver’s stored answer, andmin_solutionscan store a subset without the best vector, so this is not necessarily an encoding fault.
Under a fallback, leader stays True only when the leader’s own
vector is also the best overall. best() reads the chain each time, so
on an open order a later call can return a different answer. It never
reclaims: with no answers, it raises QuipJobFailedError and tells you
to call order.reclaim() once the order is final.
Decoding needs the in-session JobOrder, because a receipt does not
hold the model. From another session, get_receipt(order_id) gives you
the order’s status, solvers, raw vectors, settlement and reclaim, but
not a decoded sample; the deprecated query(order_id, model, ...) is
the only way to decode one there (see Deprecated
APIs).
Energy and Precision
Every backend in this chapter returns the same kind of number, computed the same way, no matter what found the sample: an integer. This page covers how that number is computed and compared, and where precision is lost or preserved as a model moves from an exact integer formula onto real hardware.
The Formula
A sample’s energy with respect to a model is:
$$E = \sum_i \text{linear}[i] \cdot x_i ;+; \sum_{i \le j} \text{quadratic}[i,j] \cdot x_i \cdot x_j$$
identical to the model’s Hamiltonian from
Quadratic Models, evaluated at one
concrete assignment x, and identical to the XQVM ENERGY opcode (see
Energy Evaluation). A verifier program
run through XQVM and xqsa’s own recomputation compute the same
formula two independent ways.
The Precision Contract
SolverResult.energy must equal model.energy(sample)
exactly, an integer-to-integer comparison with zero tolerance for
drift. Every XQMX
coefficient and every variable assignment is an integer, so the energy
formula is a sum of integer products, not a float that merely rounds to
the right answer. It is an exact integer whenever it is representable at
all, and representable is a real condition: XqmxModel.energy checks every
term product and every partial sum against the i64 range and raises
ArithmeticOverflow rather than returning a wrapped number, so a model
whose energy leaves that range has no energy this contract can report.
Solver._recompute_energy()
returns model.energy(sample); every backend in this
chapter uses it instead of trusting whatever float its underlying
library reports. That raw float, where one exists, survives only as
metadata["params"]["raw_energy"], for diagnostics – never as the
authoritative value.
A verifier program never has to take a solver’s word for its own answer.
It recomputes the same integer independently with ENERGY. There are
three outcomes, not two: the comparison holds, the comparison fails, or
ENERGY raises ArithmeticOverflow and the verifier produces no verdict
at all. The third is not a solver disagreement and must not be read as
one – it says the model’s energy left the i64 range on the way to
being computed. XqmxModel.energy is the function ENERGY itself
calls, so the two accumulate in the same sorted key order, check the
same intermediates, and agree on which models fall into it.
A Large Penalty Does Not Corrupt the Model
Choosing a Penalty Weight
argues that a large \(P\) cannot corrupt a model, because energy
differences between feasible assignments survive exactly however large
\(P\) gets. Take that page’s three-node Max-Cut model with a one-hot
penalty \(P \cdot (x_0 + x_2 - 1)^2\) added on top, and score two
feasible assignments and one infeasible one through XqmxModel.energy()
at three wildly different weights:
P= 2 | E(feasible_a)= -8 E(feasible_b)= -6 E(infeasible)= -8 diff= 2
P= 1000 | E(feasible_a)= -1006 E(feasible_b)= -1004 E(infeasible)= -8 diff= 2
P= 2000000000 | E(feasible_a)= -2000000006 E(feasible_b)= -2000000004 E(infeasible)= -8 diff= 2
The difference between the two feasible assignments stays exactly 2
at every weight. The penalty term is 0 on both, so what separates them
is the underlying objective alone, and integer arithmetic never lets it
drift.
The infeasible assignment’s own energy does not move either – it sits
at -8 at every \(P\) above. What moves is the two feasible
energies, and they fall without bound as \(P\) grows. That is the
missing +P constant, not the penalty term itself: the true,
unstorable Hamiltonian adds +P to every assignment that violates the
constraint and +0 to every assignment that satisfies it, so dropping
that constant subtracts a uniform P from every stored energy. On the
infeasible sample the missing +P exactly cancels the penalty’s own
+P, leaving it unchanged; on a feasible sample there is nothing to
cancel, so the stored energy falls by P.
What a Large Penalty Costs Instead
Nothing in the model degrades, but two things outside it do, and both matter to backends in this chapter.
Fixed-precision hardware loses resolution. The Metal engine (SolverGPU(engine="metal")) computes
coefficients in float32 on the GPU, not the integer arithmetic
ENERGY uses – result.energy is still recomputed in exact integers
afterward, but the search that finds the sample runs at float32
precision. The CUDA engine does not share this: its kernels take
const double* throughout, its acceptance draws stay float64, and
result.energy is recomputed the same way as every other backend. See
Local Solvers for the
per-backend split. The rest of this section applies to the Metal engine and
to fixed-precision annealing hardware, not to every GPU backend.
A float32 mantissa carries about seven decimal digits. Once a term in
the combined Hamiltonian is large enough that a difference of 2 no
longer changes the float, the Metal engine’s search cannot distinguish the
two assignments that difference separates, even though ENERGY still
resolves them exactly afterward:
import numpy as np
np.float32(-2000000006) == np.float32(-2000000004) # True -- the 2 vanished, at the Metal engine's width
np.float64(-2000000006) == np.float64(-2000000004) # False -- still resolved, at the CUDA engine's width
np.float32(-1006) == np.float32(-1004) # False -- still resolved
At P = 2000000000 the two feasible assignments above are
computationally indistinguishable to the Metal engine’s float32 search even
though XqmxModel.energy() still tells them apart exactly; at
P = 1000 they are not. The CUDA engine’s float64 search keeps this pair
distinguishable at this scale, though a large enough \(P\) would
eventually exhaust its wider but still finite mantissa too. Neither
GPU backend’s correctness ever suffers – the returned energy is
always the exact integer – but the Metal engine’s search can lose the
signal, at penalty weights where the CUDA engine’s still keeps it visible.
Real annealing hardware rescales, and the Quip network encodes at
fixed precision. A physical D-Wave annealer maps coefficients onto
its analog range with a monotonic, optimum-preserving rescaling, so an
oversized \(P\) is not rejected there – it costs usable range on the
device instead. SolverQuip goes further: it scales every coefficient
by MILLI_SCALE = 1000 into the chain’s i32 fields, so an oversized
coefficient can overflow i32 and raise EncodingError, and one finer
than 1/1000 is rounded to the nearest milli, with the largest rounding
error reported. MAX_NATURAL_COEFFICIENT = 2_147_483 is the
exact bound only for a SPIN model; a BINARY model – the domain every
worked example on this page uses – goes through a basis change first
that shifts the bound in both directions, and can overflow well before
any single coefficient looks anywhere near that large. See
Quip Network for the full
derivation.
A large penalty also raises the barrier between feasible regions, independent of any hardware precision question: a heuristic search such as simulated annealing has to cross that barrier to leave the first feasible region it reaches, and a larger \(P\) makes crossing less likely within a fixed number of sweeps. Sizing \(P\) in practice, and reading a solver’s output to tell which of these failure modes you hit, belongs to Constraints.
An Energy Ceiling on the Quip Network
SolverQuip’s max_energy order option (see Quality
floors) is an energy ceiling, and it is
in model units: the same units as XqmxModel.energy and
result.energy. The chain never sees your model, only its spin fields
and couplings at milli scale, so the client converts the ceiling before
sending it.
For a SPIN model the conversion is a change of scale: the ceiling times
1000, rounded down, so the chain admits exactly the energies at or below
it. A BINARY model also carries a constant. The basis change
s = 2x - 1 (see Quip Network)
turns the model into spin fields and couplings plus an offset that no
spin multiplies, and the chain, which stores only the fields and
couplings, computes energies without it. The client subtracts the offset
first:
chain ceiling (milli) = floor((max_energy - offset) * 1000)
A value within a millionth of a milli of the grid snaps to it before the
floor, so float noise such as -1.2500000000001 does not lower the
ceiling to -1251.
Take E = -2*x0 + x0*x1. Its spin form has fields h0 = -0.75 and
h1 = 0.25, a coupling of 0.25, and an offset of -0.75. The
assignment x0 = 1, x1 = 0 has model energy -2; on the chain, with
spins s0 = +1, s1 = -1, its energy is -0.75 - 0.25 - 0.25 = -1.25.
So max_energy=-2 goes on chain as -1250 milli, and the order box
shows both values:
│ Floors energy <= -2 (chain -1.25) │
The chain applies the ceiling to the model it holds. When the model’s
coefficients were rounded to the milli grid (see What a Large Penalty
Costs Instead), the chain’s
energies differ slightly from yours, so an answer near the ceiling can
land on either side of it, and create_order() warns. A ceiling below
the lowest energy the placed model can reach is refused, and one at or
above the highest warns that it filters nothing; Quality
floors lists every check.
Examples
Graph problems
Examples that encode graph cuts, colouring, covers, independent sets, and tours.
- Max-Cut – Find a 2-colour partition of a weighted graph that maximises the cut weight.
- Graph Coloring – Assign colours to graph nodes so adjacent nodes do not share a colour.
- Maximum Independent Set – Select the largest subset of graph nodes with no selected edge between them.
- Vertex Cover – Select the smallest vertex subset that covers every graph edge.
- Travelling Salesman Problem – Find the shortest Hamiltonian tour through a random symmetric distance matrix.
Selection and packing
Examples that select subsets, cover demands, pack bins, and balance integer weights.
- Knapsack – Select items that maximise value while respecting a capacity constraint.
- Bin Packing – Pack items into the minimum number of fixed-capacity bins.
- Set Cover – Select the minimum set collection whose union covers the universe.
- Weighted Set Cover – Select sets with capacities to cover element demands at minimum cost.
- Number Partition – Split positive integers into two subsets with nearly equal sums.
- Portfolio Optimization – Select a fixed-size portfolio while penalising higher-order risk interactions.
- Portfolio Rebalance – Choose signed integer asset weights against a risk matrix and a budget.
Satisfiability and higher-order
Examples that reduce clauses and higher-order pseudo-Boolean objectives to quadratic models.
- Max-3-SAT – Find the assignment that satisfies the maximum number of 3-literal clauses.
- Cubic Optimization – Minimise a cubic pseudo-Boolean objective through HOBO degree reduction.
- Quartic Optimization – Minimise a degree-4 pseudo-Boolean objective through two-stage REDUCE chaining.
Using the Examples
examples/ holds fourteen self-contained problems, one directory each,
listed on the gallery page. This page covers what every
directory has in common, how to run one, and how to turn one into a
problem of your own – the task the rest of this chapter does not cover,
because it is not specific to any single example.
What Every Example Shares
Each examples/<name>/ holds a README.md, which this chapter’s other
pages are generated from, and a runner.py. Nothing else –
no .xqasm files. problem.compile() builds the three programs in memory
at runtime as plain strings; see
Three Programs for what those three
programs are and Compiling for what
compile() emits. Every runner.py follows the same shape:
build_problem(...)– anxqcpProblemdefinition: inputs, a model, an objective, constraints, and outputs. This is the part that changes from problem to problem, and the part Modelling covers in full.run(programs, ...)– threeVM.run()calls against the compiled encoder, verifier, and decoder, with onexqsasolver call between the first two. Every example wires calldata and output slots the same way: encoder in, model out; model plus sample plusNin, energy and valid out; sample plusNin, decoded result out.main(...)– anargparseCLI, and a JSON result printed to stdout or written with-o.
examples/manifest.yaml lists every directory once, under a group
(dir, title, blurb), and drives both the gallery grouping and
scripts/gen-example-docs.py’s validation that every listed directory
has a runner.py and a README.md. This page itself is exempt: it is
named in the manifest’s preserved_pages, so make regen-docs leaves
it alone instead of overwriting it from a source README.md that does
not exist.
Running One
uv run python examples/<name>/runner.py --seed 42
Every runner accepts --seed, --solver, and -o/--output. Most
also take --n for problem size; a few split size across two flags
instead (--n plus --bins, --colors, --budget, or --m), or use
--num-elements and --num-sets in place of --n. Check the individual
page’s Usage table for the exact flags.
The compiled programs run on the Rust XQVM through xqffi.
Backends covers what differs by solver
backend. Where a model has several optima of equal energy, the solver
returns one of them, and a different optimum decodes to a different
answer even though both are equally correct: a different solver or seed
can change the decoded result without changing valid or energy.
Adapting an Example
Copying and editing an existing runner.py is the fastest way to model
a new problem, and it is what this section walks through concretely:
turning examples/maxcut/runner.py’s Max-Cut into a minimum s-t cut
with two fixed terminals.
Max-Cut splits a graph’s nodes into two groups to maximise the crossing
edge weight, with no constraint on which node ends up where.
Quadratic Models
derives its formulation; build_problem’s inner loop is the part that
matters here:
with problem.range(0, edge_count) as e:
offset = e * 3
i = problem.stow("i", edges_in.get(offset))
j = problem.stow("j", edges_in.get(offset + 1))
w = problem.stow("w", edges_in.get(offset + 2))
problem.model.linear[i].add(-w)
problem.model.linear[j].add(-w)
problem.model.quadratic[i, j].add(w * 2)
Suppose two specific nodes must end up on opposite sides – node 0 and
node n - 1, say, standing in for two machines a network partition must
separate. That is a different problem, minimum s-t cut with fixed
terminals, and it is two small edits away:
with problem.range(0, edge_count) as e:
offset = e * 3
i = problem.stow("i", edges_in.get(offset))
j = problem.stow("j", edges_in.get(offset + 1))
w = problem.stow("w", edges_in.get(offset + 2))
problem.model.linear[i].add(w) # sign flipped: minimise, not maximise
problem.model.linear[j].add(w)
problem.model.quadratic[i, j].add(w * -2)
# Fix node 0 to side 0 and node n-1 to side 1. 10_000 comfortably
# exceeds the total weight of every edge (at most C(n,2) * 100 for
# this graph), so neither fixed node is ever worth flipping to save
# weight elsewhere.
problem.model.linear[0].add(10_000)
problem.model.linear[num_nodes - 1].add(-10_000)
Everything else stays: the random weighted graph, problem.define_model,
the partition output loop, and run(). The DSL calls used –
model.linear[i].add() and model.quadratic[i, j].add() – are the same
two maxcut/README.md already lists. main() needs one further edit,
covered in the next section: drop the canonicalize_partition(partition)
call, because this variant’s two sides stop being interchangeable.
Running the original and the variant on the same seed and graph
(--n 5 --seed 42 on both):
Unmodified examples/maxcut/runner.py:
{"cut_weight": 354, "energy": -354, "partition": [0, 1, 0, 0, 1], "valid": 1}
The variant:
{"cut_weight": 196, "energy": -9804, "partition": [0, 1, 1, 1, 1], "valid": 1}
The cut drops from 354 to 196, confirming the variant found a
smaller crossing weight rather than reusing the maximiser’s answer, and
partition[0] == 0 with partition[-1] == 1 confirms the two
terminals landed where they were pinned.
Telling a Variant Wrong from a Variant Different
valid alone does not catch a modelling mistake here: Max-Cut and this
variant both declare no constraints, so valid reports only that every
sample value is 0 or 1 – see
Constraints for what a verifier’s
built-in check does and does not cover. energy is not a safe single
number either. Get the terminal bias backwards –
problem.model.linear[0].add(-10_000) and
problem.model.linear[num_nodes - 1].add(10_000), the two signs
swapped – and the run reports the same cut_weight (196) and the
same energy (-9804) as the correct version. A cut and its
complement (every node’s side flipped) always cost the same, so
reversing which two nodes get pinned just picks the other member of
that pair; nothing about the aggregate numbers changes.
partition is where the mistake shows, but only once
canonicalize_partition(partition) is gone from main(). Max-Cut calls
it because a cut and its complement are the same cut, so normalising
node 0 to side 0 throws nothing away. This variant cannot afford
that: the two terminals make the two sides distinguishable, and
canonicalising erases the one fact the pinning added. Building both
versions with that call removed and running each on the same seed and
graph (--n 5 --seed 42):
Correct:
{"cut_weight": 196, "energy": -9804, "partition": [0, 1, 1, 1, 1], "valid": 1}
Swapped:
{"cut_weight": 196, "energy": -9804, "partition": [1, 0, 0, 0, 0], "valid": 1}
cut_weight and energy still agree, for the reason above.
partition[0] and partition[-1] no longer do: 0 and 1 on the
correct run, 1 and 0 on the swapped one. Catching this mistake means
checking the property the edit was supposed to guarantee – that the two
terminals sit on the sides they were pinned to – against a sample
nothing downstream has normalised. The same habit generalises past this
one example: name the specific property an edit is supposed to produce,
and check that property against output no later step has adjusted, not
just valid and a plausible-looking energy.
Max-Cut
Source: examples/maxcut/README.md
Find a 2-colour partition of a weighted graph that maximises the total weight of edges crossing the partition.
QUBO formulation
- Input:
num_nodes(int),edges(list of flat(i, j, w)triples,3*|E|entries) - Model:
nbinary variables, one per node.x[v] in {0, 1}selects the side of the cut. - Objective: for each edge
(i, j, w), add-w*(x_i + x_j)and+2w*x_i*x_j. Minimising this minimises-sum w*[x_i != x_j], i.e. maximises the cut.
DSL methods used
problem.input()– declare typed calldata inputsproblem.define_model()– allocate binary XQMX modelproblem.stow()– bind intermediate computations to named registersproblem.range()– emit RANGE loopsmodel.linear[i].add()– accumulate linear bias on variable imodel.quadratic[i, j].add()– accumulate quadratic coupling between variables i and jproblem.output()– declare typed output slotsproblem.sample.getline()– read a row from the sample bitstring
Pipeline overview
- CP (
xqcp) – build a random weighted complete graph, declare binary variables (one per node), and add linear/quadratic QUBO terms per edge. - Assemble –
.xqasmtext to bytecode viaxquad.asm - Encode – run encoder on chosen XQVM to produce the XQMX model
- Sample – solver runs SA/QPU/GPU over the model
- Verify – verifier checks the sample is binary, then computes energy; this problem declares no constraints for it to check
- Decode – decoder extracts the 2-colour partition
Usage
uv run python examples/maxcut/runner.py --seed 42
uv run python examples/maxcut/runner.py --n 6 --seed 7 -o /tmp/mc.json
| Flag | Default | Description |
|---|---|---|
--n | 5 | Number of nodes in the complete graph |
--solver | cpu | Solver backend (see Choosing a solver) |
--seed | 42 | Random seed |
-o | stdout | Write JSON result to file |
Choosing a solver
Solver selection and install extras are the same for every example: see
Using the Examples and
Solving Overview. The default is cpu, and a
non-default solver will not reproduce the canonical result.
Canonical output
example-smoke validates that the runner produces valid == 1 with
--seed 42 --solver cpu. The smoke test is invariant-based –
it checks validity, not exact output.
Graph Coloring
Source: examples/graph_coloring/README.md
Assign one of C colors to each node of an undirected graph such that no two adjacent nodes share the same color (proper C-coloring).
QUBO formulation
- Input: number of nodes N, number of colors C, edge list
- Model: N categorical variables over C cases.
Domain.CATEGORICALrecords that as an N x C binary grid,x[v,c] = 1if node v gets color c, with a one-hot row per node. - Objective: none. The problem is pure constraint satisfaction: a colouring is scored only by its constraint violations. A valid C-colouring scores
-penalty * N, not0, because XQMX has no constant-term field and each satisfied ONEHOTR stores-penaltyrather than0. That is-1000at the--n 5, penalty200defaults. The colour count defaults to4because a G(5, 0.5) graph routinely contains a 4-clique – the default seed’s does – and a 4-clique has no 3-colouring, so--colors 3would make the canonical instance unsatisfiable. See Constraints. - Constraints:
- One-hot per node:
sum_c x[v,c] = 1(ONEHOTR, penalty 200) – implied by the domain, not written - Exclusion per (edge, color):
x[u,c] + x[v,c] <= 1(EXCLUDE, penalty 200)
- One-hot per node:
Encoding strategy
The one-hot row per node is what makes a categorical variable categorical, so
define_model(domain=Domain.CATEGORICAL, k=C, penalty=200) emits it: the grid
and one ONEHOTR per row, through the same record paths a hand-written loop
would use. The compiled program is what this example emitted before the
domain existed.
EXCLUDE is applied per (edge (u,v), color c) pair via a nested range loop.
The 2D coordinates (u, c) and (v, c) are resolved to flat indices using
IDXGRID: u * num_colors + c and v * num_colors + c.
Reading the answer back is sample.case(v), a ROWFIND for the 1 in node v’s
row. ROWFIND returns -1 where a row holds no 1, which is the uncoloured
sentinel this example used to produce in host code.
DSL methods used
problem.define_model(size=N, domain=Domain.CATEGORICAL, k=C, penalty=200)– the grid and its one-hot rowsmodel.apply_exclude((u, c), (v, c), penalty)– EXCLUDE per edge per colorsample.case(node)– the case a node took, or-1
Pipeline overview
- CP (
xqcp) – generate a random graph, declare N categorical variables over C cases, and add EXCLUDE constraints per (edge, color) pair. - Assemble –
.xqasmtext to bytecode viaxquad.asm - Encode – run encoder on chosen XQVM to produce the XQMX model
- Sample – solver runs SA/QPU/GPU over the model
- Verify – verifier checks the sample is binary, the one-hot row sums, and the per-edge exclusions, then computes energy
- Decode – decoder reads each node’s case with ROWFIND, giving one color index per node
Usage
uv run python examples/graph_coloring/runner.py --seed 42
uv run python examples/graph_coloring/runner.py --n 6 --colors 4
| Flag | Default | Description |
|---|---|---|
--n | 5 | Number of nodes |
--colors | 4 | Number of colors |
--solver | cpu | Solver backend (see Choosing a solver) |
--seed | 42 | Random seed |
-o | stdout | Write JSON result to file |
Choosing a solver
Solver selection and install extras are the same for every example: see
Using the Examples and
Solving Overview. The default is cpu, and a
non-default solver will not reproduce the canonical result.
Canonical output
example-smoke validates that the runner produces valid == 1 with
--seed 42 --solver cpu. The smoke test is invariant-based –
it checks validity, not exact output.
Maximum Independent Set
Source: examples/max_independent_set/README.md
Find the largest subset of nodes in an undirected graph such that no two selected nodes share an edge.
QUBO formulation
- Input: number of nodes N, edge list
- Model: N binary variables.
x_i = 1if node i is in the independent set. - Objective: minimise
-sum(x_i)(maximise set size) - Constraints: per edge (i,j):
x_i + x_j <= 1(SLACK + EQUALITY)
Each edge inequality is encoded via SLACK + EQUALITY. A single binary slack
variable s (capacity = 1) converts x_i + x_j <= 1 into the equality
x_i + x_j + s = 1, and EQUALITY adds the penalty P*(x_i + x_j + s - 1)^2.
Slack variable indices start at num_nodes and are allocated one per edge.
DSL methods used
problem.vec()– allocate untyped vector registers for indices and coefficientsproblem.slack(indices, coeffs, start_index, capacity)– append one slack entry per edgemodel.apply_equality(indices, coeffs, target, penalty)– EQUALITY constraint
Pipeline overview
- CP (
xqcp) – generate a random graph, declare binary variables (one per node), and encode each edge independence constraint via SLACK + EQUALITY. - Assemble –
.xqasmtext to bytecode viaxquad.asm - Encode – run encoder on chosen XQVM to produce the XQMX model
- Sample – solver runs SA/QPU/GPU over the model
- Verify – verifier checks the sample is binary and that the independence constraints hold, then computes energy
- Decode – decoder extracts the selected nodes
Usage
uv run python examples/max_independent_set/runner.py --seed 42
uv run python examples/max_independent_set/runner.py --n 7
| Flag | Default | Description |
|---|---|---|
--n | 5 | Number of nodes |
--solver | cpu | Solver backend (see Choosing a solver) |
--seed | 42 | Random seed |
-o | stdout | Write JSON result to file |
Choosing a solver
Solver selection and install extras are the same for every example: see
Using the Examples and
Solving Overview. The default is cpu, and a
non-default solver will not reproduce the canonical result.
Canonical output
example-smoke validates that the runner produces valid == 1 with
--seed 42 --solver cpu. The smoke test is invariant-based –
it checks validity, not exact output.
Vertex Cover
Source: examples/vertex_cover/README.md
Find the minimum subset of vertices such that every edge in an undirected graph has at least one endpoint in the subset.
QUBO formulation
- Input: number of nodes N, edge list
- Model: N binary variables.
x_v = 1if vertex v is in the cover. - Objective: minimise
sum(x_v) - Constraints: per edge (i,j):
x_i + x_j >= 1(ATLEAST with k=1)
The at-least-1 constraint is encoded directly with ATLEAST. For each edge,
ATLEAST allocates one slack variable at model.size and adds the penalty
P*(x_i + x_j - 1 - s)^2, where s in {0,1} accounts for the case when
both endpoints are selected (sum = 2).
DSL methods used
problem.vec()– allocate a vector register for the two endpoint indices per edgemodel.apply_atleast(indices, k, penalty)– ATLEAST constraint with k=1
Pipeline overview
- CP (
xqcp) – generate a random graph, declare binary variables (one per vertex), and encode per-edge coverage constraints via ATLEAST. - Assemble –
.xqasmtext to bytecode viaxquad.asm - Encode – run encoder on chosen XQVM to produce the XQMX model
- Sample – solver runs SA/QPU/GPU over the model
- Verify – verifier checks the sample is binary and that every edge is covered, then computes energy
- Decode – decoder extracts the selected vertices
Usage
uv run python examples/vertex_cover/runner.py --seed 42
uv run python examples/vertex_cover/runner.py --n 7
| Flag | Default | Description |
|---|---|---|
--n | 5 | Number of nodes |
--solver | cpu | Solver backend (see Choosing a solver) |
--seed | 42 | Random seed |
-o | stdout | Write JSON result to file |
Choosing a solver
Solver selection and install extras are the same for every example: see
Using the Examples and
Solving Overview. The default is cpu, and a
non-default solver will not reproduce the canonical result.
Canonical output
example-smoke validates that the runner produces valid == 1 with
--seed 42 --solver cpu. The smoke test is invariant-based –
it checks validity, not exact output.
Travelling Salesman Problem
Source: examples/tsp/README.md
Find the shortest Hamiltonian tour through N cities given a random symmetric distance matrix.
QUBO formulation
- Input:
num_cities(int),distance_matrix(list, flat upper triangle,n*(n-1)/2entries) - Model: an
n x nbinary grid.x[i, p] = 1means cityiis at tour positionp. - Objective: sum of distances between consecutive positions in the tour.
- Constraints: one-hot row (each city at exactly one position) and one-hot column (each position holds exactly one city), both with penalty 100.
Hamiltonian
The Hamiltonian is the sum of three terms:
$$H = H_{\text{dist}} + H_{\text{row}} + H_{\text{col}}$$
\(H_{\text{dist}}\) is the tour length. For every pair of cities \(i < j\) and
every position \(p\), a term fires whichever direction the tour visits them
in – city i at p and city j at the next position, or the reverse:
$$H_{\text{dist}} = \sum_{i<j} \sum_{p=0}^{n-1} d_{ij} \left( x_{i,p}, x_{j,(p+1) \bmod n} + x_{j,p}, x_{i,(p+1) \bmod n} \right)$$
\(H_{\text{row}}\) and \(H_{\text{col}}\) are the one-hot constraints
apply_onehot_row and apply_onehot_col add, at penalty \(P = 100\):
$$H_{\text{row}} = P \sum_{i=0}^{n-1} \left( \sum_{p=0}^{n-1} x_{i,p} - 1 \right)^2, \qquad H_{\text{col}} = P \sum_{p=0}^{n-1} \left( \sum_{i=0}^{n-1} x_{i,p} - 1 \right)^2$$
XQMX has no field for a constant term. Each satisfied one-hot constraint
drops its +P and leaves -P in the stored model instead of 0 – see
Constraints.
\(H_{\text{row}}\) forces each city onto exactly one position; \(H_{\text{col}}\)
forces each position to hold exactly one city. Both terms are zero as
written on a valid tour, so they never change which valid tour is
shortest – but because of the dropped constant above, what a satisfied
constraint actually contributes is -P, not 0. That is the
-2 * P * n = -800 term in the worked example below. A violation (a
city visiting two positions, or a position holding two cities) still
costs more than a satisfied constraint.
Whether that makes every invalid grid’s energy exceed every valid
tour’s depends on \(P\) relative to the tour-length difference a
violation can buy back.
Constraints
gives the general criterion. At \(P = 100\) it holds for this
seed-42, 4-city instance. The best invalid grid scores -581, 6
above the worst valid tour’s -587. It does not hold at every instance
where \(P = 100\) is used. At --n 5 --seed 7, the second command
under Usage below, an invalid grid scores -770, below 40 of the
120 valid tours.
DSL methods used
problem.input()– declare typed calldata inputsproblem.define_model()– allocate binary 2D grid XQMX modelproblem.stow()– bind intermediate computations to named registersproblem.range()– emit RANGE loopsmodel.quadratic[(city_i, pos), (city_j, pos)].add()– accumulate quadratic coupling using 2D grid coordinatesmodel.apply_onehot_row()– ONEHOTR constraint per citymodel.apply_onehot_col()– ONEHOTC constraint per positionproblem.output()– declare typed output slotsproblem.sample.colfind()– find the row index with value 1 in a given column
Pipeline overview
- CP (
xqcp) – build a random symmetric distance matrix, declare ann x nbinary grid, and add quadratic distance terms plus one-hot row/column constraints. - Assemble –
.xqasmtext to bytecode viaxquad.asm - Encode – run encoder on chosen XQVM to produce the XQMX model
- Sample – solver runs SA/QPU/GPU over the model
- Verify – verifier checks the sample is binary and the one-hot row and column constraints, then computes energy
- Decode – decoder extracts the tour as a sequence of city indices
Steps 3-6 are three independent XQVM programs plus a solver call in between, sharing data only through calldata and outputs:
flowchart LR
ENC[Encoder] -->|model| SLV[xqsa solver]
ENC -->|model| VER[Verifier]
SLV -->|sample| VER
SLV -->|sample| DEC[Decoder]
VER -->|energy, valid| OUT[Host program]
DEC -->|tour| OUT
The encoder’s calldata is num_cities, distance_matrix; the verifier’s is
model, sample, num_cities; the decoder’s is sample, num_cities. See
Three Programs for why the
split exists.
Worked example
uv run python examples/tsp/runner.py --n 4 --seed 42 builds this
4-city instance (edge labels are distance_matrix, indexed by the
upper-triangle position of each city pair):
graph LR
0 ---|82| 1
0 ---|15| 2
0 ---|95| 3
1 ---|4| 2
1 ---|36| 3
2 ---|32| 3
The runner returns this tour:
{
"_note": "canonical CI golden",
"_seed": 42,
"energy": -650,
"n": 4,
"tour": [0, 2, 1, 3],
"tour_distance": 150,
"valid": 1
}
tour_distance is d(0,2) + d(2,1) + d(1,3) + d(3,0) = 15 + 4 + 36 + 95 = 150, matching \(H_{\text{dist}}\) for a valid tour. energy is
H_{\text{dist}} + H_{\text{row}} + H_{\text{col}}: on a valid tour, each
of the 4 rows and 4 columns contributes its one active variable’s -P
linear term and nothing from the quadratic term (no two variables in the
same row or column are both 1), for -P * n per constraint family, so
energy = tour_distance - 2 * P * n = 150 - 2 * 100 * 4 = -650, matching
the run.
Usage
uv run python examples/tsp/runner.py --seed 42
uv run python examples/tsp/runner.py --n 5 --seed 7 -o /tmp/tsp.json
| Flag | Default | Description |
|---|---|---|
--n | 4 | Number of cities |
--solver | cpu | Solver backend (see Choosing a solver) |
--seed | 42 | Random seed |
-o | stdout | Write JSON result to file |
Choosing a solver
Solver selection and install extras are the same for every example: see
Using the Examples and
Solving Overview. The default is cpu, and a
non-default solver will not reproduce the canonical result.
Canonical output
example-smoke validates that the runner produces valid == 1 with
--seed 42 --solver cpu. The smoke test is invariant-based –
it checks validity, not exact output.
Knapsack
Source: examples/knapsack/README.md
The 0/1 Knapsack problem: given N items with integer weights and values, select a subset maximising total value subject to a weight capacity constraint.
QUBO formulation
- Input: N item weights and values, capacity W
- Model: N binary variables.
x_i = 1means item i is selected. - Objective: minimise
-sum(v_i * x_i) - Constraints: capacity
sum(w_i * x_i) <= W(SLACK + EQUALITY)
The inequality is encoded via SLACK + EQUALITY. SLACK appends binary slack
variable entries (s_j with coefficients 2^j) to the index and coefficient
vectors, converting the inequality to the equality sum(w_i*x_i) + sum(s_j*2^j) = W.
EQUALITY then adds the penalty term P*(sum(a_k*x_k) - W)^2 to the QUBO.
DSL methods used
problem.vec()– allocate untyped vector registers for indices and coefficientsproblem.slack(indices, coeffs, start_index, capacity)– append slack entriesmodel.apply_equality(indices, coeffs, target, penalty)– EQUALITY constraint
Pipeline overview
- CP (
xqcp) – generate random item weights and values, declare binary variables, and encode the capacity inequality via SLACK + EQUALITY. - Assemble –
.xqasmtext to bytecode viaxquad.asm - Encode – run encoder on chosen XQVM to produce the XQMX model
- Sample – solver runs SA/QPU/GPU over the model
- Verify – verifier checks the sample is binary and that the capacity constraint holds, then computes energy
- Decode – decoder extracts the item selection
Usage
uv run python examples/knapsack/runner.py --seed 42
uv run python examples/knapsack/runner.py --n 6
| Flag | Default | Description |
|---|---|---|
--n | 5 | Number of items |
--solver | cpu | Solver backend (see Choosing a solver) |
--seed | 42 | Random seed |
-o | stdout | Write JSON result to file |
Choosing a solver
Solver selection and install extras are the same for every example: see
Using the Examples and
Solving Overview. The default is cpu, and a
non-default solver will not reproduce the canonical result.
Canonical output
example-smoke validates that the runner produces valid == 1 with
--seed 42 --solver cpu. The smoke test is invariant-based –
it checks validity, not exact output.
Bin Packing
Source: examples/bin_packing/README.md
Pack N items with given integer sizes into the minimum number of bins, each with a fixed capacity C.
QUBO formulation
- Input: N item sizes (list), number of bins B, bin capacity C
- Model:
(N + 1) * Bbinary variables in an(N + 1) x Bgrid. Rows0..N-1are the assignment cells:x[i,b] = 1if item i is placed in bin b. Row N holds one indicator per bin:y[b] = 1if bin b is open. - Objective:
+BIN_COSTon each indicatory[b], so the energy counts the bins the packing opens. A bias spread over the assignment cells instead would be identically N on every feasible packing, because each item lands in exactly one bin, and could not tell a one-bin packing from a three-bin one. - Constraints:
- Assignment per item i:
sum_b x[i,b] = 1(ONEHOTR, penalty 200) - Linking per (i, b):
x[i,b] -> y[b](IMPLIES, penalty 200) - Capacity per bin b:
sum_i s_i * x[i,b] <= C(SLACK + EQUALITY, penalty 100)
- Assignment per item i:
The capacity inequality is encoded by appending binary slack variable entries
to the column index/coefficient vectors, converting it to a weighted equality.
Each bin gets its own slack block: bin b’s entries start at
(N + 1) * B + b * bitlen(C), past every model variable and past every
earlier bin’s block. Sharing one start index across bins would let one bin’s
slack absorb another’s overflow, leaving the capacity constraint
under-constrained.
DSL methods used
model.apply_onehot_row(row, penalty)– ONEHOTR assignment constraintmodel.apply_implies(coord_a, coord_b, penalty)– IMPLIES linking constraintproblem.vec()– allocate untyped vector registers for indices and coefficientsproblem.slack(indices, coeffs, start_index, capacity)– append slack entriesmodel.apply_equality(indices, coeffs, target, penalty)– EQUALITY constraintxq_bitlen(value)– BITLEN, used to size each bin’s slack block
Pipeline overview
- CP (
xqcp) – generate random item sizes, declare an(N + 1) x Bbinary grid, and add ONEHOTR assignment constraints per item, IMPLIES links per cell, and SLACK + EQUALITY capacity constraints per bin. - Assemble –
.xqasmtext to bytecode viaxquad.asm - Encode – run encoder on chosen XQVM to produce the XQMX model
- Sample – solver runs SA/QPU/GPU over the model
- Verify – verifier checks the sample is binary, the per-item assignment, the bin-usage links and the per-bin capacity, then computes energy
- Decode – decoder extracts the bin assignments
Usage
uv run python examples/bin_packing/runner.py --seed 42
uv run python examples/bin_packing/runner.py --n 5 --bins 4
| Flag | Default | Description |
|---|---|---|
--n | 4 | Number of items |
--bins | 3 | Number of bins |
--solver | cpu | Solver backend (see Choosing a solver) |
--seed | 42 | Random seed |
-o | stdout | Write JSON result to file |
Choosing a solver
Solver selection and install extras are the same for every example: see
Using the Examples and
Solving Overview. The default is cpu, and a
non-default solver will not reproduce the canonical result.
Canonical output
example-smoke validates that the runner produces valid == 1 with
--seed 42 --solver cpu. The smoke test is invariant-based –
it checks validity, not exact output.
Set Cover
Source: examples/set_cover/README.md
Given a universe of E elements and a collection of S sets, find the minimum sub-collection whose union equals the universe.
QUBO formulation
- Input: number of elements E, number of sets S, coverage membership matrix (flat list of E*S entries,
covers[e][s] = 1if set s covers element e) - Model: S binary variables.
x_s = 1if set s is selected. - Objective: minimise
sum(x_s) - Constraints: per element e:
sum_{s: covers[e][s]=1} x_s >= 1(ATLEAST with k=1)
For each element, the encoder iterates over all sets and uses a branch to conditionally push only covering set indices into the element’s index vector. ATLEAST then enforces that at least one covering set is selected.
DSL methods used
problem.vec()– allocate a vector register for each element’s covering set indicesproblem.branch(cond, arm, default)– conditional VECPUSH based on coverage membershipmodel.apply_atleast(indices, k, penalty)– ATLEAST constraint with k=1
Pipeline overview
- CP (
xqcp) – generate a random coverage matrix, declare binary variables (one per set), and encode per-element coverage constraints via conditional branching and ATLEAST. - Assemble –
.xqasmtext to bytecode viaxquad.asm - Encode – run encoder on chosen XQVM to produce the XQMX model
- Sample – solver runs SA/QPU/GPU over the model
- Verify – verifier checks the sample is binary and that every element is covered, then computes energy
- Decode – decoder extracts the selected sets
Usage
uv run python examples/set_cover/runner.py --seed 42
uv run python examples/set_cover/runner.py --num-sets 6
| Flag | Default | Description |
|---|---|---|
--num-elements | 4 | Number of elements in the universe |
--num-sets | 5 | Number of sets |
--solver | cpu | Solver backend (see Choosing a solver) |
--seed | 42 | Random seed |
-o | stdout | Write JSON result to file |
Choosing a solver
Solver selection and install extras are the same for every example: see
Using the Examples and
Solving Overview. The default is cpu, and a
non-default solver will not reproduce the canonical result.
Canonical output
example-smoke validates that the runner produces valid == 1 with
--seed 42 --solver cpu. The smoke test is invariant-based –
it checks validity, not exact output.
Weighted Set Cover
Source: examples/weighted_set_cover/README.md
A generalisation of Set Cover where each set s has a coverage capacity cap[s] and each element e has a demand demand[e]. The goal is to select sets of minimum total cost such that the total capacity of covering selected sets meets each element’s demand.
QUBO formulation
- Input: number of elements E, number of sets S, set costs, set capacities, element demands, coverage membership matrix
- Model: S binary variables.
x_s = 1if set s is selected. - Objective: minimise
sum(cost[s] * x_s) - Constraints: per element e:
sum_{s: covers[e][s]=1} cap[s] * x_s >= demand[e](ATLEASTW)
For each element, a branch conditionally pushes (set index, capacity) pairs into per-element index/coefficient vectors, then ATLEASTW enforces the weighted threshold.
DSL methods used
problem.vec()– allocate vector registers for covering set indices and capacitiesproblem.branch(cond, arm, default)– conditional VECPUSH based on coverage membershipmodel.apply_atleastw(indices, coeffs, k, penalty)– ATLEASTW constraint
Pipeline overview
- CP (
xqcp) – generate a random weighted coverage instance, declare binary variables (one per set), and encode per-element weighted demand constraints via conditional branching and ATLEASTW. - Assemble –
.xqasmtext to bytecode viaxquad.asm - Encode – run encoder on chosen XQVM to produce the XQMX model
- Sample – solver runs SA/QPU/GPU over the model
- Verify – verifier checks the sample is binary and that every demand is met, then computes energy
- Decode – decoder extracts the selected sets
Usage
uv run python examples/weighted_set_cover/runner.py --seed 42
uv run python examples/weighted_set_cover/runner.py --num-sets 6
| Flag | Default | Description |
|---|---|---|
--num-elements | 4 | Number of elements in the universe |
--num-sets | 5 | Number of sets |
--solver | cpu | Solver backend (see Choosing a solver) |
--seed | 42 | Random seed |
-o | stdout | Write JSON result to file |
Choosing a solver
Solver selection and install extras are the same for every example: see
Using the Examples and
Solving Overview. The default is cpu, and a
non-default solver will not reproduce the canonical result.
Canonical output
example-smoke validates that the runner produces valid == 1 with
--seed 42 --solver cpu. The smoke test is invariant-based –
it checks validity, not exact output.
Number Partition
Source: examples/number_partition/README.md
Given N positive integers, find a way to split them into two subsets of equal sum (or as close as possible if an exact split does not exist).
QUBO formulation
- Input: N positive integers
a_i - Model: N binary variables.
x_i = 1puts numbera_iin subset A. - Objective: minimise
P * (sum(a_i * x_i) - S/2)^2whereS = sum(a_i)
An exact partition exists when S is even and the penalty evaluates to zero. The QUBO minimiser finds the balanced partition when one exists, or the most balanced split when the total is odd.
DSL methods used
problem.vec()– allocate untyped vector registers for indices and coefficientsmodel.apply_equality(indices, coeffs, target, penalty)– EQUALITY constraint
Pipeline overview
- CP (
xqcp) – generate random positive integers, declare binary variables (one per number), and encode the half-sum equality constraint via EQUALITY. - Assemble –
.xqasmtext to bytecode viaxquad.asm - Encode – run encoder on chosen XQVM to produce the XQMX model
- Sample – solver runs SA/QPU/GPU over the model
- Verify – verifier checks the sample is binary and that the partition constraint holds, then computes energy
- Decode – decoder extracts the subset assignment
Usage
uv run python examples/number_partition/runner.py --seed 42
uv run python examples/number_partition/runner.py --n 8
| Flag | Default | Description |
|---|---|---|
--n | 6 | Number of integers |
--solver | cpu | Solver backend (see Choosing a solver) |
--seed | 42 | Random seed |
-o | stdout | Write JSON result to file |
Choosing a solver
Solver selection and install extras are the same for every example: see
Using the Examples and
Solving Overview. The default is cpu, and a
non-default solver will not reproduce the canonical result.
Canonical output
example-smoke validates that the runner produces valid == 1 with
--seed 42 --solver cpu. The smoke test is invariant-based –
it checks validity, not exact output.
Portfolio Optimization
Source: examples/portfolio_opt/README.md
Select a portfolio of exactly B assets from N candidates to maximise expected return while penalising higher-order risk cross-interactions.
QUBO formulation
- Input: N asset returns, cubic risk interactions
(i, j, k, sigma), budget B - Model: N binary variables.
x_i = 1if asset i is selected. - Objective:
-sum(r_i * x_i) + sum(sigma_ijk * x_i * x_j * x_k)– first term maximises return (minimising its negation), second penalises correlated three-asset risk interactions. - Constraints: budget
sum(x_i) = B(EQUALITY with unit coefficients, penalty 200)
Encoding strategy
Return terms are linear: ADDLINE(i, -r_i) per asset.
Cubic risk terms (i, j, k, sigma) are degree-reduced:
REDUCE(i, j, P_AUX) -> w(Rosenberg enforcement forw = x_i * x_j)ADDQUAD(w, k, sigma)(sigma * w * x_k = sigma * x_i * x_j * x_k)
Budget constraint builds uniform-coefficient index/coeff vecs then calls
EQUALITY with target = B and penalty = 200. EQUALITY is emitted after all
objective (body) actions because it lands in the constraint section.
DSL methods used
model.reduce(var_a, var_b, p_aux)– HOBO degree reduction for cubic risk termsproblem.vec()– allocate index/coefficient vecs for the budget constraintmodel.apply_equality(indices, coeffs, target, penalty)– budget EQUALITY
Pipeline overview
- CP (
xqcp) – generate random returns and cubic risk interactions, declare binary variables, degree-reduce risk terms via REDUCE, and add a budget EQUALITY constraint. - Assemble –
.xqasmtext to bytecode viaxquad.asm - Encode – run encoder on chosen XQVM to produce the XQMX model
- Sample – solver runs SA/QPU/GPU over the model
- Verify – verifier checks the sample is binary, that the budget constraint holds, and that each REDUCE auxiliary equals the product it stands for, then computes energy
- Decode – decoder extracts the selected assets
Usage
uv run python examples/portfolio_opt/runner.py --seed 42
uv run python examples/portfolio_opt/runner.py --n 6 --budget 3
| Flag | Default | Description |
|---|---|---|
--n | 5 | Number of assets |
--budget | 2 | Number of assets to select |
--solver | cpu | Solver backend (see Choosing a solver) |
--seed | 42 | Random seed |
-o | stdout | Write JSON result to file |
Choosing a solver
Solver selection and install extras are the same for every example: see
Using the Examples and
Solving Overview. The default is cpu, and a
non-default solver will not reproduce the canonical result.
Canonical output
example-smoke validates that the runner produces valid == 1 with
--seed 42 --solver cpu. The smoke test is invariant-based –
it checks validity, not exact output.
Portfolio Rebalance
Source: examples/portfolio_rebalance/README.md
Choose a signed integer weight for each asset, trading expected return off against a risk matrix, subject to the weights summing to a budget. Negative weights are short positions, which is the whole reason this example is not a binary select-or-not model.
QUBO formulation
- Input: number of assets N, a return per asset, a symmetric N x N risk matrix flattened row-major
- Model: N integer variables
x_iin[-5, 5], declared withlo=/hi=. The model holdsy_i = x_i - loin{0, ..., 10}; coefficients are written overxandsample.value()shifts back on the way out. - Objective:
-sum(r_i * x_i) + sum_{i <= j} C_ij * x_i * x_j - Constraint:
sum(x_i) = B, as the penaltyP * (sum_i x_i - B)^2 - Energy: shifted twice over, and not comparable with any other example’s. The ranged domain drops the constant that substituting
x = y + loproduces, because XQMX carries no offset field, and the budget square drops its ownP*B^2the way EQUALITY does. Both shifts are uniform across assignments, so argmin is exact even though the number is not the objective’s true value.
Encoding strategy
The budget constraint is written out by hand rather than handed to
apply_equality. Every high-level constraint in the VM expands under
x^2 = x, which holds for binary variables only, so xqcp refuses all of them
off a binary model. Expanded, P * (sum_i x_i - B)^2 is P on each diagonal,
2P on each off-diagonal pair, and -2PB on each linear coefficient. Writing
that square is the same work the HLF would have done, and it is what the open
question about per-domain expansions is about.
The ranged domain then does its own rewriting underneath. Each quadratic write
records w*lo against the linear coefficient of both named indices, because
w * x_i * x_j expands to w*y_i*y_j + w*lo*y_i + w*lo*y_j + w*lo^2 once
x = y + lo is substituted. A write to the diagonal lands both corrections on
the one index, giving the 2*w*lo that squaring asks for. None of that is
visible in the model-building code above.
lo and hi are literals. A runtime lo would want the decoder’s single
calldata scalar, which the output loop bound already spends, and xqcp raises
naming both rather than picking one.
DSL methods used
problem.define_model(size=N, domain=Domain.INTEGER, lo=-5, hi=5)– ranged integer weightsmodel.linear[i].add(w)andmodel.quadratic[i, j].add(w)– the only operations a non-binary model supportssample.value(i)– the weight in the domain it was declared over, rather than the storedy
What this example proves
valid == 1 proves that the domain check, the record-layer shift and the
decode compose on a chosen assignment. It proves nothing about optimisation:
no solver samples an integer model yet, so --solver is accepted, ignored,
and the pipeline runs against a hand-picked weight vector that sits inside the
domain and sums to the budget.
Pipeline overview
- CP (
xqcp) – generate returns and a symmetric risk matrix, declare N ranged integer weights, and write the objective and the budget square as coefficients - Assemble –
.xqasmtext to bytecode viaxquad.asm - Encode – run encoder on chosen XQVM to produce the XQMX model
- Sample – skipped; the runner supplies the assignment itself
- Verify – verifier checks every weight is inside
{0, ..., 10}against thekreplayed fromdefine_model, then computes energy - Decode – decoder reads each weight and adds
lo, giving the signed weights back
Usage
uv run python examples/portfolio_rebalance/runner.py --seed 42
uv run python examples/portfolio_rebalance/runner.py --n 6
| Flag | Default | Description |
|---|---|---|
--n | 5 | Number of assets |
--solver | cpu | Accepted and ignored |
--seed | 42 | Random seed |
-o | stdout | Write JSON result to file |
Canonical output
example-smoke validates that the runner produces valid == 1 with
--seed 42 --solver cpu. The smoke test is invariant-based –
it checks validity, not exact output.
Max-3-SAT
Source: examples/max3sat/README.md
Given M clauses of 3 positive literals over N binary variables, find the assignment that satisfies the maximum number of clauses.
QUBO formulation
- Input: N binary variables, M clauses of 3 positive literals
- Model: N binary variables.
x_v in {0, 1}. - Objective: minimise
sum over clauses of P*(1-x_i)(1-x_j)(1-x_k)
A clause (i,j,k) is violated when all three variables are 0. Expanding the product (dropping the constant term):
P*(-x_i - x_j - x_k + x_i*x_j + x_i*x_k + x_j*x_k - x_i*x_j*x_k)
The cubic term -P*x_i*x_j*x_k is degree-reduced via REDUCE(i, j) -> w,
introducing one auxiliary variable w per clause with Rosenberg enforcement
P_AUX*(x_i*x_j - 2*x_i*w - 2*x_j*w + 3*w). The cubic term becomes the
quadratic term -P*w*x_k.
DSL methods used
model.reduce(var_a, var_b, p_aux)– HOBO degree reduction; returns a RegLoad holding the auxiliary variable index for chaining into quadratic terms
Pipeline overview
- CP (
xqcp) – generate random 3-literal clauses, declare binary variables, and degree-reduce the cubic violation terms via REDUCE. - Assemble –
.xqasmtext to bytecode viaxquad.asm - Encode – run encoder on chosen XQVM to produce the XQMX model
- Sample – solver runs SA/QPU/GPU over the model
- Verify – verifier checks the sample is binary and that each Rosenberg REDUCE auxiliary equals the product it stands for, then computes energy
- Decode – decoder extracts the variable assignment
Usage
uv run python examples/max3sat/runner.py --seed 42
uv run python examples/max3sat/runner.py --n 8 --m 10
| Flag | Default | Description |
|---|---|---|
--n | 6 | Number of Boolean variables |
--m | 8 | Number of clauses |
--solver | cpu | Solver backend (see Choosing a solver) |
--seed | 42 | Random seed |
-o | stdout | Write JSON result to file |
Choosing a solver
Solver selection and install extras are the same for every example: see
Using the Examples and
Solving Overview. The default is cpu, and a
non-default solver will not reproduce the canonical result.
Canonical output
example-smoke validates that the runner produces valid == 1 with
--seed 42 --solver cpu. The smoke test is invariant-based –
it checks validity, not exact output.
Cubic Optimization
Source: examples/cubic_opt/README.md
Minimise a cubic pseudo-Boolean objective over binary variables via single-stage HOBO degree reduction.
QUBO formulation
- Input: N binary variables, M cubic interaction terms
(i, j, k, c) - Model: N binary variables. Linear bias
-1per variable rewards selection, creating tension with the positive cubic terms. - Objective:
sum(c_t * x_i * x_j * x_k) - sum(x_v)
Each cubic term (i, j, k, c) is degree-reduced to quadratic via:
REDUCE(i, j, P_AUX) -> w– allocates auxiliary variable w with Rosenberg enforcementP_AUX*(x_i*x_j - 2*x_i*w - 2*x_j*w + 3*w)ADDQUAD(w, k, c)– addsc*w*x_k = c*x_i*x_j*x_kto the QUBO
DSL methods used
model.reduce(var_a, var_b, p_aux)– single-stage HOBO degree reduction
Pipeline overview
- CP (
xqcp) – generate random cubic interaction terms, declare binary variables with linear bias, and degree-reduce each cubic term via REDUCE. - Assemble –
.xqasmtext to bytecode viaxquad.asm - Encode – run encoder on chosen XQVM to produce the XQMX model
- Sample – solver runs SA/QPU/GPU over the model
- Verify – verifier checks the sample is binary and that each Rosenberg REDUCE auxiliary equals the product it stands for, then computes energy
- Decode – decoder extracts the variable assignment
Usage
uv run python examples/cubic_opt/runner.py --seed 42
uv run python examples/cubic_opt/runner.py --n 5 --m 4
| Flag | Default | Description |
|---|---|---|
--n | 4 | Number of variables |
--m | 3 | Number of cubic terms |
--solver | cpu | Solver backend (see Choosing a solver) |
--seed | 42 | Random seed |
-o | stdout | Write JSON result to file |
Choosing a solver
Solver selection and install extras are the same for every example: see
Using the Examples and
Solving Overview. The default is cpu, and a
non-default solver will not reproduce the canonical result.
Canonical output
example-smoke validates that the runner produces valid == 1 with
--seed 42 --solver cpu. The smoke test is invariant-based –
it checks validity, not exact output.
Quartic Optimization
Source: examples/quartic_opt/README.md
Minimise a degree-4 pseudo-Boolean objective via two-stage REDUCE chaining.
QUBO formulation
- Input: N binary variables, M quartic interaction terms
(i, j, k, l, c) - Model: N binary variables. Linear bias
-1per variable rewards selection, creating tension with the positive quartic terms. - Objective:
sum(c_t * x_i * x_j * x_k * x_l) - sum(x_v)
Each quartic term (i, j, k, l, c) is encoded via two-stage REDUCE:
w = REDUCE(i, j, P_AUX)– introduces auxiliary w; w approximatesx_i*x_j.v = REDUCE(w, k, P_AUX)– introduces auxiliary v; v approximatesw*x_k = x_i*x_j*x_k. Here w is the variable index returned from the first REDUCE.ADDQUAD(v, l, c)– addsc*v*x_l = c*x_i*x_j*x_k*x_lto the QUBO.
Each quartic term allocates 2 auxiliary variables. With M terms, the model grows by 2*M variables beyond the original N.
DSL methods used
model.reduce(var_a, var_b, p_aux)– two chained HOBO degree reductions; the RegLoad returned by the first REDUCE is passed as var_a to the second
Pipeline overview
- CP (
xqcp) – generate random quartic interaction terms, declare binary variables with linear bias, and two-stage degree-reduce each quartic term via chained REDUCE. - Assemble –
.xqasmtext to bytecode viaxquad.asm - Encode – run encoder on chosen XQVM to produce the XQMX model
- Sample – solver runs SA/QPU/GPU over the model
- Verify – verifier checks the sample is binary and that each Rosenberg REDUCE auxiliary equals the product it stands for, then computes energy
- Decode – decoder extracts the variable assignment
Usage
uv run python examples/quartic_opt/runner.py --seed 42
uv run python examples/quartic_opt/runner.py --n 6 --m 3
| Flag | Default | Description |
|---|---|---|
--n | 5 | Number of variables |
--m | 2 | Number of quartic terms |
--solver | cpu | Solver backend (see Choosing a solver) |
--seed | 42 | Random seed |
-o | stdout | Write JSON result to file |
Choosing a solver
Solver selection and install extras are the same for every example: see
Using the Examples and
Solving Overview. The default is cpu, and a
non-default solver will not reproduce the canonical result.
Canonical output
example-smoke validates that the runner produces valid == 1 with
--seed 42 --solver cpu. The smoke test is invariant-based –
it checks validity, not exact output.
Cookbook
The fourteen examples under examples/ each solve one problem –
Knapsack, TSP, Graph Coloring – and each already contains a working
encoding. Nothing in the repository names those encodings as patterns you
can reuse on a problem that is not itself Knapsack or TSP. This chapter
names five recurring shapes instead, each grounded in the example or
examples that embody it and shown as code that actually ran. One further
page covers getting the arithmetic right once a shape is chosen.
This chapter assumes Modelling – it builds
problems with the same xqcp calls that chapter introduces and does not
re-explain them. It also assumes
Quadratic Models for what a penalty
term is and why one exists at all.
The Five Shapes
| Page | Reach for it when | Canonical example |
|---|---|---|
| Permutations | N things need a full ordering: each thing gets exactly one position and each position gets exactly one thing | examples/tsp/ |
| Assignment | N things each pick one of B slots, with no requirement that every slot is used or that N equals B | examples/bin_packing/ |
| Selection Under Budget | Choose a subset of weighted things without a total exceeding a fixed capacity | examples/knapsack/ |
| Mutual Exclusion | Two choices conflict, or one requires another | examples/graph_coloring/, examples/max_independent_set/ |
| Soft vs. Hard Constraints | A rule that must hold sits next to a preference that should hold | examples/portfolio_opt/, examples/max3sat/ |
Permutations and Assignment are the same grid with one constraint family
removed – see Assignment
for the direction that mistake most often runs. Selection Under Budget and
Mutual Exclusion overlap at one boundary: a capacity of exactly 1 on a
pair is the same instruction family EXCLUDE gives you directly, covered
in Mutual Exclusion.
examples/bin_packing/ and examples/graph_coloring/ each compose two of
these shapes in one problem (assignment plus a capacity, and assignment
plus exclusion, respectively) – reading a problem as “which of these
shapes does each part look like” scales to a combined problem the same
way it does to a single-pattern one.
The Arithmetic Page
Integer Scaling is not a shape – every pattern above needs it, sooner or later, while turning real-valued problem data into the integer coefficients every pattern above is written in terms of. Sizing the penalty weight itself, and reading a solver’s output to tell whether a cheap constraint violation bought a good-looking energy, is Constraints’s territory, not a cookbook page of its own.
Where This Chapter Stops
None of these pages re-derives what a one-hot, EXCLUDE, or EQUALITY
constraint expands to in linear and quadratic coefficients – that table
is High-Level Constraints, already
written, and this chapter links to it rather than repeating it. This
chapter is about recognising which instruction family a new problem
needs and what it costs once chosen, not about the instructions
themselves.
Permutations
Assign N things to N positions so that each thing gets exactly one position and each position gets exactly one thing. Recognise it whenever “order” or “visit each once” appears in a problem statement: a tour, or a seating arrangement of N guests into N chairs.
The encoding is an \(N \times N\) binary grid, x[thing, position] = 1
meaning that thing sits at that position, with a one-hot constraint on
every row and every column: ONEHOTR for “this thing takes exactly one
position,” ONEHOTC for “this position holds exactly one thing.” A
feasible assignment is a permutation matrix – exactly one 1 per row and
per column – and the two constraint families together are what force
that shape.
The Shape, Isolated
examples/tsp/ is the one example in this repository that embodies this
pattern: x[city, position] = 1 means a city sits at a tour position, and
the objective adds the distance between consecutive positions’ cities on
top of the same row/column one-hot pair
(TSP, examples/tsp/runner.py). Stripping the
distance objective out leaves the pattern on its own – allocate the grid,
apply both one-hot families, decode with colfind:
from xquad import cp
def build_problem(n: int) -> cp.Problem:
problem = cp.Problem("PurePermutation")
num_items = problem.input("num_items", type=cp.Types.Int)
problem.define_model(size=num_items * num_items, domain=cp.Domain.BINARY,
rows=num_items, cols=num_items)
with problem.range(0, num_items) as row:
problem.model.apply_onehot_row(row, penalty=10)
with problem.range(0, num_items) as col:
problem.model.apply_onehot_col(col, penalty=10)
perm = problem.output("perm", type=cp.Types.Vec)
with problem.range(0, num_items) as position:
perm.append(problem.sample.colfind(col=position, value=1))
return problem
Compiling for n = 3 and running the encoder on the Rust VM, then solving
the result with SolverCPU at seed=42. All six permutations of three
things are tied at -60, so a different seed decodes to a different one and
both lines below still hold:
decoded permutation (thing at each position): [0, 1, 2]
is a permutation of range(N): True
energy: -60
xquad vm check --text accepts the compiled encoder:
ok: permutation_encoder.xqasm (29 instructions)
and xquad vm run --text ... --calldata 3 reproduces the same model
deterministically from the assembled bytecode, independent of the Python
solve above:
outputs:
[0] = Model(XqmxModel { domain: Binary, size: 9,
linear: {0: -20, ..., 8: -20},
quadratic: {(0, 1): 20, (0, 2): 20, ..., (7, 8): 20}, rows: 3, cols: 3 })
Nine linear terms at -20 (one per cell: each cell belongs to one row and
one column, so it picks up -penalty twice) and eighteen quadratic terms at
+20 (every same-row and every same-column pair) – exactly ONEHOTR’s and
ONEHOTC’s expansions from High-Level
Constraints superimposed on the same
grid. examples/tsp/ builds the identical row/column structure and adds
distance coefficients on top; nothing about the permutation half changes
when an objective joins it.
Cost in Variables
The grid is \(N^2\) variables – no slack, no auxiliary variables, since
both ONEHOTR and ONEHOTC only rewrite coefficients on cells that already
exist. That quadratic growth is the real cost of this pattern: doubling the
number of things to order quadruples the variable count, before an
objective term is added.
Failure Mode
Applying only one axis – ONEHOTR without ONEHOTC, or the reverse –
does not raise an error; it produces a model where each thing still picks
exactly one position, but nothing stops two things from picking the same
one. That looser rule is Assignment. The other failure
is structural rather than semantic: ONEHOTR/ONEHOTC read grid
dimensions from RESIZE, so a model with no grid set has no row or
column for them to constrain. Both raise InvalidGridDimensions at the
instruction that needed the grid – see
High-Level Constraints. xquad vm check does not catch it, since grid dimensions are runtime values, so
the failure surfaces when the encoder runs rather than when it compiles.
Assignment
Match each of N things to exactly one of B slots, with no requirement that every slot receives a thing and no requirement that B equals N. Recognise it whenever one set is matched into another with no requirement that the match run both ways: items into bins, nodes onto colours.
This is Permutations with the column half removed.
Encode it as an \(N \times B\) grid, x[thing, slot] = 1 meaning that
thing occupies that slot, with a one-hot family on every row only:
each thing picks exactly one slot, but a slot may hold zero, one, or many
things. Dropping the column constraint gives a different rule, not a
weaker form of the same one, and Permutations
names the mistake in the other direction: applying only one axis where
both are wanted.
Recognising the Row-Only Shape
examples/bin_packing/ assigns each item to exactly one bin – one
ONEHOTR per item row of its grid (Bin
Packing, examples/bin_packing/runner.py):
# Assignment constraint: each item i must go in exactly one bin
with problem.range(0, num_items) as i:
problem.model.apply_onehot_row(i, 200)
ONEHOTR is the special case of EQUALITY with \(a_k = 1\) and
\(b = 1\), which
High-Level Constraints
states directly. Writing it by hand – a vec() pair per row, an index and
a 1 pushed per column, then apply_equality(indices, coeffs, 1, 200) –
expresses the same constraint and costs a loop and two vectors per row. The
model here is defined with both rows and cols set, so apply_onehot_row
applies and the hand-rolled form buys nothing.
Bin packing’s grid has one row more than it has items. Rows 0..N-1 are
the assignment cells; the extra row N holds one indicator variable per
bin, and apply_implies((i, b), (num_items, b), 200) opens bin b as soon
as any item lands in it. That indicator row is what lets the objective
count bins: a bias spread over the assignment cells would sum to N on
every feasible packing, because each item lands in exactly one bin, so it
could not tell a one-bin packing from a three-bin one.
Running examples/bin_packing/runner.py --seed 42 (4 items, 3 bins,
capacity 5, sizes [1, 2, 1, 1]) decodes to assignment: [0, 0, 0, 0]
– all four items in bin 0, total size 5 against a capacity of 5.
Which single bin gets used is a tie: packing everything into bin 1 or
bin 2 has the same energy, and the solver’s seed decides which one comes
back. Every item
appears in exactly one bin, which is what the row constraint guarantees;
nothing requires a bin to be used, and the bin-count objective pushes the
other way.
Bin packing composes this row-assignment pattern with a second, unrelated
one – each bin’s contents must not exceed its capacity, encoded with
SLACK plus EQUALITY exactly like the capacity constraint in
Selection Under Budget. That capacity half is
not this page’s concern; see Selection Under Budget
for the pattern on its own.
Cost in Variables
The grid costs \(N \times B\) variables against
Permutations’ \(N^2\), and the saving is entirely
the grid: it is cheaper exactly when there are fewer slots than things
(\(B < N\)). Dropping the column constraint costs nothing and saves
nothing in variables, since neither one-hot family allocates any –
EQUALITY with in-range indices does not grow model.size on its own
(High-Level Constraints).
Failure Mode
The one this page warns about runs the other direction from
Permutations: adding a column one-hot to
an assignment problem that does not want one silently turns “N things
into B slots” into “a bijection between things and slots,” which only
has a feasible solution at all when \(N = B\). If a Permutations-shaped
model with RESIZEd grid dimensions comes back consistently infeasible,
check whether the problem actually needs both axes constrained or only
one.
Selection Under Budget
Choose a subset of N things, each with a cost, so the total cost does not exceed a fixed capacity, while maximising (or minimising) something else about the chosen subset. Recognise it whenever a problem says “at most,” “no more than,” or “fits within” about a sum of per-item weights: a knapsack, a budget of assets, a shipment under a weight limit.
An inequality (<=) combined with a per-item weight that is not always
1 is what distinguishes this shape from
Mutual Exclusion’s <= on a pair, or a plain
one-hot’s = 1 – here the bound is a general capacity and the items
carry arbitrary integer weights. None of ONEHOTR, ONEHOTC, EXCLUDE,
or IMPLIES express an inequality; ATLEAST and ATLEASTW cover the
mirrored >= direction directly (Vertex Cover,
Weighted Set Cover), but XQVM has no
<= counterpart over an arbitrary weighted sum. SLACK bridges the gap, turning the
inequality into an equality that EQUALITY can enforce – see
Constraints
for why that substitution is exact, and SLACK
for the instruction itself. model.apply_inequality(indices, coeffs, target, capacity, penalty) composes both calls into one – see
Constraints, including
the caveat there that the target parameter is not a target value: it is
where slack variables begin, normally the count of real variables, and
capacity is the actual bound. This page assumes both and covers only the
recognition and the cost.
The Canonical Instance
examples/knapsack/ is this pattern with nothing else mixed in –
Knapsack, examples/knapsack/runner.py. Calling
its own build_problem with a fresh instance (six items, capacity 12)
rather than the seed-42 instance the Modelling chapter already worked
through:
from examples.knapsack.runner import build_problem
from xquad.vm import VM
n = 6
weights = [2, 3, 4, 5, 6, 7]
values = [3, 4, 5, 8, 9, 10]
capacity = 12
problem = build_problem(n, weights, values, capacity)
programs = problem.compile()
vm = VM()
vm.set_calldata([n, weights, values, capacity])
vm.set_output_slots(1)
vm.run(programs.encoder)
model = vm.outputs()[0]
model.size comes back 10: six item variables plus four slack bits,
S = floor(log2(12)) + 1 = 4. Solving with SolverCPU and decoding:
selection [0, 0, 0, 1, 0, 1] weight 12 capacity 12 feasible True value 18 energy -14418
verifier energy -14418 valid 1
Items 3 and 5 (weights 5 and 7, values 8 and 10), weight exactly at
capacity, value 18. Brute-forcing all 64 subsets confirms 18 is the
true optimum, not just a feasible value: 28 of the 64 fit within capacity,
and no other reaches value 18. xquad vm check on the compiled encoder
(43 instructions) passes.
Cost in Variables
\(N\) item variables plus \(S = \lfloor \log_2(\text{capacity}) \rfloor + 1\) slack bits – logarithmic in the capacity, not the capacity itself, which is what makes representing “at most a million units” cost twenty extra variables rather than a million. Integer Scaling covers a consequence of this formula worth knowing before choosing how finely to scale a fractional capacity: the slack count grows with the scaled capacity, so a needlessly large scale factor costs real variables, not just larger coefficients.
examples/bin_packing/ repeats this exact SLACK + EQUALITY shape once
per bin, for a per-bin capacity rather than a single global one – see
Assignment, which covers the rest of that problem’s
structure. Its start_index argument to SLACK advances per bin –
model_vars + b * xq_bitlen(capacity) – so each bin’s capacity constraint
reaches for its own slack block. Passing the same fixed start index on
every iteration is the trap when copying this block as a per-bin template:
the bins then share slack variables, and one bin’s overflow can be absorbed
by another’s slack. The capacity-constraint shape itself, in isolation, is
the same one this page covers.
Failure Mode
A SLACK/EQUALITY pair that looks right can still be infeasible for a
reason that has nothing to do with the penalty weight: if every item’s
weight already exceeds capacity, or the cheapest single item does, no
slack combination rescues it, since slack only adds toward the target,
never subtracts from the real items’ contribution
(SLACK). Check the raw
numbers – smallest item weight against capacity – before reaching for
penalty-weight guidance
to explain a solver that keeps returning something that looks wrong.
Mutual Exclusion
Two choices conflict, and a solution may take at most one of them. Recognise it in “not both,” “at most one,” or an adjacency rule that forbids a shared property between connected things: two colours on adjacent nodes, two overlapping bookings.
EXCLUDE is the direct opcode: pop penalty, then two variable indices,
and add quad[i, j] += penalty – a pure coupling term, no linear part,
that costs penalty exactly when both variables are 1 and nothing
otherwise (High-Level Constraints).
This page also covers IMPLIES, the directional relative: “picking i
requires j” rather than “picking both is forbidden.”
EXCLUDE, Direct
examples/graph_coloring/ applies EXCLUDE once per edge per colour: two
adjacent nodes may not both hold the same colour
(Graph Coloring, examples/graph_coloring/runner.py):
with problem.range(0, num_edges) as e:
offset = problem.stow("offset", e * 2)
u = problem.stow("u", edges_in.get(offset))
v = problem.stow("v", edges_in.get(offset + 1))
with problem.range(0, num_colors_in) as c:
problem.model.apply_exclude((u, c), (v, c), 200)
Running examples/graph_coloring/runner.py --seed 1
(5 nodes, 4 colours, 6 edges) returns colors: [0, 1, 0, 0, 2],
is_valid: true, energy: -1000 – every one of the six edges checked by
hand connects two nodes with different colours.
Forcing the default --seed 42 instance down to --colors 3 shows what
EXCLUDE can and cannot do. That seed’s random edges form a 4-clique among
nodes {0, 2, 3, 4}, every pair among them an edge, and a 4-clique has no
3-colouring at all. The run reports is_valid: false, energy: -800, and
valid: 0; no adjustment to EXCLUDE’s penalty weight changes that.
EXCLUDE enforces a rule, and it can only enforce a colouring that exists.
The colour count defaults to 4 for exactly this reason. Note that the
generated verifier’s valid agrees with the runner’s own is_valid here:
it checks each EXCLUDE against the sample directly.
The Same Rule, Encoded Without EXCLUDE
examples/max_independent_set/ needs the identical x_i + x_j <= 1 rule
per edge – two adjacent nodes cannot both be in the set – but builds it
with SLACK plus EQUALITY instead of EXCLUDE
(Max Independent Set):
edge_indices = problem.vec()
edge_coeffs = problem.vec()
edge_indices.push(ni)
edge_indices.push(nj)
edge_coeffs.push(1)
edge_coeffs.push(1)
problem.slack(edge_indices, edge_coeffs, num_nodes + e, 1)
problem.model.apply_equality(edge_indices, edge_coeffs, 1, 200)
Both encode the same inequality; EXCLUDE is a one-instruction
convenience for exactly the pairwise case, while SLACK + EQUALITY
costs one extra slack variable per edge to reach the identical rule
(Selection Under Budget’s pattern, applied
here to a capacity of 1). Reach for EXCLUDE whenever the conflict is
pairwise, which it almost always is; reach for the manual form only when
the exclusion needs to compose with other terms already riding the same
index/coefficient vectors.
Running examples/max_independent_set/runner.py --seed 42 (same 5-node,
6-edge graph as the graph-coloring default above, node set
{0, 2, 3, 4} forming the clique) returns in_set: [0, 1, 0, 0, 1] –
nodes {1, 4}, is_independent: true. Running
examples/vertex_cover/runner.py --seed 42 against the identical graph
returns cover: [1, 0, 1, 1, 0] – nodes {0, 2, 3}, the exact complement
of {1, 4} in {0, 1, 2, 3, 4}. That is not a coincidence of this one
instance: a set of nodes covers every edge exactly when the nodes left out
share no edge, so a minimum vertex cover and a maximum independent set on
the same graph are always complements of each other. But vertex_cover’s
own constraint, x_i + x_j >= 1 via ATLEAST, is not this page’s pattern
– it requires at least one endpoint, the opposite direction from
forbidding both. Do not reach for EXCLUDE when a problem says “at least
one of,” even on the same graph shape that motivates exclusion elsewhere.
IMPLIES
No example in this repository calls apply_implies. A minimal
demonstration, run rather than assumed, checks the sign directly:
from xquad import cp
from xquad.vm import VM
problem = cp.Problem("ImpliesDemo")
n = problem.input("n", type=cp.Types.Int)
problem.define_model(size=n, domain=cp.Domain.BINARY)
problem.model.apply_implies(0, 1, 50) # picking 0 requires picking 1
problem.model.linear[0].add(-30) # a reason to pick 0 at all
programs = problem.compile()
vm = VM()
vm.set_calldata([2])
vm.set_output_slots(1)
vm.run(programs.encoder)
model = vm.outputs()[0]
Reading the resulting coefficients back and evaluating all four assignments by hand:
linear: {0: 20}, quadratic[0,1]: -50
x0=0 x1=0 -> H=0
x0=0 x1=1 -> H=0
x0=1 x1=0 -> H=20
x0=1 x1=1 -> H=-30
x0=1, x1=0 – picking 0 without 1, the forbidden combination – costs
20 more than leaving both off, and exactly 50 more than the legal
x0=1, x1=1, matching the penalty=50 passed in. The -30 reward on
x0 alone is what gives the solver a reason to pick it in the first
place; without it, x0=0 would dominate trivially and the implication
would never be tested. IMPLIES’ own expansion,
linear[i] += penalty, quad[i, j] += -penalty
(High-Level Constraints),
is exactly what produced 20 and -50 here: linear[0] = -30 + 50 = 20,
no change to linear[1], quadratic[0,1] = 0 - 50 = -50.
Cost in Variables
EXCLUDE and IMPLIES add no variables at all – both rewrite an
existing coefficient pair. The SLACK-based encoding of the same rule
costs one extra slack bit per pair, since a capacity of 1 needs
\(S = \lfloor \log_2(1) \rfloor + 1 = 1\) bit
(SLACK).
Failure Mode
A graph that needs more colours than it is given, as the graph-coloring
seed-42 default above shows, is not a penalty-tuning problem: no penalty
value turns an infeasible instance feasible, since the penalty only
controls how costly a violation is, not whether one is avoidable. If
EXCLUDE or IMPLIES constraints keep the best returned sample stuck at
a nonzero violation at more than one penalty weight, check whether
the underlying combinatorial structure allows a solution to exist before
reaching for penalty-weight guidance.
Soft vs. Hard Constraints
Every rule in a quadratic model is a penalty term, but not every penalty
term means the same thing to the problem it belongs to. A hard
constraint is a rule the solution must obey. It is satisfied or it is
violated, with nothing in between, and the only question a penalty weight
answers is how expensive violating it becomes. A soft constraint – a
preference is the more precise name – is a cost a solution pays
continuously: more of the disfavoured thing costs more, but nothing marks
any amount as forbidden. apply_equality, apply_onehot_row, apply_exclude, and the
rest of the constraint family from Constraints
are how a hard rule is written. A preference is written directly into the
objective, the way Objectives covers, with
no constraint call at all.
Both in One Problem
examples/portfolio_opt/ carries one of each. The budget is hard –
sum(x_i) = B via apply_equality, unconditional, penalty 200 –
and the risk term is soft – a cubic penalty added straight into the
objective for every asset triple whose combination the problem considers
risky, with no accompanying constraint call
(Portfolio Optimization,
examples/portfolio_opt/runner.py):
# Cubic risk cross-terms via REDUCE
with problem.range(0, num_risk) as t:
...
w = problem.model.reduce(ti, tj, _P_AUX)
problem.model.quadratic[w, tk].add(sigma)
# Budget constraint: sum(x_i) = B (EQUALITY with unit coefficients)
...
problem.model.apply_equality(indices, coeffs, budget_in, 200)
reduce and ADDQUAD build the risk cost the same way any other
objective term is built – see Objectives
and High-Level Constraints
for REDUCE’s own Rosenberg terms, which enforce the auxiliary variable’s
algebraic identity, not the risk rule itself. Nothing about sigma is a
threshold; it is a coefficient like any other, weighing a return against a
risk directly in the same sum.
The Discriminating Difference: A Threshold vs. a Continuous Trade-off
Fix four assets with returns [10, 9, 8, 7], a budget of 3, and one
risky triple (0, 1, 2, sigma), then sweep sigma. run_case below
covers the encoder half – compiling the problem, running the encoder to
build the model, and solving it – the same way the earlier snippets on
this page do. Decoding the returned sample back into a portfolio runs
programs.decoder over [sample, n], exactly as
examples/portfolio_opt/runner.py does it. That step is elided here
because it is the same on both sides of the comparison:
from examples.portfolio_opt.runner import build_problem
from xquad.vm import VM
from xqsa import build_solver
def run_case(sigma):
risk_terms = [(0, 1, 2, sigma)]
problem = build_problem(4, [10, 9, 8, 7], 3, risk_terms)
programs = problem.compile()
flat_risk = [v for term in risk_terms for v in term]
vm = VM()
vm.set_calldata([4, [10, 9, 8, 7], 3, 1, flat_risk])
vm.set_output_slots(1)
vm.run(programs.encoder)
model = vm.outputs()[0]
result = build_solver("cpu", seed=42).solve(model, num_reads=200)
... # decode via programs.decoder and print, as below
Decoded and printed, sigma=1 and sigma=2 give:
sigma=1: portfolio=[1, 1, 1, 0] total_return=27 energy=-1826
sigma=2: portfolio=[1, 1, 0, 1] total_return=26 energy=-1826
Across twenty solver seeds at each sigma (seed in range(1, 21)),
sigma=2 always switches to {0, 1, 3} (return 26), stable on every
seed. sigma=1 does not behave the same way: it sits exactly at the tie,
so the sampler returns either portfolio, split close to evenly across
seeds – {0, 1, 2} on 12 of 20 and {0, 1, 3} on 8 of 20, both at the
identical energy -1826.
The tie is exact, not approximate: asset 2’s risk cost only applies
when all three of 0, 1, 2 are selected together (REDUCE’s product
term), so choosing the risky trio costs -27 + sigma against the safer
trio’s fixed -26; the two costs are equal at sigma = 1 and the risky
choice stops paying for itself once sigma > 1, which is exactly where
the stable switch to {0, 1, 3} sets in. A weight that lands exactly on
a tie is not evidence either portfolio is preferred – it is evidence the
weight is at the boundary, and a single sample there does not tell you
which side you are on. Every selection in this sweep still has exactly
three assets – the hard budget constraint held at every sigma,
unaffected – while which three assets the soft risk cost favoured
changed at its break-even point, with a tie rather than a clean switch
exactly at that point. That contrast is what the two kinds of constraint buy:
a hard rule’s own penalty weight only decides whether the rule can be
broken at all (Quadratic Models,
Constraints),
while a soft term’s weight decides how much of the preference the
solution actually buys.
Soft, With No Hard Constraint at All
examples/max3sat/ sits at the other end: every clause is a preference,
none is a rule the solution must satisfy, and the problem carries no
apply_* call anywhere (Max-3-SAT). A clause
(i, j, k) costs P_CLAUSE exactly when all three of its literals are
false, added directly to the objective; a solution that satisfies zero clauses is
legal, just expensive. Running examples/max3sat/runner.py --seed 42 (6
variables, 8 clauses) satisfies all eight (satisfied: 8, energy: -80)
for this random instance, but nothing in the model would reject a sample
that satisfied fewer – there is no valid check tied to clause
satisfaction, because there is no constraint to check.
The valid flag draws exactly this line. A hard constraint is an
apply_* call, and the generated verifier emits one check per call:
portfolio_opt’s budget is an apply_equality, so a sample selecting all
four assets against a budget of 3 comes back valid: 0. Max-3-SAT’s
clauses are objective terms, so nothing in its verifier looks at them and
a sample satisfying two clauses is as valid as one satisfying eight. If
a rule must hold, declare it as a constraint;
Verification
says what valid then covers.
Cost in Variables
Neither pattern taxes the model directly – a soft term is ordinary
linear/quadratic coefficient writes, and a hard constraint’s cost is
whatever its own instruction adds (ATLEAST/ATLEASTW/REDUCE grow
model.size; EXCLUDE/IMPLIES/EQUALITY-with-in-range-indices do
not). portfolio_opt’s risk terms cost one REDUCE auxiliary variable
each, so the model grows from n to n + num_risk, independent of the
hard budget constraint sharing the same model.
Failure Mode
Writing a preference as a hard constraint at a large penalty forces an
all-or-nothing rule where a graded cost was wanted – the model will never
choose to pay a little of the disfavoured thing even when the rest of the
objective would gain more from it, because the constraint’s cost
jumps from 0 to penalty * d^2 the instant it is touched at all
(Constraints).
The reverse mistake, writing a rule that must always hold as a soft
objective term, is worse: nothing stops the solver from breaking it
whenever the rest of the objective offers enough of a reward, and there is
no way to check afterward that it did not, the way max3sat’s missing
valid check above demonstrates directly.
Integer Scaling
Every XQMX coefficient is an i64, and every arithmetic operation on one
is exact – Quadratic Models and
Energy and Precision both rest on
that fact. Real problem data rarely arrives as integers: prices, weights
in kg, fractional returns. This page covers turning that data into
i64 coefficients without losing precision the problem needs, and what a
scale factor costs once it is chosen. No example in this repository has
fractional inputs to scale, so everything below is a fresh, executed
worked instance rather than a citation.
Choosing the Scale Factor
Multiply every fractional value by a factor large enough that the result is exactly an integer, and use that integer as the coefficient. The factor has to be large enough for every value in the problem, not just the ones that look round. A four-item knapsack with quarter-kilogram weights and cent-precision values:
weights_f = [2.50, 3.75, 1.25, 4.00]
values_f = [10.20, 15.75, 8.40, 20.00]
capacity_f = 7.50
def scale(values, factor):
scaled = [round(v * factor) for v in values]
for v, s in zip(values, scaled):
assert abs(v * factor - s) < 1e-9, (v, factor, s) # catch silent rounding
return scaled
Scaling by 4 – enough for the weights, which are all quarters – fails
the assertion the moment it reaches the values: 10.2 * 4 = 40.8 is not
integral (rounds to 41), so scale() raises before the demo builds a
model from this factor:
AssertionError: (10.2, 4, 41)
The values are cent-precision but not all quarters (10.20, 8.40 are
not multiples of 0.25), so 4 is not enough. The minimal factor that
is exact for every value here is the least common multiple of every
value’s decimal denominator – 20 (every one of these numbers is a
multiple of 0.05), not the naive 100 a “two decimal places” guess
would reach for:
from fractions import Fraction
from math import lcm
denoms = [Fraction(v).limit_denominator(10000).denominator
for v in weights_f + values_f + [capacity_f]]
lcm(*denoms) # 20
At factor = 20: weights = [50, 75, 25, 80], values = [204, 315, 168, 400], capacity = 150. Building this through xqcp (the same
SLACK + EQUALITY shape Selection Under Budget
covers, at the penalty weight of 4 derived in Coefficient
Magnitude
below), compiling, running the encoder on the Rust VM, solving with
SolverCPU, and dividing the decoded totals back by the scale
factor:
selection=[1, 1, 1, 0] total_weight=7.5 total_value=34.35 capacity_ok=True energy=-90687
7.5 and 34.35 are exact – not rounded back, computed by an integer
sum divided by 20 with no remainder, since 20 was chosen to be exact
for every input. xquad vm check accepts the compiled encoder (43
instructions), and the same selection, {0, 1, 2}, comes back
identically across five different solver seeds.
What Scaling Costs: Slack Bits
SLACK’s bit count is \(S = \lfloor \log_2(\text{capacity}) \rfloor + 1\)
(SLACK) – logarithmic in the
capacity, but the capacity that matters is the scaled one. The naive
factor = 100 scaling of the same instance needs 10 slack bits
(capacity = 750); the minimal exact factor = 20 needs 8
(capacity = 150). Scaling five times more finely than the data
requires costs two extra variables here, and the gap widens as the
capacity grows – an unnecessarily large scale factor is not free, even
before its effect on coefficient magnitude below.
What Scaling Costs: Coefficient Magnitude, and Two Backends That Disagree
A penalty weight multiplies every constraint coefficient
(High-Level Constraints),
so a scale factor and a penalty compound. Using the loose safe penalty
Constraints gives
as a fallback – one more than the sum of absolute linear coefficients,
1088 for the values above, since the rule is penalty greater than
that sum – against the factor = 20 model:
loose_penalty=1088: max |coefficient| = 23,953,408
fits MAX_NATURAL_COEFFICIENT (2,147,483): False
Quip Network confirms
the exact bound directly from xqsa.quip.codec:
from xqsa.quip.codec import MILLI_SCALE, MAX_NATURAL_COEFFICIENT
print(MILLI_SCALE, MAX_NATURAL_COEFFICIENT)
# 1000 2147483
The loose bound, safe on its own terms, overflows SolverQuip’s
milli-scale i32 encoding more than tenfold at this scale factor.
Enumerating this specific instance the way
Constraints
enumerates its own – sixteen subsets, tightest violator {0, 2, 3} at
weight 155, value gap 85 over an excess of 5 – gives a tight
threshold of 85 / 5^2 = 3.4, so the smallest safe integer penalty is
4, not 1088:
tight penalty=4: max |coefficient| = 88,064
fits MAX_NATURAL_COEFFICIENT: True
4 clears the tight threshold and the solver still returns the correct
optimum, {0, 1, 2}, identically across five seeds – the two hundred
seventy-two-fold gap between the loose and tight penalty is exactly what
made the difference between overflowing SolverQuip’s encoding and
fitting it comfortably. This is Constraints’s
own warning about the loose bound being loose, made concrete by a scale
factor large enough to expose it: a bound that is merely safe at
natural scale can become the deciding factor once a scale factor
multiplies every coefficient it touches.
The same coefficient growth affects the Metal engine’s float32 search
differently from the CUDA engine’s float64 one, the way
Energy and Precision
covers for an unscaled model. At the loose penalty above, the resulting
solved energy (-24,480,687, order \(10^7\)) is past float32’s roughly
seven decimal digits of resolution for a difference of 1, but not for a
difference of 100:
import numpy as np
e = -24480687 # this model's actual solved energy at the loose penalty
np.float32(e) != np.float32(e - 1) # False -- the 1-unit difference is lost
np.float32(e) != np.float32(e - 100) # True -- a 100-unit difference still resolves
A scaling choice that looks safe by natural-scale reasoning can cost the Metal engine resolution that the CUDA engine keeps, on the identical model – check the actual coefficient magnitudes your factor produces against the target backend, not just against the input data’s precision.
Cost in Variables
\(S = \lfloor \log_2(\text{scaled capacity}) \rfloor + 1\) slack bits on top of the item count, same formula as Selection Under Budget, with the scaled capacity – not the original – as the input.
Failure Mode
Picking a scale factor from “how many decimal places does this look
like” rather than the actual least common multiple of every value’s
denominator either drops precision silently (a factor too small, caught
here only because the demo asserts the rounding was exact) or costs
variables and coefficient headroom for no reason (a factor larger than
any value needs, the 100 vs. 20 case above). Compute the minimal
exact factor once, from every value the problem uses, rather than
guessing a round number.
CLI Reference
xquad is the command-line driver for the XQuad toolchain. It ships with
the xquad Python package, so the install that gives you the Python API
also puts the command on your PATH.
Installation
Install XQuad into a project environment first: a virtual environment,
or a uv project.
python -m venv .venv && source .venv/bin/activate
pip install xquad
# or, in a uv project:
uv add xquad
For the xquad command alone, outside any project, install it as a
uv tool, with the quip extra for the xquad quip commands:
uv tool install xquad # the xquad command
uv tool install 'xquad[quip]' # with the xquad quip commands
On macOS, a user install (pip install --user, or pip falling back to
one) puts the xquad command in ~/Library/Python/3.x/bin, which is
not on PATH; python -m xquad runs the same command without it.
More generally, if your shell reports xquad: command not found, the
script directory of that Python environment is not on PATH, and
python -m xquad --help works in its place.
From a checkout of this repository, make xquad syncs the workspace
virtual environment, and uv run xquad runs the command from it.
The command used to be a Rust binary from the xqcli crate, installed
with cargo install. That crate is retired. If you installed it, run
cargo uninstall xqcli, or the old binary in ~/.cargo/bin may shadow
the new command on PATH.
Commands
xquad groups its commands by what they work on:
| Group | Description |
|---|---|
xquad vm | Assemble, disassemble, run and verify XQVM programs. |
xquad quip | Accounts and job orders on a Quip network. Needs the quip extra. |
xquad --help
xquad <GROUP> --help
xquad --version
xquad vm
xquad vm is the command group for the XQVM tools: assemble, disassemble,
run and verify programs. It is part of the xquad command-line driver;
CLI Reference covers installing the command.
Commands
The XQVM tools sit under xquad vm, one per stage of the toolchain:
| Command | Description |
|---|---|
xquad vm asm | Assemble .xqasm source into binary bytecode. |
xquad vm dis | Disassemble bytecode into a human-readable listing. |
xquad vm run | Execute bytecode or assembly, with optional tracing. |
xquad vm check | Run the bytecode verifier over a program without running it. |
The old top-level names xquad asm, xquad dism, xquad run and xquad verify still work in this release. Each prints one deprecation warning
to stderr when it runs and otherwise behaves exactly like its xquad vm
counterpart, so stdout and the exit code are unchanged. They are removed
in v0.6.0.
General usage
xquad vm <COMMAND> [OPTIONS] [ARGS]
xquad vm <COMMAND> --help
xquad vm asm reads .xqasm source and writes bytecode; it has no
--text flag. xquad vm dis reads bytecode from a file or stdin, also
with no --text flag. xquad vm run and xquad vm check read bytecode
by default and read .xqasm assembly instead when --text is passed.
Exit codes and errors
| Code | Meaning |
|---|---|
0 | Success. |
1 | The command ran and failed: an unreadable file, an assembly or decode error, a verifier violation, or a VM fault. |
2 | A usage error: an unknown option, a missing argument, or two options that cannot be combined. |
Errors are one line on stderr, prefixed Error: and naming the stage
that failed:
Error: assembly failed: unknown mnemonic `BADOP`
A usage error prints the command’s usage line first. Errors carry no
source snippet: a VM fault names the error variant and its byte offset
(run failed: StackUnderflow { pos: 2 }), and xquad vm dis on the same
bytecode shows the instruction at that offset.
A minimal round trip
cat > add.xqasm <<'EOF'
; add.xqasm -- push two integers and add them
PUSH 10
PUSH 32
ADD
HALT
EOF
xquad vm asm add.xqasm -o add.xqb
xquad vm check add.xqb
xquad vm run add.xqb
xquad vm run add.xqb prints the residual stack:
stack (bottom to top):
42
xquad vm asm
Assemble an .xqasm source file into binary XQVM bytecode.
Usage
xquad vm asm <INPUT> [-o <OUTPUT>] [--stdout]
Arguments
| Argument | Description |
|---|---|
INPUT | Path to the assembly source file. |
Options
| Option | Default | Description |
|---|---|---|
-o, --output <OUTPUT> | <INPUT> with its extension replaced by .xqb | Output file path. |
--stdout | off | Write bytecode to stdout instead of a file. Conflicts with -o/--output. |
-o and --stdout are mutually exclusive; passing both is a usage error
(exit 2), not a runtime one:
xquad vm asm add.xqasm -o build/program.xqb --stdout
Usage: xquad vm asm [OPTIONS] INPUT
Try 'xquad vm asm --help' for help.
Error: --output cannot be used with --stdout
Examples
Assemble to the default output
xquad vm asm add.xqasm
Writes add.xqb next to the source and prints a summary to stderr:
assembled 4 instructions (21 bytes) -> add.xqb
Assemble to a specific output file
mkdir -p build
xquad vm asm add.xqasm -o build/program.xqb
Assemble does not run the verifier
xquad vm asm only assembles; it does not check the result with
xquad vm check. A source file with no mnemonic errors can
still fail verification once assembled.
Pipe bytecode to another tool
xquad vm asm add.xqasm --stdout | xquad vm dis
0x0000: PUSH1 10
0x0002: PUSH1 32
0x0004: ADD
0x0005: HALT
Output
On success, prints a summary to stderr (suppressed with --stdout, since
the bytecode itself occupies stdout). The instruction count includes every
TARGET the assembler emits, so a program with jumps counts higher than
its visible mnemonics. branch.xqasm below has eight mnemonic lines and
two labels (.0, .1), each of which emits a TARGET:
PUSH 5
PUSH 10
GT
JUMPI .0
PUSH 99
JUMP .1
.0: PUSH 0
.1: HALT
xquad vm asm branch.xqasm -o branch.xqb
assembled 10 instructions (31 bytes) -> branch.xqb
Error reporting
An assembly error is one line on stderr, and the command exits 1:
Error: assembly failed: unknown mnemonic `BADOP`
xquad vm dis
Disassemble XQVM bytecode into a human-readable listing.
Usage
xquad vm dis [FILE]
Arguments
| Argument | Description |
|---|---|
FILE | Bytecode file to disassemble. Reads from stdin when omitted. |
dis decodes FILE (or stdin) as a full XQBC container, the same
format xquad vm asm writes: a 15-byte header (magic, version, code length,
CRC32) followed by the raw instruction stream. It has no path for a bare
instruction stream missing that header – a buffer that starts straight
with opcode bytes fails to decode on the magic check before disassembly
gets a chance to run, and the command exits 1:
Error: failed to decode program: decode error: XQBC header is truncated
``` This is why the stdin example below pipes from
`xquad vm asm --stdout`, which emits a full container, rather than from a
raw instruction buffer.
## Examples
### Disassemble a file
```sh
xquad vm dis add.xqb
0x0000: PUSH1 10
0x0002: PUSH1 32
0x0004: ADD
0x0005: HALT
Disassemble from stdin
xquad vm asm add.xqasm --stdout | xquad vm dis
Produces the same listing as above; dis reads the bytes either way.
A program with jumps
branch.xqb, assembled from a program with two labels (.0, .1) and two
jumps:
0x0000: PUSH1 5
0x0002: PUSH1 10
0x0004: GT
0x0005: JUMPI1 .0
0x0007: PUSH1 99
0x0009: JUMP1 .1
0x000B: .0: TARGET
0x000C: PUSH1 0
0x000E: .1: TARGET
0x000F: HALT
Output format
Each line shows a byte offset, an optional label, and a decoded instruction:
- Byte offset (
0x0000:) – position in the instruction stream. - Label column – present only when the program contains at least one
TARGET. When present, it holds.0:,.1:, and so on at the offset where eachTARGETopcode sits, and is blank on every other line so the instruction column still lines up. When the program has noTARGETat all, as inadd.xqbabove, the column is omitted entirely rather than printed blank on every line. - Instruction – mnemonic and decoded operands.
JUMP/JUMPIoperands are printed as the same.Nlabel that marks their destination, not as a raw byte offset or an internal id. - PUSH values – shown as sign-extended decimal integers (
PUSH1 10, not a hex byte). - Undecodable bytes – an invalid opcode or a truncated operand does
not abort the listing. It is rendered as a
.byte 0xNNpseudo-instruction and the walk continues from the next byte, so no byte is silently dropped from the output.
The label numbering comes from a single left-to-right scan over the
decoded program: the first TARGET found is .0, the second is .1, and
so on. This is the same numbering the assembler resolves .0/.1-style
source labels against, so a disassembly’s labels read back as valid
.xqasm jump targets – provided the listing contains no .byte lines.
.byte is not a recognised mnemonic, so a listing that hit an
undecodable byte is not itself valid .xqasm input, even though its
labels are correctly numbered. See Bytecode Format for
how that scan works and why a JUMP operand cannot be validated in
isolation from it.
xquad vm run
Execute XQVM bytecode or assembly source, printing the residual outputs and stack, with optional step-by-step tracing.
Usage
xquad vm run [OPTIONS] <FILE>
Arguments
| Argument | Description |
|---|---|
FILE | Bytecode (.xqb) file to run, or assembly (.xqasm) source when --text is set. |
Options
| Option | Default | Description |
|---|---|---|
--text | off | Treat FILE as assembly source and assemble it before running. |
--calldata <CALLDATA> | none | Comma-separated i64 integers passed to INPUT instructions. |
--outputs <OUTPUTS> | 16 | Number of output slots available for OUTPUT instructions. |
--step-limit <STEP_LIMIT> | 10000000, the VM’s DEFAULT_STEP_LIMIT | Maximum number of steps to execute. The limit is exact: 0 executes nothing. Conflicts with --unlimited-steps. See Step limits. |
--unlimited-steps | off | Run without a step limit. Conflicts with --step-limit. A program that never halts will not return. |
--memory-limit <MEMORY_LIMIT> | 1073741824 (1 GiB), the VM’s DEFAULT_MEMORY_LIMIT | Allocation budget in bytes for models, samples and vectors. See The allocation budget. |
--trace | off | Enable step-by-step execution tracing. |
--trace-format <TRACE_FORMAT> | text | Trace output format: text or json. Requires --trace. |
--trace-file <TRACE_FILE> | stderr | Write trace output to a file instead of stderr. Requires --trace. |
xquad vm run does not call the verifier. A program that fails
verification can still be handed to run; some defects (an unset register read, for
example) are also caught by the VM’s own runtime checks and abort with a
runtime error, but others are not. See xquad vm check and
Verification for what static verification
does and does not guarantee.
Examples
Run bytecode
xquad vm run add.xqb
stack (bottom to top):
42
Run assembly directly
xquad vm run --text add.xqasm
Skips the separate xquad vm asm step; the source is assembled in memory and
run immediately.
Pass calldata and read outputs
INPUT and OUTPUT each take one register operand in assembly, but both
pop the slot index off the value stack at runtime – the index is not
implicit in the instruction. A program that reads calldata[0] into
r0, adds one, and writes the result to output slot 0 needs the index
pushed explicitly before each call: PUSH 0; INPUT r0; ...; PUSH 0; OUTPUT r1; HALT.
xquad vm run io.xqb --calldata 41
outputs:
[0] = Int(42)
--calldata accepts a comma-separated list; each value is consumed in
order by successive INPUT instructions. OUTPUT faults UnsetRegister
if the register it reads was never written, the same as LOAD. Both
INPUT and OUTPUT fault with CallDataIndex/OutputIndex
respectively if the popped index is out of range for the calldata or
output-slot array – the index itself is not guaranteed to be valid just
because it came off the stack. See Register I/O
for the full set of INPUT/OUTPUT error modes.
Enable tracing
xquad vm run regs.xqb --trace
Trace lines go to stderr:
step offset instruction stack read-regs written-regs
1 0x0000 PUSH1 7 [7]
2 0x0002 STOW r0 [] r0=7
3 0x0004 LOAD r0 [7] r0=7
4 0x0006 HALT [7]
and the residual stack goes to stdout, separately from the trace:
stack (bottom to top):
7
Trace lines go to stderr by default so the stdout result is not interleaved with them in a terminal. Redirect stderr separately to capture the trace on its own:
xquad vm run regs.xqb --trace 2>trace.txt
JSON trace
xquad vm run regs.xqb --trace --trace-format json --trace-file trace.jsonl
trace.jsonl then holds one JSON object per step:
{"step":1,"pos":0,"instruction":"PUSH1 7","stack":[7],"read_regs":{},"written_regs":{}}
{"step":2,"pos":2,"instruction":"STOW r0","stack":[],"read_regs":{},"written_regs":{"0":{"type":"int","value":7}}}
{"step":3,"pos":4,"instruction":"LOAD r0","stack":[7],"read_regs":{"0":{"type":"int","value":7}},"written_regs":{}}
{"step":4,"pos":6,"instruction":"HALT","stack":[7],"read_regs":{},"written_regs":{}}
Custom step limits
countloop.xqb assembles a five-iteration RANGE loop around a NOP:
six encoded instructions, 14 executed steps, because the body and its
NEXT run once per iteration. A limit that is reached before HALT
aborts the run:
xquad vm run countloop.xqb --step-limit 3
Error: run failed: StepLimitExceeded { pos: None, requested: 1, used: 3, limit: 3 }
The limit is exact, and 0 is not a sentinel for “unlimited”. A zero
budget permits no instructions at all, so the first fetch fails:
xquad vm run countloop.xqb --step-limit 0
Error: run failed: StepLimitExceeded { pos: None, requested: 1, used: 0, limit: 0 }
Passing --unlimited-steps alongside --step-limit is a usage error
(exit 2) rather than resolved by precedence, so opting out of the
bound cannot be said two ways at once:
xquad vm run countloop.xqb --step-limit 0 --unlimited-steps
Usage: xquad vm run [OPTIONS] FILE
Try 'xquad vm run --help' for help.
Error: --step-limit cannot be used with --unlimited-steps
The conflict fires on an explicit --step-limit, not on the default, so
--unlimited-steps on its own is accepted. It removes the bound
entirely, which is only safe where you control the program or can
abandon the thread – a program that never halts will not return:
xquad vm run countloop.xqb --unlimited-steps
prints nothing, since the program leaves no outputs and no residual
stack. To raise the limit rather than remove it, pass a larger number,
up to u64::MAX:
xquad vm run countloop.xqb --step-limit 18446744073709551615
also runs to completion, but for a different reason: the limit is higher,
not absent. A budget that exactly covers the program succeeds –
--step-limit 14 runs countloop.xqb to completion, and --step-limit 13 does not.
Output
After execution, xquad vm run prints, in order:
- Outputs – every output slot that was written, with its index and value.
- Stack – any values remaining on the value stack, bottom to top.
A run that both writes outputs and leaves values on the stack prints both sections:
outputs:
[0] = Int(42)
[1] = VecInt([1, 2, 3])
stack (bottom to top):
7
Error reporting
A runtime fault is one line on stderr naming the VM error and the byte
offset it happened at, and the command exits 1. underflow.xqb is
PUSH 1 / ADD / HALT: ADD needs two stack values and only one was
pushed.
xquad vm run underflow.xqb
Error: run failed: StackUnderflow { pos: 2 }
xquad vm dis underflow.xqb shows the ADD at 0x0002. The trace
options above write the steps that led there.
xquad vm check
Run the bytecode verifier over a program without executing it, and report the first violation found.
Usage
xquad vm check [OPTIONS] <FILE>
Arguments
| Argument | Description |
|---|---|
FILE | Program to check: bytecode (.xqb) by default, assembly (.xqasm) when --text is set. |
Options
| Option | Default | Description |
|---|---|---|
--text | off | Treat FILE as assembly source and assemble it before checking. |
xquad vm check runs the same default pipeline documented in
Verifier: structural checks, jump-target checks,
loop-nesting balance, register type-state, must-init analysis, and stack
depth, in that order, stopping at the first phase that fails. This page
covers only the command’s own behaviour; for what each phase checks and
what each error means, see that page. For a task-oriented walkthrough of
fixing a rejected program, see
Verification.
A passing program
xquad vm check add.xqb
ok: add.xqb (4 instructions)
The instruction count includes the whole decoded stream: labelled
positions as well as visible mnemonics. check writes this line to
stdout and exits 0. With --text, the same message names the .xqasm
source path rather than a .xqb file, since the source is assembled
in memory first and never written to disk.
A failing program
uninit.xqasm reads register r0 before anything ever writes it:
LOAD r0
HALT
xquad vm check --text uninit.xqasm
Error: check failed: ReadUnsetRegister: register r0 read at byte 0x0000 before being written
check exits 1 on the first violation any phase finds. A program with
two independent defects reports only this one; fixing it and re-running
may surface the next.
Passing verification is not a runtime guarantee
Verification is a set of static checks over types, control flow and
stack depth. It does not look at values. An allocator size, a grid extent, a
loop bound and every arithmetic operand are runtime quantities, so a
program that allocates a negative model, resizes past its own variable
count, overflows an i64 or exhausts its step or allocation budget
verifies cleanly and faults when run. See
Verifier for the
precise scope of what a pass proves, and Limits and
Errors for the faults that
have no static counterpart.
xquad quip
xquad quip manages Quip accounts and reads the job orders they placed
on a Quip network. It is a front end over the xqsa.quip Python API
that Quip Network and Job
Orders describe; it places no orders
itself. Submit orders from Python, then follow them from the shell.
| Command | Description |
|---|---|
account list | The keystores found, the order that picks the signer, and how to change it. |
account show | The account in effect: its source, balance, reserved amount and order slots. |
account new | Create a keystore with a fresh seed, and use it. |
account import | Create a keystore from a seed you already have, and use it. |
account use | Prefer an account at a level, or clear the preference. |
orders | The account’s orders not yet closed, one line each. |
status | One order’s receipt. |
reclaim | Recover the reward of a final order nobody answered. |
Installation
The group needs the quip extra, which brings in the chain client and
the signing extension:
pip install 'xquad[quip]'
Without it, every xquad quip command, and --help below the group
level, prints this on stderr and exits 1, while xquad quip --help,
xquad --help and xquad vm keep working:
`xquad quip` needs the Quip extra: `pip install 'xquad[quip]'`
Global options
These go between quip and the command, as in
xquad quip --network aglais account show:
| Flag | Meaning |
|---|---|
--network NAME | A named network preset, such as aglais (see Named networks). |
--url URL | A node’s RPC URL. Defaults to QUIP_RPC_URL. |
--keystore PATH | Sign with this keystore, over every preference. |
--json | Print one JSON object on stdout instead of text. |
--network and --url are exclusive. A command that reads the chain
needs one of them, or QUIP_RPC_URL:
Error: name a network with --network or a node with --url, or set QUIP_RPC_URL
account list, account new, account import and account use work
on local files only and need no network.
What signs
Only reclaim sends a transaction, and only it spends anything: each
reclaim pays its own fee. account show, orders and status read
through a read-only client that resolves no signer, so they never create
a keystore. Under QUIP_SIGNER_SEED, account show and orders still
build a signer, to learn which account the seed is; they sign nothing
with it. account new, account import and account use write files
under .quip/ or ~/.quip/ and touch no chain.
Output and exit codes
With --json, a command prints exactly one JSON object on stdout.
Accounts are SS58 text and amounts are integers in planck; the text
output formats amounts in the network’s token instead. --json is an
option of the group, so it goes before the command:
xquad quip --network aglais --json account show. Errors go to stderr
prefixed Error:, in both modes. The no-account error carries the
account list guidance under it, and a usage error prints a Usage:
line above it; the per-order lines of reclaim --all and the
missing-extra hint have no prefix.
| Exit code | Meaning |
|---|---|
0 | Success. |
1 | The command failed: a chain or account error, the missing extra, no account set up, or a reclaim --all in which some order was not reclaimed. |
2 | A usage error, such as an unknown flag or --network with --url. |
Accounts
A Quip account is a keystore file holding a 32-byte master seed in plain
text. Keystores live at two levels, as git keeps config: a project level
in the nearest .quip/ directory above the working directory, and a
user level in ~/.quip/. Each level also holds an account preference
naming the keystore to sign with. Accounts
on the Quip Network page covers the layout and the full signer order;
this page covers the commands.
The outputs on this page were captured on 2026-10-26 in a fresh project
directory with a throwaway account, against aglais at specVersion 119.
Levels
account new, account import and account use act on one level:
--projectacts on the project level, creating.quip/in the working directory when none is found.--globalacts on the user level.- With neither, the level is the project when the working directory is inside one, else the user.
Anything created under a project .quip/ also writes a
.quip/.gitignore of * when there is none, so keystores stay out of
version control.
account list
xquad quip account list
Lists every keystore found, the order that decides which one signs, and how to change it. It reads local files only:
Keystores found:
* default project .quip/keystores/default.json
5FXRydTJYE4cPGBH9CSf3dyGvaJNnYATEZJMyFqMiLPHv9oc
Resolution order (the first match signs):
1 QUIP_SIGNER_SEED unset
2 QUIP_KEYSTORE unset
* 3 project preference default (.quip/config.toml)
4 user preference unset
5 default ~/.quip/keystore.json
Setting the account:
xquad quip account use <name> prefer <name> in this project, else for your user
xquad quip account use <name> --global prefer <name> for your user
xquad quip account use --unset [--global] clear the preference at that level
--keystore PATH, QUIP_KEYSTORE, QUIP_SIGNER_SEED override the preferences
* marks the keystore that signs and the step that chose it. A keystore
that cannot be read, or a preference naming one that does not exist, is
listed with a ! line saying why, rather than failing the listing.
With --keystore PATH, the keystore list marks that file * with an
(override) suffix, as a - row when it is not one of the listed
keystores, and no step of the resolution order is marked.
account show
xquad quip --network aglais account show
The setup check: the account in effect, where it came from, and what it holds on the network.
Account 5FXRydTJYE4cPGBH9CSf3dyGvaJNnYATEZJMyFqMiLPHv9oc
Source project preference default
Balance 0.000000000000 AGLS
Reserved 0.000000000000 AGLS
Orders 0 of 32 slots used
Balance is the free balance an order’s reward and fee are paid from.
Reserved is the reward still held by orders not yet closed, plus
anything else the runtime reserves. Orders counts the slots the
account’s unclosed orders hold against the runtime’s
MaxOrdersPerProposer; each order holds a slot until a claim or a
reclaim closes it, and the line reads unlimited when the runtime sets
no cap.
With --json:
{
"account": "5FXRydTJYE4cPGBH9CSf3dyGvaJNnYATEZJMyFqMiLPHv9oc",
"source": "project",
"balance": 0,
"reserved": 0,
"order_slots": {
"used": 0,
"limit": 32
}
}
With no usable account, it prints the account list guidance on stderr,
creates nothing, and exits 1:
Error: No Quip account is set up.
No keystores found. Create one with `xquad quip account new`, or bring a seed with `xquad quip account import`.
...
When nothing at all is set up (no seed, no override, no preference and
no default keystore) and the command runs at a terminal without
--json, it first offers to create one: new, import or skip (the
default), then the level and the name. After creating it, it shows the
new account when a network is named; without --network, --url or
QUIP_RPC_URL, the keystore is still created and the command then
exits 1 with the missing-network error. Skipping exits 1. A broken
step, such as a preference naming a missing keystore, gets an error
naming the fix instead of the offer.
account new and account import
xquad quip account new [--name NAME] [--project | --global] [--force] [--no-use]
xquad quip account import [--name NAME] [--project | --global] [--no-use]
account new writes a keystore from a fresh seed; account import
writes one from a seed you already have, as 32 bytes of hex. The
seed is read from a hidden prompt at a terminal, else from the first
line of stdin, and has no flag, so it never lands in shell history:
xquad quip account import --name work < seed.txt
--name defaults to default. Names are lowercase letters, digits, _
and -, at most 64 characters, starting with a letter or digit.
$ xquad quip account new --project
created default at the project level: .quip/keystores/default.json
5FXRydTJYE4cPGBH9CSf3dyGvaJNnYATEZJMyFqMiLPHv9oc
now in use at the project level; change it with `xquad quip account use`
The new account becomes the preference at its level, unless --no-use
is given or it is the user-level default, which signs when nothing
else is set and needs no preference. Neither command replaces an
existing keystore of the same name: account new --force replaces it
and names the account it replaced, and account import asks for
another --name.
account use
xquad quip account use NAME [--project | --global]
xquad quip account use --unset [--project | --global]
Sets the preference at a level to the keystore NAME, or clears it with
--unset. A project preference may name a project or a user keystore; a
user preference applies in every directory, so it names a user keystore.
Pass a name or --unset, not both. --keystore, QUIP_KEYSTORE and
QUIP_SIGNER_SEED still override any preference.
Here a user keystore work, made with account new --name work --global --no-use, is chosen for the project, then the preference is
cleared again:
$ xquad quip account use work
work is now in use at the project level: 5FRkgm6s4rddCTdMWGU4aPm3Uu642r96oCXLZmcwp184ByNp
$ xquad quip account use --unset
cleared the project account preference
account new, account import and account use take --json too,
before the command (xquad quip --json account use work --global),
printing the account, its name, the level and the keystore path (here
with the home directory shortened):
{
"account": "5FRkgm6s4rddCTdMWGU4aPm3Uu642r96oCXLZmcwp184ByNp",
"name": "work",
"level": "user",
"path": "/home/me/.quip/keystores/work.json"
}
account use --unset prints null for the account and name, and the
cleared config.toml as the path. account list --json prints the
keystores, the resolution steps and the step in effect.
Orders
These commands follow job orders already on chain. They need no model
and no session: an order id is enough. Placing an order is done from
Python, with create_order() or solve() (see Job
Orders).
The orders and status outputs on this page were captured on
2026-10-26 against aglais at specVersion 119, reading the orders
Your First Quip Job placed.
orders
xquad quip --network aglais orders [--state STATE] [--account SS58]
Lists the account’s orders not yet closed, newest first, one line each:
the order id, its state and the leader, tab-separated. The account is
the one in effect (see account show), or
--account names another; reading another account needs no keystore.
$ xquad quip --network aglais orders --account 5DL9B7rhMw1cg7kXzqmdj5QZ1V67vBvfYPq2bTEgZfSXiBSJ
24 final 5Ho6x2cpdd34W89rgFrUTms4JsEZfiWKyUpwW9vnpbGDvsN4
23 final 5GqG8c8zWHX33ruGJX4FDokxCTFrASVi268einKJY1XnHNYn
21 final 5GqG8c8zWHX33ruGJX4FDokxCTFrASVi268einKJY1XnHNYn
20 final 5GqG8c8zWHX33ruGJX4FDokxCTFrASVi268einKJY1XnHNYn
The state column is one of:
| State | Meaning |
|---|---|
open until block N | Still accepting answers. |
final | Final and answered; the reward awaits a claim. |
reclaimable | Final with no answers; reclaim refunds it. |
closed | Claimed or reclaimed; the chain records both alike. |
The leader is none until someone answers. The chain drops an order
from the account’s list once a claim or a reclaim closes it, so a
closed order is listed only by status. --state keeps the orders that
are submitted (still open), finalized, or unanswered (final with
no answers, the ones reclaim --all would take); an empty result says
so:
$ xquad quip --network aglais orders --account 5DL9B7rhMw1cg7kXzqmdj5QZ1V67vBvfYPq2bTEgZfSXiBSJ --state unanswered
no orders in state unanswered
At most the newest 32 orders are listed, with a warning when there are
more. With --json, the same fields come as one object per order:
{
"orders": [
{
"order_id": 24,
"state": "final",
"open_until": null,
"answers": 2,
"leader": "5Ho6x2cpdd34W89rgFrUTms4JsEZfiWKyUpwW9vnpbGDvsN4"
},
...
]
}
open_until is the block an open order closes at, null otherwise;
answers counts the solvers that answered.
status
xquad quip --network aglais status ORDER_ID [--short]
Prints the order’s receipt, the same box print(receipt) renders in
Python; The receipt box
explains each row. Any order on the network can be read, not only the
account’s own:
$ xquad quip --network aglais status 20
╭─ Job order 20 receipt ───────────────────────────────────────────────╮
│ Network aglais │
│ Proposer 5DL9B7rhMw1cg7kXzqmdj5QZ1V67vBvfYPq2bTEgZfSXiBSJ │
│ Submitted block 1898, 2026-10-15 04:22:24 UTC │
├──────────────────────────────────────────────────────────────────────┤
│ Problem 16 spins, 96 couplings │
│ Spec 0x8f46f3a31321d1d093314fc769c42cbe7a83d71a0b69e6571a0f68 │
│ e2a04067f0 │
├──────────────────────────────────────────────────────────────────────┤
│ Payout single best │
│ Access open │
│ Floors none │
│ Deadline 100 blocks │
│ Block wait 10 blocks │
│ Reward 1.000000000000 AGLS │
├──────────────────────────────────────────────────────────────────────┤
│ Status final │
│ Answers 2 │
│ Leader 5GqG8c8zWHX33ruGJX4FDokxCTFrASVi268einKJY1XnHNYn │
│ Energy -1790 │
╞══════════════════════════════════════════════════════════════════════╡
│ Fee 0.002011933394 AGLS │
│ Winner 1.000000000000 AGLS, awaiting claim │
╰──────────────────────────────────────────────────────────────────────╯
--short prints the one line orders prints, for scripts:
$ xquad quip --network aglais status 20 --short
20 final 5GqG8c8zWHX33ruGJX4FDokxCTFrASVi268einKJY1XnHNYn
xquad quip --network aglais --json status 20 --short prints that
line’s fields as one object; --json belongs to the group, so it goes
before the command. --json alone prints summary (those fields),
status (the receipt’s status() dict), solvers and settlement,
as Receipts describes them.
An id that names no order exits 1:
Error: order 999999 not found on-chain (QuantumComputeMempool.JobOrders)
reclaim
xquad quip --network aglais reclaim ORDER_ID
xquad quip --network aglais reclaim --all [--yes]
Returns the reserved reward of a final order nobody answered to the
account that proposed it. It is the one xquad quip command that signs:
each reclaim is a transaction from the account in effect and pays its
own fee. Pass an order id or --all, not both.
reclaim ORDER_ID refuses before signing when the order was proposed by
another account, is already closed, is still open, or was answered; the
reasons are listed under
Reclaiming. A refused reclaim
costs nothing.
reclaim --all takes every order orders --state unanswered lists. It
shows them and their total, then asks before signing anything. The
output below is illustrative, assembled from the messages in
xquad/cli/quip.py rather than captured, with made-up order ids:
$ xquad quip --network aglais reclaim --all
order 31 1.000000000000 AGLS
order 28 1.000000000000 AGLS
2 orders, 2.000000000000 AGLS to recover; each reclaim pays its own fee
Reclaim them? [y/N]: y
reclaimed order 31: 1.000000000000 AGLS refunded
reclaimed order 28: 1.000000000000 AGLS refunded
2.000000000000 AGLS recovered from 2 of 2 orders
The prompt defaults to no; declining reclaims nothing and exits 1.
Without a terminal, or with --json, there is no prompt, so --yes is
required:
Error: pass --yes to reclaim without a prompt
With nothing to reclaim it prints nothing to reclaim on <account>, or
with --json {"reclaimed": [], "refused": [], "total": 0}, and exits
0. An order that is refused or fails part way is reported on stderr as
order N not reclaimed: <reason>, and the rest still go ahead; the
command then exits 1. With --json, reclaim ORDER_ID prints
order_id and refunded, and reclaim --all --yes prints the
reclaimed and refused lists and the total refunded, in planck.
XQVM Reference
XQVM is the virtual machine at the core of XQuad: a stack-based interpreter
with a 256-slot register file that executes compiled quadratic-optimisation
programs. This section is the reference for that machine – its execution
model, assembly language, instruction set, binary format, and verifier. For
what a QUBO/Ising model is and why you would want one, start at
Concepts instead; this section assumes you already
have a program, or want to write one directly in .xqasm, and want to know
exactly what the machine does with it.
A minimal program
Save this as add.xqasm, then assemble and run it:
; push two integers and add them
PUSH 10
PUSH 32
ADD
HALT
$ xquad vm asm add.xqasm -o add.xqb
assembled 4 instructions (21 bytes) -> add.xqb
$ xquad vm run add.xqb
stack (bottom to top):
42
The program pushes 10 and 32 onto the value stack, adds them, and halts. The
result remains on the stack and is printed by xquad vm run. xquad vm run --text add.xqasm skips the separate assembly step and runs the source directly.
What this section covers
- VM Architecture – the value stack, the 256-slot register file, and the loop stack.
- Loop Stack –
RANGE/ITER/NEXTiteration. - Calldata and Outputs – getting values into a program with
INPUTand out withOUTPUT. - Execution – the fetch-decode-execute cycle and execution tracing.
- Assembly – the
.xqasmtext syntax. - Assembly Examples – worked
.xqasmprograms. - Instructions – all 93 instructions, by category.
- Opcode Reference – the generated opcode table.
- Bytecode Format – the
.xqbwire format. - Verifier – what the pre-execution verifier checks.
- CLI –
xquad vm, the XQVM commands. - Limits and Errors – fixed and configurable limits, and every runtime and verifier error.
VM Architecture
XQVM is a stack-based bytecode interpreter. A running VM holds four pieces of mutable state:
| State | Holds | Notes |
|---|---|---|
| Stack | i64 values | LIFO operand stack, max 8,192 items |
| Register File | RegVal values | 256 slots, indexed r0–r255 |
| Loop Stack | loop frames | one per active RANGE/ITER; see Loops |
| Calldata / Outputs | RegVal values | read-only inputs (INPUT) and writable output slots (OUTPUT); see Calldata and Outputs |
Design Principles
- Stack-based computation – arithmetic and comparisons operate on an integer stack. This keeps the instruction set simple and compact.
- Typed register file – registers hold polymorphic
RegValvalues (integers, vectors, models, samples). Type checking happens at runtime. - No heap / no pointers – there is no explicit memory allocation. Vectors and models grow dynamically within registers. Programs cannot address raw memory.
- Deterministic execution – given the same program, calldata, and configuration, the VM always produces the same output. There are no random instructions or non-deterministic operations.
- Embeddable – the VM crate supports
no_std + alloc, enabling deployment in WASM runtimes and bare-metal environments.
Operand Stack
The operand stack is the primary workspace for computation. It holds i64
signed 64-bit integers and is used by arithmetic, comparison, logical, and
bitwise instructions.
Properties
| Property | Value |
|---|---|
| Element type | i64 (signed 64-bit integer) |
| Maximum depth | 8,192 items |
| Ordering | LIFO (last in, first out) |
| Initial state | Empty |
Operations
- Push –
PUSH1–PUSH8push constants.LOADpushes a register’s integer value.COPYduplicates the top element. - Pop – most instructions implicitly pop their operands.
POPexplicitly discards the top element. - Swap –
SWAPexchanges the top two elements. - Clear –
SCLRremoves all elements.
Stack Diagrams
Throughout this documentation, stack effects are written as:
$$[\ldots, a, b] \to [\ldots, r]$$
- \(\ldots\) represents elements below the operands.
- Rightmost = top of stack.
- \(b\) is popped first (it was pushed last).
- \(r\) is the result pushed after the operation.
Errors
StackUnderflow– popping from an empty stack or when there are fewer elements than the instruction requires.StackOverflow– pushing when the stack already contains 8,192 items.
Interaction with Registers
The stack holds only i64 integers. Richer types (models, vectors, samples)
live exclusively in registers. The bridge between them:
LOAD reg– pushes a register’sIntvalue onto the stack.STOW reg– pops a stack value into a register asInt.
To move non-integer values, use INPUT/OUTPUT with calldata and output slots.
Register File
The register file is a fixed array of 256 slots, indexed r0 through r255.
Each slot holds a typed RegVal value.
Properties
| Property | Value |
|---|---|
| Count | 256 (r0–r255) |
| Index type | u8 |
| Value type | RegVal (polymorphic enum) |
| Default value | Unset for all slots |
RegVal Variants
| Variant | Rust Type | Description |
|---|---|---|
Unset | – | Default. No value; a register never written, or reset by DROP. |
Int(i64) | i64 | Exchanged with the stack via LOAD/STOW. |
VecInt(Vec<i64>) | Vec<i64> | Integer vector. Created by VEC/VECI. |
VecXqmx(Vec<XqmxModel>) | Vec<XqmxModel> | Vector of models. Created by VECX. |
Model(XqmxModel) | struct | QUBO/Ising/integer Hamiltonian. Created by BQMX/SQMX/XQMX. |
Sample(XqmxSample) | struct | Variable-assignment vector. Created by BSMX/SSMX/XSMX. |
Type Checking
Register access is type-checked at runtime. Instructions that expect a
specific variant (e.g. LOAD expects Int, VECPUSH expects VecInt,
SETQUAD expects Model, and SETLINE accepts either Model or
Sample) will produce a RegisterType error if the register holds a
variant the instruction does not accept. The error message includes the expected and actual
type names. Reading an Unset register (via LOAD or OUTPUT) is a separate
case: it produces an UnsetRegister error rather than RegisterType, since
there is no variant to compare against.
XqmxModel Structure
A model represents a QUBO/Ising/integer Hamiltonian:
XqmxModel {
domain: Domain, // Binary | Spin | Integer(k)
size: usize, // number of variables
linear: BTreeMap<usize, i64>, // bias terms h_i
quadratic: BTreeMap<(usize,usize), i64>, // coupling terms J_{ij}
rows: usize, // grid rows (set by RESIZE)
cols: usize, // grid cols (set by RESIZE)
}
Coefficients are stored sparsely. Missing entries read as 0; setting a
coefficient to 0 removes it from the map.
XqmxSample Structure
A sample holds a vector of variable assignments:
XqmxSample {
domain: Domain, // must match the model's domain
values: Vec<i64>, // one value per variable
rows: usize, // grid rows (set by RESIZE; 0 if ungridded)
cols: usize, // grid cols (set by RESIZE; 0 if ungridded)
}
Memory Management
There is no garbage collector. Registers hold their values until explicitly
overwritten. Use DROP reg to reset a register to Unset, releasing any
heap allocation (models, vectors, samples) it held. A register reset this way
faults with UnsetRegister on the next LOAD or OUTPUT, until something is
written back into it.
Loop Stack
The loop stack manages RANGE and ITER loop state. Each active loop pushes a
frame; NEXT either advances the loop or pops the frame when iteration
completes.
Loop Frames
Each frame records:
- Kind –
RangeorIter. body_start– byte offset of the first instruction afterRANGE/ITER. This is whereNEXTseeks back to on each iteration.
Range Loops
A range frame tracks two values:
current– the current iteration value.end– the exclusive upper bound (start + count).
RANGE pops count and start from the stack. The loop iterates current
from start to end - 1 (where end = start + count). On each
NEXT, current is incremented. If current < end, execution seeks back to
body_start; otherwise the frame is popped and execution falls through. If
count is zero or negative, no frame is pushed at all: execution scans
forward past the matching NEXT and the body never runs.
PUSH 5 ; start = 5
PUSH 3 ; count = 3
RANGE ; iterates current = 5, 6, 7
LVAL r0 ; r0 ← Int(current)
; ... body ...
NEXT
Iterator Loops
An iterator frame tracks three values:
elements– a copy of the slicevec[start_idx..end_idx], holding either integers or models depending on the source register’s variant.start_offset– the originalstart_idx, used byLIDXto report absolute positions.index– the current position withinelements.
ITER reg pops end_idx, then start_idx, validates that reg holds
VecInt or VecXqmx, and copies vec[start_idx..end_idx] into a new
frame with index = 0. The slice is duplicated so that mutations to the
source vec inside the loop body do not affect what LVAL sees.
On each NEXT, index is incremented. If index is still within the
copied elements, execution seeks back to body_start; otherwise the frame
is popped.
; Assume r1 holds VecInt([10, 20, 30, 40, 50])
PUSH 1
PUSH 4
ITER r1 ; iterate r1[1..4] -> values 20, 30, 40
LVAL r2 ; r2 -> Int(20), Int(30), Int(40)
LIDX r3 ; r3 -> Int(1), Int(2), Int(3) (absolute positions)
; ... body ...
NEXT
ITER errors with IndexOutOfBounds if either index is negative or exceeds
vec.len().
An empty slice skips the body, exactly like RANGE with a count of zero: no
frame is pushed and execution resumes after the matching NEXT. The condition
is start_idx >= end_idx, so an inverted range is empty rather than an error,
and a slice that is never taken is not bounds-checked.
LVAL – Reading the Loop Value
LVAL reg copies the current loop value into a register:
- Range:
reg ← Int(current) - Iter over VecInt:
reg ← Int(elements[index])(the slice copy, not the source vec) - Iter over VecXqmx:
reg ← Model(elements[index])(cloned)
The element type is preserved: iterating over a VecXqmx yields Model
values, not integers. Because elements is a slice copy taken at ITER
time, mutating the source vec inside the loop body never changes what
subsequent LVAL calls return.
LIDX – Reading the Loop Index
LIDX reg copies the current loop index into a register:
- Range:
reg ← Int(current)(identical toLVALbecause Range values are themselves indices). - Iter:
reg ← Int(start_offset + index)– the absolute position in the source vec, not the 0-based slice position. This lets loop bodies reach back into the source vec by absolute index even after slicing.
Nesting
Each RANGE or ITER pushes a new frame, and LVAL and NEXT always
operate on the innermost (most recently pushed) frame. The loop stack
is capped at 8,192 frames, the same cap the value stack has carried since
the first release; a program past it fails with LoopStackOverflow. Real
nesting never approaches that. The cap exists because only NEXT pops a
frame, so a back-edge that re-enters a loop header without running its
NEXT grows the stack once per execution and would otherwise be bounded
only by the step budget.
PUSH 0
PUSH 3
RANGE ; outer loop: 0, 1, 2
LVAL r0
PUSH 0
PUSH 4
RANGE ; inner loop: 0, 1, 2, 3
LVAL r1
; r0 = outer value, r1 = inner value
NEXT
NEXT
Errors
NoActiveLoop–NEXT,LVAL, orLIDXwith an empty loop stack.RegisterType–ITERon a register that is notVecIntorVecXqmx. Raised before the empty-slice check, so anITERon the wrong register type faults whether the slice is empty or not.LoopStackOverflow–RANGEorITERpushing a frame past the 8,192-frame cap.IndexOutOfBounds–ITERwith a slice index outside the vector.ArithmeticOverflow–RANGEwhosestart + countleaves thei64range. The bound is computed before the frame is pushed, so the loop does not run at all.
Calldata and Outputs
Calldata and output slots provide the interface between the VM and the host environment. They allow programs to receive input and return results without direct access to external systems.
Calldata (Input)
Calldata is a read-only array of RegVal values, set before execution begins.
Programs access calldata via the INPUT instruction:
PUSH 0 ; slot index
INPUT r0 ; r0 ← calldata[0]
Any RegVal variant can be passed as calldata: integers, vectors, models, and
samples. This enables multi-program pipelines where one program’s output model
becomes another program’s input.
Setting Calldata (Rust API)
#![allow(unused)]
fn main() {
let mut vm = Vm::new();
vm.set_calldata(vec![
RegVal::Int(42),
RegVal::VecInt(vec![1, 2, 3]),
RegVal::Model(my_model),
]);
}
Setting Calldata (CLI)
xquad vm run program.xqb --calldata 10,20,30
The CLI --calldata flag only supports integer values. For richer types, use
the Rust API.
Output Slots
Output slots are a writable array of RegVal values, initialised to Unset
(the same default a fresh register holds). Programs write to output slots via
the OUTPUT instruction:
PUSH 0 ; slot index
OUTPUT r0 ; outputs[0] ← r0
Reading Outputs (Rust API)
#![allow(unused)]
fn main() {
let mut vm = Vm::new();
vm.set_output_slots(4);
vm.run(&program)?;
for (i, val) in vm.outputs().iter().enumerate() {
println!("[{i}] = {val:?}");
}
}
Reading Outputs (CLI)
xquad vm run prints all non-default (not Unset) output slots after
execution. --outputs defaults to 16 when omitted:
xquad vm run program.xqb --outputs 4
outputs:
[0] = Int(42)
Pipeline Pattern
Calldata and outputs enable multi-program pipelines. A common pattern in the TSP example:
- Encoder receives
Nand distances as calldata, outputs a QUBO model. - Verifier receives the model and a sample as calldata, outputs energy and validity.
- Decoder receives the sample as calldata, outputs the tour.
Each program runs in its own Vm instance. The host (Rust code or pallet)
marshals outputs from one VM into calldata for the next.
Errors
CallDataIndex–INPUTwith an index ≥ calldata length.OutputIndex–OUTPUTwith an index ≥ output slot count.UnsetRegister–OUTPUTfrom a register that was never written, or was reset withDROP.
Execution Model
This chapter describes how the VM fetches, decodes, and executes instructions.
Fetch-Decode-Execute Cycle
The VM processes instructions in a loop:
1. Fetch and decode the next instruction from the instruction stream
- end of stream → stop execution, without consulting the step budget
2. Check the step budget → StepLimitExceeded if the counter already
reached the limit; the fetched instruction does not execute
3. Increment the step counter
4. Dispatch to the handler for that instruction
5. Handle the control flow result:
- Continue → advance to next instruction
- Halt → stop execution
- Jump(id) → seek to the byte offset recorded for that TARGET id
- Seek(off) → seek to byte offset (used by NEXT)
- StartLoop → a loop frame was pushed; continue to the next instruction
- SkipLoop → loop count was zero or negative; scan past the matching NEXT
6. Repeat from step 1
The fetch comes before the budget check, not after, and that ordering is normative rather than an implementation detail – see Step budget in the specification. The fetch that finds no instruction ends the run before the budget is consulted, so it is not a step.
Instruction Stream
The instruction stream is a cursor over the program’s raw bytecode. It decodes one instruction at a time, advancing the cursor past the opcode byte and its operands. The stream supports seeking to arbitrary byte offsets for jumps and loop backs.
Before execution begins, the raw bytecode is scanned once for TARGET
opcodes: each is assigned the next sequential id (0, 1, 2, …) in program
order, and its byte offset is recorded against that id. A Jump result
carries one of these ids; the run loop resolves it to the recorded offset and
seeks the stream there.
Each decoded instruction yields:
- Byte offset – position in the bytecode buffer.
- Optional label – the sequential
TARGETid recorded at this offset, if any. - Instruction – the fully decoded instruction with typed operands.
Step Counting
The VM maintains two counters, not one. instructions counts dispatches: it
increments once per instruction, regardless of what that instruction does.
steps counts metered cost units: every instruction charges a base cost
before dispatch, and opcodes whose work scales with data the program
controls – evaluating a model, expanding a constraint, copying a register
that holds a model – charge more before they do that work. A step is not an
instruction; see spec/xqvm/METERING.md
for the full cost model. A configurable step limit (default: 10,000,000)
prevents runaway programs by bounding steps, not instructions. When a
charge does not fit the remaining budget, execution stops with a
StepLimitExceeded error.
#![allow(unused)]
fn main() {
let mut vm = Vm::new();
vm.set_step_limit(1_000_000); // custom limit
vm.set_step_limit(0); // exact: permits no instructions at all
vm.set_unlimited_steps(); // the only unbounded spelling
}
The limit is exact. Until 0.4.0 set_step_limit(0) meant u64::MAX,
which made a zero budget the most dangerous value a caller could pass
rather than the safest – the wrong way round for anything taking a limit
from untrusted input. There is no sentinel now: set_unlimited_steps()
is how a caller opts out, and it has to be written.
Both counters are accessible after execution: vm.steps() returns the
metered cost total, vm.instructions() returns the dispatch count.
instructions() reports the exact number of instructions dispatched, whether
the program ends at a HALT or by running off the end of the instruction
stream: the loop probes for the next instruction before it counts anything,
so the fetch that finds nothing and breaks the loop is never counted.
One exception is worth knowing: when a loop opener skips an empty body, the
scan forward to the matching NEXT charges steps for each instruction it
consumes but does not dispatch them, so those instructions are metered
without being counted in instructions().
Allocation Accounting
Steps bound how long a program runs, not how much memory it asks for: a
three-instruction program can name a sample of any size the value stack can
hold. A second counter tracks bytes. Every allocating instruction is charged
against a configurable budget (default: 1 GiB) before it allocates, and one
that cannot pay stops execution with a MemoryLimitExceeded error without
allocating anything.
#![allow(unused)]
fn main() {
let mut vm = Vm::new();
vm.set_memory_limit(16 * 1024 * 1024); // 16 MiB
}
vm.memory_used() reports what the run spent. See
Runtime Limits for the per-opcode charges.
Control Flow Results
Each instruction handler returns a StepResult that tells the execution loop
what to do next:
| Result | Meaning |
|---|---|
Continue | Advance to the next instruction in sequence. |
Halt | Stop execution. Returned by HALT. |
Jump(label) | Seek the instruction stream to the byte offset recorded for that TARGET id. |
Seek(offset) | Seek to a raw byte offset. Used by NEXT to loop back. |
StartLoop | A loop frame was pushed; continue to the next instruction (which becomes the loop body start). |
SkipLoop | The loop count was zero or negative; scan forward past the matching NEXT without pushing a frame. |
Tracing
The VM supports optional step-by-step tracing via the Tracer trait. When
tracing is enabled, the VM captures state before and after each instruction:
#![allow(unused)]
fn main() {
pub struct StepState<'a> {
pub pos: usize, // byte offset
pub step: u64, // step count
pub instruction: &'a Instruction, // decoded instruction
pub stack: &'a [i64], // current stack
pub read_regs: &'a [(u8, RegVal)], // registers read
pub written_regs: &'a [(u8, RegVal)], // registers written
pub loop_depth: usize, // nesting level
}
}
Two built-in tracer implementations are provided:
TextTracer– human-readable aligned columns, written to anyWritetarget.JsonTracer– one JSON object per step (JSONL format).
When tracing is disabled (NoopTracer), the tracer code is eliminated by dead
code optimisation, adding zero overhead to execution.
Error Handling
Runtime errors carry the byte offset (pos) of the faulting instruction,
enabling precise error reporting. When the std feature is enabled, errors can
be converted to miette::Diagnostic with a disassembled listing highlighting
the faulting instruction.
Assembly Language
XQVM programs are written in a simple assembly language and stored in .xqasm
files. The assembler (the xqasm crate, invoked via xquad vm asm) parses
the source, resolves labels, and emits compact bytecode.
Overview
- Line-oriented format: one instruction per line.
- Comments start with
;and run to end of line. - Mnemonics are case-insensitive (
PUSH,push,Pushall work). - Labels use numeric
.Nsyntax (.0,.1,.42). - Registers use
r<digits>syntax (r0,r255). - Integer literals may be signed decimal or
0x-prefixed hexadecimal.
Quick Example
; Compute 10 + 32 = 42
PUSH 10
PUSH 32
ADD
HALT
Assembly Syntax
This page defines the complete syntax of the XQVM assembly language, derived
from the canonical PEG grammar in xqasm/src/grammar.pest.
Line Structure
Each source line has the form:
[label_def:] [INSTRUCTION [operands...]] [; comment]
All three parts are optional. Blank lines and comment-only lines are valid.
Examples
; blank line (valid)
; this is a comment ; comment-only line
PUSH 42 ; instruction only
.0: NOP ; label + instruction
.1: ; label only (anchors a jump target)
LOAD r0 ; register operand
JUMP .0 ; label reference operand
Comments
Comments begin with ; and extend to the end of the line. They can appear
on their own or after an instruction:
; full-line comment
PUSH 10 ; inline comment
Mnemonics
Instruction mnemonics are case-insensitive ASCII identifiers. All of these are equivalent:
PUSH 42
push 42
Push 42
The assembler recognises 85 of the 93 XQVM mnemonics directly. The eight
PUSH1–PUSH8 opcodes are not typeable: PUSH is a special mnemonic that
accepts an integer operand and selects the smallest of those encodings for
you, and writing one of them by hand is rejected as an unknown mnemonic.
PUSHC is an alias for PUSH. JUMP and JUMPI are the same kind of
sugar, but layered on top of opcodes that are themselves among the 85:
JUMP1, JUMPI1, JUMP2, JUMPI2 are part of the 93 and can be written
directly, though doing so interacts badly with the unused-label check – see
Control Flow. JUMP and JUMPI
are two further mnemonics, not among the 93, that pick the narrowest width
for you the same way PUSH picks a PUSHn width. See Stack
Manipulation for the width rules.
Operands
Three operand types exist:
Registers
r0, r1, r2, ..., r255
A lowercase r followed by 1–3 decimal digits. Valid range: r0–r255.
Integer Literals
42 ; positive decimal
-99 ; negative decimal
+7 ; explicit positive
0xFF ; hexadecimal (0x prefix)
0x0 ; hex zero
Integers are signed i64 values. Decimal and hexadecimal (0x prefix) formats
are supported. An optional + or - sign may precede the digits.
Label References
.0, .1, .42, .255
A dot followed by one or more decimal digits. Label references are used as
operands for the JUMP/JUMPI mnemonics (and their explicit-width forms
JUMP1/JUMPI1/JUMP2/JUMPI2).
Labels
Labels are defined either with the .N: shorthand or the explicit TARGET .N
directive:
.0: NOP ; shorthand: define label .0 at this position
.1: ; label on its own line (useful for readability)
TARGET .2 ; explicit form: identical to ".2:"
HALT
Both forms compile to the same bytecode: placing a label emits an inline
TARGET opcode at the current position and records that position under the
label’s assigned id. .0: and TARGET .0 are interchangeable spellings for
the same operation; pick whichever reads better in context. Defining the same
label with both forms is a DuplicateLabel error, the same as defining .N:
twice.
A bare TARGET (no operand) emits a raw Target opcode without binding any
label. That’s useful only for hand-built bytecode where you do not need a
corresponding jump destination; user-facing programs should use the labelled
forms.
Labels must be defined before or after they are referenced – both forward and
backward references are resolved by the assembler. Every label used as a
JUMP/JUMPI target must be defined somewhere in the program.
The .N digits are assembler-only syntax: they let the assembler pair a
JUMP/JUMPI reference with the .N: (or TARGET .N) that defines it, and
they are never emitted into the bytecode. What reaches the instruction
stream is only a sequence of bare TARGET opcodes. See Bytecode
Format for how a decoder
assigns those opcodes their sequential ids and resolves a jump operand
against them.
Whitespace
Spaces and tabs between tokens are ignored. Lines are separated by \n or
\r\n. Indentation is purely cosmetic and has no semantic meaning. A common
convention is to indent loop bodies:
PUSH 0
PUSH 10
RANGE
LVAL r0
LOAD r0
PUSH 2
MUL
POP
NEXT
Error Reporting
The assembler uses miette for rich terminal diagnostics. Errors include the source file name, line/column numbers, and a snippet highlighting the problematic token:
Error: xqasm::unknown_mnemonic
× unknown mnemonic `BADOP`
╭─[bad.xqasm:2:1]
1 │ PUSH 1
2 │ BADOP r0
· ──┬──
· ╰── unknown mnemonic
3 │ HALT
╰────
These are the Rust API’s diagnostics. xquad vm asm
reports the same error from the command line as one line, without the
snippet.
Assembly Examples
This page presents annotated XQVM assembly programs, from simple to complex.
Hello, Stack
Push two numbers, add them, and halt. The result remains on the stack.
PUSH 10 ; stack: [10]
PUSH 32 ; stack: [10, 32]
ADD ; stack: [42]
HALT
Conditional Branch
Skip an instruction if a condition is true:
PUSH 5
PUSH 10
GT ; 5 > 10 ? -> 0 (false)
JUMPI .0 ; condition is 0, so we fall through
PUSH 99 ; this executes (condition was false)
JUMP .1
.0: PUSH 0 ; taken path leaves the stack at the same depth
.1: HALT
Summing a Range
Accumulate the values of a range loop into a register:
PUSH 0 ; accumulator in r0
STOW r0
PUSH 0
PUSH 3
RANGE ; iterate 0, 1, 2
LVAL r1 ; r1 = loop value
LOAD r0
LOAD r1
ADD
STOW r0 ; r0 += r1
NEXT
LOAD r0 ; push accumulated value (0+1+2 = 3)
HALT
Fibonacci Sequence
Compute the first N Fibonacci numbers and store them in a vector:
; N is passed as calldata[0]
PUSH 0
INPUT r0 ; r0 = N
VEC r1 ; r1 = empty vec
; Push first two values
PUSH 0
VECPUSH r1 ; vec = [0]
PUSH 1
VECPUSH r1 ; vec = [0, 1]
; Compute remaining values
LOAD r0
PUSH 2
SUB ; count = N - 2
STOW r2
PUSH 0
LOAD r2
RANGE
LVAL r3 ; r3 = loop index (unused, just for iteration)
VECLEN r1
DEC
STOW r4 ; r4 = last index
LOAD r4
DEC
VECGET r1 ; stack: fib[n-2]
LOAD r4
VECGET r1 ; stack: fib[n-2], fib[n-1]
ADD ; stack: fib[n]
VECPUSH r1 ; append to vec
NEXT
; Output the vector
PUSH 0
OUTPUT r1
HALT
Building a QUBO Model
Create a simple 3-variable QUBO and set coefficients:
; Allocate a 3-variable binary model
PUSH 3
BQMX r0
; Set linear coefficients: h = [-1, -2, -3]
PUSH 0
PUSH -1
SETLINE r0 ; linear[0] = -1
PUSH 1
PUSH -2
SETLINE r0 ; linear[1] = -2
PUSH 2
PUSH -3
SETLINE r0 ; linear[2] = -3
; Set quadratic coefficient: J[0,1] = 4
PUSH 0
PUSH 1
PUSH 4
SETQUAD r0 ; quad[0,1] = 4
; Output the model
PUSH 0
OUTPUT r0
HALT
Grid with One-Hot Constraints
Set up a 2x3 grid model with one-hot constraints on each row:
; 6 variables in a 2x3 grid
PUSH 6
BQMX r0
PUSH 2 ; rows
PUSH 3 ; cols
RESIZE r0
; One-hot constraint on each row with penalty = 100
PUSH 0
PUSH 2
RANGE
LVAL r1
LOAD r1
PUSH 100
ONEHOTR r0
NEXT
; Output
PUSH 0
OUTPUT r0
HALT
Instruction Set Reference
This section documents all 93 XQVM instructions, organised into the 14 categories below. Each page covers the semantics, error conditions, and worked examples for one category. For the opcode byte, mnemonic, operand layout, and stack effect of every instruction, see the generated Opcode Reference – that table is the single source of truth for wire-level facts, so the pages in this section carry prose and teaching content instead of repeating it.
Notation
- Stack effect – described in prose on this page (“Pop
b, thena”) and summarised on the generated Opcode Reference as aStackcolumn of the form2 → 1, meaning two values popped and one pushed. The value stack is last-in-first-out: whichever value was pushed most recently is popped first, soPUSH a; PUSH b; SUBcomputesa - b, notb - a. Every “popx, theny” operand order on this section’s pages, including the ones on constraints.md and index-math.md, depends on this rule. reg– theu8operand encoded in the instruction byte stream, identifying a register slot (r0–r255).label– a sequential id assigned to eachTARGETinstruction, in program order, during the load-time pre-scan described in Bytecode Format. It is not a byte offset, and not the.Ntoken used in.xqasmsource – that token is assembler-only and is resolved to the sequential id before encoding.JUMP1/JUMPI1encode the id as a singleu8operand;JUMP2/JUMPI2encode it as au16operand (big-endian). See Control Flow for how the assembler picks between the two.- Assignments use \(\leftarrow\) (register write) and \(\to\) (stack push).
- Iverson brackets – \([P]\) equals \(1\) if \(P\) is true, \(0\) otherwise.
- Checked arithmetic – every integer operation is checked against the
i64range. A result that would leave it raisesArithmeticOverflowrather than wrapping or panicking; see Arithmetic.
Register Effect Vocabulary
A handful of pages, constraints.md and
energy.md, annotate individual opcodes with a **Register effect:** line using three terms:
read– register contents are inspected but not changed.write– register is replaced wholesale with a new value.mutate– register’s existing value is modified in-place (e.g. appending to a vec, incrementing a coefficient).
Most category pages group opcodes by topic or mechanism rather than walking through them one at a time, so they carry no such line.
RegVal – The Register Value Type
Each of the 256 registers holds one variant of RegVal – Unset, Int,
VecInt, VecXqmx, Model, or Sample. See VM
Architecture for the full table.
Type mismatches at runtime produce a RegisterType error with the expected
and actual variant names; reading an Unset register via LOAD or OUTPUT
produces UnsetRegister instead, since there is no variant to compare
against.
Categories
| Category | Page | Opcodes |
|---|---|---|
| Control Flow | control-flow.md | TARGET, JUMP1, JUMP2, JUMPI1, JUMPI2, NEXT, LVAL, LIDX, RANGE, ITER, NOP, HALT |
| Register I/O | register-io.md | LOAD, STOW, DROP, INPUT, OUTPUT |
| Stack Manipulation | stack-manipulation.md | POP, PUSH1-PUSH8, SCLR, SWAP, COPY |
| Arithmetic | arithmetic.md | ADD, SUB, MUL, DIV, MOD, SQR, ABS, NEG, MIN, MAX, INC, DEC, BITLEN |
| Comparison | comparison.md | EQ, LT, GT, LTE, GTE |
| Logical Boolean | logical.md | NOT, AND, OR, XOR |
| Bitwise | bitwise.md | BAND, BOR, BXOR, BNOT, SHL, SHR |
| Allocators | allocators.md | BQMX, SQMX, XQMX, BSMX, SSMX, XSMX, VEC, VECI, VECX |
| Vector Operations | vector-ops.md | VECPUSH, VECGET, VECSET, VECLEN, SLACK |
| Index Math | index-math.md | IDXGRID, IDXTRIU |
| Coefficient Access | coefficient-access.md | GETLINE, SETLINE, ADDLINE, GETQUAD, SETQUAD, ADDQUAD |
| Grid Operations | grid.md | RESIZE, ROWFIND, COLFIND, ROWSUM, COLSUM |
| High-Level Constraints | constraints.md | ONEHOTR, ONEHOTC, EXCLUDE, IMPLIES, EQUALITY, ATLEAST, ATLEASTW, REDUCE |
| Energy Evaluation | energy.md | ENERGY |
Reserved Opcodes
Every byte value not listed in Opcode Reference is illegal;
the decoder rejects it. The 93 assigned bytes are not contiguous – there are
gaps both between and within the ranges above, plus one large unassigned
block above the normal instruction space. The full gap set, derived by
diffing the 93 assigned byte values against the complete 0x00-0xFF space:
| Range | Unassigned bytes |
|---|---|
| Register I/O | 0x0D |
| Stack Manipulation | 0x19, 0x1D-0x1F |
| Arithmetic | 0x2D-0x2F |
| Comparison | 0x35 |
| Allocators | 0x46-0x49, 0x4D-0x4F |
| Vector Operations | 0x55-0x59 |
| Index Math / Coefficient Access | 0x5C-0x5F |
| Grid Operations | 0x6B-0x6F |
| High-Level Constraints | 0x78-0x7E |
| Outside the normal instruction space | 0x80-0xEF, 0xF1-0xFE |
NOP (0xF0) and HALT (0xFF) are the only two assigned bytes outside
0x00-0x7F; every other byte in 0x80-0xFF is illegal. See
spec/xqvm/ISA.md
for the normative list, organised the same way.
Control Flow
Instructions for branching, looping, and program termination: TARGET,
JUMP1/JUMP2, JUMPI1/JUMPI2, NEXT, LVAL, LIDX, RANGE, ITER,
NOP, and HALT. For each instruction’s opcode byte, operand layout, and
stack effect, see the Opcode Reference.
The TARGET pre-scan
TARGET has no operand and does nothing at runtime – it exists purely to
mark a valid jump destination at a fixed byte position. Before execution
begins, the VM scans the raw instruction stream once for TARGET opcodes:
the first one encountered is assigned sequential id 0, the second id 1,
and so on in program order, with each id recorded against the byte offset
where its TARGET starts. This is the same scan the
verifier uses to check that every jump references a valid
id, and the Bytecode Format chapter covers its
wire-level details.
JUMP1, JUMP2, JUMPI1, and JUMPI2 operands carry one of these
sequential ids – never a raw byte offset, and never the .N token that
appears in .xqasm source. .N is resolved to the sequential id by the
assembler before encoding and is never itself emitted into the bytecode.
When the VM executes a jump, it looks up the id’s recorded byte offset and
seeks the instruction stream there; see Execution Model
for how this fits into the fetch-decode-execute loop.
TARGET must appear at every label destination. The assembler inserts one
automatically wherever a label is placed in source, so authors do not
normally type it by hand:
; Shorthand: label form
.0: HALT
; Explicit form: TARGET directive bound to a label
TARGET .0
HALT
Both compile to [TARGET, HALT]. Use whichever is clearer in context. A
bare TARGET (with no operand) emits a raw Target opcode without binding
any label; that is only useful for direct bytecode construction, and most
user programs should prefer one of the label-bearing forms above.
Branching
JUMP unconditionally seeks to the byte offset recorded for a label.
JUMPI pops the top of the stack and seeks only if that value is
non-zero; otherwise it falls through to the next instruction.
Each has a narrow and a wide encoding, distinguished by the width of the label operand:
JUMP1/JUMPI1encode the label as a singleu8byte.JUMP2/JUMPI2encode the label as au16big-endian pair.
At the assembly level, both are written as JUMP .N / JUMPI .N (.N being
the dot-prefixed label token, e.g. .0, .1); the assembler resolves .N
to its sequential id and picks the narrowest encoding automatically – the
*1 form whenever the id fits in a u8 (the common case, since most
programs have fewer than 256 labels), falling back to the *2 form
otherwise. The desugared forms (JUMP1 .N, JUMP2 .N, JUMPI1 .N,
JUMPI2 .N) are accepted by the grammar, but the assembler’s unused-label
check does not count them as a use: a program whose only reference to .0
is JUMP1 .0 fails with xqasm::unused_label, whichever side of the label
the jump sits on. The one exception is a label whose TARGET lands at byte
offset 0, the entry block, which the check exempts. Write JUMP/JUMPI
and let the assembler pick the width. Disassembled output
always shows the explicit form the program was actually encoded with, so a
round-tripped disassembly preserves the exact wire encoding rather than
the assembler’s shorthand.
Looping
RANGE and ITER each push a loop frame and hand control to the loop body;
NEXT advances the innermost frame, seeking back to the body’s start until
the loop is exhausted, then pops the frame. LVAL copies the current loop
value into a register, and LIDX copies the current loop index. Calling
LVAL, LIDX, or NEXT with no active loop frame raises NoActiveLoop.
PUSH 0 ; start
PUSH 10 ; count
RANGE
LVAL r0 ; r0 = current iteration value (0, 1, ..., 9)
NEXT
HALT
VECI r1
PUSH 10
VECPUSH r1
PUSH 20
VECPUSH r1
PUSH 30
VECPUSH r1
PUSH 40
VECPUSH r1
PUSH 0 ; start_idx
PUSH 4 ; end_idx
ITER r1 ; r1 must hold a VecInt or VecXqmx
LVAL r2 ; r2 = current element (10, 20, 30, 40)
LIDX r3 ; r3 = absolute position in r1 (0, 1, 2, 3)
NEXT
HALT
Loops nest to arbitrary depth; LVAL, LIDX, and NEXT always act on the
innermost frame. Loops covers frame contents, the RANGE
versus ITER distinction, nesting, and the full error conditions in depth
– this page only orients; that one is the reference.
NOP and HALT
NOP does nothing and advances to the next instruction; it exists mainly
for hand-assembled bytecode and testing. HALT stops execution
immediately, leaving the stack and registers as they were at the point of
the halt.
Register I/O
Instructions for moving data between the value stack, the 256-slot register
file, calldata, and output slots: LOAD, STOW, DROP, INPUT, OUTPUT.
Byte values, operand layouts and stack effects are in the
Register I/O section of the opcode reference.
Registers Default to Unset, Not Int(0)
Every register starts as RegVal::Unset, not Int(0). Only LOAD and
OUTPUT fault UnsetRegister when reading an unset register; every other
instruction that reads a register instead reports RegisterType, with
unset as the actual variant held, for example register r7 holds unset, expected vec<int> from VECPUSH r7, register r7 holds unset, expected model|sample from GETLINE r7, or register r7 holds unset, expected sample from ENERGY r0 r7. LOAD r3 as the first instruction of a
program, before anything has written to r3, fails at runtime with
register r3 is unset. The verifier’s uninitialised-register check
catches the same defect statically for straight-line code, one reason to
run xquad vm check before xquad vm run: the sequence PUSH 1 / STOW r0 / DROP r0 / LOAD r0 is rejected by check with register r0 read at byte 0x0006 before being written, before the runtime fault is ever reached.
LOAD distinguishes two failure modes for a register that is not usable
as an integer. An unset register faults UnsetRegister; a register holding
something other than Int, for example a Model from
BQMX, faults RegisterType instead, naming the actual
variant held. LOAD on a register that holds a freshly allocated Model
fails with register r0 holds model, expected int, a different message
from the unset case.
Stack-Register Bridge
The value stack holds only i64 integers. LOAD pushes a register’s
Int value onto the stack; STOW pops the stack top and writes it into a
register as Int. Neither coerces: LOAD on a non-Int register errors
rather than reinterpreting the value as an integer.
Richer types, models, vectors and samples, never touch the stack directly.
Moving them into or out of the VM goes through INPUT and OUTPUT
instead, against the calldata and output-slot arrays rather than the
stack. INPUT reg pops a calldata slot index and clones
calldata[slot] into reg; OUTPUT reg pops an output slot index and
clones reg’s current value into outputs[slot]. Either direction
transfers any RegVal variant, not only Int, since calldata and outputs
are typed as Vec<RegVal> on the Rust side; the xquad CLI’s
--calldata flag only accepts a comma-separated integer list, so
injecting a Model or Sample through calldata is something host code
building on the xqvm/xqasm crates can do, not something the CLI
exposes directly. PUSH 0; INPUT r0; PUSH 0; OUTPUT r0, given
--calldata 77, round-trips a value through a register with no
arithmetic in between: output slot 0 ends up holding Int(77).
Both INPUT and OUTPUT fault with CallDataIndex/OutputIndex
respectively when the popped slot index is out of range, and OUTPUT
faults UnsetRegister if reg was never written, the same as LOAD.
Memory Management
DROP reg is the only instruction that explicitly frees a register’s
allocation, resetting it to Unset and releasing whatever Model,
Sample, VecInt or VecXqmx the slot held. It does not leave an integer
zero behind: the register is unreadable until the next STOW, INPUT or
allocator call.
Stack Manipulation
Instructions for pushing constants and rearranging the top of the value
stack: POP, PUSH1..PUSH8, SCLR, SWAP and COPY. Byte values,
operand layouts and stack effects are in the
Stack Manipulation section of the
opcode reference. This page is mostly about the constant-pushing family,
since the four rearrangement instructions (POP, SCLR, SWAP, COPY)
are exactly what their names say: discard the top, clear everything,
exchange the top two, or duplicate the top without consuming it. SWAP
and COPY both error StackUnderflow rather than reading past an empty
stack: a bare PUSH 1; SWAP needs a second element that is not there.
PUSH Size Selection
PUSH1 through PUSH8 are not mnemonics you write. In assembly, and
through InstructionBuilder::push(), there is only PUSH <value>:
PUSH 42 ; assembler selects PUSH1 (fits in i8)
PUSH 1000 ; assembler selects PUSH2 (fits in i16)
PUSH -1 ; assembler selects PUSH1 (0xFF sign-extends to -1)
The assembler picks the narrowest PUSH1..PUSH8 variant that faithfully
round-trips the literal, so small constants cost 2 bytes total (1 opcode +
1 operand byte) and the full 8-byte PUSH8 is only ever emitted for values
that need all 64 bits. In the program above, PUSH 42 assembles to
PUSH1 42 (0x2A), PUSH 1000 assembles to PUSH2 1000 (0x03E8), and
PUSH -1 assembles to PUSH1 -1, whose single operand byte is 0xFF.
Writing PUSH1 directly does not work: it is not a recognised mnemonic,
and the assembler rejects it with unknown mnemonic 'PUSH1'. PUSHC is
accepted as an alias for PUSH and goes through the same size-selection
path; there is no reason to prefer one over the other beyond house style.
The encoding itself is big-endian and sign-extended regardless of which
PUSHn variant is chosen, which is what makes PUSH -1 a 1-byte operand
rather than an 8-byte one: PUSH1 0xFF decodes as \(-1_{i64}\), not
\(255_{i64}\), because the single byte is read as signed and then sign
extended, not as an unsigned magnitude.
Arithmetic
The usual signed-integer operations, all on i64, plus two helpers that
show up often enough in penalty-weight and slack-variable arithmetic to
earn their own opcodes: SQR, a one-instruction shorthand for COPY plus
MUL, and BITLEN, which the VM has no other way to compute at all,
since there is no logarithm instruction to compose it from. Byte values,
operand layouts and stack effects are in the
Arithmetic section of the opcode reference.
None of these instructions touch a register; every operand comes off the
stack and every result goes back onto it.
Overflow
Every arithmetic instruction on this page is checked. ADD, SUB, MUL,
NEG, ABS, SQR, INC and DEC raise ArithmeticOverflow when the
result would leave the i64 range, rather than wrapping to a value the
program never asked for:
PUSH 9223372036854775807 ; i64::MAX
PUSH 1
ADD ; raises ArithmeticOverflow
The rule is on the result, not on the machine operation underneath it.
ABS and NEG of i64::MIN raise, because no positive i64 has that
magnitude. i64::MIN / -1 raises for the same reason. i64::MIN % -1
does not: the remainder is 0, which is perfectly representable, even
though a fixed-width machine reaches it through an overflowing division.
Values built from several operations – a constraint expansion’s
coefficients, an ENERGY accumulation – are checked at every step, so a
computation that leaves the range on its way to an in-range answer raises
rather than quietly recovering.
Division and Remainder
DIV rounds toward negative infinity: it is floor division, matching
Python’s //, not Rust’s default /, which truncates toward zero. MOD
returns a remainder with the sign of the divisor, matching Python’s %,
so the identity \(a = \lfloor a / b \rfloor \cdot b + (a \bmod b)\) holds
under floored division. PUSH -7; PUSH 2; DIV pushes -4, not the -3
that truncating division would give, and PUSH -7; PUSH 2; MOD pushes
1, not -1. Both error with DivisionByZero when the divisor is zero
rather than wrapping or producing a sentinel value: PUSH 5; PUSH 0; DIV
halts execution with division by zero instead of continuing.
MIN, MAX and BITLEN
MIN and MAX are signed comparisons with no domain restriction. BITLEN
pops a value and pushes the number of bits needed to represent it in
binary, \(\lfloor\log_2(a)\rfloor + 1\), returning \(0\) for
non-positive input rather than erroring (there is no bit length for a
number that is not positive, and 0 is a more useful sentinel here than a
fault, since BITLEN output typically feeds straight into SLACK’s
capacity calculation). BITLEN(1) = 1, BITLEN(7) = 3, BITLEN(8) = 4,
BITLEN(255) = 8.
Comparison
EQ, LT, GT, LTE and GTE compare two signed i64 values and push
exactly 1 (true) or 0 (false). Byte values, operand layouts and stack
effects are in the Comparison section of the
opcode reference. None of these instructions touch a register, and none
of them can fail beyond the ordinary StackUnderflow every popping
instruction shares; every i64 value is comparable to every other.
The Iverson bracket notation \([P]\), used throughout this book, equals
\(1\) if \(P\) is true and \(0\) otherwise; EQ a b is exactly
\([a = b]\), and the other four follow the same pattern.
Boolean Convention
XQVM uses the integer convention for booleans: 0 is false, any non-zero
value is true. Comparison instructions always produce exactly 1 or 0,
never some other non-zero encoding of “true”, which is what makes their
output directly usable as a JUMPI condition or as an operand to
Logical AND/OR/XOR/NOT without an extra normalisation
step. PUSH 3; PUSH 5; LT pushes 1, and PUSH 3; PUSH 5; GT pushes 0,
both read back through STOW/OUTPUT.
Logical Boolean
NOT, AND, OR and XOR treat their operands as booleans under the
integer convention: 0 is false, any non-zero value, including negative
values, is true. Results are always exactly 1 or 0. Byte values,
operand layouts and stack effects are in the
Logical Boolean section of the opcode
reference. None of these instructions touch a register.
AND, OR and XOR each pop both operands unconditionally before
producing a result; there is no short-circuit evaluation the way there
would be in a language with lazy boolean operators, since a stack VM has
already evaluated both operand expressions and pushed both values by the
time the logical instruction runs. This only matters if evaluating one of
the operand expressions has a side effect, register writes, or a
Coefficient Access mutation, that a
short-circuiting language would skip; AND/OR/XOR never skip it.
Logical vs. Bitwise
These instructions perform boolean operations on the truthiness of a
value, not on its bit pattern. For bit-pattern operations, see
Bitwise (BAND, BOR, BXOR, BNOT). The two families
diverge sharply outside {0, 1} inputs: NOT 5 is 0 (5 is truthy, so
its logical negation is false), while BNOT 5 is ~5, the bitwise
complement, a large negative number (-6). NOT on 5 returns 0, and
BNOT on 5 returns -6, from the same program run against both
instructions.
Bitwise
BAND, BOR, BXOR and BNOT operate on the raw two’s-complement bit
pattern of an i64; SHL and SHR shift it. Byte values, operand layouts
and stack effects are in the Bitwise section of
the opcode reference. None of these instructions touch a register, and
none but the shifts can fail: BAND/BOR/BXOR/BNOT are defined for
every i64 bit pattern, with no invalid input. SHL and SHR fault on
a shift amount outside \([0, 64)\), and SHL also faults when the
shift would lose a significant bit.
For the boolean-algebra equivalents that treat 0/non-zero as
false/true instead of operating bit by bit, see Logical.
Shift Behaviour
SHL performs a left shift, filling the vacated low bits with zero. It
does not discard bits that leave the high end: a shift that loses a
significant bit takes the value outside the i64 range, so it raises
ArithmeticOverflow like any other overflowing operation. PUSH 4611686018427387904; PUSH 1; SHL fails rather than yielding
i64::MIN. Shifting the result back recovers the operand exactly
whenever nothing was lost, which is the test the VM applies.
SHR performs an arithmetic (sign-preserving) right shift, not
a logical (zero-filling) one: the sign bit is replicated into the vacated
high bits, so a negative operand stays negative. -8 >> 1 yields -4,
and i64::MIN >> 1 yields -4611686018427387904, half the magnitude of
i64::MIN and still negative, rather than a small positive number a
logical shift would produce. This matches Rust’s i64 >> b operator and
Python’s >> on integers.
Both instructions require the shift amount to satisfy \(0 \le b < 64\)
and error InvalidShift otherwise; a shift by 64 or more is not defined
as “shift everything out to zero” the way it might be on some hardware,
it is rejected outright: PUSH 8; PUSH 64; SHR fails with invalid shift amount 64.
Where a logical right shift is actually needed, mask the sign-extended
bits off after SHR with BAND. For a shift of exactly 1, BAND with
0x7FFFFFFFFFFFFFFF (i64::MAX) clears only the replicated sign bit,
which is everything an arithmetic shift by 1 could have filled from the
sign: SHR -8 1 followed by masking with i64::MAX gives
9223372036854775804, the same result a zero-filling right shift by 1
would give. A shift by more than 1 replicates the sign
into more than one bit, so the mask has to widen to match: BAND with a
fixed i64::MAX mask only emulates a logical shift for b = 1.
Allocators
Every problem starts here: allocate a Model to accumulate coefficients into,
or a Sample to hold a candidate assignment, or a Vec to stage indices and
weights before a high-level constraint call. Byte values, operand layouts and
stack effects for this family are in the
Allocators and
Vector Operations sections of the opcode
reference; this page is about the three domains and when each applies.
Model Allocators
BQMX and SQMX each pop a variable count, size, and write a fresh
XqmxModel into a register, with empty linear and quadratic coefficient
maps. XQMX pops two values, k (top of stack) then size, since an
integer model also needs the per-variable domain width. The model holds
the Hamiltonian being built up:
$$H(x) = \sum_i \text{linear}[i] \cdot x_i + \sum_{i \le j} \text{quadratic}[i, j] \cdot x_i \cdot x_j$$
which Coefficient Access instructions populate one term at a time. What differs between the three allocators is the domain the variables \(x_i\) are drawn from, and that choice is a problem-modelling decision, not an implementation detail:
BQMXallocates a QUBO model: variables are binary, \(x_i \in \{0, 1\}\). This is the domain most combinatorial-optimisation formulations target directly (selection, assignment, one-hot encodings), and the one most solver backends consume without translation.SQMXallocates an Ising model: variables are spins, \(x_i \in \{-1, 1\}\). Physically motivated (each variable is a magnetic moment pointing up or down), and the native representation for quantum annealers, which minimise an Ising Hamiltonian directly. Allocate the domain the target backend expects rather than assuming a QUBO model can be handed to an Ising-only backend unchanged.XQMXallocates an integer model: variables take one of \(k\) values, \(x_i \in \{0, 1, \ldots, k{-}1\}\). \(k\) is a count and not a half-width. This generalises past binary and spin to give a variable more than two states directly, suited to a quantity with a natural ordering or magnitude – a position in a small enumerated set – without one-hot-encoding it into several binary variables first. It does not encode an unordered categorical choice such as a colour: a quadratic form over integer variables cannot express that two values merely differ without also expressing by how much.XQMXerrors withInvalidIntegerKwhen \(k < 2\), since a domain needs at least two values to carry a decision: \(k = 1\) leaves the single value \(\{0\}\), which is a constant rather than a variable.
Sample Allocators
BSMX, SSMX and XSMX mirror the three model allocators, including the
same pop count per opcode – BSMX and SSMX pop only size; XSMX pops
k then size, the same order as XQMX – but write an XqmxSample: a
vector of per-variable assignments in the same domain, rather than a
coefficient map. A sample is what a solver returns, or what ENERGY
evaluates against a model to score a candidate solution.
The default assignment differs by domain and is chosen so it is always in-domain without a special case: binary and integer samples default every variable to \(0\), and spin samples default every variable to \(-1\) (spin-down), since \(0\) is not a member of \(\{-1, 1\}\). The integer default of \(0\) is always valid because the domain \(\{0, \ldots, k{-}1\}\) starts at zero for every \(k \ge 2\).
Those defaults are not merely conventional. SETLINE and ADDLINE check
every write into a sample against its domain and raise SampleOutOfDomain
otherwise, so a freshly allocated sample has to be in-domain from the start
or the first read of an untouched variable would return a value the same
register could not have been written.
Vec Allocators
VECI creates an empty VecInt and VECX creates an empty VecXqmx (a
vector of models, used to batch several sub-models together). VEC is an
untyped convenience form: at the bytecode level it produces exactly the same
VecInt as VECI, so VEC r0 and VECI r0 are interchangeable. Prefer
VECI in generated or reviewed bytecode, where being explicit about the
element type documents intent; VEC reads naturally in hand-written
assembly where the type is obvious from what gets pushed next.
Domain Types
| Domain | Variable values | Model | Sample |
|---|---|---|---|
| Binary | \(\{0, 1\}\) | BQMX | BSMX |
| Spin | \(\{-1, 1\}\) | SQMX | SSMX |
| Integer(\(k\)) | \(\{0, 1, \ldots, k{-}1\}\), \(k \ge 2\) | XQMX | XSMX |
Example
PUSH 4
PUSH 1
XQMX r0 ; errors: InvalidIntegerK, k = 1 is not >= 2
XQMX and XSMX both check k only after popping both operands, so the
stack is consumed either way; on k < 2 they fault InvalidIntegerK
with the message XQMX/XSMX requires k >= 2 for the {0, ..., k-1} domain, got k = 1.
Vector Operations
Instructions for reading, writing and querying the VecInt and VecXqmx
containers that Allocators create. Byte values, operand
layouts and stack effects are in the
Vector Operations section of the opcode
reference. This page covers the type rules that apply across the family and
the details of SLACK, the one instruction here that does more than plain
container access.
Reading, Writing and Sizing
VECPUSH appends to the end of a VecInt, growing it by one element.
VECGET and VECSET read and write by index, both bounds-checked against
the current length: an out-of-range index errors IndexOutOfBounds at
runtime rather than reading past the end of the vector. VECLEN reads the
current length as an i64, and is the only instruction in this family that
accepts either VecInt or VecXqmx; the other three require VecInt
specifically, since VecXqmx elements are whole models rather than
integers and there is no VECXGET/VECXSET.
VEC r0 ; r0 = empty VecInt
PUSH 10
VECPUSH r0 ; r0 = [10]
PUSH 20
VECPUSH r0 ; r0 = [10, 20]
PUSH 0
VECGET r0 ; stack top = 10
An OUTPUT of the loaded value writes 10 to the output slot, and a
VECLEN r0 issued after the two VECPUSHes reads 2.
SLACK
SLACK indices coeffs is the one instruction in this family that is not a
plain accessor: it exists to turn an inequality constraint into an equality
one, by appending binary-weighted slack variables to two parallel vecs.
Pop capacity (top of stack), then start_index. Compute
$$S = \lfloor \log_2(\text{capacity}) \rfloor + 1$$
and append \(S\) entries to each register:
- to
indices: \([\text{start}, \text{start}{+}1, \ldots, \text{start}{+}S{-}1]\), consecutive variable indices for the new slack variables; - to
coeffs: \([1, 2, 4, \ldots, 2^{S-1}]\), their binary weights.
SLACK appends rather than overwrites, so item variables and slack
variables coexist in the same indices/coeffs pair, ready to hand to
EQUALITY. This is what makes a knapsack-style “total
weight at most capacity” constraint expressible as a single weighted
equality: the slack variables absorb any unused capacity, so the equality
holds exactly whenever the inequality would have held. If capacity <= 0,
no slack variables are needed and SLACK appends nothing.
VEC r5 ; indices
VEC r6 ; coeffs
; ... populate with item indices and weights ...
PUSH 3 ; start_index (first slack variable index)
PUSH 10 ; capacity
SLACK r5 r6 ; appends 4 slack entries: floor(log2(10)) + 1 = 4
With start_index = 3, capacity = 10: VECLEN r5 after SLACK reads
4, indices starts at 3 and ends at 6 ([3, 4, 5, 6]), and
coeffs is [1, 2, 4, 8], matching \(S = 4\) and the powers of two up
to \(2^{S-1}\).
Index Math
IDXGRID and IDXTRIU compute the two flat-indexing schemes the rest of
the VM relies on, so that bytecode which builds indices by hand does not
have to reimplement either formula. Byte values, operand layouts and stack
effects are in the Index Math section of the
opcode reference. Neither instruction touches a register, and both use the
same wrapping i64 arithmetic as Arithmetic; a
sufficiently large IDXGRID product wraps rather than trapping, the same
as MUL would.
IDXGRID: Row-Major Flat Index
IDXGRID pops C (columns), then c (column), then r (row), and
pushes
$$\text{index} = r \cdot C + c$$
the same row-major convention RESIZE attaches to a model.
Computing this by hand and computing it with IDXGRID produce identical
results, since RESIZE does not change how flat indices are interpreted
internally; IDXGRID exists so bytecode that needs the flat index as an
ordinary stack value, for example to pass to
SETLINE/SETQUAD without going through a grid
instruction, does not have to spell out r * C + c as separate MUL and
ADD instructions.
A worked example: a TSP over 4 cities and 4 positions models \(x[\text{city}][\text{position}]\) as a \(4 \times 4\) grid, so city 2 at position 1 is variable
PUSH 2 ; row = city = 2
PUSH 1 ; col = position = 1
PUSH 4 ; cols = 4
IDXGRID ; index = 2 * 4 + 1 = 9
This program’s result, read back with STOW/OUTPUT, is 9.
IDXTRIU: Upper-Triangular Packed Index
IDXTRIU pops j, then i, and pushes
$$\text{index} = \frac{j \cdot (j - 1)}{2} + i \qquad (i < j)$$
the packed index for the pair \((i, j)\) in the strictly upper
triangle of a symmetric matrix, useful for iterating over quadratic
coefficient pairs without visiting \((i, j)\) and \((j, i)\) as two
different positions. If i > j, IDXTRIU swaps them before computing
the index, so the result is order-independent: \((i, j)\) and \((j,
i)\) pack to the same value, the same guarantee
SETQUAD/GETQUAD give by normalising the pair
internally.
The enumeration is strictly upper-triangular, so it has no slot for a
diagonal cell even though the quadratic table has one. SETQUAD and
GETQUAD accept \(i = j\) and store a self-coupling; IDXTRIU called
with \(i = j\) yields the index of some off-diagonal pair rather than
of the diagonal one. Do not use it to address a diagonal coefficient.
IDXTRIU and IDXGRID do not range-check their operands, but every
intermediate of the computation is checked, so a product or sum that
leaves the i64 range raises ArithmeticOverflow even when the final
index would land back inside it.
PUSH 1 ; i = 1
PUSH 3 ; j = 3
IDXTRIU ; index = 3 * 2 / 2 + 1 = 4
This program’s result is 4.
Coefficient Access
Read and write the linear (bias) and quadratic (coupling) terms of the Hamiltonian
$$H(x) = \sum_i \text{linear}[i] \cdot x_i + \sum_{i \le j} \text{quadratic}[i, j] \cdot x_i \cdot x_j$$
that a Model accumulates. Byte values, operand layouts and
stack effects are in the
XQMX Coefficient Access section of
the opcode reference. All coefficient values are i64.
Linear Access Reads and Writes Samples Too
GETLINE, SETLINE and ADDLINE accept reg holding either a Model or
a Sample: on a Model register they read or write the sparse bias map,
and on a Sample register the identical instruction reads or writes the
sample’s per-variable assignment at the same index instead. GETLINE r0 on
a freshly allocated BSMX sample (no coefficients, only assignments)
returns the assigned value at that index.
xquad vm check accepts the same programs. It did not until QUI-1168:
xqvm/src/verifier/reg_type.rs
grouped the linear trio with the quadratic and constraint opcodes under a
single check requiring Model, so GETLINE r0 on the program above failed
with register r0 at byte 0x0006: expected model, got sample even though
the VM ran it. spec/xqvm/ISA.md had said all along that the linear
opcodes accept either mode, so that was a defect in the verifier rather
than a documented restriction, and it is fixed.
The quadratic trio, GETQUAD, SETQUAD and ADDQUAD, has no sample
equivalent at either level and rejects a Sample register with
RegisterType (“expected model, got sample”), since a sample has no
coupling terms to read or write.
Sample Writes Are Domain-Checked
A sample’s entry is an assignment, so it has to be a value the variable can
actually take. SETLINE and ADDLINE raise SampleOutOfDomain when the
value they would store is not a member of the register’s domain – {0, 1}
for BSMX, {-1, +1} for SSMX, {0, ..., k-1} for XSMX. Note that
0 is out of domain for spin: the spin domain has two members and a gap
between them, so a check written as a range would wrongly admit it.
ADDLINE checks the result of the addition, not the delta. Adding 1 to a
binary variable already holding 1 raises; adding -1 to it succeeds and
leaves 0, even though -1 is not itself a binary value.
A Model register is never checked. Its linear[i] is a bias coefficient,
whose magnitude is the weight the objective gives that variable and has
nothing to do with the values the variable may take – a binary model
routinely carries large negative biases.
The guarantee is about the two opcodes and not about the register. A host
that installs a whole sample through calldata bypasses them, and GETLINE
will read back whatever it installed; the xqffi bindings validate
at that boundary instead.
Every Index Is Bounds-Checked
All six instructions bounds-check their popped indices against the
register’s declared size (model.size or sample.values.len()) and error
IndexOutOfBounds if an index is negative or at least size: GETLINE r0
for i = 99 on a 4-variable model fails with index 99 out of bounds (len 4), even though the coefficient map holds no fixed-size backing array. The
size bound comes from the model’s declared variable count, not from the map
itself, which is why an absent in-range coefficient reads as 0 while a
read past the end raises.
Reads are bounded exactly as writes are. GETQUAD r0 for (i, j) = (99, 100) on a 4-variable model raises rather than answering 0, because a
GETQUAD that reads back 0 from a pair SETQUAD refuses would leave the
family disagreeing with itself about which variables exist.
SETQUAD and ADDQUAD check i before j, and both before the i > j
normalisation below, so the reported index is the operand the program
supplied rather than whichever one sorted lower.
The same bound covers EXCLUDE and IMPLIES, which write coefficients
through the same surfaces. Nothing in either family can put a coefficient
outside the variable count a BQMX/SQMX/XQMX call declared: an
unbounded write would grow the sparse map and leave the model carrying a
constraint over variables that do not exist, which still solves cleanly and
answers wrongly.
Sparse Storage
Coefficients live in sparse BTreeMap structures, not a fixed-size array:
- Linear:
BTreeMap<usize, i64>keyed by variable index. - Quadratic:
BTreeMap<(usize, usize), i64>keyed by variable pair.
An absent key reads as 0. Setting a coefficient to exactly 0 removes
its entry rather than storing a zero, for both the linear and the
quadratic map, so memory usage tracks the number of non-zero terms rather
than the number of SETLINE/SETQUAD calls ever made; writing 0 and
never reading that index again leaves no trace in the map.
Key Normalisation
Quadratic keys are normalised so that \(i \le j\): SETQUAD, ADDQUAD
and GETQUAD all swap i and j internally when i > j, so
\(\text{quad}[3, 5]\) and \(\text{quad}[5, 3]\) name the same entry.
SETQUAD r0 with (i, j) = (5, 3) followed by GETQUAD r0 with
(i, j) = (3, 5) returns the value just written, not 0.
Grid Operations
A model’s linear surface is stored flat, indexed by a single usize. Grid
operations let it additionally be addressed as a 2-D matrix,
\((\text{row}, \text{col})\), by attaching row and column counts as
metadata and reinterpreting the flat index as
\(\text{row} \cdot \text{cols} + \text{col}\), the same convention
IDXGRID computes by hand. Byte values, operand layouts
and stack effects are in the XQMX Grid section of
the opcode reference.
Every instruction here accepts reg holding either a Model or a
Sample, not only a Model: ROWFIND, COLFIND, ROWSUM and COLSUM
read a “linear surface” that is the model’s sparse coefficient map for a
Model register, and the sample’s dense assignment vector for a Sample
register, so the same instruction reads either a model’s biases or a
solved sample’s variable values depending on what is in the register.
ROWSUM on a BSMX sample (no coefficients to speak of, only
assignments) raises no RegisterType error and returns the row’s summed
values. A sample has to be gridded first, though: a freshly allocated one
carries rows = cols = 0, and all four opcodes raise
InvalidGridDimensions on a register with no grid.
RESIZE Attaches, It Does Not Reshape
RESIZE pops cols then rows and stores them on the model or sample; it
does not touch the coefficient map or assignment vector at all. A model
with 16 variables and no grid set, and the same model after RESIZE r0
with rows = 4, cols = 4, have byte-for-byte identical linear and
quadratic maps; what changes is how ROWFIND, COLFIND, ROWSUM,
COLSUM and, outside this page, ONEHOTR/ONEHOTC
interpret a flat index. A grid has to satisfy two conditions. Both rows
and cols must be strictly positive, so RESIZE r0 with either argument
\(\le 0\) errors at runtime with invalid grid dimensions, for example
with rows = 0, cols = 4. And \(\text{rows} \cdot \text{cols}\) must
not exceed the register’s declared size: a grid is a reinterpretation of
variables the program already declared, so it cannot describe cells that
do not exist. RESIZE r0 to \(3 \times 3\) on a 4-variable model
raises InvalidGridDimensions for the same reason a negative row count
does.
The extent bound is \(\le\), not \(=\). EQUALITY, ATLEAST,
ATLEASTW and REDUCE append slack and auxiliary variables past the
grid and nothing ever shrinks a register’s size, so a model whose size
exceeds its extent is the normal state after any of them; a later
RESIZE over a strict subset of the variables is still accepted.
Both checks are runtime checks, not ones the verifier catches statically, since grid dimensions are ordinary popped stack values rather than something the verifier’s dataflow passes track.
The canonical use is encoding a two-index variable directly instead of computing flat indices by hand at every access site. A 4-city TSP, for example, models \(x[\text{city}][\text{position}]\) as a \(4 \times 4\) grid:
PUSH 16 ; size = 4 * 4
BQMX r0 ; allocate binary model
PUSH 4 ; rows = 4
PUSH 4 ; cols = 4
RESIZE r0 ; set grid dimensions
after which row 2 is every variable for city 2 across all four positions,
and ONEHOTR r0 over row 2 is exactly the constraint “city 2 occupies
exactly one position”.
Row and Column Bounds Are Enforced
ROWFIND, COLFIND, ROWSUM and COLSUM each check the index they pop
against the grid axis they address. A negative index and an index at or
past the declared extent both raise IndexOutOfBounds, naming the index
and the extent it exceeded. ROWSUM r0 for row = 99 on a model
RESIZEd to \(2 \times 2\) raises rather than returning 0:
PUSH 4
BQMX r0
PUSH 2 ; rows
PUSH 2 ; cols
RESIZE r0
PUSH 99 ; row -- past the two rows the grid declares
ROWSUM r0
HALT
Error: run failed: IndexOutOfBounds { pos: 12, index: 99, len: 2 }
A register with no grid at all is a separate fault: rows = cols = 0
addresses no line, so all four opcodes raise InvalidGridDimensions
rather than reducing over nothing. That is the same identity ONEHOTR
and ONEHOTC raise without a grid.
RESIZE’s dimensions are a bound the VM enforces, not a convention
correct bytecode is trusted to honour. xqvm/tests/vectors/xqmx-grid/
pins all three cases.
ROWFIND and COLFIND
ROWFIND pops v then r, scans row r left to right, and pushes the
column of the first entry whose value equals v, or \(-1\) if none
matches. COLFIND pops v then c and is the column-major mirror. Since
a model’s storage is sparse, an unset coefficient reads as 0 (see
Coefficient Access), so searching for v = 0
against a model can match either an explicit zero or nothing at all,
depending on write history. An unmatched search returns \(-1\) rather
than erroring, but only once the search has run: the register still has
to carry a grid, and r still has to name a row that grid declares.
\(-1\) means “scanned, no match”, never “no such row”. On a \(2
\times 2\) model with linear[0] = 9 and nothing else set, ROWFIND
for v = 9 in row 0 returns 0 (the match), and the identical search
in row 1 returns \(-1\); the same search in row 2 raises
IndexOutOfBounds.
ROWSUM and COLSUM
ROWSUM pops r and pushes
\(s = \sum_{c=0}^{C-1} \text{linear}[r \cdot C + c]\); COLSUM pops c
and pushes the column-major equivalent. Both sum over the full declared
row or column width, treating absent model entries as 0 rather than
skipping them. On a Sample register the same sum runs over the sample’s
assignment values instead of coefficients, which is how bytecode checks a
solved one-hot row without decoding each variable index by hand: after a
solver’s result is loaded back into a Sample register, ROWSUM over a
row that carried a ONEHOTR constraint should read exactly 1 if the
constraint holds in that sample.
High-Level Constraints
These instructions inject QUBO penalty terms for common combinatorial
constraints, expanding into linear and quadratic coefficient deltas
automatically. The model register must hold a Model in model mode.
Grid-based opcodes (ONEHOTR, ONEHOTC) read the grid dimensions set by
RESIZE and require them, in both halves of the grid precondition
spec/xqvm/ISA.md
states normatively. On a model with no grid set (rows and cols both
0) both raise InvalidGridDimensions rather than expanding over
nothing, so forgetting RESIZE fails at the instruction that needed it
rather than producing a model missing a constraint. A row outside
[0, rows), or a col outside [0, cols), raises IndexOutOfBounds
rather than applying the constraint to variables the grid does not
address – the same fault the four
grid opcodes raise for an out-of-range row or column.
xquad vm check cannot catch either,
because the grid dimensions and the index alike are popped stack values
rather than something the verifier’s dataflow passes track. Vec-based
opcodes (EQUALITY, ATLEAST, ATLEASTW, REDUCE) operate on
arbitrary variable sets. All coefficients are i64. For each opcode’s
byte value and operand layout, see the
Opcode Reference.
ONEHOTR reg
Register effect: mutate
Pop penalty, then row. Apply the one-hot constraint over all variables in
grid row row:
$$H \mathrel{+}= \text{penalty} \cdot \left(\sum_c x_{\text{row},c} - 1\right)^2$$
Expanding (binary variables: \(x^2 = x\)):
$$\text{linear}[\text{row} \cdot \text{cols} + c] \mathrel{+}= -\text{penalty} \qquad \forall; c \in [0, \text{cols})$$
$$\text{quad}[\text{row} \cdot \text{cols} + c_i,; \text{row} \cdot \text{cols} + c_j] \mathrel{+}= 2 \cdot \text{penalty} \qquad \forall; c_i < c_j$$
ONEHOTC reg
Register effect: mutate
Pop penalty, then col. One-hot over all variables in grid column col:
$$\text{linear}[r_i \cdot \text{cols} + \text{col}] \mathrel{+}= -\text{penalty} \qquad \forall; r_i \in [0, \text{rows})$$
$$\text{quad}[r_i \cdot \text{cols} + \text{col},; r_j \cdot \text{cols} + \text{col}] \mathrel{+}= 2 \cdot \text{penalty} \qquad \forall; r_i < r_j$$
EXCLUDE reg
Register effect: mutate
Pop penalty, then \(j\), then \(i\). Add mutual-exclusion: penalise
\(x_i = 1\) and \(x_j = 1\) simultaneously.
$$\text{quad}[i, j] \mathrel{+}= \text{penalty}$$
IMPLIES reg
Register effect: mutate
Pop penalty, then \(j\), then \(i\). Add implication \(i \Rightarrow j\):
penalise \(x_i = 1\) with \(x_j = 0\).
$$H \mathrel{+}= \text{penalty} \cdot x_i \cdot (1 - x_j) = \text{penalty} \cdot x_i - \text{penalty} \cdot x_i \cdot x_j$$
$$\text{linear}[i] \mathrel{+}= \text{penalty}$$
$$\text{quad}[i, j] \mathrel{+}= -\text{penalty}$$
EQUALITY model indices coeffs
Register effect: read indices, coeffs; mutate model
Pop penalty, then target. Read variable indices from indices (VecInt)
and coefficients from coeffs (VecInt). Expand the weighted equality
constraint into QUBO terms on model:
$$H \mathrel{+}= P \cdot \left(\sum_k a_k \cdot x_{\text{idx}_k} - b\right)^2$$
Expanding:
$$\text{linear}[\text{idx}_k] \mathrel{+}= P \cdot a_k \cdot (a_k - 2b) \qquad \forall; k$$
$$\text{quad}[\text{idx}_k, \text{idx}_m] \mathrel{+}= 2P \cdot a_k \cdot a_m \qquad \forall; k < m$$
The constant term \(P \cdot b^2\) is dropped. EQUALITY is the general form of
ONEHOTR/ONEHOTC – setting all \(a_k = 1\) and \(b = 1\) produces the same
expansion.
If an index in indices is at or past the model’s current size, EQUALITY
grows model.size to fit it rather than erroring – unlike ATLEAST and
ATLEASTW below, which validate incoming indices against the model’s
existing size and raise IndexOutOfBounds on an out-of-range one. Otherwise,
indices and coeffs must have equal length or the instruction raises
VecLengthMismatch.
ATLEAST model indices
Register effect: read indices; mutate model (grows size)
Pop penalty, then \(k\). Read variable indices from indices. Enforce
\(\sum x_i \ge k\) by allocating \(S = \lfloor\log_2(N - k)\rfloor + 1\)
slack variables at model.size and applying an EQUALITY expansion with
target \(k\), where \(N\) is the number of indices:
$$\sum_i x_{\text{idx}i} - \sum{j=0}^{S-1} 2^j \cdot s_j = k$$
This formula covers \(N - k > 0\). When \(N - k \le 0\) – only
possible at \(k = N\), since IndexOutOfBounds below already rejects
\(k > N\) – the constraint is already an equality with nothing left to
slacken, so ATLEAST allocates zero slack variables and applies the
EQUALITY expansion directly, with no \(S\) term at all.
Raises IndexOutOfBounds if \(k \le 0\) or \(k > N\), and if any index in
indices is at or past the model’s existing size – ATLEAST does not grow
the model to fit an out-of-range input index, only to hold the slack
variables it allocates itself.
ATLEASTW model indices coeffs
Register effect: read indices, coeffs; mutate model (grows size)
Pop penalty, then \(k\). Same as ATLEAST but with arbitrary weights from
coeffs. Enforces \(\sum w_i \cdot x_i \ge k\). The slack count is computed
from \(\text{max_excess} = \sum w_i - k\).
Raises VecLengthMismatch if indices and coeffs have different lengths,
or IndexOutOfBounds if \(k \le 0\).
REDUCE model
Register effect: mutate model (grows size)
Pop \(P_{\text{aux}}\), then \(\text{var_b}\), then \(\text{var_a}\).
Allocate auxiliary variable \(w\) at model.size. Add Rosenberg enforcement
terms constraining \(w = x_a \cdot x_b\):
$$\text{quad}[\text{var_a}, \text{var_b}] \mathrel{+}= P_{\text{aux}}$$
$$\text{quad}[\text{var_a}, w] \mathrel{+}= -2 P_{\text{aux}}$$
$$\text{quad}[\text{var_b}, w] \mathrel{+}= -2 P_{\text{aux}}$$
$$\text{linear}[w] \mathrel{+}= 3 P_{\text{aux}}$$
Push \(w\) (the auxiliary index). Enables chaining for higher-order terms:
reduce a quartic \(x_i x_j x_k x_l\) by calling REDUCE twice to get
\(w_1 = x_i x_j\) then \(w_2 = w_1 x_k\), and finish with ADDQUAD on
\((w_2, x_l)\).
Usage Pattern
Constraint instructions are designed to work with grid models. A typical pattern for a TSP-style assignment grid:
; Allocate model and set grid
PUSH 16
BQMX r0
PUSH 4
PUSH 4
RESIZE r0
; Apply one-hot constraints on each row and column
PUSH 0
PUSH 4
RANGE
LVAL r1
LOAD r1
PUSH 100 ; penalty weight
ONEHOTR r0 ; each city visits exactly one position
NEXT
PUSH 0
PUSH 4
RANGE
LVAL r1
LOAD r1
PUSH 100
ONEHOTC r0 ; each position has exactly one city
NEXT
HALT
Energy Evaluation
ENERGY is the sole instruction in this category: it evaluates a model’s
Hamiltonian against a candidate sample and pushes the result. Byte value,
operand layout and stack effect are in the
XQMX High-Level Constraints
section of the opcode reference. See Allocators for how a
Model and a Sample are built in the first place.
ENERGY model sample
Register effect: read – both model and sample are read-only
ENERGY takes two register operands, model and sample. Both checks are
strict: the model register must hold a Model, the sample register must
hold a Sample, and a RegisterType error is raised if either register
holds any other variant – a Model cannot be passed in the sample slot
or vice versa.
To populate a sample with concrete variable assignments, construct an
xqvm::XqmxSample in the host and pass it to the program through a
calldata slot, then INPUT it into a register before calling ENERGY.
SETLINE and ADDLINE can also write a sample’s per-variable assignment
values in place, the same way they write a model’s linear bias map, and
xquad vm check accepts them – see
Coefficient Access. Each write is checked against
the sample’s domain, so a program can only build assignments the variables
can actually take. Constructing an XqmxSample in the host and passing it
through calldata remains the way to hand a verified program a whole
candidate solution at once.
Hamiltonian
Evaluates the quadratic Hamiltonian:
$$E = \sum_{i} \text{linear}[i] \cdot x_i ;+; \sum_{i \le j} \text{quad}[i,j] \cdot x_i \cdot x_j$$
where \(x_i\) is sample.values[i], the variable assignment at index
\(i\). The sum runs over \(i \le j\): the diagonal is legal, and a
self-coupling quad[i,i] is evaluated as \(\text{quad}[i,i] \cdot x_i
\cdot x_i\) like any other entry. The result is pushed as i64.
Overflow Is a Fault, Not a Wrap
Every term product and every partial sum is range-checked, and one that
leaves the i64 range raises ArithmeticOverflow. The check is per
step rather than on the final total, so a model whose mathematical energy
is perfectly representable still faults when an intermediate is not.
Accumulation runs in sorted key order, linear terms before quadratic
ones, and that order is normative precisely because it decides which
partial sums a checked implementation sees.
Take a 3-variable binary model with linear = {0: i64::MAX, 1: 1, 2: -1}
and a sample assigning all three variables 1. The energy is
\(2^{63} - 1 + 1 - 1 = 2^{63} - 1\), which is i64::MAX and representable.
Wrapping arithmetic would have computed it correctly, overflowing to
i64::MIN at the second term and back at the third. Per-step checking
raises at the second term instead:
Error: run failed: ArithmeticOverflow { pos: None }
Reordering the model’s coefficients so no partial sum leaves the range makes the same program succeed. A program that relies on cancellation between large terms has to be written to keep every running total representable.
Errors
RegisterType– ifmodelis not aModelorsampleis not aSample.SizeMismatch– if \(\lvert\text{sample}\rvert \neq \text{model.size}\).ArithmeticOverflow– if any term product or partial sum leaves thei64range.
Example
; Build a 2-variable binary model in r0:
; linear[0] = 3, linear[1] = -2, quad[0,1] = 5.
PUSH 2
BQMX r0
PUSH 0
PUSH 3
SETLINE r0
PUSH 1
PUSH -2
SETLINE r0
PUSH 0
PUSH 1
PUSH 5
SETQUAD r0
; A freshly-allocated binary sample is initialised to all zeros, so
; H(0, 0) = 0.
PUSH 2
BSMX r1
ENERGY r0 r1
HALT
In this example, the sample is [0, 0] and the Hamiltonian evaluates to
\(E = 0\). To exercise a non-zero assignment, construct an XqmxSample in
host code with XqmxSample::new(Domain::Binary, vec![1, 1]) and INPUT it
into r1 before calling ENERGY.
Opcode Reference
Concise reference table for every opcode in the XQVM bytecode format.
Derived directly from xqvm/opcodes.yaml,
which is kept in sync with the Rust opcodes! x-macro.
For the normative bytecode specification, see
spec/xqvm/SPEC.md.
Columns:
- Code – wire-encoding byte.
- Mnemonic – uppercase assembly name.
- Operands – post-opcode operand layout; empty for no-operand instructions.
- Stack – stack effect as
pop → push;0 → 1means one value produced.any → 0marks an instruction that empties the stack outright rather than applying a fixed net effect. - Description – single-sentence semantic summary.
Reserved wire bytes (rejected by the decoder as illegal): 0x0D, 0x19, 0x1D-0x1F, 0x2D-0x2F, 0x35, 0x46-0x49, 0x4D-0x4F, 0x55-0x59, 0x5C-0x5F, 0x6B-0x6F, 0x78-0x7E, 0x80-0xEF, 0xF1-0xFE.
Total: 93 opcodes.
Control Flow
| Code | Mnemonic | Operands | Stack | Description |
|---|---|---|---|---|
0x00 | TARGET | – | 0 → 0 | Mark a valid jump destination. |
0x01 | JUMP1 | label: u8 | 0 → 0 | Unconditionally jump to a basic block by u8 label index (narrow form). |
0x02 | JUMPI1 | label: u8 | 1 → 0 | Jump to a basic block by u8 label index if the top of the stack is non-zero (narrow form). |
0x03 | JUMP2 | label: u16 | 0 → 0 | Unconditionally jump to a basic block by u16 label index (wide form). |
0x04 | JUMPI2 | label: u16 | 1 → 0 | Jump to a basic block by u16 label index if the top of the stack is non-zero (wide form). |
0x05 | LIDX | reg: Register | 0 → 0 | Copy the current loop index (offset-adjusted) into a register. |
0x06 | LVAL | reg: Register | 0 → 0 | Copy the current loop value into a register. |
0x07 | NEXT | – | 0 → 0 | Advance the loop index; jump back or exit the current loop. |
0x08 | RANGE | – | 2 → 0 | Start a range loop over [start, start + count). |
0x09 | ITER | reg: Register | 2 → 0 | Start a vec iteration over a slice of a register’s vec. |
Register I/O
| Code | Mnemonic | Operands | Stack | Description |
|---|---|---|---|---|
0x0A | LOAD | reg: Register | 0 → 1 | Push the value of an int register onto the stack. |
0x0B | STOW | reg: Register | 1 → 0 | Pop the top of the stack into an int register. |
0x0C | DROP | reg: Register | 0 → 0 | Reset a register to Unset, releasing any value it held. |
0x0E | INPUT | reg: Register | 1 → 0 | Pop a calldata slot index and load that slot into a register. |
0x0F | OUTPUT | reg: Register | 1 → 0 | Pop an output slot index and write the register to it. |
Stack Manipulation
| Code | Mnemonic | Operands | Stack | Description |
|---|---|---|---|---|
0x10 | POP | – | 1 → 0 | Discard the top of the stack. |
0x11 | PUSH1 | val: [u8; 1] | 0 → 1 | Push a 1-byte big-endian signed constant, sign-extended to i64. |
0x12 | PUSH2 | val: [u8; 2] | 0 → 1 | Push a 2-byte big-endian signed constant, sign-extended to i64. |
0x13 | PUSH3 | val: [u8; 3] | 0 → 1 | Push a 3-byte big-endian signed constant, sign-extended to i64. |
0x14 | PUSH4 | val: [u8; 4] | 0 → 1 | Push a 4-byte big-endian signed constant, sign-extended to i64. |
0x15 | PUSH5 | val: [u8; 5] | 0 → 1 | Push a 5-byte big-endian signed constant, sign-extended to i64. |
0x16 | PUSH6 | val: [u8; 6] | 0 → 1 | Push a 6-byte big-endian signed constant, sign-extended to i64. |
0x17 | PUSH7 | val: [u8; 7] | 0 → 1 | Push a 7-byte big-endian signed constant, sign-extended to i64. |
0x18 | PUSH8 | val: [u8; 8] | 0 → 1 | Push a full 8-byte big-endian signed constant (i64). |
0x1A | SCLR | – | any → 0 | Clear the entire value stack. |
0x1B | SWAP | – | 2 → 2 | Swap the top two stack elements. |
0x1C | COPY | – | 1 → 2 | Duplicate the top of the stack. |
Arithmetic
| Code | Mnemonic | Operands | Stack | Description |
|---|---|---|---|---|
0x20 | ADD | – | 2 → 1 | Pop b and a; push a + b. |
0x21 | SUB | – | 2 → 1 | Pop b and a; push a - b. |
0x22 | MUL | – | 2 → 1 | Pop b and a; push a * b. |
0x23 | DIV | – | 2 → 1 | Pop b and a; push a / b (floor division, rounds toward negative infinity). |
0x24 | MOD | – | 2 → 1 | Pop b and a; push a % b. |
0x25 | SQR | – | 1 → 1 | Pop a; push a * a. |
0x26 | ABS | – | 1 → 1 | Pop a; push |a|. |
0x27 | NEG | – | 1 → 1 | Pop a; push -a. |
0x28 | MIN | – | 2 → 1 | Pop b and a; push min(a, b). |
0x29 | MAX | – | 2 → 1 | Pop b and a; push max(a, b). |
0x2A | INC | – | 1 → 1 | Pop a; push a + 1. |
0x2B | DEC | – | 1 → 1 | Pop a; push a - 1. |
0x2C | BITLEN | – | 1 → 1 | Pop a; push floor(log2(a))+1. If a <= 0, push 0. |
Comparison
| Code | Mnemonic | Operands | Stack | Description |
|---|---|---|---|---|
0x30 | EQ | – | 2 → 1 | Pop b and a; push 1 if a == b, else 0. |
0x31 | LT | – | 2 → 1 | Pop b and a; push 1 if a < b, else 0. |
0x32 | GT | – | 2 → 1 | Pop b and a; push 1 if a > b, else 0. |
0x33 | LTE | – | 2 → 1 | Pop b and a; push 1 if a <= b, else 0. |
0x34 | GTE | – | 2 → 1 | Pop b and a; push 1 if a >= b, else 0. |
Logical Boolean
| Code | Mnemonic | Operands | Stack | Description |
|---|---|---|---|---|
0x36 | NOT | – | 1 → 1 | Pop a; push 1 if a == 0, else 0. |
0x37 | AND | – | 2 → 1 | Pop b and a; push 1 if both are non-zero, else 0. |
0x38 | OR | – | 2 → 1 | Pop b and a; push 1 if either is non-zero, else 0. |
0x39 | XOR | – | 2 → 1 | Pop b and a; push 1 if exactly one is non-zero, else 0. |
Bitwise
| Code | Mnemonic | Operands | Stack | Description |
|---|---|---|---|---|
0x3A | BAND | – | 2 → 1 | Pop b and a; push a & b. |
0x3B | BOR | – | 2 → 1 | Pop b and a; push a | b. |
0x3C | BXOR | – | 2 → 1 | Pop b and a; push a ^ b. |
0x3D | BNOT | – | 1 → 1 | Pop a; push ~a. |
0x3E | SHL | – | 2 → 1 | Pop b and a; push a << b. |
0x3F | SHR | – | 2 → 1 | Pop b and a; push a >> b (arithmetic right shift, sign-preserving). |
Allocators
| Code | Mnemonic | Operands | Stack | Description |
|---|---|---|---|---|
0x40 | BQMX | reg: Register | 1 → 0 | Pop size; allocate a binary QUBO model ({0, 1} domain) into a register. |
0x41 | SQMX | reg: Register | 1 → 0 | Pop size; allocate a spin Ising model ({-1, +1} domain) into a register. |
0x42 | XQMX | reg: Register | 2 → 0 | Pop k then size; allocate an integer model with domain {0, …, k-1} into a register. Errors when k < 2. |
0x43 | BSMX | reg: Register | 1 → 0 | Pop size; allocate a binary sample ({0, 1} domain) into a register. |
0x44 | SSMX | reg: Register | 1 → 0 | Pop size; allocate a spin sample ({-1, +1} domain) into a register. |
0x45 | XSMX | reg: Register | 2 → 0 | Pop k then size; allocate an integer sample with domain {0, …, k-1} into a register. Errors when k < 2. |
0x4A | VEC | reg: Register | 0 → 0 | Create an empty vec<int> in a register, identical to VECI. |
0x4B | VECI | reg: Register | 0 → 0 | Create an empty vec<int> in a register. |
0x4C | VECX | reg: Register | 0 → 0 | Create an empty vec<xqmx> in a register. |
Vector Operations
| Code | Mnemonic | Operands | Stack | Description |
|---|---|---|---|---|
0x50 | VECPUSH | reg: Register | 1 → 0 | Pop a value; append it to the register’s vec. |
0x51 | VECGET | reg: Register | 1 → 1 | Pop index; push vec[index] from the register’s vec. |
0x52 | VECSET | reg: Register | 2 → 0 | Pop value and index; set vec[index] in the register’s vec. |
0x53 | VECLEN | reg: Register | 0 → 1 | Push the length of the register’s vec onto the stack. |
0x54 | SLACK | indices: Register, coeffs: Register | 2 → 0 | Pop capacity and start_index; append slack variable indices and power-of-two coefficients to two register vecs. |
Index Math
| Code | Mnemonic | Operands | Stack | Description |
|---|---|---|---|---|
0x5A | IDXGRID | – | 3 → 1 | Pop cols, col, row; push the flat grid index row * cols + col. |
0x5B | IDXTRIU | – | 2 → 1 | Pop j and i; push the upper-triangular index for the unordered pair (i, j). |
XQMX Coefficient Access
| Code | Mnemonic | Operands | Stack | Description |
|---|---|---|---|---|
0x60 | GETLINE | reg: Register | 1 → 1 | Pop i; push linear[i] from the register’s model, or a sample’s assignment at i; raises IndexOutOfBounds outside [0, size). |
0x61 | SETLINE | reg: Register | 2 → 0 | Pop value and i; set linear[i] in the register’s model, or a sample’s assignment at i; a sample value outside its domain raises SampleOutOfDomain. |
0x62 | ADDLINE | reg: Register | 2 → 0 | Pop delta and i; add delta to linear[i] in the register’s model, or a sample’s assignment at i; a sample result outside its domain raises SampleOutOfDomain. |
0x63 | GETQUAD | reg: Register | 2 → 1 | Pop j and i; push quadratic[i, j] from the register’s model; requires MODEL mode – a sample register raises TypeMismatch; raises IndexOutOfBounds outside [0, size). |
0x64 | SETQUAD | reg: Register | 3 → 0 | Pop value, j, and i; set quadratic[i, j] in the register’s model; requires MODEL mode – a sample register raises TypeMismatch; raises IndexOutOfBounds outside [0, size). |
0x65 | ADDQUAD | reg: Register | 3 → 0 | Pop delta, j, and i; add delta to quadratic[i, j] in the register’s model; requires MODEL mode – a sample register raises TypeMismatch; raises IndexOutOfBounds outside [0, size). |
XQMX Grid
| Code | Mnemonic | Operands | Stack | Description |
|---|---|---|---|---|
0x66 | RESIZE | reg: Register | 2 → 0 | Pop cols and rows; set the grid dimensions of the register’s model or sample. |
0x67 | ROWFIND | reg: Register | 2 → 1 | Pop value and row; push the first column where the value matches, or -1. |
0x68 | COLFIND | reg: Register | 2 → 1 | Pop value and col; push the first row where the value matches, or -1. |
0x69 | ROWSUM | reg: Register | 1 → 1 | Pop row; push the sum of all linear values in that grid row. |
0x6A | COLSUM | reg: Register | 1 → 1 | Pop col; push the sum of all linear values in that grid column. |
XQMX High-Level Constraints
| Code | Mnemonic | Operands | Stack | Description |
|---|---|---|---|---|
0x70 | ONEHOTR | reg: Register | 2 → 0 | Pop penalty and row; add a one-hot constraint over the grid row. |
0x71 | ONEHOTC | reg: Register | 2 → 0 | Pop penalty and col; add a one-hot constraint over the grid column. |
0x72 | EXCLUDE | reg: Register | 3 → 0 | Pop penalty, j, and i; add a mutual-exclusion constraint between variables i and j; raises IndexOutOfBounds outside [0, size). |
0x73 | IMPLIES | reg: Register | 3 → 0 | Pop penalty, j, and i; add an implication constraint from variable i to variable j; raises IndexOutOfBounds outside [0, size). |
0x74 | EQUALITY | model: Register, indices: Register, coeffs: Register | 2 → 0 | Pop penalty and target; expand weighted equality constraint into QUBO terms on a model. |
0x75 | ATLEAST | model: Register, indices: Register | 2 → 0 | Pop penalty and k; allocate slack variables and apply at-least-k constraint. |
0x76 | ATLEASTW | model: Register, indices: Register, coeffs: Register | 2 → 0 | Pop penalty and k; allocate slack variables and apply weighted at-least-k constraint. |
0x77 | REDUCE | model: Register | 3 → 1 | Pop P_aux, var_b, var_a; allocate auxiliary variable and add Rosenberg enforcement terms; push aux index. |
0x7F | ENERGY | model: Register, sample: Register | 0 → 1 | Compute the Hamiltonian energy of a sample against a model; push the result. |
Special
| Code | Mnemonic | Operands | Stack | Description |
|---|---|---|---|---|
0xF0 | NOP | – | 0 → 0 | No operation. |
0xFF | HALT | – | 0 → 0 | Stop execution. |
Bytecode Format
A .xqb file is the binary form of an XQVM program: a fixed 15-byte XQBC
header followed immediately by the raw instruction stream. This page covers
that wire format. For the human-readable .xqasm source format and how it
compiles down to this, see Assembly.
The normative source is
spec/xqvm/ENCODING.md.
The Rust implementation of the header is
Program::encode/Program::decode.
The XQBC header
| Offset | Width | Field | Description |
|---|---|---|---|
| 0..4 | 4 bytes | Magic | The ASCII bytes XQBC |
| 4 | 1 byte | Version | Format version, currently 0x01 |
| 5 | 1 byte | input_slots | Count of INPUT instructions in the program (calldata arity) |
| 6 | 1 byte | output_slots | Count of OUTPUT instructions in the program |
| 7..11 | 4 bytes | code_len | Byte length of the instruction stream, u32 big-endian |
| 11..15 | 4 bytes | crc32 | CRC-32/ISO-HDLC checksum of the instruction stream, u32 big-endian |
| 15+ | – | Instruction stream | Raw opcode and operand bytes |
input_slots and output_slots are informational and are not validated by
the decoder. Each counts instructions of that kind in the stream. Neither
is a slot count: a program with three OUTPUT instructions that all write
slot 0 records output_slots = 3 against a required slot count of 1, and a
program with one OUTPUT inside a loop that writes slots 0 through 9
records output_slots = 1 against a required count of 10. Neither byte
can therefore be used to pre-size a calldata or output-slot array. The
host fixes both counts before the run – Vm::set_calldata and
Vm::set_output_slots – and a slot outside them raises CallDataIndex or
OutputIndex at run time. Both counts saturate at 255 (u8::MAX):
a program with 300 INPUT instructions still encodes input_slots as 255,
and there is no error path for the overflow. The count is also best-effort
in another sense – it is produced by walking the instruction stream and
skipping any instruction that fails to decode, so a malformed stream still
yields a (possibly incomplete) count rather than aborting the walk.
A decoder rejects a file if any of the following hold:
- It is shorter than 15 bytes.
- Its first four bytes are not
XQBC. - Its version byte is not
0x01. - The instruction-stream length does not match
code_len. - The CRC-32/ISO-HDLC of the instruction stream does not match
crc32.
The instruction stream
Every instruction is an opcode byte followed by zero to eight operand bytes:
[ opcode : 1 byte ] [ operand bytes : 0-8 bytes ]
Instruction length is fixed per opcode – it is never encoded in the stream
itself, so a decoder needs the opcode table, not a length prefix, to know how
many operand bytes follow a given opcode byte. Register operands are a
single u8 (0-255); PUSH1-PUSH8 operands are 1 to 8 bytes of
big-endian signed two’s complement; label operands are a u8
(JUMP1/JUMPI1) or a u16 big-endian (JUMP2/JUMPI2). Multi-operand
and multi-register opcodes concatenate their operands in the order the
opcode table lists them – ENERGY r0 r1 encodes as 0x7F 0x00 0x01.
The opcode byte occupies 0x00-0x7F for the normal instruction space, plus
two single-byte opcodes outside that range: 0xF0 (NOP) and 0xFF
(HALT). Every other byte value in 0x80-0xFF, and any unassigned gap
below 0x80, is rejected by the decoder as an unknown opcode.
TARGET and the label pre-scan
TARGET (0x00) has no operand – the opcode byte is the whole instruction.
It marks a jump destination and does nothing at runtime; its only job is to
exist at a fixed byte position so a decoder can find it.
A decoder builds an id-to-offset lookup by scanning the instruction stream
once for TARGET opcodes: the first one encountered is assigned id 0, the
second id 1, and so on in program order, each recorded against the byte
offset where it starts. JUMP1/JUMP2/JUMPI1/JUMPI2 operands carry
these sequential ids – not byte offsets, and not the .N label token that
appears in .xqasm source. .N is assembler-only syntax used to resolve
jump references before encoding; it is never emitted into the bytecode. In
the Rust implementation this scan produces a JumpTable value (the
JumpTable::scan associated function), and Program::new runs it once when
a program is constructed from raw bytes.
This pre-scan is why a decoder cannot fully validate a single instruction in
isolation: whether JUMP1 .3 is well-formed depends on how many TARGET
opcodes exist anywhere in the program, which is only known once the whole
stream has been scanned. See Verifier for how an out-of-range
label is rejected before execution.
Instruction lengths by opcode
| Length | Opcodes |
|---|---|
| 1 byte (opcode only) | TARGET, NEXT, RANGE, NOP, HALT, POP, SCLR, SWAP, COPY, ADD, SUB, MUL, DIV, MOD, SQR, ABS, NEG, MIN, MAX, INC, DEC, BITLEN, EQ, LT, GT, LTE, GTE, NOT, AND, OR, XOR, BAND, BOR, BXOR, BNOT, SHL, SHR, IDXGRID, IDXTRIU |
| 2 bytes (opcode + 1) | JUMP1, JUMPI1, LIDX, LVAL, ITER, LOAD, STOW, DROP, INPUT, OUTPUT, PUSH1, VEC, VECI, VECX, BQMX, SQMX, XQMX, BSMX, SSMX, XSMX, VECPUSH, VECGET, VECSET, VECLEN, GETLINE, SETLINE, ADDLINE, GETQUAD, SETQUAD, ADDQUAD, RESIZE, ROWFIND, COLFIND, ROWSUM, COLSUM, ONEHOTR, ONEHOTC, EXCLUDE, IMPLIES, REDUCE |
| 3 bytes (opcode + 2) | JUMP2, JUMPI2, PUSH2, ENERGY, ATLEAST, SLACK |
| 4 bytes | PUSH3, EQUALITY, ATLEASTW |
| 5 bytes | PUSH4 |
| 6 bytes | PUSH5 |
| 7 bytes | PUSH6 |
| 8 bytes | PUSH7 |
| 9 bytes | PUSH8 |
XQMX and XSMX are exceptions worth flagging: each takes one register
operand (2 bytes on the wire) but additionally pops two values off the value
stack at runtime. Their bytecode length is 2, not 3 – the popped stack
values never appear in the encoding.
Examples
| Instruction | Bytes |
|---|---|
NOP | 0xF0 |
HALT | 0xFF |
TARGET | 0x00 |
PUSH1 42 | 0x11 0x2A |
PUSH2 -1 | 0x12 0xFF 0xFF |
LOAD r5 | 0x0A 0x05 |
JUMP1 .100 | 0x01 0x64 |
JUMP2 .1000 | 0x03 0x03 0xE8 |
JUMPI1 .5 | 0x02 0x05 |
ENERGY r0 r1 | 0x7F 0x00 0x01 |
BQMX r2 | 0x40 0x02 |
The PUSHn rows show the encoded instruction, not source you can type: the
assembler accepts the PUSH <value> sugar (and its PUSHC alias) and
selects the width itself. The JUMPn/JUMPIn forms are typeable but
interact badly with the unused-label check – see Control
Flow. Write JUMP/JUMPI and let
the assembler pick the width.
Where this is implemented
spec/xqvm/ENCODING.mdis the normative reference.xqvm/src/bytecode/program.rsimplements the header encode/decode and owns the pre-scannedJumpTable.xqvm/src/bytecode/codec.rsimplements the per-instruction operand encoding.xqvm/src/verifier/scan.rsimplements the single linear pass that builds the id-to-offset lookup and checks for undefined jump targets in the same walk.
Verifier
The verifier performs static analysis over a program before it runs, so structural and semantic errors are caught at load time rather than partway through execution. It runs as a fixed pipeline of phases; each phase makes one pass over the program and the pipeline is fail-fast, stopping at the first violation any phase finds. A program with two independent defects reports only the first one encountered.
The Rust implementation is xqvm::verifier, exposed to Python via
xqffi.verifier and re-exported as xquad.verifier.
This page is the reference: what each phase checks and what each error means. For why a specific program was rejected and how to change it, see Verification.
What passing verification guarantees
Verifier::default() builds the standard pipeline described below. Calling
its run method against a program returns Ok(()) if every phase passes,
or the first VerifierError encountered otherwise. A program that passes is
guaranteed structurally sound: every opcode decodes, every jump lands on a
real TARGET, every loop is balanced, and every register is read only after
it is written on every reachable path. For the value stack, phase 4 found no
underflow, no join-point depth disagreement, and no basic block whose depth
exceeds the 8,192-item limit. Every instruction on every reachable path has
at least as many values on the stack as it pops, counted before any of its
pushes, so a program that passes cannot underflow the value stack.
Every phase reasons about types, control flow and depths. None reasons
about values, so no phase can decide a question whose answer the program
computes at run time. An allocator size, a grid extent, a loop bound, a
shift amount, a calldata or output index and every arithmetic operand are
all ordinary popped stack values, which is why InvalidAllocation,
InvalidGridDimensions, InvalidIntegerK, InvalidShift,
ArithmeticOverflow, IndexOutOfBounds, SampleOutOfDomain,
LoopStackOverflow and the two budget faults exist only at runtime and
have no verifier counterpart. SampleOutOfDomain is the clearest of them:
SETLINE on a sample is well-typed whatever it writes, and only the value
the program computes decides whether the write is in domain.
That is a boundary rather than a gap: an embedder gets its bound from the
step and allocation budgets it sets, not from a verification pass.
Pipeline
| Order | Phase | Checks |
|---|---|---|
| 1a | Structural | Truncated instruction bytes, unknown opcodes |
| 1b | Jump target | A jump label id at or past the program’s target count |
| 1c | Loop nesting | RANGE/ITER/NEXT balance, loop-only instructions used outside a loop |
| 2 | Register type-state | Control-flow-based read-before-write and register type checks |
| 3 | Must-init | Control-flow AND-meet analysis: a register read that is unset on at least one incoming path |
| 4 | Stack depth | Control-flow-based stack underflow, overflow risk, and join-point depth mismatches |
Phases 1a-1c share one linear scan over the instruction bytes – the same
scan that produces the program’s id-to-offset lookup for TARGET labels
(see Bytecode Format). Phases 2 through 4 share one
control-flow graph of basic blocks, built once, and each runs its own
forward worklist analysis over it. The control-flow graph correctly accounts for unreachable code,
conditional branches, and loop back-edges, catching errors that a purely
linear scan would miss.
Phase 1 – structural, jump target, loop nesting
1a, structural. Every byte in the instruction stream must decode to a
known opcode with the correct number of operand bytes. An unrecognised
opcode byte or a truncated operand sequence is rejected immediately.
Errors: TruncatedInstruction, BadOpcode.
1b, jump target. Every JUMP1, JUMPI1, JUMP2, and JUMPI2
instruction must reference a label id produced by the TARGET pre-scan; an
id greater than or equal to the program’s target count is invalid.
Error: UndefinedJumpTarget.
1c, loop nesting. RANGE and ITER each open a loop frame; NEXT
closes one. The verifier rejects NEXT, LVAL, or LIDX reached with no
open loop frame, and any loop frame still open at the end of the program
(reported at the byte offset of the outermost unmatched opener).
Errors: NoActiveLoop, UnmatchedLoop.
Phase 2 – register type-state
A control-flow-based forward analysis over 256 register slots. Each
register starts Unset. An instruction that writes a register advances its
slot to a concrete type (Int, VecInt, VecXqmx, Model, Sample) or to
Any; an instruction that reads a register is checked against what that
instruction requires.
At a join point – two or more incoming control-flow paths – the
per-register meet is permissive: disagreeing types, including a register
unset on one path and Int on the other, resolve to Any rather than an
immediate error. That permissiveness avoids false positives for a register
written on only one branch of a conditional; Phase 3 is what actually
catches that case. Reading an Unset register is rejected, and so is a type
mismatch, for example reading a Model register where an instruction
requires Int.
A join is held back from that permissiveness, because Any satisfies every
requirement. Joining a model with a sample gives model or sample, and
joining the two vector types gives vec<int> or vec<xqmx>; each satisfies
only what its two members have in common – the grid operations and the
linear coefficient trio for the first, ITER and VECLEN for the second.
Any other pair of types gives conflicting types, which satisfies nothing
but a read that accepts any set register, such as OUTPUT. A program that
allocates a model on one branch and a sample on the other and then writes a
quadratic coefficient is rejected, which is the point: it would fault at run
time on one of its two paths. Answering unrelated pairs this way is also
what keeps the result the same however many branches meet, and whatever
order they are in.
DROP is a special case worth knowing. At runtime it resets the register to
Unset, and the verifier models that faithfully:
DROP resets the register’s tracked type to Unset, so a read after
DROP is rejected as ReadUnsetRegister and would also fault at runtime as
UnsetRegister. Unreachable blocks are skipped, so dead code after an
unconditional jump never produces a false positive.
Errors: ReadUnsetRegister, RegisterTypeMismatch.
Phase 3 – must-init analysis
A control-flow-based forward analysis using an AND-meet over a bitmap of which registers are definitely initialised. At program entry every register is un-written. At a join point, a register’s bit is cleared unless every incoming path wrote it – so a register written on only one branch of a conditional is flagged as possibly-unset at any read after the join, even though Phase 2’s permissive meet let the same register pass its type check. Unreachable blocks are skipped.
Error: ReadUnsetRegister.
Phase 4 – stack depth
A control-flow-based forward analysis over the abstract depth of the value stack. Each basic block is characterised by the minimum depth it needs on entry and its effect on exit; the analysis propagates the most conservative depth to each successor.
Checks run in this order:
-
Loop stack imbalance. A separate pass walks each loop body, ignoring the back-edge, and compares the depth at
NEXTagainst the depth at the matchingRANGE/ITERopener. A non-zero net effect is an imbalance, reported before anything else in that loop is checked. -
Stack depth mismatch. After the pass converges, every join block – two or more incoming edges – has the arrival depths of all its reachable incoming edges compared; any two that differ mean the stack state at that point is not well-defined.
Program entry counts as an incoming edge of the entry block, arriving at depth 0. So a back-edge targeting the entry block makes it a join block even though it has only one predecessor block, and the loop body’s exit depth is compared against entry’s depth 0.
-
Underflow and overflow risk. A block whose converged depth would fall below zero, or exceed 8,192, is flagged. The block’s entry requirement is the lowest depth any of its instructions’ pops reaches: each instruction’s pops are counted before its pushes, so
PUSH 1 / SWAPneeds one more value than it has, even thoughSWAPleaves the depth unchanged.
SCLR resets the abstract depth unconditionally to zero, which the analysis
treats as a reset rather than a delta. A reset does not undo an underflow
before it: SCLR / POP / SCLR / HALT is rejected, because the POP faults
before the second SCLR runs. If the entry depth was N > 0 and the
exit depth is 0, that reset is itself the imbalance: inside a loop it is
reported directly as LoopStackImbalance by check 1, ahead of everything
else in that loop. Outside a loop, the same reset is only a problem if it
creates a discrepancy between incoming edges at a join, in which case check 2
catches it as StackDepthMismatch.
Errors: LoopStackImbalance, StackDepthMismatch, StackUnderflow,
StackOverflowRisk.
Error reference
| Error | Phase | Meaning |
|---|---|---|
TruncatedInstruction | 1a | The instruction stream ends mid-operand |
BadOpcode | 1a | An opcode byte does not map to a known instruction |
UndefinedJumpTarget | 1b | A jump references a label id at or past the program’s target count |
NoActiveLoop | 1c | NEXT, LVAL, or LIDX reached with no open loop frame |
UnmatchedLoop | 1c | A loop frame is still open at the end of the program |
ReadUnsetRegister | 2 or 3 | A register is read before it is written, or before it is written on every path |
RegisterTypeMismatch | 2 | A register holds a different RegVal variant than the instruction expects |
LoopStackImbalance | 4 | A loop body’s entry and exit stack depths differ |
StackDepthMismatch | 4 | Two control-flow paths reach a join point with different stack depths |
StackUnderflow | 4 | Stack depth would go negative at a reachable instruction |
StackOverflowRisk | 4 | Stack depth would exceed 8,192 at a reachable instruction |
Every VerifierError variant carries the byte offset of the offending
instruction; several (RegisterTypeMismatch, UndefinedJumpTarget, and
others) also carry the register number or label id involved, so a
diagnostic can point at the exact spot in the bytecode.
When a program fails
A verifier failure is a load-time rejection: nothing in the program has
executed. xquad vm check runs the pipeline from the command line without
running the program; see CLI. For what to change in your
source when a specific error fires, see
Verification, which covers the same errors
from the side of fixing them rather than defining them.
Sources
spec/xqvm/VERIFIER.mdis the normative reference, including the full per-opcode stack-effect table.xqvm/src/verifier/phase.rsdefines thePhasetrait and the default pipeline (CombinedPhasefor 1a-1c,AllCfgPhasesfor 2-4).- The individual phase modules live alongside it in
xqvm/src/verifier/. xqvm/src/verifier/error.rsdefinesVerifierErrorand its display strings, reproduced in the table above.
Limits and Errors
XQVM has two separate error surfaces. The verifier is static
analysis that runs before a program executes; xquad vm check runs it without
running the program. The VM is runtime: xquad vm run does not verify
automatically, so a program that was never checked can reach the VM and
fault there instead. Several conditions – an out-of-range jump label, in
particular – have both a verifier error and a distinct VM runtime error for
this reason.
Fixed Limits
| Limit | Value | Enforced by |
|---|---|---|
| Stack depth | 8,192 items | VM: StackOverflow. Verifier: StackUnderflow/StackOverflowRisk (Phase 4) |
| Register count | 256 slots (r0-r255), statically allocated | – |
| Jump label range | 0-65,535 (u16), 65,536 labels max | Assembler: TooManyTargets. Verifier: UndefinedJumpTarget. VM: InvalidLabel |
| Shift amount | 0-63 bits | VM: InvalidShift |
| Grid dimensions | rows and cols must be > 0, and rows * cols must not exceed the register’s declared size | VM: InvalidGridDimensions |
| XQMX size | 4,294,967,295 (2^32 - 1) variables, and the allocation budget | VM: InvalidAllocation past the maximum, MemoryLimitExceeded when the budget cannot pay. Applies to a size an allocator was given and to one ATLEAST, ATLEASTW, REDUCE or EQUALITY grew a model to |
| Loop nesting | 8,192 frames | VM: LoopStackOverflow |
Integer domain size (XQMX/XSMX) | k >= 2 | VM: InvalidIntegerK |
| Sample assignment value | a member of the register’s domain: {0, 1} binary, {-1, +1} spin, {0, ..., k-1} integer | VM: SampleOutOfDomain on SETLINE/ADDLINE. Model coefficients are unbounded and never raise it |
Configurable Limits
| Limit | Library default | Method | VM error when exceeded |
|---|---|---|---|
| Step budget | 10,000,000 | Vm::set_step_limit(n) / Vm::set_unlimited_steps() | StepLimitExceeded |
| Allocation budget | 1 GiB | Vm::set_memory_limit(bytes) | MemoryLimitExceeded |
| Calldata slots | 0 | Vm::set_calldata(vec) | CallDataIndex |
| Output slots | 0 | Vm::set_output_slots(n) | OutputIndex |
The library defaults above apply to Vm::new() directly; the xquad vm run
CLI sets its own defaults before handing control to the VM – 16 output
slots unless --outputs overrides it. See xquad vm run for the
CLI’s own default table.
The step limit is exact: set_step_limit(0) permits no instructions at
all, not unlimited ones. To remove the bound, call
Vm::set_unlimited_steps(). Nothing else removes it, and nothing removes
it by default. Until 0.4.0 0 was the sentinel for “unlimited”, which
made a zero budget the most dangerous value a caller could pass rather
than the safest.
The step budget
A step is a unit of metered execution cost, not an instruction: every
instruction charges a base cost before dispatch, and opcodes whose work
scales with data the program controls – evaluating a model, expanding a
constraint, copying a register that holds a model – charge more before
they do that work. An instruction that cannot pay the charge fails with
StepLimitExceeded and does none of the work it would have charged for.
Vm::steps() reports the metered total; Vm::instructions() reports the
plain dispatch count, which is always less than or equal to steps(). See
spec/xqvm/METERING.md
for the full cost model, including the per-opcode charges and the
constants they use.
The allocation budget
Every instruction that allocates a sample buffer, declares model variables,
grows a vector, or expands constraint coefficients is charged against the
budget before it allocates. An instruction that cannot pay fails with
MemoryLimitExceeded and allocates nothing, leaving its target register
untouched. Vm::memory_used() reports what a run spent.
Charges are cumulative rather than a high-water mark of live memory: bytes are
charged when they are allocated and are never refunded, so a loop that
allocates and discards cannot spend more than the budget in total. Each run()
starts from zero. There is no sentinel for “unlimited” – pass u64::MAX.
| Charged | Rate |
|---|---|
BQMX, SQMX, XQMX (declared model size) | 8 bytes per variable |
BSMX, SSMX, XSMX (sample buffer) | 8 bytes per variable |
VECPUSH, SLACK (vector growth) | 16 bytes per element |
SETLINE, ADDLINE on a model | 32 bytes per coefficient |
SETQUAD, ADDQUAD, EXCLUDE, IMPLIES | 48 bytes per coefficient |
ONEHOTR, ONEHOTC, EQUALITY, ATLEAST, ATLEASTW | worst-case expansion: one linear term per variable and one quadratic term per pair |
REDUCE | one auxiliary variable, three quadratic terms, one linear term |
ITER on a vec<int> (slice copied into the loop frame) | 8 bytes per element |
ITER on a vec<xqmx> (slice copied into the loop frame) | one whole-model copy per element – see below |
Models store their coefficients sparsely, so a declared model size costs nothing immediately; it is charged because every consumer of the model – the sample needed to evaluate it, each solver backend – has to materialise it. The expanding constraint opcodes are charged for their worst case, so an expansion whose indices collide can be charged more than it ultimately stores.
VEC, VECI and VECX install an empty vector and allocate nothing; their
storage is charged as VECPUSH and SLACK create it.
ITER copies the slice it iterates into its loop frame, and a loop frame is
released only by NEXT. A back-edge that re-enters an ITER without reaching
its NEXT therefore accumulates copies, which is why the copy is charged.
The loop frame itself is not charged, but it is bounded: the loop stack is
capped at 8,192 frames and a program that grows it past that fails with
LoopStackOverflow.
An element of a vec<xqmx> is charged the whole-model copy rate, because
cloning a model clones its coefficient maps: 8 bytes per declared variable
plus 32 per non-zero linear coefficient and 48 per non-zero quadratic one.
A write that leaves a coefficient at 0 removes its entry, so a zeroed
coefficient is not copied, though the write that zeroed it was still
charged. The copy therefore never costs more than the allocator and the
coefficient writes charged to build the model, and it is the same number
wherever the copy happens – through an ITER, or through INPUT/OUTPUT
copying a whole register across the host boundary. A model does not get cheaper by being
duplicated through one opcode rather than another.
The charge schedule is defined over program-visible quantities – variables
declared, elements appended, coefficients written – rather than over the
interpreter’s internal representation, so every host rejects the same
programs at the same instruction having charged the same bytes.
xquad.vm.VM.set_memory_limit() sets the budget and VM.memory_used()
reads the result back.
VM runtime errors
Produced by xqvm::Error
(xqvm/src/error.rs)
while a program is executing. Every variant that carries a byte position
(pos) can be turned into a RuntimeDiagnostic via Error::into_diagnostic,
which disassembles the program and points at the failing instruction.
| Error | Cause |
|---|---|
StackUnderflow | Popping from an empty or too-shallow stack |
StackOverflow | Pushing when the stack is already at 8,192 items |
RegisterType | Instruction expects a different RegVal variant than the register holds |
IncompatibleType | Type mismatch reported without register context |
UnsetRegister | LOAD or OUTPUT on a register that was never written, or was DROPped |
DivisionByZero | DIV or MOD with divisor 0 |
ArithmeticOverflow | An i64 operation left the signed 64-bit range. Every operation the VM performs on a program’s behalf is checked, intermediates included, so a computation whose mathematical answer is representable still faults when a partial result is not |
IndexOutOfBounds | An index operand outside what it addresses: a vec index, a coefficient index against the model’s declared size, or a grid row or column against the extent RESIZE declared |
NoActiveLoop | NEXT, LVAL, or LIDX with no active loop |
BadJumpTarget | Jump target lands outside the bytecode buffer |
InvalidLabel | JUMP/JUMPI references a label id the id-to-offset scan never resolved |
BadOpcode | Unrecognised opcode byte |
TruncatedInstruction | Bytecode ends mid-instruction |
CallDataIndex | INPUT index out of range |
OutputIndex | OUTPUT index out of range |
SizeMismatch | ENERGY sample length does not match model size |
VecLengthMismatch | Two parallel vectors used together (for example EQUALITY’s indices and coefficients) have different lengths |
StepLimitExceeded | A step charge could not be paid: "step charge of {requested} exceeds the step limit of {limit} ({used} steps already charged)", where requested is 1 for the base per-instruction cost or the larger charge an opcode asked for before doing data-scaled work |
MemoryLimitExceeded | An allocating instruction exceeded the configured allocation budget |
InvalidShift | SHL/SHR shift amount outside [0, 64) |
InvalidGridDimensions | RESIZE with rows or cols <= 0, RESIZE with rows * cols past the register’s declared size, or a grid-reading opcode on a register with no grid |
InvalidAllocation | An allocator given a negative size, or one past the maximum allocator size 2^32 - 1; also a constraint that would grow a model past it, since ATLEAST, ATLEASTW, REDUCE and EQUALITY all append variables. Both are properties of the operand rather than of the machine running it, so the same size is refused everywhere. Under an ordinary budget an oversized size raises MemoryLimitExceeded first, since the charge precedes the range check |
LoopStackOverflow | RANGE/ITER nesting past 8,192 frames |
InvalidIntegerK | XQMX/XSMX called with k < 2 – k counts the values in {0, ..., k-1}, so k = 1 leaves a single value and no decision to make |
SampleOutOfDomain | SETLINE/ADDLINE wrote a value outside a sample’s domain. ADDLINE checks the result of the addition rather than the delta. Sample registers only: a model’s linear[i] is a bias, not an assignment, and is unbounded |
UnmatchedLoop | A RANGE/ITER skip-forward scan reached the end of the stream without a matching NEXT |
TraceFailed | A tracer callback returned an error (for example an I/O write failure) |
Verifier errors
Produced by xqvm::verifier::VerifierError
(xqvm/src/verifier/error.rs)
before a program runs, by the four-phase pipeline described in
Verifier: structural, jump-target and loop-nesting checks;
register type-state; must-init analysis; and stack depth.
The eleven variants, which phase raises each one, and what each one means are catalogued in Verifier’s error reference rather than repeated here, since that page also explains the phase that produces each one. See Verification for how to fix a rejected program.
Substrate pallet fixture limits
fixtures/pallet-xqvm (excluded from the main Cargo workspace build; see
Embedding Overview) adds two additional bounds on
top of the ones above, enforced by the runtime’s Config trait rather than
the VM:
| Limit | Config item | Purpose |
|---|---|---|
| Program size | MaxProgramSize | Maximum bytecode byte length accepted by submit_program |
| Calldata and output count | MaxCalldata | Shared bound on both the calldata vector and the output-slot vector |
submit_program takes the step budget as an extrinsic argument rather
than reading a pallet constant, so a caller names the bound the VM may
spend. The bound is exact, and a step_limit of 0 fails rather than
succeeding vacuously. The fixture sets no allocation budget, so the VM’s
1 GiB default applies – far too generous for a runtime that has to price
what it admits. See Substrate Pallet.
Embedding Overview
Everything so far in this book drives XQuad from Python or the xquad CLI.
This chapter is for embedding the Rust crates directly. That means building
bytecode with a fluent Rust API instead of .xqasm text, running it without
a Python process in the loop, and compiling for no_std targets.
Crate Map
| Crate | Role | no_std |
|---|---|---|
xqvm | Opcode table, InstructionBuilder, the wire codec, and the Vm interpreter | Yes, with --no-default-features |
xqasm | Parses .xqasm text into an xqvm::Program | No |
A Rust program that only needs to build and run bytecode depends on xqvm
alone; add xqasm to parse .xqasm source instead of building bytecode by
hand with InstructionBuilder.
The no_std Story
xqvm builds no_std + alloc. Two layers are always compiled without
std:
- Bytecode – the opcode table,
Instruction,Register,InstructionBuilder,Program, and the wire codec. - Interpreter core –
Vm,Error,RegVal,XqmxModel/XqmxSample, the bytecode verifier, and theTracertrait with its no-op implementation.
The std feature, enabled by default, adds three things a no_std
embedder gives up: the disassembler (disasm::Disassembly), miette-backed
runtime diagnostics (RuntimeDiagnostic), and the JsonTracer /
TextTracer implementations. None of these change what a program computes;
they change how a fault or a trace is reported. Build for a no_std target
with:
cargo add xqvm --no-default-features
This is exercised, not just claimed: fixtures/xqvm-wasm is a freestanding
wasm32-unknown-unknown test crate depending on xqvm with
default-features = false, and CI’s test:wasm job runs cargo build -p xqvm --target wasm32v1-none --no-default-features followed by
wasm-pack test --node against it. The job is path-gated: it runs on
merge requests and feature branches whenever the diff can reach xqvm or
the fixture, and unconditionally on protected refs and release tags.
Running In-Process, Without .xqasm
Builder API covers InstructionBuilder in full: emitting
instructions one at a time, resolving forward and backward labels, and
reading back the resulting JumpTable. It is the API a no_std embedder
uses when parsing text is not an option.
On-Chain: The Pallet Fixture
Pallet Fixture documents fixtures/pallet-xqvm, a Substrate
FRAME pallet that embeds xqvm inside a runtime as an integration gate, not
a production deployment path. It is a real, CI-tested example of a no_std
embedder, and the one currently in this repository.
On-Chain: Which Opcodes a Chain May Admit
On-Chain Admissibility answers which of the 93 opcodes a
runtime embedding xqvm may accept from an untrusted account. The answer is
all of them, because chain compatibility is a property of the instruction set
rather than a per-embedder decision; the page states the bar an opcode has to
clear, where each half of it is enforced, what every family charges, and
which parts of that boundary the tree does not yet enforce.
Checking an Implementation Against the Spec
Conformance covers the vector suite that holds xqvm to
the behaviour spec/xqvm/ prescribes. Read it if you are embedding xqvm
somewhere that needs to trust its output matches the specification.
Builder API
InstructionBuilder is a fluent Rust API for constructing XQVM bytecode
programmatically, without going through the text assembler. It lives in the
xqvm crate.
Basic Usage
#![allow(unused)]
fn main() {
use xqvm::InstructionBuilder;
let mut b = InstructionBuilder::new();
b.emit_push(10)
.emit_push(32)
.emit_add()
.emit_halt();
let program = b.build().unwrap();
assert_eq!(program.code().len(), 6);
}
Labels
Labels are opaque handles. Create them with label(), anchor them with
place(), and reference them in emit_jump()/emit_jump_if(). Both forward
and backward references work. place() returns a Result – placing the
same label twice, or placing a label from a different builder, is an error.
Backward Reference
#![allow(unused)]
fn main() {
use xqvm::InstructionBuilder;
let mut b = InstructionBuilder::new();
let loop_top = b.label();
b.emit_push(3);
b.place(loop_top).unwrap(); // anchor label at this position
b.emit_push(-1);
b.emit_add();
b.emit_copy();
b.emit_jump_if(loop_top); // backward jump to loop_top
b.emit_pop();
b.emit_halt();
let program = b.build().unwrap();
}
Forward Reference
#![allow(unused)]
fn main() {
use xqvm::InstructionBuilder;
let mut b = InstructionBuilder::new();
let done = b.label();
b.emit_push(0);
b.emit_jump_if(done); // forward jump -- target not yet placed
b.emit_push(42);
b.place(done).unwrap(); // anchor here
b.emit_halt();
let program = b.build().unwrap();
}
PUSH Auto-Sizing
emit_push(val) automatically selects the smallest PUSH1–PUSH8 instruction:
#![allow(unused)]
fn main() {
use xqvm::InstructionBuilder;
let mut b = InstructionBuilder::new();
b.emit_push(0); // emits PUSH1 (2 bytes)
b.emit_push(42); // emits PUSH1 (2 bytes)
b.emit_push(1000); // emits PUSH2 (3 bytes)
b.emit_push(i64::MAX); // emits PUSH8 (9 bytes)
b.emit_halt();
let program = b.build().unwrap();
assert_eq!(program.code().len(), 17); // 2 + 2 + 3 + 9 + 1 (HALT)
}
Register Operations
Most register instructions have a corresponding method:
#![allow(unused)]
fn main() {
use xqvm::{InstructionBuilder, Register};
let mut b = InstructionBuilder::new();
b.emit_push(42)
.emit_stow(Register(0)) // r0 <- Int(42)
.emit_load(Register(0)) // push r0's value back onto the stack
.emit_bqmx(Register(1)) // pop 42 as size; allocate a binary model in r1
.emit_halt();
let program = b.build().unwrap();
}
DROP is available as emit_drop(). It resets the register to Unset, the
same state an unwritten register starts in – not Int(0):
#![allow(unused)]
fn main() {
use xqvm::{InstructionBuilder, Register};
let mut b = InstructionBuilder::new();
b.emit_drop(Register(5)); // r5 <- Unset
b.emit_halt();
let program = b.build().unwrap();
}
ENERGY
The emit_energy() method takes two register operands:
#![allow(unused)]
fn main() {
use xqvm::{InstructionBuilder, Register};
let mut b = InstructionBuilder::new();
b.emit_energy(Register(0), Register(1)); // ENERGY r0 r1
b.emit_halt();
let program = b.build().unwrap();
}
Raw Instruction Emit
For instructions without a dedicated method, use emit():
#![allow(unused)]
fn main() {
use xqvm::{InstructionBuilder, Instruction};
let mut b = InstructionBuilder::new();
b.emit(Instruction::Copy {})
.emit(Instruction::Halt {});
let program = b.build().unwrap();
assert_eq!(program.code().len(), 2);
}
Build Errors
build() validates all labels and returns errors for:
UnplacedLabel– a label was used in aJUMP/JUMPIbut never placed.UnusedLabel– a label was placed but never referenced by any jump. The label at byte offset 0 is exempt from this check: the builder treats offset 0 as the implicit entry point, so a label placed there and never jumped to does not count as unused. (Thel0/l1example in the next section relies on exactly this –l0sits at offset 0 and is never referenced by a jump, yetbuild()succeeds.)TooManyTargets– more thanu16::MAX + 1(65,536) labels were placed in the program, exceeding the wire-format limit on sequentialTARGETids.FixupOutOfBounds– a fixup site falls outside the assembled buffer. This indicates a bug in the builder itself, not a mistake in caller code.
Two more Error variants exist but are returned by place(), not
build(): DuplicateLabel (a label placed more than once) and
ForeignLabel (a label passed to a builder that did not create it).
#![allow(unused)]
fn main() {
use xqvm::InstructionBuilder;
let mut b = InstructionBuilder::new();
let ghost = b.label();
b.emit_jump(ghost).emit_halt();
assert!(b.build().is_err()); // UnplacedLabel
}
Labels and the JumpTable
Every place() call anchors a label to the current write position and emits
an inline TARGET opcode there. build() resolves each JUMP/JUMPI fixup
against the placed TARGET positions, renumbers labels into stream order, and
returns a Program. The result of that resolution is queryable afterward:
Program::jump_table() returns a JumpTable, mapping each TARGET’s
sequential id (0, 1, 2, ... in program order) to its byte offset.
JumpTable::len() equals the number of distinct labels placed, and
JumpTable::get(id) returns the byte offset for a sequential id:
#![allow(unused)]
fn main() {
use xqvm::InstructionBuilder;
let mut b = InstructionBuilder::new();
let l0 = b.label();
let l1 = b.label();
b.place(l0).unwrap()
.emit_nop()
.emit_jump(l1)
.place(l1).unwrap()
.emit_halt();
let program = b.build().unwrap();
assert_eq!(program.jump_table().len(), 2);
assert_eq!(program.jump_table().get(0), Some(0)); // first TARGET, byte 0
assert_eq!(program.jump_table().get(1), Some(4)); // second TARGET, byte 4
}
This mapping is built once, at load time, by a single scan of the
instruction stream for TARGET opcodes – it is not part of the .xqb wire
format. See Execution for how the VM’s run loop
resolves a Jump result against it.
Pallet Fixture
pallet-xqvm
embeds xqvm inside a Substrate FRAME runtime, as a compile-time and
runtime integration gate. Its own module documentation states the purpose
plainly: if xqvm’s public API changes in a way that breaks Substrate
pallet integration, CI fails here first.
fixtures/pallet-xqvm is an excluded member of the main Cargo workspace,
declared in
Cargo.toml,
because it pulls in a heavy polkadot-sdk git dependency.
Treat this chapter as a reference integration and a fixture to build on,
not as a supported deployment path with its own release cycle. Which
opcodes a runtime that embeds the VM may admit, and what it has to enforce
to admit them, is stated separately in
On-Chain Admissibility.
CI still runs it, and blocks on it. test:substrate in
.gitlab/ci/test.yml
is path-gated: on a merge request or a feature branch it runs only when
the diff touches xqvm, the fixture, or the files that define the job
itself, and it runs unconditionally on protected refs and release tags.
It carries no allow_failure:, so it blocks the pipeline whenever it is
created. make test-substrate-fixture runs cargo test --manifest-path fixtures/pallet-xqvm/Cargo.toml, exercising eight pallet tests plus two
runtime-integrity checks FRAME generates from construct_runtime!. Every
claim below is checked against
fixtures/pallet-xqvm/src/lib.rs.
Configuration
The pallet is configured via the Config trait:
#[pallet::config]
pub trait Config: frame_system::Config<RuntimeEvent: From<Event<Self>>> {
/// Maximum byte length of an uploaded XQBC program.
#[pallet::constant]
type MaxProgramSize: Get<u32>;
/// Maximum number of calldata input slots and output slots.
#[pallet::constant]
type MaxCalldata: Get<u32>;
}
Two associated types, both bounds rather than tunable behaviour. The mock
runtime used by the fixture’s own tests sets MaxProgramSize = 65_536 (64
KiB) and MaxCalldata = 256, matching the VM’s 256-slot register file. A
single MaxCalldata bound caps both the calldata a caller supplies and the
outputs the pallet can return – there is no separate output-side limit.
Storage
#[pallet::storage]
pub type StoredProgram<T: Config> =
StorageValue<_, BoundedVec<u8, T::MaxProgramSize>, OptionQuery>;
One value, not a map. StoredProgram holds the bytecode of the most
recently executed program; calling the pallet’s extrinsic again overwrites
it. There is no per-program storage keyed by hash, and no record of which
account submitted which program beyond the event stream below.
The submit_program Extrinsic
The pallet exposes exactly one dispatchable, call index 0:
#[pallet::call_index(0)]
pub fn submit_program(
origin: OriginFor<T>,
bytecode: BoundedVec<u8, T::MaxProgramSize>,
calldata: BoundedVec<i64, T::MaxCalldata>,
output_slots: u32,
step_limit: u64,
memory_limit: u64,
) -> DispatchResult
| Parameter | Type | Description |
|---|---|---|
bytecode | BoundedVec<u8, T::MaxProgramSize> | XQBC-encoded program, produced by InstructionBuilder::build().encode() or by assembling .xqasm source. |
calldata | BoundedVec<i64, T::MaxCalldata> | Integer values injected as RegVal::Int into the VM’s calldata slots, in order. |
output_slots | u32 | Output slots to reserve. The caller declares its own arity; see step 3. May not exceed MaxCalldata. |
step_limit | u64 | Instructions the run may execute. The bound is exact: 0 executes nothing and the call fails with ExecutionFailed rather than succeeding vacuously. |
memory_limit | u64 | Bytes the run may allocate. Charged before each allocation, so an over-large one returns ExecutionFailed instead of reaching the allocator. |
Decode, execute, store, in that order:
-
Require a signed origin (
ensure_signed); an unsigned call is rejected withBadOriginbefore pallet logic runs. -
Decode
bytecodeas anxqvm::Program. A decode failure returnsError::BytecodeInvalidand nothing is stored. -
Take the output-slot count from the caller’s
output_slotsargument, rejecting a request aboveMaxCalldatawithOutputSlotsTooLarge.Do not derive it from
program.output_slots(). That header byte is a saturating count ofOUTPUTinstructions, not of slots, so oneOUTPUTinside a loop that writes slots 0 and 1 records1; sizing the output vec from it makes the slot-1 write raiseOutputIndexand the call fail withExecutionFailed, rejecting a program the VM and the verifier both accept. See the bytecode format, which states that neither header count may be used to pre-size a slot array. -
Build a fresh
Vm, set the calldata, the output-slot count and the caller’s two budgets, and run the program. Any VM fault returnsError::ExecutionFailed; the pallet does not distinguish which fault occurred, so a budget exhausted one instruction short ofHALTis indistinguishable from a decode-clean program that divided by zero. -
Collect the
Intoutputs (any otherRegValvariant in an output slot is silently dropped from the result) and bound them toT::MaxCalldata. Exceeding that bound returnsError::OutputOverflow. -
Store
bytecodeinStoredProgram, overwriting any previous value, and emitEvent::ProgramExecuted { who, outputs }.
There is no separate store-then-execute split and no program lookup by hash. Both budgets are extrinsic arguments rather than pallet constants because that is the shape the real pallet has to take: what a caller pre-pays for is what the VM may spend, and a budget the caller cannot name is a threat model the fixture cannot express.
The allocation budget is the one that has to be named on-chain rather
than inherited. Vm::new() installs 1 GiB, which is far more than a
wasm32 runtime heap, so the heap gives out long before the budget does
and the allocator traps the whole execution instead of returning a
fault the pallet can report as ExecutionFailed. A caller-supplied
memory_limit, capped by MaxMemoryLimit, is what keeps an over-large
allocation a reportable dispatch error. The mock sets the cap to the
VM’s own 1 GiB so that only the caller’s ability to name something
smaller is under test; a production runtime sets it to a figure its
heap can actually honour.
What the fixture does not do is call xqvm::verifier::verify: it
decodes the bytecode and runs it, so every static check the verifier
performs is skipped, and the faults those checks would have caught
surface at runtime as ExecutionFailed instead. A production pallet
must verify before it executes, for the reasons set out in
On-Chain Admissibility.
Off-chain, produce the bytecode however you like; the assembler CLI is
xquad vm asm.
The cargo profile the runtime is built with is a second default an
operator must not inherit without reading it. Do not compile the runtime
that carries this pallet with overflow-checks = true until every
#[expect(clippy::arithmetic_side_effects, ...)] entry in xqvm has been
re-read as a claim about that build: the VM already raises
ArithmeticOverflow for the arithmetic it performs on a program’s behalf,
so the flag adds nothing there and instead turns the host-side operations
that allow-list records into panics, and a panic inside block execution is
a block-production fault, strictly worse than a wrong answer. The flag
would not touch the VM’s deliberate wraps, which are method calls
(SHL’s wrapping_shl, SLACK’s wrapping_mul); it acts on the bare
operator sites, which are exactly the ones the allow-list claims cannot
leave range. That is why the reading is the gate, and it does not expire
once the allow-list exists.
Weight
#[pallet::weight(Weight::from_parts(10_000, 0).saturating_add(T::DbWeight::get().writes(1)))]
A fixed placeholder, not a metered cost model: the same weight is charged
regardless of program size or step count actually used. The extrinsic
neither computes an actual weight from vm.steps() nor refunds any
difference via PostDispatchInfo. The pallet’s own source comment says as
much: a production integration must supply real benchmarks. This weight
exists only so the extrinsic compiles and the fixture’s tests can dispatch
it.
Events and Errors
| Event | Fields | Description |
|---|---|---|
ProgramExecuted | who, outputs | Emitted once, after a successful run. outputs holds only the Int-valued output slots, in slot order. |
| Error | Meaning |
|---|---|
BytecodeInvalid | bytecode failed XQBC decode. |
ExecutionFailed | The VM faulted at runtime – stack underflow, an unresolved jump, or any other xqvm::Error variant, all mapped to this one case. |
OutputOverflow | The program produced more Int outputs than MaxCalldata allows. |
StepLimitTooLarge | step_limit exceeds MaxStepLimit. Checked before the decode, so an over-budget request never pays to parse the program it would have run. |
MemoryLimitTooLarge | memory_limit exceeds MaxMemoryLimit. Checked before the decode, for the same reason. |
OutputSlotsTooLarge | output_slots exceeds MaxCalldata. |
What the Fixture’s Tests Check
fixtures/pallet-xqvm/src/tests.rs covers five groups, sixteen tests in
all:
- Happy paths – an arithmetic program with no calldata, a calldata passthrough, and a two-value sum.
- Error paths – invalid bytecode, a stack underflow that reaches
ExecutionFailed, and an unsigned origin rejected byensure_signed. - The step budget – a request above
MaxStepLimit, one exactly at it, a zero budget, and a budget shorter than the program. - The allocation budget – a request above
MaxMemoryLimit, one exactly at it, and a budget aBQMXoutgrows. The third is the one the argument exists for: it fails outright ifsubmit_programstops callingset_memory_limit, which is how the gap this closes went unnoticed. - The output-slot count – one
OUTPUTinside a loop writing a slot per iteration, the same program with the count the header byte would have supplied, and a count aboveMaxCalldata. The middle test assertsprogram.output_slots() == 1directly, so the pair records what that header byte is and is not good for.
Running make test-substrate-fixture against this tree passes eighteen:
the sixteen above plus two FRAME-generated checks, a genesis-config
build and a construct_runtime! integrity test.
Calldata Limitations
submit_program only accepts i64 calldata (not models, vectors, or
samples), because BoundedVec<i64, T::MaxCalldata> is the extrinsic’s
parameter type. A program that needs a richer input must construct it
internally rather than receive it from the caller.
On-Chain Admissibility
A runtime that embeds xqvm runs bytecode it did not write, submitted by
an account it does not trust, inside a block whose weight it has to declare
in advance. The question this page answers is which of the VM’s 93 opcodes
such a runtime may admit.
The answer is all of them, and not as a concession each runtime makes
separately. On-chain verifiable deterministic execution is what XQVM is
for, so chain compatibility is a property of the instruction set rather
than a per-embedder admission decision. An operation that cannot be made to
meet the bar below is not an opcode that needs gating: it is functionality
that belongs in xqcp, xqsa, the xquad API or a helper library, and it
never enters the ISA at all.
This page is the reader-facing summary of that rule. All six clauses are
specified where a third implementation reads them: clause 1 in
spec/xqvm/SPEC.md’s Type System, which makes checked arithmetic normative
with no implementation-defined alternative; clause 2 in the same file’s
Determinism section, which states that an instruction’s result is a
function of the program, the calldata and the VM’s own state, and that no
instruction reads host state the calldata did not carry; clause 3 in
HLF.md’s ordering rules, covering delta application, sparse-table
accumulation and the grid fold; clause 4 in SPEC.md’s Allocation budget
section, which charges every allocation before it happens; clause 5 in
METERING.md, which fixes the step schedule, its charging points and its
counting units; and clause 6 in SPEC.md’s Faults section, which gives
every fault a normative identity that ISA.md is bound to name faults by,
together with the conformance vectors that pin the behaviour itself. One
row of that section records an identity that is not yet settled; the
section on what is not enforced says which.
The gate that holds a proposed opcode against all six belongs to the
contribution process rather than to the specification. It is stated in
docs/guide/development-workflow.md under “The opcode-addition gate”:
adding a row to the opcode table requires the merge request to argue each
of the six clauses in its description, and an opcode that cannot clear all
six does not ship. There is deliberately no CI guard for it, because the
gate asks for a correctness argument a reviewer weighs rather than a string
a script can find.
The bar
An XQVM opcode has to satisfy all of the following.
- No floating point. Integer arithmetic only, with every operation range-checked so that it faults rather than wrapping.
- No host I/O, wall-clock, or ambient state. An instruction’s result is a function of the program, the calldata and the VM’s own state.
- No nondeterministic iteration order. Anything that walks a collection walks it in an order the spec fixes.
- Bounded allocation. Every allocation is charged against a budget before it happens, and the budget is not escapable by a value the submitting account controls.
- Bounded per-instruction work. The work one instruction performs is charged against the step budget before it happens, at a rate that scales with the data the program controls, so that a step is a unit of cost rather than a unit of dispatch.
- Specified behaviour, pinned by a vector. The result and every fault
the opcode can raise are specified normatively in
spec/xqvm/, and a conformance vector covers the behaviour, failure paths included.
Rules 4 and 5 are the two halves of bounded work, and rule 5 is the one
that is easy to lose: an allocation budget bounds how much an instruction
keeps, not how long it takes. Rule 6 is the one that is easy to skip. A
chain does not need determinism against a single implementation; it needs
behaviour that a reader of the specification can predict, because a
divergence between the specification and an implementation is unspecified
behaviour with a plausible result, and no memory budget makes that safe.
spec/xqvm/ and the vectors derived from it are the whole of rule 6: the
vectors run the VM against outcomes the specification prescribes, and a
mismatch fails CI.
Because the bar is a property of the instruction set, the denied set is empty by construction. A proposed opcode that cannot clear it does not ship, so no embedder inherits a per-opcode decision.
Why not an allowlist
An earlier framing of this question asked which opcode families to admit and which to gate behind a later runtime upgrade, on the theory that the integer core is cheap and the model-building surface is not. That framing is no longer the right one: the model-building opcodes are exactly the reason to run XQVM on chain at all, and the two properties that made them unsafe – unbounded allocation and divergence from the specification – were defects with owners rather than inherent properties of the family.
So a runtime should not carry a static allowlist scanned over the decoded instruction stream. With the bar above there is no denied set for such a scan to find, and an allowlist would be code that has to be revisited on every addition to the instruction set, silently rejecting valid programs until someone remembers to update it. The static half of enforcement is the verifier, which checks properties of the program rather than membership of its opcodes.
Where the bar is enforced
| Layer | Runs | Enforces |
|---|---|---|
| Static | xqvm::verifier::verify at program submission | Structural integrity, jump targets, loop balance, register type-state, must-init, stack depth |
| Runtime | Vm::set_memory_limit before execution | The allocation budget, charged before each allocation |
| Runtime | Vm::set_step_limit before execution | The step budget, charged before each unit of work |
| Weight | The extrinsic’s declared and refunded weight | The block’s share of the above |
The static layer is what makes a rejection cheap: a program that cannot run correctly is refused before it consumes execution weight. The runtime layer is what makes admission safe: the verifier does not bound allocation or running time, and cannot, because both depend on values computed at runtime. Neither layer substitutes for the other.
The budgets belong in the runtime’s configuration rather than in the VM
defaults. The library defaults documented in
Limits and Errors – 10,000,000 steps and a
1 GiB allocation budget – are sized for an off-chain host, and a 1 GiB
budget admits a sample of 134 million variables. A chain has to set both
from its own Config, sized so that the worst case a submitted program can
reach still fits the block.
Family by family
The table below uses the same fourteen categories as the
Instruction Set Reference, so the two can be
read side by side. Cost is what an instruction charges against the
allocation budget; the full schedule, with rates, is in
Limits and Errors.
Several categories charge different rates for different opcodes, and the
cells say which. The step budget is a separate schedule with its own
rates, specified in
spec/xqvm/METERING.md;
an opcode free against one budget is not necessarily free against the
other. ENERGY and the four grid readers are the clearest cases: each
allocates nothing and each charges the step budget per unit of work it
does.
| Family | Count | Cost | Notes |
|---|---|---|---|
| Control flow | 12 | ITER charges for the slice it copies – 8 bytes per element of a vec<int>, a whole-model copy per element of a vec<xqmx>; the rest are free | Loop balance is checked statically; RANGE and ITER skip an empty body rather than running it once. The loop frame itself is uncharged but capped: the loop stack holds 8,192 frames and a program that grows past that faults with LoopStackOverflow. LVAL copies the current loop value without charging the allocation budget – see below. NOP and HALT live here |
| Register I/O | 5 | INPUT and OUTPUT charge for the value they copy across the host boundary, at the schedule’s own rates; the rest are free | Both are also bounded by the calldata and output-slot counts the runtime sets. A model copies at the whole-model rate ITER pays, but a vec<int> costs 16 bytes per element here against ITER’s 8: the copy rate prices a Vec that grew by doubling, which is what VECPUSH charged for, while ITER allocates its slice at exact capacity |
| Stack manipulation | 12 | Free | Depth is bounded at 8,192 items, checked statically and at runtime |
| Arithmetic | 13 | Free | Integer only; every operation is range-checked and faults rather than wrapping |
| Comparison | 5 | Free | |
| Logical | 4 | Free | |
| Bitwise | 6 | Free | Shift amounts outside [0, 64) fault |
| Allocators | 9 | BQMX, SQMX, XQMX: 8 bytes per declared variable. BSMX, SSMX, XSMX: 8 bytes per variable. VEC, VECI, VECX: free | The three charges are not the same kind. A model stores coefficients sparsely, so its charge is a proxy for what every consumer of the model has to materialise; a sample allocates a dense buffer at that rate, so its charge buys real bytes. An empty vec allocates nothing and its storage is charged as it grows |
| Vector operations | 5 | VECPUSH: 16 bytes per element. SLACK: 16 bytes per element appended, two per entry. VECGET, VECSET, VECLEN: free | SLACK appends to two vecs and is charged for both, so an entry costs 32 bytes; the three access forms neither grow nor allocate |
| Index math | 2 | Free | Pure index arithmetic on the stack |
| Coefficient access | 6 | SETLINE, ADDLINE: 32 bytes per linear coefficient. SETQUAD, ADDQUAD: 48 bytes per quadratic | Reads are free, and so are writes into a sample, whose buffer was charged when it was allocated. Both rates are literals rather than derived from the host’s pointer width, so a wasm32 runtime charges what a native build and the book state |
| Grid operations | 5 | Free | A grid reinterprets variables the program already declared and paid for: RESIZE rejects extents whose product exceeds the model’s size, which bounds memory. The step budget bounds the work: ROWSUM, COLSUM, ROWFIND and COLFIND charge GRID_CELL_STEPS per cell of the axis they scan, after validating the operand and before walking it, whether or not the scan short-circuits |
| High-level constraints | 8 | EXCLUDE: one quadratic term. IMPLIES: one linear and one quadratic. REDUCE: one variable, three quadratic terms and one linear. ONEHOTR, ONEHOTC, EQUALITY, ATLEAST, ATLEASTW: worst-case expansion, one linear term per variable and one quadratic term per pair | Only the last five expand; see below |
| Energy | 1 | Free | Allocates nothing, and charges the step budget for the sample it copies and every model term it accumulates |
Ninety-three opcodes across the fourteen categories, all of them integer-only by construction: the ISA has no floating-point type, no floating-point opcode and no way to produce one. What the conformance vectors pin is the stronger property that makes this useful for consensus – the checked-arithmetic rule, under which every result is the exact integer or a fault, and every overflow faults at the same instruction on every host.
The expanding constraints deserve a second look
Three sets are worth separating, because Limits and Errors separates them and a weight model needs each:
- Quadratic in a stack-controlled operand.
ONEHOTR,ONEHOTC,EQUALITY,ATLEASTandATLEASTWwrite a number of coefficients that grows with the square of a term count the caller chooses. - Allocating variables.
EQUALITY,ATLEAST,ATLEASTWandREDUCEadd variables to the model.REDUCEadds exactly one. - Both at once, from a single operand.
EQUALITY,ATLEASTandATLEASTW.
EXCLUDE, IMPLIES and REDUCE charge constant amounts and do not
expand. Every opcode in the first set is charged for its worst case before
it writes anything, so the aggregate budget does bound them. A runtime that
wants a submitted program to fail early and cheaply rather than after
spending most of its budget in one instruction should consider a
per-instruction cap on top of the aggregate one. That is a pricing
decision, not an admissibility one.
What is not enforced today
The bar above is what the instruction set is designed to, and most of it is
now backed by code. The v0.4.0 hardening pass bounded grid extents to the
allocation a program already paid for, made the coefficient rates
target-independent literals, charged the INPUT/OUTPUT copy across the
host boundary, capped the loop stack, and closed three divergences from the
specification in the model-building surface. Step
metering then made a step a unit of cost rather than a unit of dispatch:
every instruction charges a base cost before dispatch, the opcodes whose
work scales with caller-controlled data charge for that work before doing
it, and the forward scan of an empty-loop skip charges for each
instruction it consumes. The four grid scans charge GRID_CELL_STEPS per
cell of the axis they walk, which was the last place a memory bound stood in
for a work bound: RESIZE bounds the extent by an allocation the program
paid for, and that bounds what the grid holds, not what a scan over it
costs. An embedder pricing WeightPerStep * steps is pricing work rather
than dispatches.
The same pass closed the sample value domain, which had been documented as
a producer convention rather than a rule: SETLINE and ADDLINE now raise
SampleOutOfDomain for a write outside the domain the register’s allocator
declared. It is a bounded per-instruction check on a value already on the
stack, so it costs nothing against the bar above, and it removes a way for
a program to hand a solver an assignment that is not an assignment.
What is left is narrow.
A tracer copies registers outside the step budget. The Rust VM clones
every register an instruction reads and writes around each dispatch when a
tracer is attached – O(model) per instruction, charged nothing, because the
observable step count must not depend on whether a tracer is attached. This
is a constraint on the embedder rather than a hole in the meter: a chain runs
NoopTracer and the branch compiles out. A runtime that attaches a real
tracer meters untrusted programs untraced, or accounts for the tracing copy
outside the step budget. Stated normatively in spec/xqvm/METERING.md under
Conformance.
LVAL copies without charging the allocation budget. The step budget
now prices the copy – LVAL charges for the coefficients it clones out of
a loop frame – but the bytes that copy occupies are not charged, so a
vec<xqmx> iterated with LVAL in the body holds one unaccounted model
copy per live register beyond what ITER paid for. Bounded by the 256-slot
register file, so a constant factor rather than an unbounded obligation,
but one a memory bound has to include.
The reference integration does not verify. The fixture takes both
budgets from the caller and bounds them with MaxStepLimit and
MaxMemoryLimit, which is the shape a real pallet needs, so the runtime
layer of the boundary is now exercised end to end. The static layer is not:
it never calls xqvm::verifier::verify – it decodes and runs, so every
static check is skipped and the faults those checks would have caught
surface at runtime as ExecutionFailed. Running the verifier on chain is
QUI-1055; pricing the budgets into a benchmarked weight rather than the
fixed placeholder is QUI-1012.
Agreement with the specification, meanwhile, is close to where the bar
wants it. The xqvm build checks its opcode table against
xqvm/opcodes.yaml, make conformance runs every vector on the VM, and
xqvm/tests/metering_spec.rs checks the metering constants against
spec/xqvm/METERING.md value for value. The vectors now cover the model-building surface that the
arithmetic-only suite once missed, including failure paths: constraints
without a grid, invalid grid dimensions, integer allocation, index-math
operand ordering, and accumulation overflow in energy and grid sums.
Operand validation order is part of that agreement and is normative:
spec/xqvm/METERING.md fixes it under Conformance, and the VM validates
each register completely before looking at the next. The identity of a
mode fault is normative too: spec/xqvm/SPEC.md’s Faults section fixes a
sample in a model slot as TypeMismatch, and the vectors pin it. See
Conformance for how the suite is structured and what
adding a vector involves.
Conformance
spec/xqvm/ says what an XQVM program must do, and the Rust xqvm crate
this book documents implements it. The specification vectors –
xqvm/tests/vectors/ – are the mechanical
check that the crate does what the spec says. This page explains what
their result means for you: whether you are embedding xqvm or deciding
how much to trust it.
What a Vector Is
A conformance vector is a directory holding a fixed test case: a canonical
.xqasm program, the calldata it runs with, and the outputs and residual
stack it is expected to produce. The runner assembles the program, runs
it on the VM, and asserts the observed result matches the recorded
expectation.
Each vector directory holds three files:
program.xqasm– the canonical, human-readable source. The assembler is authoritative: whateverxqasm::assemble_source()produces from this file is the canonical bytecode, assembled in-process on every run. No pre-assembled bytecode artifact is committed.inputs.json– calldata, output slot count and the two budgets the run is given, for example{"calldata": [6, 7], "output_slots": 16}. Every key butcalldatais optional:output_slotsdefaults to 16,step_limitto 10000000 andmemory_limitto 1073741824, each matchingxquad vm run. The runner resolves the defaults itself, so a budget is a property of the vector rather than of whichever default the runner carries. Set one only for a vector that is about that budget.expected.json– the recorded result, for example{"outputs": [42], "final_stack": [], "steps": 11}.outputsis a sparse map: each entry is ani64written byOUTPUT, ornullfor a slot that was reserved but never written; trailing unset slots are omitted entirely, so a program that writes slot 0 of 16 reserved slots produces[42], not[42, null, ...]. Explicitly-written zeroes are preserved – only slotsOUTPUTnever touched disappear.final_stackis the residual stack atHALT, bottom to top.stepsis the run’s metered cost, perspec/xqvm/METERING.md, and every successful vector must assert it: it is the quantity the chain prices execution by. A vector that expects a fault writes{"error": "DIVISION_BY_ZERO"}instead, with neitherstepsnorfinal_stack.
Vectors are grouped, for human navigation, into eight directories under
xqvm/tests/vectors/: arithmetic, constraints, control-flow,
energy, index-math, metering, vector-ops, and xqmx-grid. These
names are not the same vocabulary as
opcodes.yaml’s
own category field, which has 15 values and matches spec/xqvm/SPEC.md’s
section names, not the vector directories. Only five names happen to
coincide (arithmetic, control-flow, index-math, vector-ops,
xqmx-grid);
constraints, energy and metering have no matching YAML category at
all – the constraint opcodes are catalogued under xqmx-high-level, and so
is ENERGY itself, while a metering vector is about a budget rather than
about any one opcode. Don’t expect a vector directory to line up with an
opcodes.yaml category by name.
Running the Vectors Locally
# Every vector
cargo test -p xqvm --test vectors
# One category
cargo test -p xqvm --test vectors -- arithmetic
# One vector
cargo test -p xqvm --test vectors -- --exact vector::arithmetic::add_basic
The runner assembles each program with xqasm, which the published xqvm
tarball does not carry, so the vectors run from a checkout of the
repository rather than from the crate downloaded from crates.io.
What Passing Means
Every vector runs as its own named test in the xqvm crate’s vectors
test target, which CI runs with the rest of the Rust integration tests. A
passing vector means: for that specific program and that specific
calldata, the VM produces every output value, the final stack and the step
count the vector records. It says nothing about programs the vector does
not cover.
Two more guarantees apply alongside the vectors, each checked separately:
- The opcode table itself – a build-time assertion ties the Rust
opcodes!macro toopcodes.yaml, so the VM cannot silently diverge from the published table on which opcodes exist or how many operands they take. - Bytecode encoding – owned by the
xqasmcrate’s own test suite. The vectors trust the assembler to produce correct bytecode from.xqasmsource; they do not re-check the encoding independently, though each vector does run a second time after an encode/decode round trip and must produce the same result.
For what the VM actually does with a program, see the XQVM Reference.
Coverage Is Per-Opcode, and Incomplete
A vector exists only where someone wrote one, and xqvm/tests/vectors/
holds far more vectors than it covers distinct opcodes: bitlen_small
and bitlen_negative both cover BITLEN; three separate slack_*
vectors all cover SLACK; the xqmx-grid directory alone spends
seventeen vectors on six opcodes. Where a vector is missing, the suite
makes no claim about that opcode at all. Passing CI does not mean every
opcode has been checked against the spec – only that every opcode a
vector currently exercises has been.
Which opcodes those are is now computed rather than guessed. CI prints
the report on every pipeline, as the last step of make test-rust (the
test:rust job),
and it runs locally with:
make conformance-coverage
The report gives two numbers, because one is not enough. Present
counts opcodes appearing in some vector’s assembled program; reached
counts those a vector executes to completion. Reached is the stronger
measure and always the smaller one: an opcode behind an untaken branch is
present but not reached, and so is the instruction an error vector exists
to make fault – a faulting instruction never completes a step. Reporting
only “reached” would call DIV uncovered despite
arithmetic/div_by_zero; reporting only “present” would credit an opcode
sitting in dead code.
Both numbers sit below the size of the opcode table, and the report names
the shortfall rather than only counting it. Run make conformance-coverage
for the current figures; this page does not repeat them, because they move
whenever a vector lands. The opcodes in no vector at all are the real
holes. Those present but never reached are a weaker signal worth knowing:
each has an error vector pinning how it fails, and no vector pinning what
it does when it succeeds.
Coverage reports; it does not gate on completeness, which would fail
today. It does gate on regression –
xqvm/tests/vector_suite/coverage.rs
holds both numbers as floors that a merge request may raise and may not
lower without saying why.
IDXTRIU is the worked example of what that costs. It had no vector, and
its behaviour was wrong in two separate ways: on operand order, and on
whether an intermediate that leaves i64 range faults. Both were found by
reading the code against the spec rather than by any mechanical check, and
both are now closed, with
index-math/idxtriu_intermediate_overflow and
index-math/idxgrid_intermediate_overflow pinning the second. Neither
gap was exotic; both were simply in the part of the opcode table nothing
had written a vector for – which is the hole the coverage report exists
to make visible before someone has to find it by reading.
Treat a green vector run as evidence for the programs it actually tests, not as a blanket guarantee that the VM matches the spec on every opcode in every configuration.
Glossary
Terms as this book uses them, alphabetically. Each entry links to the page that treats it in full.
Backend. Where a model actually solves: locally on a CPU or GPU, on D-Wave’s cloud QPU, or on the Quip network. Choosing one does not change the model, the encoder, or the verifier. See Backends.
Calldata. The read-only array of values a host program supplies to a
VM run before execution starts. INPUT reads from it by slot index. See
Calldata and Outputs.
Chain strength. A D-Wave QPU parameter. It sets how strongly a group of physical qubits standing in for one logical variable are coupled together, so they read out as one value rather than breaking apart under the problem’s own couplings. See D-Wave QPU.
Conformance vector. A fixed test case – program, calldata, and
expected outcome, written from the specification – that the vector suite
runs on xqvm and checks against its recorded expectation. See Conformance.
Decoder. One of the three programs a problem compiles to: takes a sample and extracts the answer in the problem’s own terms (a tour, a partition, a set of selected items). See Three Programs.
Domain. The set of values a model’s variables take: binary (\({0, 1}\)), spin (\({-1, 1}\)), or integer (\({0, \ldots, k-1}\)). A model and the sample solving it must share a domain. See Quadratic Models.
Embedding (D-Wave). Mapping a model’s logical variables onto a QPU’s
physical qubit graph, called minor embedding: each logical variable
becomes a chain of one or more physical qubits held together by chain
strength (above). SolverDWave builds this automatically. See D-Wave
QPU.
Embedding (Rust). Using the xqvm and xqasm crates directly from a
Rust program, without the Python packages. See Embedding
Overview.
Encoder. One of the three programs a problem compiles to: reads runtime inputs from calldata and builds the model. See Three Programs.
Energy. The single number a model computes for a candidate assignment
– the Hamiltonian evaluated at that point. A solver searches for the
assignment that minimises it; ENERGY computes it directly so a program
can check a solver’s answer instead of trusting it. See Quadratic
Models.
Hamiltonian. The function a quadratic model represents: a sum of linear and quadratic terms over the variables, borrowed from the physics term for a system’s total energy. See Quadratic Models.
Ising model. The spin domain: variables take values \(-1\) or \(+1\), the domain quantum annealing hardware minimises natively. See Quadratic Models.
Model. An XqmxModel: two sparse coefficient maps, linear and
quadratic, that together define a Hamiltonian over a fixed number of
variables. Built with BQMX/SQMX/XQMX and the coefficient-access
opcodes. See Quadratic Models and
VM Architecture.
Output slot. The writable array a VM run populates via OUTPUT,
read back by the host program after the run halts. See Calldata and
Outputs.
Penalty. A term added to a model’s Hamiltonian that is zero when a constraint holds and positive when it does not. Minimising the combined Hamiltonian then tends to satisfy the constraint too. Sizing the penalty weight correctly is the real engineering problem in constraint-by-penalty modelling. See Quadratic Models and Constraints.
QUBO. Quadratic Unconstrained Binary Optimisation: the binary domain, where each variable is either selected or not. See Quadratic Models.
Register. One of 256 typed slots (r0-r255) holding a RegVal:
an integer, a vector, a model, or a sample. See
VM Architecture.
Sample. An XqmxSample: one candidate assignment, a single value per
variable, produced by a solver or built by hand. Shares a domain and
variable count with the model it answers, but not its shape. See
Quadratic Models.
Solver. Anything implementing the xqsa interface: takes a model,
returns a sample and its reported energy. xqsa ships five backends: a
CPU annealer, CUDA and Metal GPU annealers (both SolverGPU), the D-Wave
QPU, and the Quip network. See Solving Overview.
Step limit. The maximum number of instructions a VM run will execute
before faulting with StepLimitExceeded, guarding against runaway
programs. Configurable per run; defaults to 10,000,000. See Limits and
Errors.
TARGET. The opcode marking a valid jump destination. Every label a
program jumps to must have a TARGET at that position. The VM scans for
them once at load time, rather than reading a precomputed mapping out of
the wire format, and resolves jumps against that scan. See
Execution and Builder
API.
Verifier. Two related but distinct things in this book. The bytecode
verifier is static analysis (xqvm::verifier) that checks a program’s
structure before it runs: every jump lands on a TARGET, and every
register is read only after it is written. See
Verifier. The verifier program is one of the
three programs a problem compiles to: it checks a sample against the
encoder’s constraints and recomputes its energy independently. See
Three Programs.
XQBC. The binary wire format: a 15-byte header (magic, version, slot counts, code length, checksum) followed by the raw instruction stream. See Bytecode Format.
XQCP. X-Quadratic Constraint Programming: the Python DSL that turns a problem description into the three XQVM programs. See Modelling Lifecycle.
XQMX. The matrix type the VM allocates and manipulates, in either of
two modes: a model (XqmxModel, a Hamiltonian’s coefficients) or a
sample (XqmxSample, one candidate assignment). See VM
Architecture.
XQSA. X-Quadratic Solver Adapters: the Python package holding one adapter per solving backend. See Solving Overview.
XQVM. X-Quadratic Virtual Machine: the stack machine every XQuad
problem compiles to, specified in spec/xqvm/ and implemented by the
xqvm crate. See XQVM Reference.
Stability
XQuad is pre-1.0. The root
README.md
says so directly: the instruction set, the binary format, and the public
API may still change before v1.0, and production use is not recommended
yet. Read everything below in that light – it describes the project’s
current process discipline, not a promise that anything is frozen.
What Is Versioned Together
Six packages ship from this repository, two to crates.io (xqvm,
xqasm) and four to PyPI (xqffi, xqcp, xqsa, xquad). All six
always carry the same version. They release together: one release MR
bumps every version, one tag triggers the one pipeline that publishes all
six, and the changelog is generated from that same tag.
There is no independent release cadence per package today.
What CI Actually Guards
Two things fail the build if they drift, checked mechanically by default rather than caught only by review discipline; the second of the two has a deliberate, contributor-controlled way out, noted below:
- VM behaviour against the spec. Conformance
checks
xqvmagainst the outcomespec/xqvm/prescribes for every behaviour a vector covers. Coverage is real but partial – see that page for what “partial” means concretely. - Spec and implementation agreement. Any change to VM semantics –
opcode table, control flow, stack depth, type system, or the high-level
constraint expansions – must touch three things in the same change: the
normative
spec/xqvm/files, the Rust implementation, and the conformance vectors. CI’s atomic-spec-MR guard rejects a change that touches only some of them, unless a commit in the range carries anAtomic-Spec-Exempt: <reason>trailer, which deliberately bypasses it for a one-sided change such as aligning the implementation to behaviour the spec already states. This keeps the three descriptions of VM behaviour from drifting apart silently by default; it does not keep the behaviour itself from changing, and the exemption is a contributor’s call, not a machine guarantee.
Both guarantee how a change to observable behaviour is made. Neither guarantees that observable behaviour stays fixed.
What Is Not Guaranteed
Nothing here promises binary compatibility across versions. Versions follow
semver as Cargo reads it, where the leftmost non-zero field is the major: before
1.0 a breaking change bumps the minor (0.4 to 0.5) and a patch release
(0.4.0 to 0.4.1) carries only compatible changes, fixes and features alike.
That policy governs the crate versions – it is not a commitment that nothing
observable will ever break before 1.0. Treat every 0.x release as a
snapshot, not a foundation to build on without re-checking. The breaking
changes in v0.5.0 are listed in Migrating from 0.4 to
0.5. If your use
case needs a stability guarantee this project does not yet make, file an
issue against the
repository rather than assuming one.
Reading the Documentation for an Older Release
The published book tracks the most recent release. Every tagged release carries its own copy of these pages in the repository, so an older version is a checkout away:
git switch --detach v0.4.0
make build-docs # renders to docs/book/build/
make serve-docs # serves it and opens a browser
make serve-docs rebuilds on edit, so it is also the way to preview a change
before opening a merge request. Return with git switch -.
Tags are listed with git tag --list 'v*' | sort -V, and the released packages
for each are on crates.io and
PyPI. Tags carrying -rc or -beta are
pre-releases: they are on both registries, but the published book never
describes one.
Migrating from 0.4 to 0.5
v0.5.0 is a breaking release. This page lists every change in it that can
break code written against 0.4, each with what to write instead. Upgrade
the Python packages together: xquad pins xqsa, xqcp and xqffi to its
own version, and xqsa and xqcp pin xqffi the same way. On the Rust side,
move xqvm and xqasm to 0.5 together.
One VM
The Python reference VM is gone. The Rust VM, reached through xqffi, is
the only implementation, and the xqvm_py distribution has no 0.5 release.
| 0.4 | 0.5 |
|---|---|
from xqvm_py ... | from xqffi.vm ..., or the re-exports in xquad.types |
VM(backend=VMBackend.RUST), vm.backend | VM(). VMBackend and the backend argument are gone |
xquad.types.XQMX, XQMXDomain, XQMXMode, Vec, VecElem | XqmxModel, XqmxSample and Domain from xquad.types (see Model types) |
Outputs converted to XQMX and Vec | Outputs are the xqffi values, unconverted |
SampleOutOfDomain from VM.set_calldata() | ValueError where the XqmxSample is built or written; no out-of-domain sample can exist |
--interpreter on the example runners | Removed; every runner uses the Rust VM |
Model Types
xqsa, xqcp and xquad all use the typed model API from xqffi.vm.
| 0.4 | 0.5 |
|---|---|
XQMX.binary_model(size=4) | XqmxModel.binary(4) |
XQMX.integer_model(size=3, k=4) | XqmxModel.integer(3, k=4) |
XqmxModel("binary", size=4), XqmxSample("spin", values=[...]) | XqmxModel.binary(4), XqmxSample.spin([...]), or XqmxModel(Domain.BINARY, 4) |
A domain string, plus k= for an integer domain | Domain.BINARY, Domain.SPIN, Domain.integer(k) |
model.domain == "binary" | model.domain == Domain.BINARY; the getter returns a Domain, with name and k |
XQMX.binary_sample(size), spin_sample, integer_sample | XqmxSample.default(Domain.BINARY, size) and so on; XqmxSample.binary(values) builds one from values |
set_quadratic, get_quadratic, add_quadratic | set_quad, get_quad, add_quad |
iter_linear(), iter_quadratic() | linear_items(), quadratic_items() |
xqmx.mode, is_model(), is_sample() | Gone: XqmxModel and XqmxSample are separate types |
The fields .linear, .quadratic and .integer_k | linear_items(), quadratic_items() and get_linear(i) on a model, .values on a sample, and .k |
grid_index(row, col) | No replacement |
domain_contains(domain, k, value) | domain.contains(value) |
compute_energy(model, sample) | model.energy(sample) |
"StepLimitExceeded" in str(e) | except StepLimitExceeded. Vm.run raises one XqvmError subclass per fault, and the message is no longer the Debug form |
The module functions of xqvm_py.xqmx, such as row_sum, col_find and
expand_onehot, have no replacement. triu is in xquad.types.
A model or sample that no program could build is still refused when it is
constructed, but with a different exception. A size past MAX_ALLOCATION_SIZE
raises InvalidAllocation and a grid that RESIZE would refuse raises
InvalidGridDimensions, both XqvmError subclasses and so RuntimeError,
where 0.4 raised ValueError. Code that catches ValueError around these
constructors has to catch the new types. The coefficient accessors
(set_linear, get_linear, set_quad, get_quad) likewise raise
IndexOutOfBounds, also an XqvmError, for an index outside 0..size,
where 0.4 raised IndexError.
XQCP Domains
define_model() takes an xqcp.Domain only. 0.4 also accepted an
XQMXDomain from xqvm_py and converted it; that type is gone with the
Python VM. The member names BINARY, SPIN, INTEGER and CATEGORICAL
are unchanged, but each value is now the domain’s name as a string:
Domain.INTEGER.value == "integer", and CATEGORICAL is "categorical"
rather than None. Code that read .value as an XQMXDomain breaks, and
problem.model.domain is now the xqcp.Domain member rather than an
XQMXDomain.
Solvers
The four per-hardware classes become three classes on one Solver base.
| 0.4 | 0.5 |
|---|---|
SolverDWaveCPU(...) | SolverCPU(...) |
SolverCudaGPU(...) | SolverGPU(engine="cuda", ...) |
SolverMetalGPU(...) | SolverGPU(engine="metal", ...) |
SolverDWaveQPU(...) | SolverDWave(...); mode="qpu" is the default |
strategy= on the GPU constructors | algorithm= |
solve(model, strategy=...) | Not accepted: the algorithm is fixed when the solver is built, so build one solver per algorithm |
metadata["params"]["strategy"] | metadata["params"]["algorithm"] |
from xqsa.registry import SOLVERS, DEFAULT_SOLVER, build_solver | The same names from xqsa or xqsa.solvers. A SOLVERS value is a callable that builds a solver, not always a class |
Registry keys dwave-cpu, cuda-gpu, metal-gpu, dwave-qpu | cpu, cuda, metal, dwave; quip is unchanged and cpu is the default |
from xqsa.dwave_cpu import ... and the other per-hardware modules | Import the solver classes from xqsa |
| An XQMX model in, an XQMX sample out | An XqmxModel in, an XqmxSample out |
SolverQuip keeps its class and its constructor. These change for the other
solvers:
- The constructors take keyword arguments only, so a positional call such as
SolverDWaveCPU(100, 1000)fails. - An unknown keyword to
solve()raisesTypeError. 0.4 ignored it, or forSolverDWaveQPUpassed it on to Ocean’s sampler;SolverDWaveforwards onlychain_strengthandauto_scale. result.metadataholds exactlyseed,readsandparams. Thealgorithm,engineormodethe solver ran with is inparams, andSolverDWavemovessolverandqpu_timingunderparamsas well.
A solver of your own that subclasses Solver also changes. Solver.__init__
now takes the implementation it runs, so a subclass that only overrides
solve() fails with TypeError when it is built. Give it its own __init__
and keep overriding solve(), as SolverQuip does. The _model_to_bqm and
_sample_to_xqmx methods are gone; xqsa.solver has model_to_bqm and
sample_to_xqmx as module functions.
Verifier and Metering
The verifier and the VM refuse programs that 0.4 accepted:
- The verifier checks stack underflow against each instruction’s pop
count, and an underflow between two
SCLRinstructions stays an underflow. A program that passed the 0.4 verifier and then faulted on every run with a stack underflow is now rejected before it runs. - A step charge that would carry the step counter past
2^64 - 1raisesStepLimitExceeded, whatever the step limit.
In the xqvm crate, StackEffect::Delta and StackEffect::from_delta
become StackEffect::Fixed { pops, pushes } and StackEffect::fixed.
Deprecated Names
The names that 0.4 deprecated still work in 0.5 and still warn, and they are
removed in v0.6.0. The Deprecated APIs
table lists each one with its replacement. The top-level commands xquad asm,
dism, run and verify are among them; use xquad vm instead (see
xquad vm).
Spec Index
The book teaches; spec/ is what XQuad actually is. Every file below is
the normative source for its topic’s intent. The specification vectors
check the Rust VM against these documents one program at a time, and its
build checks the opcode table against xqvm/opcodes.yaml. The
specification is the authority: where the VM diverges from it, the VM is
wrong, and where a divergence is known, the relevant book page says so.
Conformance
explains why a green run is evidence about the programs a vector covers
rather than a blanket guarantee.
The lists below are not closed. For anything they omit, check the book chapter covering the component you are working with, or search the spec files directly.
Top Level
spec/README.md– index of the three layers below, and the drift policy that ties spec to implementation.
XQVM
Normative for the virtual machine: the instruction set, the wire format, and the bytecode verifier.
spec/xqvm/README.md– document index for this layer.spec/xqvm/SPEC.md– normative for the machine overview, the three-program architecture, the state model, determinism, the type system, and runtime limits.spec/xqvm/ISA.md– normative for the instruction set: notation, per-category opcode tables, semantic notes, and reserved opcodes.spec/xqvm/HLF.md– normative for the high-level constraint expansion formulas: the QUBO penalty termsONEHOTR,ONEHOTC,EXCLUDE,IMPLIES,EQUALITY,ATLEAST,ATLEASTW, andREDUCEinject.spec/xqvm/ENCODING.md– normative for both file formats:.xqasmassembly syntax and the.xqbbinary encoding, including the XQBC header layout.spec/xqvm/VERIFIER.md– normative for the bytecode verifier: its phase pipeline and every error it can raise.spec/xqvm/METERING.md– normative for step metering: what a step is, the cost constants and how they were measured, the per-opcode charge table, the three formulas, and the conformance rules for step counts and operand validation order.
XQCP
Normative for the constraint-programming DSL that compiles a Problem
into the three XQVM programs.
spec/xqcp/README.md– document index for this layer.spec/xqcp/SPEC.md– normative for the DSL overview, the three-program architecture asxqcpimplements it, the problem lifecycle, and the compilation contract.spec/xqcp/TYPES.md– normative for the symbolic value types, the expression tree, the operator algebra, and the free functions built on top of it.spec/xqcp/CONSTRAINTS.md– normative for the constraint taxonomy, each constraint method’s signature, and its cross-reference into the matching HLF expansion.spec/xqcp/COMPILER.md– normative for the compilation pipeline: encoder/verifier/decoder generation, register allocation, and action recording.
XQSA
Normative for the solver-adapter interface between a model and a backend.
spec/xqsa/README.md– document index for this layer.spec/xqsa/SPEC.md– normative for the architecture overview, where solving sits in the pipeline, and the plugin model new solvers implement.spec/xqsa/INTERFACE.md– normative for theSolverbase class, thesolve()contract, theSolverResulttype, and parameter-passing conventions.spec/xqsa/ENERGY.md– normative for the energy computation formula, its precision contract, and the sparse representation solvers exchange it in.spec/xqsa/DOMAINS.md– normative for the domain support matrix, sample encoding, grid metadata, and capability negotiation between a model and a solver.spec/xqsa/SOLVERS.md– normative for the solver registry, its naming convention, algorithm families, and the dependency model behind each optional extra.
Reading a Spec File Like a Reference, Not a Tutorial
Every file above assumes the concepts this book explains from scratch – what a quadratic model is, why a problem becomes three programs, what a penalty weight does. Arrive from Concepts or the relevant chapter first; the spec files are where to go once you need the exact rule the book page summarised, not where to start.
Contributing to These Docs
This book is written and reviewed in the same repository as the code it
documents. If something here is wrong, incomplete, or out of date, the
fix goes through the same review process as a code change. Start from
CONTRIBUTING.md
for the general contribution rules – sign-off, commit format, and
review expectations apply to documentation changes too.
Where the Pages Live
Every page you can read here comes from one Markdown file under
docs/book/src/.
The URL mirrors the path: this page is appendix/contributing.md.
Two files control the shape of the book rather than its content:
docs/book/src/SUMMARY.mdis the table of contents and the sidebar. Every page must be linked from it, and every link in it must point at a page that exists. Continuous integration checks both directions, so adding a new page without adding itsSUMMARY.mdentry fails the build rather than producing an orphan.book.toml, at the repository root, holds the renderer configuration: the theme, the output directory, and the preprocessor list.
Pages You Should Not Edit by Hand
Sixteen pages are generated, not written. Editing one of them directly
works until the next regeneration silently reverts it. Each generated
file opens with a DO NOT EDIT banner as its first line, which you will
see immediately if you open it in an editor.
They are the instruction reference, generated from
xqvm/opcodes.yaml, and the fifteen
example pages, generated from
examples/manifest.yaml together with each example’s own README.md.
To change one, edit its source and regenerate:
make regen-docs
Continuous integration runs the same generators in check mode and fails if the committed pages differ from freshly generated output, so a regenerated page has to be committed alongside the source that produced it.
Building the Book Locally
The renderer and its diagram preprocessor are pinned in
scripts/cargo-tools.lock and installed by make deps. With those in
place:
make build-docs # build once into docs/book/build/
make serve-docs # rebuild on save and open a browser
make build-docs also asserts that every diagram in the sources actually
rendered, which catches a preprocessor failure that would otherwise show
up as a page of raw diagram source.
Two more checks are worth running before you open a merge request:
make check-docs-generated # generated pages match their sources
make check-docs-drift # table-of-contents coverage and prose guards
Both run in continuous integration as well, so running them locally just saves you a round trip.
Reporting Something Instead
Not every problem is worth a merge request. If you have found an error but not the fix, or you want to argue that a page should exist at all, open an issue and say which page you mean.