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, CLI) with Python interfaces (reference VM, constraint-programming DSL, solver adapters, FFI bindings).
Three Rust crates, published to crates.io:
| Crate | Binary | 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 |
xqcli | xquad | The unified CLI – xquad asm, xquad dism, xquad run, xquad verify |
A fourth 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.
Five Python distributions, published to PyPI:
| Package | Role |
|---|---|
xqvm_py | Pure-Python reference VM, used as the cross-implementation conformance oracle |
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 |
Parity on every committed conformance vector between the Rust xqvm
interpreter and the Python xqvm_py reference VM is enforced mechanically:
each vector runs on both implementations in CI, and disagreement 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 cross-implementation conformance. - 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. The two commands most readers need:
cargo install xqcli # gives you the `xquad` binary
pip install xquad # Python umbrella: the full pipeline
Verify the Install
xquad --version prints the installed CLI’s version, confirming the
xqcli binary is on PATH and runs. Importing the umbrella package
confirms the Python half:
$ 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 cargo install and
pip install above do 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 the Rust toolchain cargo install xqcli above already needed. 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
dwave-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, xqffi, and xqvm_py 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 dwave-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.
The Same Answer, a Different Machine
$ uv run python examples/maxcut/runner.py --seed 42 --interpreter rust
--interpreter picks which XQVM runs the compiled programs: the
pure-Python reference VM (the default above) or the Rust interpreter.
The encoder, verifier, and decoder are deterministic per interpreter –
same bytecode in, same output out, on either one. The solve sitting
between them is pinned only to the seed and the dwave-samplers
version: solver.solve(model) runs simulated annealing, so a different
version of that library can return a different valid sample for the same
seed. For this seed the
solve happens to land on the same sample either way, so cut_weight,
energy, and partition also match above. Three
Programs runs this exact
seed and size on both interpreters side by side and says more about what
is and is not guaranteed to match.
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.
dwave-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.
--interpreter rust in the previous page swapped which machine ran steps
2, 4, and 5. Three Programs
covers what is and is not guaranteed to match between the two
interpreters when that swap happens.
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 or xqvm_py"]
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: identical bytes run on the Rust xqvm interpreter or the Python xqvm_py reference VM | Input to the VM |
VM (xqvm / xqvm_py) | 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 asm add.xqasm -o add.xqb
assembled 4 instructions (21 bytes) -> add.xqb
$ xquad 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 / VMBackend: runs .xqasm source text directly against either the Rust interpreter or the pure-Python reference VM, with xquad.types.XQMX at the boundary | Backend-parity checking, or working in xquad.types terms; the surface every example runner under examples/ actually uses |
xquad CLI | xquad asm / run / dism / verify | 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 – Backend Dispatch
xquad.vm.VM selects between the Rust interpreter and the pure-Python
reference VM via VMBackend, converting FFI objects to and from the
canonical xquad.types.XQMX at the boundary, and runs .xqasm source
text directly rather than pre-assembled bytecode. VMBackend.RUST is
the default and reaches the Rust interpreter through xqffi.vm.Vm
internally. Every example runner under examples/ imports VM and
VMBackend from here, not xqffi.vm directly.
Stop here if you want backend-parity checking or your calldata and
outputs are already in xquad.types terms. Keep reading if you need
the raw FFI underneath instead. Full coverage: Running
Programs.
xquad CLI
xquad asm, xquad run, xquad dism, xquad verify – 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 asm add.xqasm -o add.xqb followed by xquad run add.xqb,
xquad dism add.xqb, and xquad verify 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
}
Adding --interpreter rust to the same command prints that block byte for
byte for this seed. That is not a guarantee: the encoder, verifier, and
decoder are deterministic per interpreter – same bytecode in, same
output out, on either one – but the solve sitting between them is only
pinned to the seed and the dwave-samplers version, since SA is
sensitive to BQM construction order. A different version of that library
can return a different valid sample for the same seed. See below for
what make example-smoke actually
checks instead of byte-for-byte parity.
_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 both interpreters and checks valid == 1,
and does not compare cut_weight, energy, or partition between them.
These numbers depend on the dwave-samplers version behind dwave-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 |
|---|---|---|---|
dwave-cpu (default) | SolverDWaveCPU | Locally, on the CPU: simulated annealing | base xqsa |
dwave-qpu | SolverDWaveQPU | D-Wave Leap cloud: physical quantum annealing hardware | xqsa[dwave] |
cuda-gpu | SolverCudaGPU | Locally, on an NVIDIA GPU: simulated annealing | xqsa[cuda] |
metal-gpu | SolverMetalGPU | 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-qpu (physical
hardware has no seed) and quip (env-configured, no local randomness).
What Actually Distinguishes Them
Where the computation happens. dwave-cpu, cuda-gpu, and
metal-gpu 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-qpu 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
dwave-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-gpu or metal-gpu, D-Wave Leap credentials for
dwave-qpu, 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 Part III, assumes the same import:
from xquad.cp import Domain, Problem, Types
It is shown once, here, and omitted everywhere after.
Inputs
problem.input(name, type=Types.Int) or type=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=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)
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=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. Domain also accepts the XQMXDomain it wraps, so code written
against the VM enum keeps working.
Integer Variables
An integer variable takes one of k values rather than two. Give the
width directly:
problem.define_model(size=num_assets, domain=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=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=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=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=Types.Int)
problem.define_model(size=n * n, domain=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 imports from the same module as Problem and
Types:
from xquad.cp import (
xq_not, xq_and, xq_or, xq_xor, xq_bnot,
xq_sqr, xq_abs, xq_min, xq_max, xq_bitlen,
xq_triu, xq_grid,
)
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] = 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:
xq_triu(i, j) # IDXTRIU: upper-triangular packed index for (i, j)
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:
xq_not(x) # NOT: 0 -> 1, non-zero -> 0
xq_and(a, b) # AND
xq_or(a, b) # OR
xq_xor(a, b) # XOR
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:
xq_sqr(x) # SQR: x * x
xq_abs(x) # ABS: absolute value
xq_min(a, b) # MIN
xq_max(a, b) # MAX
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, on either interpreter –
xquad verify 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 SolverDWaveCPU
(200 reads, Rust backend) 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) 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. .append(value) grows the output by one element, and the
emitted decoder allocates it with VECI before the block that fills it:
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 ; === Decode selected === in the decoder assembly
Compiling shows in full: VECI r2, a RANGE loop reading
GETLINE r0 and pushing with VECPUSH r2, then PUSH 0 / OUTPUT r2.
OutputRef does not support random-access write or read. Both raise
immediately, at problem-definition time – out[i] = value with
TypeError: Random-access write to output 'selected' is not supported; use selected.append(value) instead
and value = out[i] with
TypeError: Reading from output 'selected' is not supported; outputs are write-only via .append(value)
.append(value) is the only way to fill an output, and every output is
write-only: the decoder builds each output vector by appending, in the
order the decoder program executes, and nothing reads a value back out
of one. Knapsack’s decoder above is
already the general shape – an .append() per iteration inside a
problem.range loop, compiling to VECPUSH against the register the
output was allocated with VECI in.
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 five read methods above, its
own loop variables, and one scalar. 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 vector reaches
the decoder. Walk the indices with problem.range() and read each one from
problem.sample.
One more ordering rule follows from how outputs are emitted. Each output is
written to its slot as soon as its own block ends, so appending to an
earlier output after a later problem.output() has been declared would
land after its target had already shipped. Finish filling one output before
declaring the next.
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 ordinary XQMX object
with a linear dict, 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 and VMBackend import 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, on both
VMBackend.RUST and VMBackend.PYTHON.
list(result) works whether vm.outputs()[0] comes back as a plain
list, on the default VMBackend.RUST, or as a Vec, on
VMBackend.PYTHON. Running both against the same seed-42 knapsack
sample (weights = [2, 1, 5, 4, 4], values = [5, 4, 18, 3, 19],
capacity = 18) produces identical lists: [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, then emits one block
per problem.output() call, each starting with VECI to allocate the
output vector and ending with PUSH {slot} / OUTPUT r{out}. Inside a
decoder block, 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 selected ===
VECI r2
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), on either
interpreter.
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 asm and xquad dism
round-trip a .xqasm file to bytecode and back to a readable listing, and
xquad verify 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 verify and xquad dism work on any of the three programs as they
stand, but xquad 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 run --text knapsack.encoder.xqasm --calldata 2,10,20,5
Error: xqvm::runtime_error
× register r2 holds int, expected vec<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 verify checks and how
to fix a rejected program, see Verification.
Every example on this page ran against the Rust backend, the default for
both xquad.program and xquad.vm.VM.
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.
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 RuntimeError as e:
assert "StepLimitExceeded" in str(e)
# 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 RuntimeError as e:
assert "StepLimitExceeded" in str(e)
# 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 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: TruncatedHeader
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 XqmxModel, XqmxSample
model = XqmxModel("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:
session.set_calldata([object()])
except TypeError as e:
print(e)
# unsupported calldata element type: object; expected int, list[int],
# XqmxModel, XqmxSample, or None
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.set_quad(0, 1, 2)
model.domain # "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)'
__repr__ is intentionally minimal. Convert to xquad.types.XQMX (the
canonical, backend-independent type covered next) if you want a Python
object you can inspect with normal attribute access, or serialise by
iterating linear_items() / quadratic_items() yourself.
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. It always targets
the Rust backend. .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 selects between the Rust interpreter and
the pure-Python reference VM via VMBackend, converts FFI
objects to and from the canonical xquad.types.XQMX at the boundary, and
runs .xqasm source text directly rather than pre-assembled bytecode:
from xquad.vm import VM, VMBackend
src = "PUSH 7\nPUSH 5\nADD\nSTOW r0\nPUSH 0\nOUTPUT r0\nHALT\n"
v = VM(backend=VMBackend.RUST) # RUST is also the default with no argument
v.set_output_slots(1)
v.run(src)
assert v.outputs() == [12]
assert v.stack() == []
VM.outputs() returns xquad.types.XQMX instances for model and sample
outputs, where xqffi.vm.Vm.outputs() and Session.run().outputs return
the raw pyo3 XqmxModel / XqmxSample objects. Pick VM when you need
Python-backend parity checking or when your calldata and outputs are
already in xquad.types terms; 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.
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 verify 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 verify --text file.xqasm assembles the source in memory and
verifies the result; xquad verify file.xqb verifies pre-assembled
bytecode. Both forms and their exit codes are covered in
CLI: verify; 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 verify 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: × 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: × 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: × 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: × 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: × 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: × 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: × 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: × 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: × 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: × 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: × 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. The stack-depth phase reasons about each basic
block’s net effect, so an instruction that pops more operands than it
pushes has its pop requirement absorbed whenever the running depth stays
non-negative – an operand-ordering error is invisible to it. PUSH 1 / ADD / HALT passes because the scan sees a net effect of +1 - 1 = 0 for the
two instructions together, not that ADD needs two operands and only one
was ever pushed:
$ xquad verify --text add_underflow.xqasm
ok: add_underflow.xqasm (3 instructions)
$ xquad run --text add_underflow.xqasm
Error: xqvm::runtime_error
× stack underflow at byte 0x0002
╭─[add_underflow.xqasm:2:1]
1 │ 0x0000: PUSH1 1
2 │ 0x0002: ADD
· ─────────┬─────────
· ╰── execution failed here
3 │ 0x0003: HALT
╰────
This is a documented, current limitation of the per-basic-block analysis, not a bug specific to this program. 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 for a different
reason: 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 verify and the verifier program are two different things that
share a name by coincidence of vocabulary.
xquad verify 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 verify 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 verify 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 abstract interface,
Solver, and five classes built on it – SolverDWaveCPU,
SolverDWaveQPU, SolverCudaGPU, SolverMetalGPU, and SolverQuip. 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 defines the same single method:
class Solver(ABC):
@abstractmethod
def solve(self, model: XQMX, **kwargs: Any) -> SolverResult: ...
model is an XQMX in MODEL mode, binary or spin domain. **kwargs
override that solver’s constructor defaults for this call only –
solver = SolverDWaveCPU(num_reads=100) then solver.solve(model, num_reads=500) runs 500 reads without building a new solver. solve()
raises on failure (hardware unreachable, no embedding found, connection
lost); it never returns a partial or empty result.
The return value is a frozen dataclass:
@dataclass(frozen=True)
class SolverResult:
sample: XQMX
energy: int
timing: float
metadata: dict[str, Any] = field(default_factory=dict)
sample is the solution, an XQMX in SAMPLE mode. energy is the
authoritative Hamiltonian energy. timing is wall-clock seconds spent
solving. metadata defaults to an empty dict; its actual shape is
per-backend, 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’s shape is not identical across all five backends. The three
classical simulated-annealing backends – dwave-cpu, cuda-gpu, and
metal-gpu – carry seed and reads at the top level plus a params
dict of solver-specific detail (sweep counts, the raw pre-recompute
energy). dwave-qpu carries a params dict the same way, but omits
seed (there is no seed on physical hardware) and adds solver and
qpu_timing at the top level, alongside reads; see
D-Wave QPU. quip returns a
different set entirely – no seed, reads, or params at all; see
Quip Network. Check the backend you are
calling before indexing into result.metadata.
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))
# ['cuda-gpu', 'dwave-cpu', 'dwave-qpu', 'metal-gpu', 'quip']
print(DEFAULT_SOLVER)
# dwave-cpu
solver = build_solver("dwave-cpu", seed=42)
result = solver.solve(model)
seed reaches the three classical simulated-annealing backends
(dwave-cpu, cuda-gpu, metal-gpu) and is ignored for dwave-qpu
(physical hardware has no seed) and quip (configured from the
environment; the miner’s own randomness is out of the caller’s control).
build_solver otherwise uses each backend’s own constructor defaults,
with one exception: for cuda-gpu and metal-gpu it raises num_reads
to 200 and num_sweeps to 2000, rather than the 100 and 1000 the bare
constructor defaults to. 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 – dwave-cpu
(CPU simulated annealing, 100 reads and 1000 sweeps) and metal-gpu (an
Apple Silicon GPU kernel, a different process on different hardware, and
– per build_solver’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 --interpreter rust --solver dwave-cpu
uv run python examples/maxcut/runner.py --n 6 --seed 42 --interpreter rust --solver metal-gpu
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 SolverDWaveCPU
from xqvm_py.xqmx import XQMX
model = XQMX.integer_model(size=3, k=4)
SolverDWaveCPU().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, dwave-cpu |
| A local GPU for a bigger or faster run | Local Solvers, cuda-gpu / metal-gpu |
| 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
dwave-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. dwave-cpu runs on the CPU;
cuda-gpu and metal-gpu run custom simulated-annealing kernels on an
NVIDIA or Apple Silicon GPU. The metal-gpu examples below ran for
real, on an Apple Silicon Mac; no NVIDIA GPU was available while
writing this chapter, so the cuda-gpu material comes from reading
xqsa/cuda_gpu.py
rather than from execution.
dwave-cpu: The Baseline
SolverDWaveCPU wraps dwave-samplers’ SimulatedAnnealingSampler.
It needs only a CPU and no credentials, and that is exactly what makes
it 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 SolverDWaveCPU
from xqvm_py.xqmx import XQMX
model = XQMX.binary_model(size=4)
model.set_linear(0, -1)
model.set_quadratic(0, 1, 2)
solver = SolverDWaveCPU(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 |
GPU Backends: cuda-gpu and metal-gpu
SolverCudaGPU and SolverMetalGPU bypass the D-Wave SDK entirely.
Both convert the model to a dense array and run parallel-replica
simulated annealing directly on the GPU, in custom CUDA or Metal
kernels. Picking between the CPU backend and either GPU backend 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 SolverMetalGPU
from xqvm_py.xqmx import XQMX
model = XQMX.binary_model(size=4)
model.set_linear(0, -1)
model.set_quadratic(0, 1, 2)
solver = SolverMetalGPU(strategy="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. metal-gpu also
supports strategy="gibbs" (block Gibbs sampling over a greedy graph
colouring of the coupling graph). Both strategies 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.
cuda-gpu supports only strategy="sa" – CUDA has no Gibbs kernel.
See
xqsa/cuda_gpu.py
for the implementation the rest of this section describes.
| Parameter | cuda-gpu default | metal-gpu default | Meaning |
|---|---|---|---|
strategy | "sa" (only option) | "sa" or "gibbs" | Kernel to dispatch |
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 backends 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 |
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 backends
instead of using these defaults.
metal-gpu computes coefficients in float32; cuda-gpu 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 metal-gpu 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 SolverCudaGPU; SolverCudaGPU(); print('ok')"
python -c "from xqsa import SolverMetalGPU; SolverMetalGPU(); 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
SolverDWaveQPU submits the model to a physical D-Wave Advantage
quantum annealer over the D-Wave Leap cloud API. This page is derived
from
xqsa/dwave_qpu.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]
SolverDWaveQPU() 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 SolverDWaveQPU
from xqvm_py.xqmx import XQMX
model = XQMX.binary_model(4)
model.set_linear(0, -1)
model.set_quadratic(0, 1, 2)
solver = SolverDWaveQPU() # auto-selects a Pegasus-topology Advantage system
result = solver.solve(model)
print(result.metadata["solver"]) # e.g. "Advantage_system5.4"
print(result.metadata["qpu_timing"]) # QPU timing breakdown from Leap
Pass solver="Advantage_system5.4" to target a specific system instead
of the default Pegasus-topology filter.
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. SolverDWaveQPU 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 |
Both num_reads and annealing_time are overridable per call:
solver.solve(model, annealing_time=100, chain_strength=2.0).
chain_strength and other EmbeddingComposite options pass straight
through **kwargs to the underlying sampler call.
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 carries QPU-specific detail that the local backends
do not report at all: metadata["solver"] names the physical Advantage
system that ran the job, and metadata["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. solver, qpu_timing, and reads sit
at the top level of metadata, alongside a params dict structured
the same way it is for the three simulated-annealing backends
(params["annealing_time"], params["raw_energy"]) – only solver
and qpu_timing sit outside params, and this call does not set
metadata["seed"] at all (there is no seed on physical hardware). See
Solving Overview for the full,
per-backend split. If you write code against result.metadata for
more than one backend, do not assume its shape is identical across all
five; check the backend you are calling.
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.py,
spec/xqsa/SOLVERS.md, and an internal contributor testing guide. The
mechanism it describes has been exercised end to end against a live Quip
deployment, but the coordinates for reaching one are not public – see
Gaps for what that leaves unanswered for a reader.
Installation and Configuration
Requires the [quip] extra:
pip install xqsa[quip]
The umbrella xquad package forwards [cuda], [dwave], and
[metal] to the matching xqsa extra, but has no [quip] extra of
its own – install it against xqsa directly. The extra brings in
substrate-interface (the chain RPC client) 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.
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) |
reward | QUIP_REWARD | Reward in planck; falls back to the chain’s MinReward |
Provide exactly one of seed or keystore. spec_id and topology
are constructor-only overrides – both default to chain state
(DefaultIsingSpecId and DefaultTopology) and have no environment
variable. mode, resolution, and delivery are pass-through job
parameters defaulting to Open, SingleBest, and OnChainOnly; the
pallet’s data-carrying variants (Callback delivery, Bid mode) exist
on-chain but SolverQuip does not exercise them.
Job Lifecycle
solve() 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.
No on-chain hook closes an order; its status changes only when an
extrinsic touches 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)
solve() polls until the chain head reaches that height, then reads
the result. query(order_id, model, *, mapping=None, topology=None)
returns None before finality, and a full SolverResult after – not
a bare sample: order_id, solver, and the rest of the fields in
Metadata come with it. Pass mapping=/topology= only
when they were non-default at proposal time; they let the order be
read back from a different process, for example after catching a
QuipTimeoutError, using the same deterministic placement.
status(order_id) is a separate, single-read snapshot that does not
wait and never decodes a result. Both solve() and query() share the
same finalization path: if an order finalizes with zero solutions, the
reward is best-effort reclaimed via reclaim_order (a failed reclaim
is logged, not raised) and QuipJobFailedError is raised.
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.
Coefficient Encoding
The normative rules for this section are
Coefficient encoding and allowed values
– the same URL EncodingError and the allowed-value warning below both
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 and multiplies
each by MILLI_SCALE before encoding. A coefficient not exactly
representable at 1/1000 precision, or one whose milli value would
overflow i32, raises EncodingError rather than silently truncating.
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
Before submission, solve() reserves the configured reward (or the
chain’s MinReward if none is given) from the caller’s account, in the
chain’s smallest denomination, planck. A client-side balance check adds
a fee-headroom buffer on top of the reward to catch an insufficient
balance early with a readable error; the chain itself is the
authoritative source for the actual reserve and fee deduction. If the
order finalizes with no solutions, the adapter best-effort reclaims the
reserved reward before raising – a failed job costs the transaction
fee, not the reward. A successfully solved job’s reward is paid out
on-chain to the miner; SolverQuip does not expose that payout as part
of SolverResult.
This page deliberately does not give a current MinReward figure or
translate planck into any external currency – see Gaps.
Metadata
SolverResult.metadata for quip carries six keys, populated from the
winning submission (xqsa/quip.py:807-816):
| Key | Type | Meaning |
|---|---|---|
order_id | int | The on-chain order id – the handle query(order_id, model) needs to recover a result after a QuipTimeoutError. |
solver | str | None | The miner account that won the order (chosen.get("solver")); can be absent. |
best_energy_milli | int | The chain’s own reported best energy, in milli units. |
energy_matches_chain | bool | Canary: whether the local milli recompute from the returned spin vector equals best_energy_milli, confirming index alignment and encoding. |
num_submissions | int | How many miner submissions the order received. |
num_solutions | int | How many solution vectors the winning submission carried. |
best_energy_milli is milli-scale (see
Coefficient Encoding) and is not directly
comparable to result.energy, the authoritative natural-scale integer
_recompute_energy() computes (quip.py:795); diffing the two without
converting scale first will make a correct result look wrong.
metadata["solver"] here names the miner that solved the job, not a
piece of hardware – D-Wave QPU
uses the same key for the physical Advantage system that ran the job,
so the same key names a different kind of thing depending on the
backend.
energy_matches_chain is the most useful key day to day: it is the
encoding-correctness canary, confirming that the placement and decoding
SolverQuip used line up with what the chain itself computed.
Failure Taxonomy
Nine exception classes cover this backend, confirmed by import from
xqsa.__all__ – one base class and eight concrete failures, spanning
both local encoding checks and network-dependent lifecycle failures:
| Exception | Raised when |
|---|---|
QuipError | Base class for every error below |
EncodingError | The model is not MODEL-mode, its domain is unsupported, or both its linear and quadratic dicts are empty. Also covers a coefficient that fails milli-scale conversion (see Coefficient Encoding) |
PlacementError | The model’s coupling graph is not a subgraph of the target topology |
QuipSigningError | Extrinsic assembly, keystore handling, or submission fails |
QuipConnectionError | The node is unreachable, or a configured Ising spec is not registered on-chain |
QuipSubmissionError | An extrinsic cannot be submitted or the chain rejects it |
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 so the caller can recover the result later with query() |
QuipJobFailedError | A final order has no usable solution; carries order_id |
The EncodingError emptiness check tests the dicts themselves, not the
values inside them (quip_codec.py:643-644) – but XQMX.set_linear()
/ set_quadratic() pop a key the moment its value reaches 0
(xqvm_py/xqmx.py:175-226), so for any model built through the normal
API, “has no terms” and “carries only zero terms” are the same
condition in practice. A model constructed directly as a dataclass,
bypassing those setters, can hold explicit zero entries that leave the
dicts non-empty; that model passes the EncodingError check and
submits all-zero coefficient arrays.
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 rest depend on chain state or the round trip completing.
Gaps
This chapter answers the mechanism – lifecycle, topology, encoding, cost accounting, failures – in detail. It does not answer what a reader outside the project needs to actually run a job:
- No reader-obtainable endpoint or funding path. Nothing in this
repository gives a reader a
QUIP_RPC_URLor a way to fund an account. Contributors get both from an internal testing guide, which flags the coordinates as unstable and not meant to be hard-coded – which is also why they are not reproduced here. - No current reward or fee figures.
MinRewardand transaction fees are live chain state that the contributor guide itself warns changes between releases; this chapter does not assert a number that could go stale in the published book. - No numbers from an observed job. The propose -> solve -> decode
path is exercised against a live deployment by the contributor suite,
but the figures a particular submission produces – timing, an actual
order_id, a realqpu_timing-equivalent, how many blocks a fresh job typically takes – are deployment- and fleet-dependent, so none is quoted here.
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 compute_energy(model, 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: compute_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()
calls compute_energy() and casts to int; 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. Both ENERGY and compute_energy accumulate in the same
sorted key order and check the same intermediates, so the two 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 compute_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. metal-gpu 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. cuda-gpu 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 metal-gpu 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, metal-gpu’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 metal-gpu's width
np.float64(-2000000006) == np.float64(-2000000004) # False -- still resolved, at cuda-gpu's width
np.float32(-1006) == np.float32(-1004) # False -- still resolved
At P = 2000000000 the two feasible assignments above are
computationally indistinguishable to metal-gpu’s float32 search even
though compute_energy() still tells them apart exactly; at
P = 1000 they are not. cuda-gpu’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 metal-gpu’s search can lose the
signal, at penalty weights where cuda-gpu’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 rather than
losing precision silently. 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.
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, --interpreter (python or rust,
default python), --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.
--interpreter selects which XQVM runs the compiled programs: the
pure-Python reference VM (xqvm_py) or the Rust interpreter through
xqffi. Backends covers what else differs by
solver backend; --interpreter is a different axis entirely – it picks
which VM executes the programs, not which solver samples the model. A
correct model produces the same valid and the same energy on both,
always. The decoded result itself can differ where a model has several
optima of equal energy: the solver returns one of the tied optima, not
necessarily the same one on both interpreters, and a different optimum
decodes to a different answer even though both are equally correct.
maxcut, tsp, and knapsack happen to have no such tie at their
default seed and so return identical decoded results either way;
graph_coloring, bin_packing, set_cover, and max3sat do not –
running examples/graph_coloring/runner.py --seed 1 gives
colors: [2, 1, 2, 2, 1] on python and colors: [0, 1, 0, 0, 2] on
rust, both at the same energy and both valid.
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}
Both interpreters return identical output for both versions. 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(Vec 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 | dwave-cpu | Solver backend (see Choosing a solver) |
--interpreter | python | XQVM backend: python or rust |
--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 dwave-cpu, and a
non-default solver will not reproduce the canonical result.
Canonical output
example-smoke validates both interpreters produce valid == 1 with
--seed 42 --solver dwave-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 --interpreter rust
| Flag | Default | Description |
|---|---|---|
--n | 5 | Number of nodes |
--colors | 4 | Number of colors |
--solver | dwave-cpu | Solver backend (see Choosing a solver) |
--interpreter | python | XQVM backend: python or rust |
--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 dwave-cpu, and a
non-default solver will not reproduce the canonical result.
Canonical output
example-smoke validates both interpreters produce valid == 1 with
--seed 42 --solver dwave-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 --interpreter rust
| Flag | Default | Description |
|---|---|---|
--n | 5 | Number of nodes |
--solver | dwave-cpu | Solver backend (see Choosing a solver) |
--interpreter | python | XQVM backend: python or rust |
--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 dwave-cpu, and a
non-default solver will not reproduce the canonical result.
Canonical output
example-smoke validates both interpreters produce valid == 1 with
--seed 42 --solver dwave-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 --interpreter rust
| Flag | Default | Description |
|---|---|---|
--n | 5 | Number of nodes |
--solver | dwave-cpu | Solver backend (see Choosing a solver) |
--interpreter | python | XQVM backend: python or rust |
--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 dwave-cpu, and a
non-default solver will not reproduce the canonical result.
Canonical output
example-smoke validates both interpreters produce valid == 1 with
--seed 42 --solver dwave-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(Vec, 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, which is where the
worked example’s -800 below comes from. 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
Both interpreters return the same 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 | dwave-cpu | Solver backend (see Choosing a solver) |
--interpreter | python | XQVM backend: python or rust |
--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 dwave-cpu, and a
non-default solver will not reproduce the canonical result.
Canonical output
example-smoke validates both interpreters produce valid == 1 with
--seed 42 --solver dwave-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 --interpreter rust
| Flag | Default | Description |
|---|---|---|
--n | 5 | Number of items |
--solver | dwave-cpu | Solver backend (see Choosing a solver) |
--interpreter | python | XQVM backend: python or rust |
--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 dwave-cpu, and a
non-default solver will not reproduce the canonical result.
Canonical output
example-smoke validates both interpreters produce valid == 1 with
--seed 42 --solver dwave-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 (Vec), 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 --interpreter rust
| Flag | Default | Description |
|---|---|---|
--n | 4 | Number of items |
--bins | 3 | Number of bins |
--solver | dwave-cpu | Solver backend (see Choosing a solver) |
--interpreter | python | XQVM backend: python or rust |
--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 dwave-cpu, and a
non-default solver will not reproduce the canonical result.
Canonical output
example-smoke validates both interpreters produce valid == 1 with
--seed 42 --solver dwave-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 Vec 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 --interpreter rust
| Flag | Default | Description |
|---|---|---|
--num-elements | 4 | Number of elements in the universe |
--num-sets | 5 | Number of sets |
--solver | dwave-cpu | Solver backend (see Choosing a solver) |
--interpreter | python | XQVM backend: python or rust |
--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 dwave-cpu, and a
non-default solver will not reproduce the canonical result.
Canonical output
example-smoke validates both interpreters produce valid == 1 with
--seed 42 --solver dwave-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 --interpreter rust
| Flag | Default | Description |
|---|---|---|
--num-elements | 4 | Number of elements in the universe |
--num-sets | 5 | Number of sets |
--solver | dwave-cpu | Solver backend (see Choosing a solver) |
--interpreter | python | XQVM backend: python or rust |
--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 dwave-cpu, and a
non-default solver will not reproduce the canonical result.
Canonical output
example-smoke validates both interpreters produce valid == 1 with
--seed 42 --solver dwave-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 --interpreter rust
| Flag | Default | Description |
|---|---|---|
--n | 6 | Number of integers |
--solver | dwave-cpu | Solver backend (see Choosing a solver) |
--interpreter | python | XQVM backend: python or rust |
--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 dwave-cpu, and a
non-default solver will not reproduce the canonical result.
Canonical output
example-smoke validates both interpreters produce valid == 1 with
--seed 42 --solver dwave-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 --interpreter rust
| Flag | Default | Description |
|---|---|---|
--n | 5 | Number of assets |
--budget | 2 | Number of assets to select |
--solver | dwave-cpu | Solver backend (see Choosing a solver) |
--interpreter | python | XQVM backend: python or rust |
--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 dwave-cpu, and a
non-default solver will not reproduce the canonical result.
Canonical output
example-smoke validates both interpreters produce valid == 1 with
--seed 42 --solver dwave-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. Integer lowering is XQSA v0.5.0 work.
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 --interpreter rust
| Flag | Default | Description |
|---|---|---|
--n | 5 | Number of assets |
--solver | dwave-cpu | Accepted and ignored |
--interpreter | python | XQVM backend: python or rust |
--seed | 42 | Random seed |
-o | stdout | Write JSON result to file |
Canonical output
example-smoke validates both interpreters produce valid == 1 with
--seed 42 --solver dwave-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 --interpreter rust
| Flag | Default | Description |
|---|---|---|
--n | 6 | Number of Boolean variables |
--m | 8 | Number of clauses |
--solver | dwave-cpu | Solver backend (see Choosing a solver) |
--interpreter | python | XQVM backend: python or rust |
--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 dwave-cpu, and a
non-default solver will not reproduce the canonical result.
Canonical output
example-smoke validates both interpreters produce valid == 1 with
--seed 42 --solver dwave-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 --interpreter rust
| Flag | Default | Description |
|---|---|---|
--n | 4 | Number of variables |
--m | 3 | Number of cubic terms |
--solver | dwave-cpu | Solver backend (see Choosing a solver) |
--interpreter | python | XQVM backend: python or rust |
--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 dwave-cpu, and a
non-default solver will not reproduce the canonical result.
Canonical output
example-smoke validates both interpreters produce valid == 1 with
--seed 42 --solver dwave-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 --interpreter rust
| Flag | Default | Description |
|---|---|---|
--n | 5 | Number of variables |
--m | 2 | Number of quartic terms |
--solver | dwave-cpu | Solver backend (see Choosing a solver) |
--interpreter | python | XQVM backend: python or rust |
--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 dwave-cpu, and a
non-default solver will not reproduce the canonical result.
Canonical output
example-smoke validates both interpreters produce valid == 1 with
--seed 42 --solver dwave-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.cp import Domain, Problem, Types
def build_problem(n: int) -> Problem:
problem = Problem("PurePermutation")
num_items = problem.input("num_items", type=Types.Int)
problem.define_model(size=num_items * num_items, domain=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=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 SolverDWaveCPU 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 verify --text accepts the compiled encoder:
ok: permutation_encoder.xqasm (29 instructions)
and xquad 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, on either interpreter – see
High-Level Constraints. xquad verify 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: [2, 2, 2, 2]
– all four items in bin 2, total size 5 against a capacity of 5. The
rust interpreter returns [0, 0, 0, 0] instead: which single bin gets
used is a tie, and the two interpreters break it differently. 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, VMBackend
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(backend=VMBackend.RUST)
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 SolverDWaveCPU 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 verify 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 --interpreter rust
(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.cp import Domain, Problem, Types
from xquad.vm import VM, VMBackend
problem = Problem("ImpliesDemo")
n = problem.input("n", type=Types.Int)
problem.define_model(size=n, domain=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(backend=VMBackend.RUST)
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, VMBackend
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(backend=VMBackend.RUST)
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("dwave-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
SolverDWaveCPU, 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 verify 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 metal-gpu’s float32 search
differently from cuda-gpu’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
metal-gpu resolution cuda-gpu keeps, at 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.
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 asm add.xqasm -o add.xqb
assembled 4 instructions (21 bytes) -> add.xqb
$ xquad 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 run. xquad 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 – the
xquadcommand-line tool. - 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 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 run prints all non-default (not Unset) output slots after
execution. --outputs defaults to 16 when omitted:
xquad 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.
xqvm_py’s executor has the same loop shape, so the two interpreters report
the same count for the same program.
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 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
╰────
xquad asm shows the same diagnostics from the
command line.
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 verify before xquad run: the sequence PUSH 1 / STOW r0 / DROP r0 / LOAD r0 is rejected by verify 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. Both reference interpreters implement the
same rule, so a program that raises on one raises on the other.
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 verify 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 Python and FFI 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: xqvm::runtime_error
× index 99 out of bounds (len 2) at byte 0x000c
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. Both implementations agree on all
three cases, and conformance/vectors/xqmx-grid/ pins them.
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. Both
implementations agree on both halves. xquad verify 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 verify 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: xqvm::runtime_error
× arithmetic overflow
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 conformance/opcodes.yaml,
which is kept in sync with the Rust opcodes! x-macro
and the Python Opcode enum.
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. There is one
implementation; xqvm_py, the pure-Python reference VM, does not verify.
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. That analysis models each basic block as a
net stack delta, so an instruction that pops more operands than it pushes
has its pop requirement absorbed whenever the running depth stays
non-negative – a stack underflow caused by an operand-ordering error can
still pass verification.
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 a running minimum that rises only when the depth within the block goes negative: a pop requirement absorbed by pushes earlier in the same block never makes the running depth negative, so it is invisible to this check.
SCLR resets the abstract depth unconditionally to zero, which the analysis
treats as a reset rather than a delta. 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 verify 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.
CLI Reference
xquad is the unified command-line driver for the XQVM toolchain. It is
built by the xqcli crate, but the crate and the binary have different
names: cargo install xqcli installs a command called xquad, not
xqcli. Running xqcli --help after installing fails because no such
binary exists.
Installation
cargo install xqcli
xquad --help
Or build from this repository:
cargo build -p xqcli --release
The binary is at target/release/xquad.
Subcommands
xquad has four subcommands, one per stage of the toolchain:
| Command | Description |
|---|---|
xquad asm | Assemble .xqasm source into binary bytecode. |
xquad dism | Disassemble bytecode into a human-readable listing. |
xquad run | Execute bytecode or assembly, with optional tracing. |
xquad verify | Run the bytecode verifier over a program without running it. |
General usage
xquad <COMMAND> [OPTIONS] [ARGS]
xquad --help
xquad <COMMAND> --help
xquad asm reads .xqasm source and writes bytecode; it has no --text
flag and rejects a .xqb file. xquad dism reads bytecode from a file or
stdin, also with no --text flag. xquad run and xquad verify read
bytecode by default and read .xqasm assembly instead when --text is
passed.
Error reporting is not uniform across the four subcommands. Only xquad asm propagates the real xqasm::Error, with its NamedSource and
SourceSpan, so only asm prints a highlighted source snippet pointing
at the failing token. xquad run --text and xquad verify --text both
flatten an assembly failure through miette::miette!("{e}"), which
formats the error to a plain string and discards the span; xquad verify
on a .xqb file flattens a verifier failure the same way. Decode errors
(a malformed .xqb file) go through .into_diagnostic(), which also
carries no span. So an assembly, verification or decode failure reaches
you as a bare message – see xquad verify
for what that looks like in practice. A VM runtime fault under xquad run is the one exception: it goes through into_diagnostic(), which
disassembles the program around the failing offset and annotates it, so
that path does print a snippet. Argument-parsing failures, such as
asm’s -o/--stdout conflict, are plain clap errors, a third and
unrelated error path.
A minimal round trip
cat > add.xqasm <<'EOF'
; add.xqasm -- push two integers and add them
PUSH 10
PUSH 32
ADD
HALT
EOF
xquad asm add.xqasm -o add.xqb
xquad verify add.xqb
xquad run add.xqb
xquad run add.xqb prints the residual stack:
stack (bottom to top):
42
xquad asm
Assemble an .xqasm source file into binary XQVM bytecode.
Usage
xquad 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 clap argument
error, not a runtime one:
xquad asm add.xqasm -o build/program.xqb --stdout
error: the argument '--output <OUTPUT>' cannot be used with '--stdout'
Usage: xquad asm --output <OUTPUT> <INPUT>
For more information, try '--help'.
Examples
Assemble to the default output
xquad 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 asm add.xqasm -o build/program.xqb
Assemble does not run the verifier
xquad asm only assembles; it does not check the result with
xquad verify. A source file with no mnemonic errors can
still fail verification once assembled.
Pipe bytecode to another tool
xquad asm add.xqasm --stdout | xquad dism
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 asm branch.xqasm -o branch.xqb
assembled 10 instructions (31 bytes) -> branch.xqb
Error reporting
Assembly errors include the source file location and a highlighted snippet:
Error: xqasm::unknown_mnemonic
× unknown mnemonic `BADOP`
╭─[bad.xqasm:2:1]
1 │ PUSH 1
2 │ BADOP r0
· ──┬──
· ╰── unknown mnemonic
3 │ HALT
╰────
xquad dism
Disassemble XQVM bytecode into a human-readable listing.
Usage
xquad dism [FILE]
Arguments
| Argument | Description |
|---|---|
FILE | Bytecode file to disassemble. Reads from stdin when omitted. |
dism decodes FILE (or stdin) as a full XQBC container, the same
format xquad 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. This is why the stdin example below pipes from
xquad asm --stdout, which emits a full container, rather than from a
raw instruction buffer.
Examples
Disassemble a file
xquad dism add.xqb
0x0000: PUSH1 10
0x0002: PUSH1 32
0x0004: ADD
0x0005: HALT
Disassemble from stdin
xquad asm add.xqasm --stdout | xquad dism
Produces the same listing as above; dism 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 run
Execute XQVM bytecode or assembly source, printing the residual outputs and stack, with optional step-by-step tracing.
Usage
xquad 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 | Maximum number of instructions 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 | 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 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 verify and
Verification for what static verification
does and does not guarantee.
Examples
Run bytecode
xquad run add.xqb
stack (bottom to top):
42
Run assembly directly
xquad run --text add.xqasm
Skips the separate xquad 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 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 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 run regs.xqb --trace 2>trace.txt
JSON trace
xquad 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 run countloop.xqb --step-limit 3
Error: xqvm::runtime_error
× step limit of 3 exceeded
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 run countloop.xqb --step-limit 0
Error: xqvm::runtime_error
× step limit of 0 exceeded
Passing --unlimited-steps alongside --step-limit is rejected by the
argument parser rather than resolved by precedence, so opting out of the
bound cannot be said two ways at once:
xquad run countloop.xqb --step-limit 0 --unlimited-steps
error: the argument '--step-limit <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 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 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 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
Runtime errors print the faulting instruction with a disassembled context
listing around it. underflow.xqb is PUSH 1 / ADD / HALT: ADD needs
two stack values and only one was pushed.
xquad run underflow.xqb
Error: xqvm::runtime_error
× stack underflow at byte 0x0002
╭─[underflow.xqb:2:1]
1 │ 0x0000: PUSH1 1
2 │ 0x0002: ADD
· ─────────┬─────────
· ╰── execution failed here
3 │ 0x0003: HALT
╰────
xquad verify
Run the bytecode verifier over a program without executing it, and report the first violation found.
Usage
xquad verify [OPTIONS] <FILE>
Arguments
| Argument | Description |
|---|---|
FILE | Program to verify: 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 verifying. |
xquad verify 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 subcommand’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 verify add.xqb
ok: add.xqb (4 instructions)
The instruction count includes the whole decoded stream: labelled
positions as well as visible mnemonics. verify 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 verify --text uninit.xqasm
Error: × register r0 read at byte 0x0000 before being written
verify 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, per-basic-block checks. The stack-depth
phase sees each block’s net stack effect, so an instruction that pops
more operands than it pushes has its pop requirement absorbed by earlier
pushes. idx_underflow.xqasm pushes two values and then runs IDXGRID,
which pops three:
PUSH 1
PUSH 2
IDXGRID
HALT
xquad verify --text idx_underflow.xqasm
ok: idx_underflow.xqasm (4 instructions)
xquad run --text idx_underflow.xqasm then underflows the value stack at
the IDXGRID:
Error: xqvm::runtime_error
× stack underflow at byte 0x0004
╭─[idx_underflow.xqasm:3:1]
2 │ 0x0002: PUSH1 2
3 │ 0x0004: IDXGRID
· ─────────┬─────────
· ╰── execution failed here
4 │ 0x0005: HALT
╰────
Nor does verification 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.
Limits and Errors
XQVM has two separate error surfaces. The verifier is static
analysis that runs before a program executes; xquad verify runs it without
running the program. The VM is runtime: xquad 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 run
CLI sets its own defaults before handing control to the VM – 16 output
slots unless --outputs overrides it. See xquad 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 live linear coefficient and 48 per live quadratic one. That is
exactly what the allocator and the coefficient writes charged to build the
model in the first place, 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 either
interpreter’s internal representation. The Python reference interpreter
charges the same rates via Executor.execute(..., memory_limit=...),
raising xqvm_py.errors.MemoryLimitExceeded, so both implementations reject
the same programs at the same instruction having charged the same bytes.
xquad.vm.VM.set_memory_limit() sets it on either backend 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 |
xqcli | Builds the xquad CLI binary on top of the two crates above | 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. xqcli is the CLI’s own
crate, useful as a reference for wiring the other two together rather than
as a library dependency.
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 harness that holds the Rust xqvm
and the Python xqvm_py to the same observable behaviour. Read it if you
are embedding xqvm somewhere that needs to trust its output matches the
reference implementation.
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 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.
Today that check is implemented as agreement between the Rust VM and the
Python reference interpreter, checked by the conformance suite; after
QUI-1082 removes the Python interpreter, spec/xqvm/ and the vectors
derived from it remain the whole of rule 6.
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 Rust-versus-Python divergence – 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 the reference VM 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 Rust’s i64 and Python’s
unbounded integers agree on every result and fault the same way on every
overflow.
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 the three
Rust-versus-Python divergences 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.
Cross-implementation agreement, meanwhile, is close to where the bar
wants it.
make opcode-parity holds the two opcode tables to each other,
make conformance runs every vector on both interpreters, and the metering
constants are mirrored value for value in xqvm_py/metering.py under their
own parity check. 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 now normative:
spec/xqvm/METERING.md fixes it under Conformance, and both VMs validate
each register completely before looking at the next. What the specification
has not yet settled is the identity of a mode fault. xqvm::Error has no
mode variant – a RegVal is either a model or a sample, so the Rust VM
reports a sample in a model slot as a register-type error, where xqvm_py,
whose XQMX carries a mode flag, reports a mode error. That difference
spans every mode check rather than one opcode, predates step metering and is
tracked separately. Unsettled is not the same as permitted: SPEC.md’s
Faults section holds every identity normative and records this row as the
one still unresolved, so one of the two implementations is wrong and a third
must not read either spelling as settled. A runtime that surfaces fault
identity to submitters should know it is not yet uniform. See
Conformance for how the suite is structured and what
adding a vector involves.
Conformance
XQuad ships two independent VM implementations: the Rust xqvm crate this
book otherwise documents, and a pure-Python reference VM, xqvm_py. A
program compiled once to XQVM bytecode is meant to produce the same result
on either. The conformance harness –
xquad-conformance
– is the mechanical check of that claim. This page explains what its
result means for you: whether you are embedding xqvm, writing against
xqvm_py, or deciding how much to trust either one.
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 harness assembles the program, runs
it on both VMs, and asserts each one’s 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 run. The harness resolves the defaults itself and passes them to both VMs, so a budget is a property of the vector rather than of whichever default each runner carries. Set one only for a vector that is about that budget.expected.json– the recorded result, for example{"outputs": [42], "final_stack": []}.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.
Vectors are grouped, for human navigation, into eight directories under
conformance/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 Harness Locally
# Full matrix: both runtimes, every vector
cargo test -p xquad-conformance
# Rust only
cargo test -p xquad-conformance --no-default-features --features rust
# Python only (needs uv and a synced workspace venv)
cargo test -p xquad-conformance --no-default-features --features python
# Triage a single vector on both runtimes
cargo run -p xquad-conformance -- --filter arithmetic/add_basic --impl both
The Python runs shell out through uv run python -m xqvm_py, so the
workspace venv is picked up without a manual activation step.
XQUAD_CONFORMANCE_PYTHON overrides the uv wrapper command, not the
interpreter behind it.
What Passing Means
Every vector runs as two separate tests, one per runtime, generated at build
time so a regression in one implementation cannot be masked by the other
passing. CI runs those two test suites within the verify:parity job’s
make -k check-parity, which keeps running every target after one fails so a
Rust-side failure cannot suppress a Python-side one (or vice versa). A vector
that passes on both VMs means: for that specific program and that specific
calldata, both implementations agree on every output value and the final
stack. It says nothing about programs the vector does not cover.
Two more guarantees apply alongside vector agreement, each checked separately:
- The opcode table itself – a build-time assertion ties the Rust
opcodes!macro toopcodes.yaml, and a CI script tiesopcodes.yamltoxqvm_py’s own opcode table, so the two implementations cannot silently diverge on which opcodes exist or how many operands they take. - Bytecode encoding – owned by the
xqasmcrate’s own test suite, not by this harness. Conformance vectors trust the assembler to produce correct bytecode from.xqasmsource; they do not re-check the encoding independently.
For the architectural reason two implementations exist at all – independent verification of a solver’s answer, not just parity between runtimes – see Three Programs. For what each 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 conformance/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 harness
makes no claim about that opcode at all. Passing CI does not mean every
opcode has been checked for cross-implementation agreement – 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 check-parity,
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 –
conformance/tests/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
the two implementations disagreed on it in two separate ways: on operand
order, and on whether an intermediate that leaves i64 range faults. Both
were found by reading the implementations side by side 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 conformance run as evidence for the programs it actually tests, not as a blanket guarantee that the two implementations agree 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 output – that the conformance harness runs on both the Rust and Python VMs and checks for agreement. 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). SolverDWaveQPU 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: a CPU
annealer, CUDA and Metal GPU annealers, 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, and the layer both the Rust and Python implementations implement. 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
Eight packages ship from this repository, three to crates.io (xqvm,
xqasm, xqcli) and five to PyPI (xqffi, xqvm_py, xqcp, xqsa,
xquad). All eight always carry the same version. They release together:
one release MR bumps every version, one tag triggers the one pipeline that
publishes all eight, 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:
- Rust and Python VM agreement. Conformance
checks that
xqvmandxqvm_pyagree on 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 four things in the same change: the
normative
spec/xqvm/files, the Rust implementation, the Python reference 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 one implementation to the other’s existing behaviour. This keeps the four 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. CONTRIBUTING.md
states that public API changes must be semver-compatible and that breaking
changes require a major version bump. For a 0.x project, 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. If your use
case needs a stability guarantee this project does not yet make, file an
issue against the
repository rather than assuming one.
Spec Index
The book teaches; spec/ is what XQuad actually is. Every file below is
the normative source for its topic’s intent. The conformance harness does
not check either implementation against these documents: it checks the
Rust and Python implementations against each other and against
conformance/opcodes.yaml. The implementation is what ships, and the two
can diverge; 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 theSolverabstract 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
conformance/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.