xq-rs Documentation

A hardware-agnostic SDK for optimization problems: define a problem once, then lower it to any quantum or classical solver

5,582 stars Python Python 58 files ยท ~131,983 tokens #constraint-programming#quadratic-programming#quantum-computing
RAW Doc

Technical Documentation: QuipNetwork/xq-rs

โ„น๏ธ Provenance: Multi-Tier Ingestion ยท CodeWiki Reference

Overview (`README.md`)

XQuad

Early public release. The instruction set, binary format, and public API
may still change before v1.0. Production use is not recommended yet.

XQuad is a hardware-agnostic toolchain for expressing and running quadratic
optimization problems (QUBO / Ising / integer) across quantum annealers and
classical solvers. It ships as three Rust crates plus five Python distributions
built around a single virtual-machine specification.

Think of it as LLVM for quadratic models -- a common intermediate
representation that lets you write a problem once and retarget it to any
supported backend.

  • Spec-first. Every behaviour is nailed down in spec/xqvm/SPEC.md
    and cross-implementation parity is mechanically enforced: every committed
    conformance vector runs on both the Rust production VM (xqvm) and the
    Python reference VM (xqvm_py) in CI; disagreement fails the build.
  • Embeddable. The Rust core supports no_std + alloc, so the same VM
    runs inside WASM runtimes, Substrate pallets, and native binaries.
  • Interactive. The Python umbrella (xquad) is REPL and Jupyter-friendly:
    load a program once, run it with different calldata, inspect outputs by
    slot. Low-level FFI lives in xqffi; user-facing Program / Session /
    RunResult live in xquad.program.

Install

Rust (from crates.io)

sh
cargo install xqcli           # gives you the `xquad` binary

Python (from PyPI)

sh
pip install xquad             # umbrella -- full pipeline
# or pick individual pieces:
pip install xqffi xqcp xqsa   # FFI bindings + DSL + solvers

Prebuilt wheels are available for Linux x86_64 and aarch64 (CPython >= 3.13). On macOS and Windows, pip builds the xqffi Rust extension from source -- this requires a Rust toolchain (>= 1.85).

GPU/QPU support

The base install includes CPU simulated annealing only. To use GPU or quantum hardware solvers, install the corresponding extra:

Solver Install Prerequisite
NVIDIA CUDA GPU (cuda-gpu) pip install xquad[cuda] CUDA 12.x driver + NVIDIA GPU
Apple Metal GPU (metal-gpu) pip install xquad[metal] Apple Silicon Mac with a Metal device
D-Wave Advantage QPU (dwave-qpu) pip install xquad[dwave] D-Wave Leap account + API token
Quip Network solver (quip) pip install xquad[quip] QUIP_RPC_URL + a configured signer

Extras are composable: pip install xquad[cuda,dwave]. See xqsa/README.md for driver prerequisites and per-solver quick-starts.

For a guided walkthrough that installs XQuad, verifies it, and runs a
complete problem end to end, see
Install XQuad in the book.

Package map

Three Rust crates on crates.io:

Crate Binary Description
xqvm none X-Quadratic Virtual Machine interpreter, bytecode codec, opcode table (no_std + alloc)
xqasm none .xqasm text-format assembler
xqcli xquad Unified CLI -- xquad asm / dism / run / verify

Five Python distributions on PyPI:

Package Description
xqffi PyO3 FFI bindings for xqvm + xqasm (xqffi.vm, xqffi.asm, xqffi.verifier)
xqvm_py Pure-Python reference VM (conformance oracle)
xqcp Constraint-programming DSL that compiles to .xqasm
xqsa Solver adapters -- CPU/GPU annealers, D-Wave QPU, Quip network
xquad Umbrella meta-package with interactive Program / Session / RunResult API

The one Rust package without a crates.io artefact is the xqffi pyo3
crate itself -- it ships as the xqffi PyPI wheel rather than a reusable
Rust library.

Quick start

A minimal program (CLI)

asm
; add.xqasm -- push two integers and add them
PUSH 10
PUSH 32
ADD
HALT
sh
xquad asm add.xqasm -o add.xqb && xquad run add.xqb

End-to-end (Python, via the umbrella)

The recommended user-facing surface is xquad.program.Program +
Session + RunResult. Load once, run many times with different
calldata, inspect outputs by slot:

python
from xquad.program import Program

program = Program.from_source("""
PUSH 0
INPUT r0
PUSH 1
INPUT r1
LOAD r0
LOAD r1
ADD
STOW r2
PUSH 0
OUTPUT r2
HALT
""")

session = program.session(output_slots=1)
session.set_calldata([40, 2])
result = session.run()
assert dict(result.outputs) == {0: 42}

Program.load(bytes) takes wire-format bytecode; Session.run()
returns a RunResult with dict-keyed outputs (unset slots present as
None), residual stack, and step count. See
Running Programs for the full tour.

Low-level / conformance surface

For conformance harnesses and one-shot execution, the raw
xqffi.vm.Vm is still available directly:

python
from xqffi.asm import assemble_source
from xqffi.vm import Vm

bytecode = assemble_source(src)
v = Vm()
v.set_calldata([40, 2])
v.set_output_slots(1)
v.run(bytecode)
assert v.outputs() == [42]

The xquad.vm.VM class offers a middle tier -- a unified wrapper with
backend dispatch (VMBackend.RUST / VMBackend.PYTHON) that accepts
.xqasm source and normalises types across the two interpreters.

End-to-end worked example

examples/tsp/ shows a full Travelling Salesman Problem
driven from the xqcp DSL through the VM and xqsa solver, runnable on
either the Python reference VM or the Rust VM:

sh
uv run python examples/tsp/runner.py --seed 42
uv run python examples/maxcut/runner.py --seed 42 --interpreter rust

# Run every example on both interpreters, check each finds a valid solution:
make example-smoke

Architecture

XQuad is a stack-based interpreter with a 256-slot register file. Registers
hold typed values -- integers, integer vectors, QUBO/Ising models
(XqmxModel), and candidate solutions (XqmxSample). A dedicated loop stack
drives RANGE/ITER iteration.

The opcode table is declared once in xqvm/src/bytecode/types/table.rs via
the opcodes! x-macro; conformance/opcodes.yaml is the machine-readable
mirror that all three representations (YAML, Rust macro, xqvm_py.opcodes)
are checked against at build time and in CI
(scripts/check-opcode-parity.py).

The binary format (.xqb) opens with a fixed 15-byte XQBC header --
magic, version, calldata/output-slot counts, code length, and a CRC-32
checksum of the payload -- followed by the instruction stream: an
opcode byte followed by its operands in big-endian byte order.

See conformance/opcodes.md for
instruction-by-instruction semantics and
spec/xqvm/SPEC.md for the normative spec.

Development

Prerequisites

sh
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh   # stable Rust
make deps                                                          # dev tools

Local setup

sh
make xquad

This syncs the Python workspace (xqvm_py, xqcp, xqsa, xqffi,
xquad) into .venv/, builds the xqffi pyo3 extension via maturin,
puts the repo root on sys.path so scripts anywhere in the repo can
import xqcp / xqsa / xqvm_py / xqffi / xquad naturally, and
installs the xquad CLI binary under ~/.cargo/bin/.

Open a REPL with everything ready:

sh
make repl

CI-equivalent locally

sh
make preflight    # what CI enforces, split into preflight-rs / -py / -parity / -docs / -policy
make conformance  # cross-impl parity suite (Rust + Python)

Contributing

See CONTRIBUTING.md for the development workflow, commit
conventions, and DCO sign-off requirements. Agent guidelines for AI coding
assistants are in AGENTS.md; cutting a release is documented in
RELEASING.md.

License

Licensed under the GNU Affero General Public License v3.0 or later.


Overview (`conformance/README.md`)

XQVM Conformance Harness

Mechanical cross-implementation check that the Rust production VM
(xqvm) and the Python reference VM
(xqvm_py) agree on every observable behaviour defined
by spec/xqvm/SPEC.md.

The harness is a Rust test crate (xquad-conformance). Each vector
becomes two #[test] functions -- one per runtime -- generated at
cargo build time by build.rs. CI runs both through the
verify:parity job's make -k check-parity, which keeps running
every target after one fails so a Python-side regression cannot mask a
Rust-side pass (or vice versa).

Layout

text
conformance/
โ”œโ”€โ”€ opcodes.yaml          single machine-readable source for the 93-opcode table
โ”œโ”€โ”€ vectors/
โ”‚   โ”œโ”€โ”€ arithmetic/<name>/
โ”‚   โ”‚   โ”œโ”€โ”€ program.xqasm       canonical assembly source
โ”‚   โ”‚   โ”œโ”€โ”€ inputs.json         {"calldata": [i64, ...], "output_slots": N}
โ”‚   โ”‚   โ””โ”€โ”€ expected.json       {"outputs": [...], "final_stack": [...]} or {"error": FAULT}
โ”‚   โ”œโ”€โ”€ control-flow/<name>/
โ”‚   โ”œโ”€โ”€ energy/<name>/
โ”‚   โ”œโ”€โ”€ constraints/<name>/
โ”‚   โ”œโ”€โ”€ vector-ops/<name>/
โ”‚   โ””โ”€โ”€ xqmx-grid/<name>/
โ”œโ”€โ”€ src/                  Rust library + manual CLI binary
โ”œโ”€โ”€ tests/rust.rs         generated in-process Rust runner tests
โ””โ”€โ”€ tests/python.rs       generated subprocess Python runner tests

File format

`program.xqasm`

Canonical human-readable source. The assembler is authoritative: whatever
xqasm::assemble_source() produces becomes the canonical bytecode.
Prefer explicit PUSH1/PUSH2/... forms when the constant width matters
for the vector; use the PUSH sugar where it doesn't.

The harness assembles program.xqasm in-process on every run; encoding
correctness is owned by the xqasm crate's own test suite, so no
pre-assembled bytecode artifact is committed.

`inputs.json`

json
{
  "calldata": [6, 7],
  "output_slots": 16,
  "step_limit": 50,
  "memory_limit": 1048576
}

output_slots is optional (defaults to 16, matching xquad run). The
calldata array is exposed to the program in slot order: slot 0 holds
the first value, slot 1 the second, and so on. step_limit is optional
and defaults to 10000000, matching xquad run; the harness resolves the
default itself and passes it to both runners, because the two
implementations disagree on what an omitted budget means and a vector
that left it out used to run bounded on Rust and unbounded on Python.
Set it only for a vector that is specifically exercising the budget.

memory_limit is the same arrangement for the allocation budget: optional,
defaulting to 1073741824 (xqvm::DEFAULT_MEMORY_LIMIT), resolved once by
the harness and passed to both runners. It is what makes the
allocation-charge half of the fault-ordering rule below assertable, since
those faults are only reachable against a budget the charge exhausts.

`expected.json`

A vector asserts either an outcome or a fault. Supplying both, or
neither, is rejected as a vector-authoring mistake rather than silently
preferring one half.

json
{
  "outputs": [42],
  "final_stack": []
}

outputs is a sparse map, per the state model in spec/xqvm/SPEC.md --
each entry is either an i64 (the value written by OUTPUT) or null
(the slot was reserved but explicitly zeroed by a peer entry). Trailing
unset slots are omitted entirely, so a program that writes slot 0 out of
16 reserved slots produces [42], not [42, null, ...].
Explicitly-written zeroes are preserved; only slots that OUTPUT never
touched disappear. final_stack is the residual stack at HALT (bottom
to top); an empty stack is typical.

A vector that expects the program to fault writes the fault's identity
instead:

json
{
  "error": "DIVISION_BY_ZERO"
}

Fault identities

Neither implementation's spelling can serve as the identity: Rust raises
enum variants, Python raises exception classes, and the two vocabularies
do not line up one-to-one. expected.json therefore names a third,
implementation-neutral vocabulary that both sides map onto.

Byte position is deliberately not part of the identity. Only the
fault itself is compared, so vectors do not turn brittle against
unrelated codegen changes. Position assertions can be added later if a
bug ever motivates them.

expected.json Rust xqvm::Error Python xqvm_py.errors
STACK_UNDERFLOW StackUnderflow StackUnderflow
STACK_OVERFLOW StackOverflow StackOverflow
TYPE_MISMATCH RegisterType, IncompatibleType TypeMismatch, XQMXModeError
UNSET_REGISTER UnsetRegister RegisterNotFound
DIVISION_BY_ZERO DivisionByZero DivisionByZero
ARITHMETIC_OVERFLOW ArithmeticOverflow ArithmeticOverflow
INDEX_OUT_OF_BOUNDS IndexOutOfBounds IndexOutOfBounds
NO_ACTIVE_LOOP NoActiveLoop NoActiveLoop
UNMATCHED_LOOP UnmatchedLoop UnmatchedLoop
BAD_JUMP_TARGET BadJumpTarget --
INVALID_LABEL InvalidLabel TargetNotFound
BAD_OPCODE BadOpcode InvalidOpcode
TRUNCATED_INSTRUCTION TruncatedInstruction TruncatedInstruction
CALL_DATA_INDEX CallDataIndex CallDataIndex
OUTPUT_INDEX OutputIndex OutputIndex
SIZE_MISMATCH SizeMismatch SizeMismatch
VEC_LENGTH_MISMATCH VecLengthMismatch VecLengthMismatch
STEP_LIMIT_EXCEEDED StepLimitExceeded StepLimitExceeded
MEMORY_LIMIT_EXCEEDED MemoryLimitExceeded MemoryLimitExceeded
INVALID_SHIFT InvalidShift InvalidShift
INVALID_GRID_DIMENSIONS InvalidGridDimensions InvalidGridDimensions
INVALID_INTEGER_K InvalidIntegerK InvalidIntegerK
SAMPLE_OUT_OF_DOMAIN SampleOutOfDomain SampleOutOfDomain
TRACE_FAILED TraceFailed --
INVALID_ALLOCATION InvalidAllocation InvalidAllocation
LOOP_STACK_OVERFLOW LoopStackOverflow LoopStackOverflow

The Rust mapping is an exhaustive match, so adding a variant to
xqvm::Error without extending this table fails to compile rather than
silently degrading to an "unknown" fault. The Python mapping is by class
name and is necessarily partial: a -- above means that implementation
has no distinct class for the fault yet, and a vector asserting it will
fail loudly on the Python runner until one exists.

The loop faults were that gap until QUI-1289: xqvm_py raised one
LoopError class for both NO_ACTIVE_LOOP and UNMATCHED_LOOP, which
the harness could not resolve to either identity without picking
arbitrarily, so it mapped neither. The class is now split into
NoActiveLoop and UnmatchedLoop, and each identity is pinned by its own
vector: vectors/control-flow/next_outside_loop and
vectors/control-flow/range_empty_unmatched.

Fault ordering (QUI-1178)

spec/xqvm/SPEC.md fixes the order of work within one instruction: pops,
then the allocation charge, then validation. Where a register read once
preceded the pops in xqvm_py, the divergence was reachable two ways --
a short stack (which the verifier rejects) and an operand fault raised
ahead of the register read (which it admits). Only the second is
consensus-visible.

Of the 22 opcodes reordered, the second route reaches:

Opcode(s) Fault raised ahead of the register read Pinned by
RESIZE InvalidGridDimensions, unconditional vectors/xqmx-grid/resize_type_after_dimension_check
VECPUSH, SETLINE, ADDLINE, SETQUAD, ADDQUAD, EXCLUDE, IMPLIES, REDUCE MemoryLimitExceeded, only against a near-exhausted budget vectors/metering/vecpush_charge_before_type_check pins VECPUSH; the other seven by xqvm_py/tests/test_executor.py alone
ATLEAST IndexOutOfBounds on k, unconditional vectors/constraints/atleast_k_range_before_model_type
ATLEASTW VecLengthMismatch, unconditional vectors/constraints/atleastw_length_before_model_type
EQUALITY VecLengthMismatch, unconditional -- the vec lengths are compared before the model register is discriminated, and the charge follows both vec reads vectors/constraints/equality_length_mismatch_beats_model_type
VECGET, VECSET, GETLINE, GETQUAD, ROWFIND, COLFIND, ROWSUM, COLSUM none -- Rust type-checks immediately after its pops short stack only, not verifier-clean
ONEHOTR, ONEHOTC none, since QUI-1202 -- the charge is sized from a model-only peek on both VMs, so a sample sizes it at zero xqvm_py/tests/test_executor.py -- test_onehot_does_not_charge_for_a_sample; a vector could express it now that Inputs carries memory_limit

The ONEHOTR/ONEHOTC row read "none" before QUI-1202 as well, on a
justification that did not hold. exec_one_hot_r sizes its charge from a
peek that yields cols only for RegVal::Model and 0 for everything
else, then resolves with as_model_mut; neither Python runner peeked at
all -- both went pop, _get_register_as_xqmx (which accepts a sample),
dimensions, charge -- so a sample's real extent sized the charge and a
tight budget raised MemoryLimitExceeded where the Rust VM charged
nothing. _peek_model_grid closes it: the peek is is_model()-gated on
both VMs now, and the budget can no longer decide the fault.

That mattered because the register reaches ONEHOTR as a sample without
the verifier objecting. check_reads requires R::Model there, and a
register the verifier knows is a Sample is rejected statically -- but it
does not always know. Two routes got past it, of which one is still open:

  • Host calldata. Vm::set_calldata takes any RegVal, samples
    included ("This allows passing models, samples, and vectors between
    programs"), and INPUT clones the entry into the register. INPUT
    writes RegType::Any, which satisfies every requirement, so
    PUSH 0 / INPUT r0 / PUSH 0 / PUSH 1 / ONEHOTR r0 is verifier-clean and
    arrives holding whatever the embedder supplied. The --calldata CLI flag
    parses i64s and cannot express this; the embedding API is the reachable
    surface, and on a chain runtime it is the host that fills those slots.
  • A branch join. meet_reg merged Model and Sample to
    RegType::Any, so a program that wrote BQMX on one branch and BSMX
    on the other, joined, and called ONEHOTR verified clean using nothing
    but its own instructions. QUI-1160 closed that: the join now yields
    RegType::Grid, which satisfies the grid operations and not R::Model.
    Host calldata is the remaining route, and it is open by design.

The name the surviving fault carries is settled as of QUI-1160:
xqvm_py rejects the sample with XQMXModeError and the Rust VM with
RegisterType, and both map onto TYPE_MISMATCH. The test above still
pins the charge rather than the name, because the charge is what QUI-1202
fixed; the name is pinned by the coefficient-access vectors instead.

Authoring a new vector

  1. Write program.xqasm with a minimal scenario that exercises the
    behaviour you care about.
  2. Pick the matching vector directory under vectors/ (or create a new
    one). These directory names are a separate, coarser vocabulary from
    opcodes.yaml's own category field -- the two do not need to match,
    and mostly don't.
  3. Write inputs.json with the calldata the program reads.
  4. Produce expected.json by running one of the two impls and
    inspecting the result. The harness then asserts the other impl
    agrees. If they disagree, you've found spec drift -- file a bug
    ticket, and either fix the divergent impl or exclude the vector
    with a comment referencing the ticket.

Drift policy

There is no DRIFT.md. Either every vector passes on both runtimes or
the build is broken. Concrete enforcement:

  • Opcode table -- xqvm/build.rs asserts opcodes.yaml against the
    opcodes! x-macro at compile time; scripts/check-opcode-parity.py
    asserts opcodes.yaml against xqvm_py/opcodes.py in CI. The Rust
    check compares the wire byte, the mnemonic, the net stack effect and
    each operand's name and encoded byte width. It compares the net effect
    rather than the stack_pop/stack_push pair, because the x-macro
    stores only the net delta; the pair is compared on the Python side.
  • Bytecode encoding -- owned by the xqasm crate's own test suite
    (xqasm/tests/integration.rs plus assembler unit tests).
  • Observable behaviour -- check_vector asserts the
    observed {outputs, final_stack}, or the observed fault identity,
    matches expected.json for both runtimes.
  • Coverage -- coverage.rs computes which opcodes
    the vectors cover, and CI prints the report on every pipeline as the
    last step of make check-parity. It does not require completeness,
    which would fail today; tests/coverage.rs holds
    the current numbers as floors so coverage cannot regress unremarked.

Running locally

sh
# Full matrix (both runtimes, all vectors)
cargo test -p xquad-conformance

# Rust only
cargo test -p xquad-conformance --no-default-features --features rust

# Python only (needs python3 + xqvm_py importable from xqvm_py/)
cargo test -p xquad-conformance --no-default-features --features python

# Manual CLI for authoring / triaging a single vector
cargo run -p xquad-conformance -- --filter arithmetic/add_basic --impl both

# Which opcodes no vector covers
make conformance-coverage

The coverage report gives two numbers. Present counts opcodes
appearing in a vector's assembled program; reached counts those a
vector executes to completion. Reached is always the smaller: an opcode
behind an untaken branch is present but not reached, and so is the
instruction an error vector exists to fault on, since a faulting
instruction never completes a step. An opcode missing from both lists is
a genuine hole; one that is present but never reached has an error vector
and no success-path vector.

Override the Python interpreter with XQUAD_CONFORMANCE_PYTHON=....


Overview (`examples/README.md`)

XQuad Examples

Fourteen self-contained optimisation problems, one directory each. Every
directory holds a README.md describing the formulation and a runner.py
that builds the model, solves it, and verifies the result end to end.

Run one from the repository root:

sh
uv run python examples/maxcut/runner.py --seed 42

Every runner accepts --seed, --interpreter (python or rust),
--solver, and -o/--output. Problem size flags vary; each directory's
README.md carries the exact table.

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.

Further reading

  • Using the Examples -- what every
    directory has in common, how to run one, and how to adapt one into a problem
    of your own
  • Modelling with XQCP -- the DSL these
    runners are written in
  • Cookbook -- the recurring encoding
    patterns these examples are built from

Overview (`spec/README.md`)

XQuad Toolchain Specifications

Authoritative specifications for each layer of the XQuad Toolchain. Every
specification here is the single source of truth for its layer. The
conformance/ harness does not check either implementation against this
prose: it checks the Rust and Python implementations against each other,
and against conformance/opcodes.yaml. Where a divergence between a spec
and an implementation is known, the relevant book page records it.

Layers

  • xqvm/ -- X-Quadratic Virtual Machine. Bytecode format,
    instruction semantics, stack and register model, binary encoding.
    See xqvm/README.md for the document index.
  • xqcp/ -- X-Quadratic Constraint Programming DSL. Symbolic
    problem description and compilation to XQVM assembly. See
    xqcp/README.md.
  • xqsa/ -- X-Quadratic Solver Adapters. Solver-backend
    interface and sample/energy contracts. See
    xqsa/README.md.

Drift policy

There is no tolerated drift between spec and implementations. Any change
to the xqvm/ spec files that affects observable behaviour must land with:

  1. A matching update to ../conformance/opcodes.yaml.
  2. Updated or new conformance vectors under ../conformance/vectors/.
  3. Both xqvm (Rust) and xqvm_py (Python) passing the full conformance
    suite.

Builds fail on mismatch -- there is no DRIFT.md.


Overview (`xqasm/README.md`)

xqasm

Text assembler for the XQuad Toolchain.

xqasm turns .xqasm assembly source into xqvm::Program bytecode: a
pest grammar parses source text into an
AST, the assembler lowers the AST to instructions via
xqvm::InstructionBuilder, and errors are reported as rich
miette diagnostics with source spans.
This crate declares no binary; the xquad asm subcommand in
xqcli is the
command-line entry point.

Install

sh
cargo add xqasm

Quick start

The one-call entry point is assemble_source:

rust
use xqasm::assemble_source;
use xqvm::Vm;

let program = assemble_source("PUSH 10\nPUSH 32\nADD\nHALT").unwrap();

let mut vm = Vm::new();
vm.run(&program).unwrap();
assert_eq!(vm.stack(), &[42]);

For finer control over the two phases, parse and assemble are exposed
separately:

rust
use xqasm::{assemble, parse};

let source = "PUSH 10\nPUSH 32\nADD\nHALT";
let lines = parse(source, "<input>").unwrap();
let program = assemble(&lines, source, "<input>").unwrap();
assert!(!program.code().is_empty());

Assembly syntax, briefly

  • Line comments start with ; and run to end of line.
  • Jump targets are declared with TARGET .N and referenced as JUMP .N /
    JUMPI .N, where .N is a numeric label ID (.0, .1, and so on)
    assigned sequentially during pre-scan; the .N text is assembler-only and
    never appears in the bytecode.
  • PUSH <value> desugars to the smallest PUSH1-PUSH8 opcode that fits
    the signed integer literal.
  • Registers are r0..r255.

License

AGPL-3.0-or-later.


Overview (`xqcli/README.md`)

xqcli

Unified CLI for the XQuad Toolchain.

This crate is named xqcli; it builds the xquad binary. That is:
cargo install xqcli installs a command called xquad, not xqcli.
Running xqcli --help after installing will fail because no such binary
exists.

Install

sh
cargo install xqcli
xquad --help

Subcommands

xquad has four subcommands, one per stage of the toolchain: asm,
dism, run, verify.

Subcommand Does
xquad asm Assemble .xqasm source into .xqb bytecode
xquad dism [FILE] Disassemble bytecode into a human-readable listing
xquad run <FILE> Run bytecode (or assembly, with --text) on the interpreter
xquad verify <FILE> Run the bytecode verifier (all phases)

Run xquad <subcommand> --help for the full option list; the summaries
below are the parts most people need first.

  • xquad asm: -o, --output <OUTPUT> (defaults to .xqb), --stdout to write bytecode to stdout instead of a file.
  • xquad dism: reads from stdin when FILE is omitted.
  • xquad run: --text to treat FILE as assembly, --calldata <CALLDATA> (comma-separated integers), --outputs <OUTPUTS> (default 16), --step-limit <STEP_LIMIT> (default 10000000; the value is an exact limit, so 0 executes nothing), --unlimited-steps to run without a step limit, --trace with --trace-format text|json and --trace-file <FILE>.
  • xquad verify: --text to treat FILE as assembly before verifying.

Quick start

sh
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 run add.xqb
xquad dism add.xqb
xquad verify add.xqb

xquad run add.xqb prints the residual stack:

text
stack (bottom to top):
  42

License

AGPL-3.0-or-later.


Overview (`xqcp/README.md`)

XQCP -- X-Quadratic Constraint Programmer

A constraint-programming DSL that compiles a problem description into three
XQVM assembly programs: an encoder, a verifier, and a decoder. See
Three Programs
for what those three are and why there are three.

Install

sh
pip install xqcp

A complete example

Max-Cut for three nodes -- the same graph
Quadratic Models
derives by hand -- built with Problem and compiled to .xqasm:

python
from xqcp import Domain, Problem, Types

problem = Problem("Triangle")
n = problem.input("n", type=Types.Int)
edges = problem.input("edges", type=Types.Vec)
problem.define_model(size=n, domain=Domain.BINARY)

edge_count = problem.stow("edge_count", edges.veclen() // 3)
with problem.range(0, edge_count) as e:
    offset = e * 3
    i = problem.stow("i", edges.get(offset))
    j = problem.stow("j", edges.get(offset + 1))
    w = problem.stow("w", edges.get(offset + 2))
    problem.model.linear[i].add(-w)
    problem.model.linear[j].add(-w)
    problem.model.quadratic[i, j].add(w * 2)

partition = problem.output("partition", type=Types.Vec)
with problem.range(0, n) as node:
    partition.append(problem.sample.getline(node))

programs = problem.compile()
print(programs.encoder.count("\n"), "lines in the compiled encoder")
# 57 lines in the compiled encoder

programs.encoder builds the model; programs.verifier checks a sample
against it and computes its energy; programs.decoder reads a sample back
into a partition. Each is a standalone .xqasm string, runnable through
any XQVM interpreter.

Domain also covers integer variables (INTEGER with k=, or with
lo=/hi= for a range XQCP shifts into the model and back out through
sample.value()) and unordered cases (CATEGORICAL with k= and
penalty=, recorded as a one-hot binary grid read with sample.case()).
The Modelling
chapter covers each form and what constraints are available on it.

How it works

Problem records every DSL call (input, define_model, range,
stow, model.linear[i].add(w), and the rest) as an Action. Nothing is
assembled until compile() runs: it walks the recorded actions once per
program and emits assembly with automatic register allocation and loop
nesting.

DSL reference

The full DSL -- inputs, expressions, objectives, constraints, control flow,
outputs, and what compile() produces -- is
Modelling,
eight chapters built around this exact package. The normative reference is
spec/xqcp/SPEC.md;
this package follows it, and any divergence here is a bug.

Examples

Fifteen complete problems built with this DSL, from
Max-Cut
to
Travelling Salesman,
are listed in the
example gallery.

Also see

  • xqvm_py -- pure-Python reference VM that runs the compiled programs.
  • xqffi -- pyo3 FFI bindings to the Rust runtime.
  • xqsa -- solver adapters for the models this package builds.
  • xquad -- umbrella meta-package.

License

AGPL-3.0-or-later.


Overview (`xqffi/README.md`)

xqffi -- PyO3 FFI bindings for the XQuad Rust runtime

The Python extension that exposes the Rust xqvm interpreter and xqasm assembler. Ships as abi3 manylinux wheels (x86_64 + aarch64, CPython >= 3.13) plus a universal sdist for other platforms, all built by maturin.

xqffi is a pure FFI shim -- it only re-exposes the Rust types and entry points. User-facing conveniences (Program / Session / RunResult, keyword calldata, trace inspection) live in the xquad umbrella package.

Install

sh
pip install xqffi

Surface

`xqffi.vm` -- low-level one-shot

What the conformance harness drives. Small wrapper over xqvm::Vm:

python
from xqffi.vm import Vm
from xqffi.asm import assemble_source

vm = Vm()
vm.set_calldata([40, 2])
vm.set_output_slots(1)
vm.run(assemble_source(src))
assert vm.outputs() == [42]

XqmxModel and XqmxSample are thin getter/setter wrappers around the Rust types for direct model/sample manipulation. They refuse what bytecode could not build: a size over MAX_ALLOCATION_SIZE or a grid that does not fit (ValueError), a sample value outside its domain (ValueError), and a coefficient index outside 0..size, negative included (IndexError).

`xqffi.asm` -- assembly

parse_xqasm(str) -> wire dict; assemble_source(str) -> raw bytes; disassemble(bytes) -> human-readable listing; instruction_count(bytes) -> instruction count.

`xqffi.verifier` -- static verification

verify(bytes) and verify_source(str) run the pre-execution verifier: every jump lands on a TARGET, every register is read after it is written, and stack depth balances. Both return None on success and raise ValueError on a rejected program. See Verifier.

Interactive / multi-run API

Use xquad.program for the REPL / notebook / script workflow:

python
from xquad.program import Program

program = Program.from_source(src)
session = program.session(output_slots=1)
session.set_calldata([40, 2])
result = session.run()
assert dict(result.outputs) == {0: 42}

Program.load(bytes) takes wire-format bytecode; Session.run() returns a RunResult with dict-keyed outputs (unset slots present as None), residual stack, and step count.

Also see

  • xqvm_py -- pure-Python reference VM (conformance oracle).
  • xqcp -- constraint programming DSL that compiles to .xqasm.
  • xqsa -- solver adapters: CPU/GPU annealers, D-Wave QPU, Quip network.
  • xquad -- umbrella meta-package with the interactive API.
  • Running Programs -- full tour.

License

AGPL-3.0-or-later.


Overview (`xqsa/README.md`)

xqsa -- Solver adapters for XQMX models

Pluggable solvers for quadratic optimisation models produced by the XQuad
toolchain. One interface, five backends:

Solver Class Where it solves Install
DWave CPU simulated annealing SolverDWaveCPU Locally, on the CPU pip install xqsa
D-Wave Advantage QPU SolverDWaveQPU D-Wave Leap cloud pip install xqsa[dwave]
CUDA GPU simulated annealing SolverCudaGPU Locally, NVIDIA GPU pip install xqsa[cuda]
Metal GPU SA / Gibbs SolverMetalGPU Locally, Apple GPU (macOS) pip install xqsa[metal]
Quip network (on-chain mempool) SolverQuip Quip network pip install xqsa[quip]

Install

sh
pip install xqsa               # CPU simulated annealing only
pip install xqsa[dwave]        # add D-Wave QPU support
pip install xqsa[cuda]         # add CUDA GPU support
pip install xqsa[metal]        # add Metal GPU support
pip install xqsa[quip]         # add Quip network support

Extras are composable: pip install "xqsa[cuda,dwave]".

A complete example

build_solver picks a backend by name, so a caller can stay
backend-agnostic across all five:

python
from xqsa import SOLVERS, build_solver
from xqvm_py import XQMX

model = XQMX.binary_model(size=4)
model.set_linear(0, -1)
model.set_quadratic(0, 1, 2)

print(sorted(SOLVERS))
# ['cuda-gpu', 'dwave-cpu', 'dwave-qpu', 'metal-gpu', 'quip']

solver = build_solver("dwave-cpu", seed=42)
result = solver.solve(model)
print(result.sample)
print(result.energy)
# XQMX(mode=SAMPLE, domain=BINARY, size=4, linear_terms=1, quadratic_terms=0)
# -1

Every backend's solve() returns the same SolverResult(sample, energy, timing, metadata): sample is the best assignment found, energy is the
Hamiltonian recomputed independently in exact integer arithmetic (never
taken on faith from the backend), and metadata's shape is per-backend.
build_solver("dwave-cpu", ...) is equivalent to constructing
SolverDWaveCPU(...) directly; reach for the class constructor instead of
build_solver when the backend is fixed at write time rather than chosen
by name at run time.

SolverQuip also splits a job into steps: create_order(model) returns
a draft JobOrder whose options (payout rule, allowlist, quality
floors) are checked before anything is signed, quote() prices it and
submit() sends it. A submitted order leaves a JobOrderReceipt, which
any session rebuilds from the order id with get_receipt(). Import them
from xqsa.quip.

Reference

Driver prerequisites, per-backend parameters, embedding on a D-Wave QPU,
the Quip network job lifecycle and its job orders, and what the reported
energy means on
fixed-precision hardware are covered in
Solving Overview
and its chapters. The normative reference is
spec/xqsa/SPEC.md;
this package follows it, and any divergence here is a bug.

Also see

  • xqvm_py -- pure-Python reference VM.
  • xqffi -- pyo3 FFI bindings to the Rust runtime.
  • xqcp -- constraint-programming DSL that compiles to models this package can sample.
  • xquad -- umbrella meta-package.
  • Running Programs -- end-to-end tour.

License

AGPL-3.0-or-later.


Overview (`xquad/README.md`)

xquad -- Primary user-facing package for the XQuad toolchain

xquad is the Python distribution most users should install. It provides the interactive Program / Session / RunResult API, a unified VM wrapper that dispatches to either the Rust or Python interpreter, and re-exports the lower-level peer packages (xqffi, xqcp, xqsa) under a single namespace.

Install

sh
pip install xquad

Pulls xqffi, xqcp, xqsa, and xqvm_py as runtime dependencies.
Peer installs stay valid (pip install xqffi xqcp xqsa) if you only
need a subset.

Interactive usage

Load a program once, run it with different calldata, inspect outputs by slot:

python
from xquad.program import Program

program = Program.from_source("""
PUSH 0
INPUT r0
PUSH 1
INPUT r1
LOAD r0
LOAD r1
ADD
STOW r2
PUSH 0
OUTPUT r2
HALT
""")

session = program.session(output_slots=1)
session.set_calldata([40, 2])
result = session.run()
assert dict(result.outputs) == {0: 42}

Program.load(bytes) takes wire-format bytecode; Session.run() returns a RunResult with dict-keyed outputs (unset slots present as None), residual stack, and step count.

Full pipeline

Compile a problem via the DSL, execute, sample:

python
from xquad import cp, sa
from xquad.vm import VM, VMBackend

problem = cp.Problem("MyProblem")
# ... define inputs, model, objective, decoder ...
programs = problem.compile()

vm = VM(backend=VMBackend.RUST)   # or VMBackend.PYTHON for the reference interpreter
vm.set_output_slots(1)
vm.run(programs.encoder)
model = vm.outputs()[0]

sample = sa.SolverDWaveCPU().solve(model).sample

Re-exports of peer packages are identity, not copies:

python
import xquad, xqcp
assert xquad.cp.Problem is xqcp.Problem   # always True

So isinstance works whether the caller imported directly from the peer or via the umbrella.

Subnamespaces

xquad.* Origin Contents
xquad.program first-party Interactive API -- Program, Session, RunResult
xquad.vm first-party Unified VM wrapper with backend dispatch (VMBackend.RUST / VMBackend.PYTHON)
xquad.types first-party Canonical Python type aliases used across the API
xquad.asm re-exports xqffi.asm parse_xqasm, assemble_source, disassemble
xquad.verifier re-exports xqffi.verifier verify, verify_source
xquad.cp re-exports xqcp DSL -- Problem, Types, expression builders
xquad.sa re-exports xqsa Solvers -- SolverDWaveCPU, Solver

Lower-level escape hatches remain available directly via the peer packages (xqffi.vm.Vm for the raw FFI one-shot surface; xqvm_py.Executor for the pure-Python reference VM).

Also see

  • xqffi -- PyO3 FFI bindings (low-level / conformance).
  • xqvm_py -- pure-Python reference VM (conformance oracle).
  • xqcp -- constraint-programming DSL.
  • xqsa -- solver adapters.
  • Running Programs -- end-to-end tour including the Program/Session, xqffi.vm, and xquad.vm surfaces.

License

AGPL-3.0-or-later.


Overview (`xqvm/README.md`)

xqvm

X-Quadratic Virtual Machine -- bytecode interpreter for the XQuad Toolchain.

xqvm is the runtime layer of XQuad: the opcode table, instruction types, a
bytecode builder and codec, an incremental instruction-stream reader, the
VM interpreter, and a disassembler. A problem compiled once to XQVM
bytecode runs unchanged on this interpreter or on the pure-Python
reference VM (xqvm_py); the two are checked for behavioural parity in CI.

The crate is no_std + alloc-compatible. The bytecode layer (opcode
table, instruction codec, InstructionBuilder, Program) and the
interpreter core always build without std. The std feature, enabled
by default, adds the disassembler, JSON/text execution tracers, and
miette-backed runtime diagnostics.

Install

sh
cargo add xqvm

For a no_std target (WASM runtimes, Substrate pallets, embedded):

sh
cargo add xqvm --no-default-features

Quick start

Build a program with InstructionBuilder, then run it on Vm:

rust
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]);

This builds the same four instructions (PUSH 10, PUSH 32, ADD,
HALT) that the xqasm
text assembler produces from source; see that crate to load .xqasm
files instead of building bytecode by hand.

What is in this crate

Layer Contents
Bytecode (no_std) opcodes! x-macro (single source of truth for the opcode table), Opcode, Instruction, Register, Program, InstructionBuilder, InstructionStream, bytecode::codec
Interpreter Vm, Error, RegVal, XqmxModel / XqmxSample (QUBO/Ising/integer models and candidate solutions)
std-only disasm::Disassembly, RuntimeDiagnostic, JsonTracer / TextTracer

Wire format

A .xqb file opens with a fixed 15-byte XQBC header (magic, version,
calldata/output-slot counts, code length, and a CRC-32 checksum of the
payload), followed immediately by the instruction stream: an opcode byte
followed by its operands in big-endian byte order. See the encoding spec
linked below for the full field layout.

License

AGPL-3.0-or-later.


Overview (`xqvm_py/README.md`)

xqvm_py

Status -- transitional. xqvm_py is the executable conformance
oracle for the XQuad toolchain: every vector under
conformance/ must produce identical
observable state on both this Python reference and the Rust xqvm
production runtime. The arrangement is explicitly transitional. Once
the Rust runtime is fully battle-tested, xqvm_py may be dropped
entirely
(with conformance vectors graduating to "Rust must
produce exactly these outputs" reference data) or demoted to a
prototyping sandbox
for trying out new opcodes / VM features in
Python before they earn a spec slot. Do not build infrastructure
that hard-depends on xqvm_py's existence.

Python reference implementation of the X-Quadratic Virtual Machine.
See spec/xqvm/SPEC.md for the authoritative
technical specification.

Scope

  • Pure-Python executor, state model, opcodes, xqmx
    (sparse quadratic matrix), vector, and tracer.
  • No assembler or disassembler: the Rust xqasm crate is the only
    implementation, exposed to Python via xqffi.asm (pyo3). Tests
    and the CLI shim call
    xqvm_py.program_from_xqasm to turn .xqasm text
    into an executable Program.

Layout

text
xqvm_py/                  <-- this directory IS the package (flat layout)
  __init__.py             re-exports the public surface (Executor, Program, ...)
  __main__.py             entry point for `python -m xqvm_py`
  executor.py             fetch-decode-execute loop
  state.py                stack, registers, loop control, jump table
  opcodes.py              Opcode enum + operand metadata
  program.py              Program dataclass + program_from_xqasm()
  xqmx.py                 sparse quadratic matrix (model + sample modes)
  vector.py               typed vec<int> / vec<xqmx>
  errors.py               typed runtime errors
  tracer.py               step-by-step execution tracer
  cli/                    python -m xqvm_py run ...
  tests/                  pytest suite (wheel-excluded)

Quick start

From the xquad workspace root:

sh
uv sync                                       # installs xqvm_py editable + xqffi via maturin
uv run pytest xqvm_py/tests                   # run the full test suite
echo "PUSH 5
PUSH 3
ADD
HALT" > /tmp/prog.xqasm
uv run python -m xqvm_py run /tmp/prog.xqasm  # CLI shim

Programmatic use:

python
from xqvm_py import Executor, program_from_xqasm

prog = program_from_xqasm("PUSH 10\nPUSH 5\nADD\nSTOW r0\nHALT\n")
executor = Executor()
executor.execute(prog)
print(executor.state.get_register(0))  # 15

Conformance

Behavioural parity with the Rust xqvm crate is enforced by the
xquad-conformance Rust test harness at
conformance/. New VM semantics require a new
vector. Divergence between implementations fails CI with no "drift
tracking" middle ground.


Overview (`examples/bin_packing/README.md`)

Bin Packing

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) * B binary variables in an (N + 1) x B grid. Rows 0..N-1 are the assignment cells: x[i,b] = 1 if item i is placed in bin b. Row N holds one indicator per bin: y[b] = 1 if bin b is open.
  • Objective: +BIN_COST on each indicator y[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)

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 constraint
  • model.apply_implies(coord_a, coord_b, penalty) -- IMPLIES linking constraint
  • problem.vec() -- allocate untyped vector registers for indices and coefficients
  • problem.slack(indices, coeffs, start_index, capacity) -- append slack entries
  • model.apply_equality(indices, coeffs, target, penalty) -- EQUALITY constraint
  • xq_bitlen(value) -- BITLEN, used to size each bin's slack block

Pipeline overview

  1. CP (xqcp) -- generate random item sizes, declare an (N + 1) x B binary grid, and add ONEHOTR assignment constraints per item, IMPLIES links per cell, and SLACK + EQUALITY capacity constraints per bin.
  2. Assemble -- .xqasm text to bytecode via xquad.asm
  3. Encode -- run encoder on chosen XQVM to produce the XQMX model
  4. Sample -- solver runs SA/QPU/GPU over the model
  5. Verify -- verifier checks the sample is binary, the per-item assignment, the bin-usage links and the per-bin capacity, then computes energy
  6. Decode -- decoder extracts the bin assignments

Usage

sh
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

Name Hardware Install
dwave-cpu CPU (default) pip install xquad
dwave-qpu D-Wave Leap account pip install xquad[dwave]
cuda-gpu NVIDIA CUDA GPU pip install xquad[cuda]
metal-gpu Apple Silicon (macOS) pip install xquad[metal]
quip Quip Network (remote miner, env-configured) pip install xqsa[quip]

See GPU/QPU installation for driver
prerequisites and xqsa solver quick-starts for
per-solver parameter tuning.

Non-default solvers will not reproduce the canonical output (different
RNG/hardware). example-smoke always runs dwave-cpu.

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.


Overview (`examples/cubic_opt/README.md`)

Cubic Optimization

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 -1 per 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:

  1. REDUCE(i, j, P_AUX) -> w -- allocates auxiliary variable w with Rosenberg enforcement P_AUX*(x_i*x_j - 2*x_i*w - 2*x_j*w + 3*w)
  2. ADDQUAD(w, k, c) -- adds c*w*x_k = c*x_i*x_j*x_k to the QUBO

DSL methods used

  • model.reduce(var_a, var_b, p_aux) -- single-stage HOBO degree reduction

Pipeline overview

  1. CP (xqcp) -- generate random cubic interaction terms, declare binary variables with linear bias, and degree-reduce each cubic term via REDUCE.
  2. Assemble -- .xqasm text to bytecode via xquad.asm
  3. Encode -- run encoder on chosen XQVM to produce the XQMX model
  4. Sample -- solver runs SA/QPU/GPU over the model
  5. Verify -- verifier checks the sample is binary and that each Rosenberg REDUCE auxiliary equals the product it stands for, then computes energy
  6. Decode -- decoder extracts the variable assignment

Usage

sh
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

Name Hardware Install
dwave-cpu CPU (default) pip install xquad
dwave-qpu D-Wave Leap account pip install xquad[dwave]
cuda-gpu NVIDIA CUDA GPU pip install xquad[cuda]
metal-gpu Apple Silicon (macOS) pip install xquad[metal]
quip Quip Network (remote miner, env-configured) pip install xqsa[quip]

See GPU/QPU installation for driver
prerequisites and xqsa solver quick-starts for
per-solver parameter tuning.

Non-default solvers will not reproduce the canonical output (different
RNG/hardware). example-smoke always runs dwave-cpu.

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.


Overview (`examples/graph_coloring/README.md`)

Graph Coloring

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.CATEGORICAL records that as an N x C binary grid, x[v,c] = 1 if 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, not 0, because XQMX has no constant-term field and each satisfied ONEHOTR stores -penalty rather than 0. That is -1000 at the --n 5, penalty 200 defaults. The colour count defaults to 4 because 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 3 would 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)

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 rows
  • model.apply_exclude((u, c), (v, c), penalty) -- EXCLUDE per edge per color
  • sample.case(node) -- the case a node took, or -1

Pipeline overview

  1. CP (xqcp) -- generate a random graph, declare N categorical variables over C cases, and add EXCLUDE constraints per (edge, color) pair.
  2. Assemble -- .xqasm text to bytecode via xquad.asm
  3. Encode -- run encoder on chosen XQVM to produce the XQMX model
  4. Sample -- solver runs SA/QPU/GPU over the model
  5. Verify -- verifier checks the sample is binary, the one-hot row sums, and the per-edge exclusions, then computes energy
  6. Decode -- decoder reads each node's case with ROWFIND, giving one color index per node

Usage

sh
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

Name Hardware Install
dwave-cpu CPU (default) pip install xquad
dwave-qpu D-Wave Leap account pip install xquad[dwave]
cuda-gpu NVIDIA CUDA GPU pip install xquad[cuda]
metal-gpu Apple Silicon (macOS) pip install xquad[metal]
quip Quip Network (remote miner, env-configured) pip install xqsa[quip]

See GPU/QPU installation for driver
prerequisites and xqsa solver quick-starts for
per-solver parameter tuning.

Non-default solvers will not reproduce the canonical output (different
RNG/hardware). example-smoke always runs dwave-cpu.

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.


Overview (`examples/knapsack/README.md`)

Knapsack

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 = 1 means 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 coefficients
  • problem.slack(indices, coeffs, start_index, capacity) -- append slack entries
  • model.apply_equality(indices, coeffs, target, penalty) -- EQUALITY constraint

Pipeline overview

  1. CP (xqcp) -- generate random item weights and values, declare binary variables, and encode the capacity inequality via SLACK + EQUALITY.
  2. Assemble -- .xqasm text to bytecode via xquad.asm
  3. Encode -- run encoder on chosen XQVM to produce the XQMX model
  4. Sample -- solver runs SA/QPU/GPU over the model
  5. Verify -- verifier checks the sample is binary and that the capacity constraint holds, then computes energy
  6. Decode -- decoder extracts the item selection

Usage

sh
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

Name Hardware Install
dwave-cpu CPU (default) pip install xquad
dwave-qpu D-Wave Leap account pip install xquad[dwave]
cuda-gpu NVIDIA CUDA GPU pip install xquad[cuda]
metal-gpu Apple Silicon (macOS) pip install xquad[metal]
quip Quip Network (remote miner, env-configured) pip install xqsa[quip]

See GPU/QPU installation for driver
prerequisites and xqsa solver quick-starts for
per-solver parameter tuning.

Non-default solvers will not reproduce the canonical output (different
RNG/hardware). example-smoke always runs dwave-cpu.

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.


Overview (`examples/max3sat/README.md`)

Max-3-SAT

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):

text
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

  1. CP (xqcp) -- generate random 3-literal clauses, declare binary variables, and degree-reduce the cubic violation terms via REDUCE.
  2. Assemble -- .xqasm text to bytecode via xquad.asm
  3. Encode -- run encoder on chosen XQVM to produce the XQMX model
  4. Sample -- solver runs SA/QPU/GPU over the model
  5. Verify -- verifier checks the sample is binary and that each Rosenberg REDUCE auxiliary equals the product it stands for, then computes energy
  6. Decode -- decoder extracts the variable assignment

Usage

sh
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

Name Hardware Install
dwave-cpu CPU (default) pip install xquad
dwave-qpu D-Wave Leap account pip install xquad[dwave]
cuda-gpu NVIDIA CUDA GPU pip install xquad[cuda]
metal-gpu Apple Silicon (macOS) pip install xquad[metal]
quip Quip Network (remote miner, env-configured) pip install xqsa[quip]

See GPU/QPU installation for driver
prerequisites and xqsa solver quick-starts for
per-solver parameter tuning.

Non-default solvers will not reproduce the canonical output (different
RNG/hardware). example-smoke always runs dwave-cpu.

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.


Overview (`examples/max_independent_set/README.md`)

Max Independent Set

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 = 1 if 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 coefficients
  • problem.slack(indices, coeffs, start_index, capacity) -- append one slack entry per edge
  • model.apply_equality(indices, coeffs, target, penalty) -- EQUALITY constraint

Pipeline overview

  1. CP (xqcp) -- generate a random graph, declare binary variables (one per node), and encode each edge independence constraint via SLACK + EQUALITY.
  2. Assemble -- .xqasm text to bytecode via xquad.asm
  3. Encode -- run encoder on chosen XQVM to produce the XQMX model
  4. Sample -- solver runs SA/QPU/GPU over the model
  5. Verify -- verifier checks the sample is binary and that the independence constraints hold, then computes energy
  6. Decode -- decoder extracts the selected nodes

Usage

sh
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

Name Hardware Install
dwave-cpu CPU (default) pip install xquad
dwave-qpu D-Wave Leap account pip install xquad[dwave]
cuda-gpu NVIDIA CUDA GPU pip install xquad[cuda]
metal-gpu Apple Silicon (macOS) pip install xquad[metal]
quip Quip Network (remote miner, env-configured) pip install xqsa[quip]

See GPU/QPU installation for driver
prerequisites and xqsa solver quick-starts for
per-solver parameter tuning.

Non-default solvers will not reproduce the canonical output (different
RNG/hardware). example-smoke always runs dwave-cpu.

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.


Overview (`examples/maxcut/README.md`)

Max-Cut

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: n binary 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 inputs
  • problem.define_model() -- allocate binary XQMX model
  • problem.stow() -- bind intermediate computations to named registers
  • problem.range() -- emit RANGE loops
  • model.linear[i].add() -- accumulate linear bias on variable i
  • model.quadratic[i, j].add() -- accumulate quadratic coupling between variables i and j
  • problem.output() -- declare typed output slots
  • problem.sample.getline() -- read a row from the sample bitstring

Pipeline overview

  1. CP (xqcp) -- build a random weighted complete graph, declare binary variables (one per node), and add linear/quadratic QUBO terms per edge.
  2. Assemble -- .xqasm text to bytecode via xquad.asm
  3. Encode -- run encoder on chosen XQVM to produce the XQMX model
  4. Sample -- solver runs SA/QPU/GPU over the model
  5. Verify -- verifier checks the sample is binary, then computes energy; this problem declares no constraints for it to check
  6. Decode -- decoder extracts the 2-colour partition

Usage

sh
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

Name Hardware Install
dwave-cpu CPU (default) pip install xquad
dwave-qpu D-Wave Leap account pip install xquad[dwave]
cuda-gpu NVIDIA CUDA GPU pip install xquad[cuda]
metal-gpu Apple Silicon (macOS) pip install xquad[metal]
quip Quip Network (remote miner, env-configured) pip install xqsa[quip]

See GPU/QPU installation for driver
prerequisites and xqsa solver quick-starts for
per-solver parameter tuning.

Non-default solvers will not reproduce the canonical output (different
RNG/hardware). example-smoke always runs dwave-cpu.

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.


Overview (`examples/number_partition/README.md`)

Number Partitioning

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 = 1 puts number a_i in subset A.
  • Objective: minimise P * (sum(a_i * x_i) - S/2)^2 where S = 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 coefficients
  • model.apply_equality(indices, coeffs, target, penalty) -- EQUALITY constraint

Pipeline overview

  1. CP (xqcp) -- generate random positive integers, declare binary variables (one per number), and encode the half-sum equality constraint via EQUALITY.
  2. Assemble -- .xqasm text to bytecode via xquad.asm
  3. Encode -- run encoder on chosen XQVM to produce the XQMX model
  4. Sample -- solver runs SA/QPU/GPU over the model
  5. Verify -- verifier checks the sample is binary and that the partition constraint holds, then computes energy
  6. Decode -- decoder extracts the subset assignment

Usage

sh
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

Name Hardware Install
dwave-cpu CPU (default) pip install xquad
dwave-qpu D-Wave Leap account pip install xquad[dwave]
cuda-gpu NVIDIA CUDA GPU pip install xquad[cuda]
metal-gpu Apple Silicon (macOS) pip install xquad[metal]
quip Quip Network (remote miner, env-configured) pip install xqsa[quip]

See GPU/QPU installation for driver
prerequisites and xqsa solver quick-starts for
per-solver parameter tuning.

Non-default solvers will not reproduce the canonical output (different
RNG/hardware). example-smoke always runs dwave-cpu.

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.


Overview (`examples/portfolio_opt/README.md`)

Portfolio Optimization

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 = 1 if 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:

  1. REDUCE(i, j, P_AUX) -> w (Rosenberg enforcement for w = x_i * x_j)
  2. 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 terms
  • problem.vec() -- allocate index/coefficient vecs for the budget constraint
  • model.apply_equality(indices, coeffs, target, penalty) -- budget EQUALITY

Pipeline overview

  1. CP (xqcp) -- generate random returns and cubic risk interactions, declare binary variables, degree-reduce risk terms via REDUCE, and add a budget EQUALITY constraint.
  2. Assemble -- .xqasm text to bytecode via xquad.asm
  3. Encode -- run encoder on chosen XQVM to produce the XQMX model
  4. Sample -- solver runs SA/QPU/GPU over the model
  5. 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
  6. Decode -- decoder extracts the selected assets

Usage

sh
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

Name Hardware Install
dwave-cpu CPU (default) pip install xquad
dwave-qpu D-Wave Leap account pip install xquad[dwave]
cuda-gpu NVIDIA CUDA GPU pip install xquad[cuda]
metal-gpu Apple Silicon (macOS) pip install xquad[metal]
quip Quip Network (remote miner, env-configured) pip install xqsa[quip]

See GPU/QPU installation for driver
prerequisites and xqsa solver quick-starts for
per-solver parameter tuning.

Non-default solvers will not reproduce the canonical output (different
RNG/hardware). example-smoke always runs dwave-cpu.

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.


Overview (`examples/portfolio_rebalance/README.md`)

Portfolio Rebalance

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_i in [-5, 5], declared with lo=/hi=. The model holds y_i = x_i - lo in {0, ..., 10}; coefficients are written over x and sample.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 penalty P * (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 + lo produces, because XQMX carries no offset field, and the budget square drops its own P*B^2 the 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 weights
  • model.linear[i].add(w) and model.quadratic[i, j].add(w) -- the only operations a non-binary model supports
  • sample.value(i) -- the weight in the domain it was declared over, rather than the stored y

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

  1. CP (xqcp) -- generate returns and a symmetric risk matrix, declare N ranged integer weights, and write the objective and the budget square as coefficients
  2. Assemble -- .xqasm text to bytecode via xquad.asm
  3. Encode -- run encoder on chosen XQVM to produce the XQMX model
  4. Sample -- skipped; the runner supplies the assignment itself
  5. Verify -- verifier checks every weight is inside {0, ..., 10} against the k replayed from define_model, then computes energy
  6. Decode -- decoder reads each weight and adds lo, giving the signed weights back

Usage

sh
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.


Overview (`examples/quartic_opt/README.md`)

Quartic Optimization

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 -1 per 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:

  1. w = REDUCE(i, j, P_AUX) -- introduces auxiliary w; w approximates x_i*x_j.
  2. v = REDUCE(w, k, P_AUX) -- introduces auxiliary v; v approximates w*x_k = x_i*x_j*x_k. Here w is the variable index returned from the first REDUCE.
  3. ADDQUAD(v, l, c) -- adds c*v*x_l = c*x_i*x_j*x_k*x_l to 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

  1. CP (xqcp) -- generate random quartic interaction terms, declare binary variables with linear bias, and two-stage degree-reduce each quartic term via chained REDUCE.
  2. Assemble -- .xqasm text to bytecode via xquad.asm
  3. Encode -- run encoder on chosen XQVM to produce the XQMX model
  4. Sample -- solver runs SA/QPU/GPU over the model
  5. Verify -- verifier checks the sample is binary and that each Rosenberg REDUCE auxiliary equals the product it stands for, then computes energy
  6. Decode -- decoder extracts the variable assignment

Usage

sh
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

Name Hardware Install
dwave-cpu CPU (default) pip install xquad
dwave-qpu D-Wave Leap account pip install xquad[dwave]
cuda-gpu NVIDIA CUDA GPU pip install xquad[cuda]
metal-gpu Apple Silicon (macOS) pip install xquad[metal]
quip Quip Network (remote miner, env-configured) pip install xqsa[quip]

See GPU/QPU installation for driver
prerequisites and xqsa solver quick-starts for
per-solver parameter tuning.

Non-default solvers will not reproduce the canonical output (different
RNG/hardware). example-smoke always runs dwave-cpu.

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.


Overview (`examples/set_cover/README.md`)

Set Cover

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] = 1 if set s covers element e)
  • Model: S binary variables. x_s = 1 if 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 indices
  • problem.branch(cond, arm, default) -- conditional VECPUSH based on coverage membership
  • model.apply_atleast(indices, k, penalty) -- ATLEAST constraint with k=1

Pipeline overview

  1. CP (xqcp) -- generate a random coverage matrix, declare binary variables (one per set), and encode per-element coverage constraints via conditional branching and ATLEAST.
  2. Assemble -- .xqasm text to bytecode via xquad.asm
  3. Encode -- run encoder on chosen XQVM to produce the XQMX model
  4. Sample -- solver runs SA/QPU/GPU over the model
  5. Verify -- verifier checks the sample is binary and that every element is covered, then computes energy
  6. Decode -- decoder extracts the selected sets

Usage

sh
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

Name Hardware Install
dwave-cpu CPU (default) pip install xquad
dwave-qpu D-Wave Leap account pip install xquad[dwave]
cuda-gpu NVIDIA CUDA GPU pip install xquad[cuda]
metal-gpu Apple Silicon (macOS) pip install xquad[metal]
quip Quip Network (remote miner, env-configured) pip install xqsa[quip]

See GPU/QPU installation for driver
prerequisites and xqsa solver quick-starts for
per-solver parameter tuning.

Non-default solvers will not reproduce the canonical output (different
RNG/hardware). example-smoke always runs dwave-cpu.

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.


Overview (`examples/tsp/README.md`)

TSP -- Travelling Salesman Problem

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)/2 entries)
  • Model: an n x n binary grid. x[i, p] = 1 means city i is at tour position p.
  • Objective: sum of distances between consecutive positions in the tour.
  • Constraints: one-hot row (each city at exactly one position) and one-hot column (each position holds exactly one city), both with penalty 100.

Hamiltonian

The Hamiltonian is the sum of three terms:

$$H = H_{\text{dist}} + H_{\text{row}} + H_{\text{col}}$$

\(H_{\text{dist}}\) is the tour length. For every pair of cities \(i < j\) and
every position \(p\), a term fires whichever direction the tour visits them
in -- city i at p and city j at the next position, or the reverse:

$$H_{\text{dist}} = \sum_{i<j} \sum_{p=0}^{n-1} d_{ij} \left( x_{i,p}, x_{j,(p+1) \bmod n} + x_{j,p}, x_{i,(p+1) \bmod n} \right)$$

\(H_{\text{row}}\) and \(H_{\text{col}}\) are the one-hot constraints
apply_onehot_row and apply_onehot_col add, at penalty \(P = 100\):

$$H_{\text{row}} = P \sum_{i=0}^{n-1} \left( \sum_{p=0}^{n-1} x_{i,p} - 1 \right)^2, \qquad H_{\text{col}} = P \sum_{p=0}^{n-1} \left( \sum_{i=0}^{n-1} x_{i,p} - 1 \right)^2$$

XQMX has no field for a constant term. Each satisfied one-hot constraint
drops its +P and leaves -P in the stored model instead of 0 -- see
Constraints.

\(H_{\text{row}}\) forces each city onto exactly one position; \(H_{\text{col}}\)
forces each position to hold exactly one city. Both terms are zero as
written
on a valid tour, so they never change which valid tour is
shortest -- but because of the dropped constant above, what a satisfied
constraint actually contributes is -P, not 0. That is the
-2 * P * n = -800 term in the worked example below. A violation (a
city visiting two positions, or a position holding two cities) still
costs more than a satisfied constraint.

Whether that makes every invalid grid's energy exceed every valid
tour's depends on \(P\) relative to the tour-length difference a
violation can buy back.
Constraints
gives the general criterion. At \(P = 100\) it holds for this
seed-42, 4-city instance. The best invalid grid scores -581, 6
above the worst valid tour's -587. It does not hold at every instance
where \(P = 100\) is used. At --n 5 --seed 7, the second command
under Usage below, an invalid grid scores -770, below 40 of the
120 valid tours.

DSL methods used

  • problem.input() -- declare typed calldata inputs
  • problem.define_model() -- allocate binary 2D grid XQMX model
  • problem.stow() -- bind intermediate computations to named registers
  • problem.range() -- emit RANGE loops
  • model.quadratic[(city_i, pos), (city_j, pos)].add() -- accumulate quadratic coupling using 2D grid coordinates
  • model.apply_onehot_row() -- ONEHOTR constraint per city
  • model.apply_onehot_col() -- ONEHOTC constraint per position
  • problem.output() -- declare typed output slots
  • problem.sample.colfind() -- find the row index with value 1 in a given column

Pipeline overview

  1. CP (xqcp) -- build a random symmetric distance matrix, declare an n x n binary grid, and add quadratic distance terms plus one-hot row/column constraints.
  2. Assemble -- .xqasm text to bytecode via xquad.asm
  3. Encode -- run encoder on chosen XQVM to produce the XQMX model
  4. Sample -- solver runs SA/QPU/GPU over the model
  5. Verify -- verifier checks the sample is binary and the one-hot row and column constraints, then computes energy
  6. 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:

mermaid
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):

mermaid
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:

json
{
  "_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

sh
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

Name Hardware Install
dwave-cpu CPU (default) pip install xquad
dwave-qpu D-Wave Leap account pip install xquad[dwave]
cuda-gpu NVIDIA CUDA GPU pip install xquad[cuda]
metal-gpu Apple Silicon (macOS) pip install xquad[metal]
quip Quip Network (remote miner, env-configured) pip install xqsa[quip]

See GPU/QPU installation for driver
prerequisites and xqsa solver quick-starts for
per-solver parameter tuning.

Non-default solvers will not reproduce the canonical output (different
RNG/hardware). example-smoke always runs dwave-cpu.

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.


Overview (`examples/vertex_cover/README.md`)

Vertex Cover

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 = 1 if 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 edge
  • model.apply_atleast(indices, k, penalty) -- ATLEAST constraint with k=1

Pipeline overview

  1. CP (xqcp) -- generate a random graph, declare binary variables (one per vertex), and encode per-edge coverage constraints via ATLEAST.
  2. Assemble -- .xqasm text to bytecode via xquad.asm
  3. Encode -- run encoder on chosen XQVM to produce the XQMX model
  4. Sample -- solver runs SA/QPU/GPU over the model
  5. Verify -- verifier checks the sample is binary and that every edge is covered, then computes energy
  6. Decode -- decoder extracts the selected vertices

Usage

sh
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

Name Hardware Install
dwave-cpu CPU (default) pip install xquad
dwave-qpu D-Wave Leap account pip install xquad[dwave]
cuda-gpu NVIDIA CUDA GPU pip install xquad[cuda]
metal-gpu Apple Silicon (macOS) pip install xquad[metal]
quip Quip Network (remote miner, env-configured) pip install xqsa[quip]

See GPU/QPU installation for driver
prerequisites and xqsa solver quick-starts for
per-solver parameter tuning.

Non-default solvers will not reproduce the canonical output (different
RNG/hardware). example-smoke always runs dwave-cpu.

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.


Overview (`examples/weighted_set_cover/README.md`)

Weighted Set Cover

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 = 1 if 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 capacities
  • problem.branch(cond, arm, default) -- conditional VECPUSH based on coverage membership
  • model.apply_atleastw(indices, coeffs, k, penalty) -- ATLEASTW constraint

Pipeline overview

  1. 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.
  2. Assemble -- .xqasm text to bytecode via xquad.asm
  3. Encode -- run encoder on chosen XQVM to produce the XQMX model
  4. Sample -- solver runs SA/QPU/GPU over the model
  5. Verify -- verifier checks the sample is binary and that every demand is met, then computes energy
  6. Decode -- decoder extracts the selected sets

Usage

sh
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

Name Hardware Install
dwave-cpu CPU (default) pip install xquad
dwave-qpu D-Wave Leap account pip install xquad[dwave]
cuda-gpu NVIDIA CUDA GPU pip install xquad[cuda]
metal-gpu Apple Silicon (macOS) pip install xquad[metal]
quip Quip Network (remote miner, env-configured) pip install xqsa[quip]

See GPU/QPU installation for driver
prerequisites and xqsa solver quick-starts for
per-solver parameter tuning.

Non-default solvers will not reproduce the canonical output (different
RNG/hardware). example-smoke always runs dwave-cpu.

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.


Overview (`spec/xqcp/README.md`)

XQCP Specification

Authoritative specification for X-Quadratic Constraint Programming. The spec is split across focused documents; each is the single source of truth for its topic.

Documents

File Content
SPEC.md DSL overview, three-program architecture, problem lifecycle, compilation contract
TYPES.md Symbolic value types, expression tree, operator algebra, free functions
CONSTRAINTS.md Constraint taxonomy, method signatures, HLF expansion cross-references
COMPILER.md Compilation pipeline: encoder/verifier/decoder generation, register allocation, action recording

Reference implementation

../../xqcp/ -- Python package. The spec is reverse-engineered from this reference; once landed, the spec is authoritative and any divergence in the reference is a bug.

  • XQVM -- the virtual machine that executes the compiled programs
  • XQSA -- solver adapters that sit between the encoder and verifier in the pipeline

Overview (`spec/xqsa/README.md`)

XQSA Specification

Authoritative specification for X-Quadratic Solver Adapters. The spec is split across focused documents; each is the single source of truth for its topic.

Documents

File Content
SPEC.md Architecture overview, pipeline position, plugin model
INTERFACE.md Solver abstract class, solve() contract, SolverResult type, parameter conventions
ENERGY.md Energy computation formula, precision contract, sparse representation
DOMAINS.md Domain support matrix, sample encoding, grid metadata, capability negotiation
SOLVERS.md Solver registry, naming convention, algorithm families, dependency model

Reference implementation

../../xqsa/ -- Python package. v0.3.0 implements SolverDWaveCPU wrapping dwave-samplers simulated annealing.

  • XQVM -- the virtual machine that executes encoder, verifier, and decoder programs around the solver
  • XQCP -- the constraint-programming DSL that compiles problems into XQVM programs and XQMX models

Overview (`spec/xqvm/README.md`)

XQVM Specification

Authoritative specification for the X-Quadratic Virtual Machine. The spec is split across focused documents; each is the single source of truth for its topic.

Documents

File Content
SPEC.md Machine overview, three-program architecture, state model, determinism, type system, runtime limits
ISA.md Instruction set architecture: notation, opcode tables by category, semantic notes, reserved opcodes
HLF.md High-level function expansion formulas and derivations (QUBO penalty terms for combinatorial constraints)
ENCODING.md File formats (.xqasm text, .xqb bytecode), assembly syntax, binary bytecode encoding
VERIFIER.md Bytecode verification: phase pipeline, error semantics, composable architecture, per-opcode stack effects
METERING.md Step metering: what a step is, the cost constants, the per-opcode charge table, the formulas, and conformance

Overview (`docs/book/src/README.md`)

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_std support, 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.


Overview (`docs/book/src/concepts/README.md`)

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.

mermaid
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:

asm
; push two integers and add them
PUSH 10
PUSH 32
ADD
HALT
sh
$ 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 xqcp compiles 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.


Overview (`docs/book/src/cookbook/README.md`)

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.


Overview (`docs/book/src/embedding/README.md`)

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 the Tracer trait 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:

sh
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.


Overview (`docs/book/src/examples/README.md`)

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.

Overview (`docs/book/src/modelling/README.md`)

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:

text
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:

python
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:

sh
$ 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/quadratic coefficients, sign included.
  • Constraints -- folding a rule into the objective
    as a penalty.
  • Control Flow -- range, iter, and branch.
  • Outputs and Decoding -- reading a solved
    sample back into your problem's own terms.
  • Compiling -- what problem.compile() actually
    produces.

Overview (`docs/book/src/running/README.md`)

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:

python
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.

python
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:

python
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:

python
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:

python
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:
    XqmxModel("binary", size=4, rows=2, cols=3)
except ValueError as e:
    print(e)
# a 2 x 3 grid does not fit 4 variables; rows and cols must both be 0,
# or both positive with rows * cols <= size

try:
    session.set_calldata([object()])
except TypeError as e:
    print(e)
# unsupported calldata element type: object; expected int, list[int],
# XqmxModel, XqmxSample, or None

The constructors also refuse a size over
xqffi.vm.MAX_ALLOCATION_SIZE, the largest allocation bytecode can
make, with ValueError, and XqmxSample refuses a value outside its
domain the same way. The coefficient accessors (set_linear,
get_linear, set_quad, get_quad) raise IndexError for a variable
index outside 0..size, a negative index included:

python
try:
    model.set_linear(4, 1)
except IndexError as e:
    print(e)
# variable index 4 is out of range for a model of size 4

Inspecting a model or sample

XqmxModel and XqmxSample are pyo3 objects; there is no dict-conversion
helper in this package today. Read a model's coefficients through its own
accessors:

python
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:

python
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:

python
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.

VM.set_calldata() applies the same size, grid and index rules to every
xquad.types.XQMX it is given, with the same errors on both backends,
and applies them again at run(), since it keeps your objects rather
than copying them. A sample value outside its domain raises
xqvm_py.errors.SampleOutOfDomain, a ValueError subclass, from
set_calldata() on both backends. A sample changed out of its domain
after set_calldata() is caught again at run(), where RUST raises a
plain ValueError instead.

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):

python
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 Session
    today.
  • Keyword calldata (session.set_calldata(n=4, ...)). Session takes a
    positional list only; there is no input-slot labelling to key against.
  • A _repr_html_ for notebooks. __repr__ is the only representation
    Program, Session, RunResult, XqmxModel and XqmxSample provide.
  • A to_numpy() helper. Convert via linear_items() /
    quadratic_items() and numpy.asarray(...) on your own side; this
    package does not carry a numpy dependency.

Overview (`docs/book/src/solving/README.md`)

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:

python
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:

python
@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:

python
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:

sh
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:

python
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.


Overview (`docs/book/src/start/README.md`)

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:

sh
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:

sh
$ 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:

sh
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, and
    nvidia-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, marked sys_platform == 'darwin', for a local Apple GPU. The marker means pip install xquad[metal] succeeds on Linux and installs nothing.
  • quip -- substrate-interface>=1.7.4,<2 plus
    quip-signer>=0.3.0,<0.4,
    for the Quip network solver, which needs a QUIP_RPC_URL and 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
    .xqasm program, 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.

Overview (`docs/book/src/xqvm/README.md`)

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:

asm
; push two integers and add them
PUSH 10
PUSH 32
ADD
HALT
sh
$ 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


Overview (`docs/book/src/xqvm/cli/README.md`)

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

sh
cargo install xqcli
xquad --help

Or build from this repository:

sh
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

sh
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

sh
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:

text
stack (bottom to top):
  42

Overview (`docs/book/src/xqvm/instructions/README.md`)

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, then a")
    and summarised on the generated Opcode Reference as a
    Stack column of the form 2 โ†’ 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, so PUSH a; PUSH b; SUB computes
    a - b, not b - a. Every "pop x, then y" operand order on this
    section's pages, including the ones on constraints.md and
    index-math.md, depends on this rule.
  • reg -- the u8 operand encoded in the instruction byte stream,
    identifying a register slot (r0--r255).
  • label -- a sequential id assigned to each TARGET instruction, in
    program order, during the load-time pre-scan described in
    Bytecode Format. It is not a byte offset, and not
    the .N token used in .xqasm source -- that token is assembler-only and
    is resolved to the sequential id before encoding. JUMP1/JUMPI1 encode
    the id as a single u8 operand; JUMP2/JUMPI2 encode it as a u16
    operand (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
    i64 range. A result that would leave it raises ArithmeticOverflow
    rather 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.


Book Src Appendix Spec Index (`docs/book/src/appendix/spec-index.md`)

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 terms ONEHOTR, ONEHOTC, EXCLUDE, IMPLIES, EQUALITY,
    ATLEAST, ATLEASTW, and REDUCE inject.
  • spec/xqvm/ENCODING.md
    -- normative for both file formats: .xqasm assembly syntax and the
    .xqb binary 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 as
    xqcp implements 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 the Solver abstract class, the solve() contract,
    the SolverResult type, 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.


Guide Development Workflow (`docs/guide/development-workflow.md`)

xquad development workflow

This document describes how changes move from authoring to main and
dev in the xquad repository. The central idea -- carried over from the
xq-rs <-> xq-py merge (QUI-412) -- is that two implementations of the
same VM must stay in lockstep. Everything else in the workflow is in
service of that invariant.

Who maintains what

One repository, two implementations, one spec:

Component Owner(s) Role
spec/xqvm/SPEC.md all Normative description of VM behaviour. Every conformance vector derives from here.
xqvm/ Rust track Production interpreter. no_std + alloc. Used by the Substrate pallet, the xquad CLI, and the xqffi pyo3 extension.
xqvm_py/ Python track Reference interpreter. Pure Python. The conformance oracle.
conformance/ shared Cross-impl parity harness. Every committed vector runs on both VMs in CI; disagreement fails the build.
xqffi/ Rust track PyO3 FFI layer. Rust crate compiled via maturin to a Python wheel -- exposes xqvm and xqasm to the Python side. Not a pure-Python package.
xqcp/, xqsa/, xquad/ Python track Python surface: DSL, solver adapters, and umbrella. Consume the VMs (via xqffi) rather than define their semantics.

Branches

Two long-lived branches. Before 1.0, main is the non-breaking line
and dev is the breaking one; which a change targets is decided by
whether it breaks, not by whether it is a fix.
gitflow-protocol.md is the normative
description -- routing, releases, betas and the back-merge -- and what
follows is only the naming.

  • main -- the non-breaking line, at the next patch's -dev
    version. Protected: everything lands by merge request except the
    version bump that reopens it after a release, which the code owners push
    directly and CI holds to a version-only change. A merge lands as a
    real merge commit with a GitLab-generated merge: branch '' into 'main' subject. Merges are not squashed.
  • dev -- the breaking line, at the next minor's -dev version.
    Protected; Maintainers may push to it, for back-merges and version
    bumps only. It contains main at all times, and CI marks dev red
    while a back-merge is outstanding.
  • feature/qui-[-tag] -- short-lived branches for individual
    tickets, cut from and merged into whichever line the change belongs
    on. One branch may carry several tickets when they touch the same
    files. The MR body lists each ticket it consumes (QUI-*), so Linear
    status moves in lockstep with git state.
  • release/v<version> -- prepares a release: cut from main for a
    patch or from dev for a minor, carries the version bump, and merges
    into main unsquashed. release:auto-tag cuts the tag from the
    merge.
  • chore/<tag> -- repository maintenance belonging to no ticket.

The atomic spec-MR rule

Any MR that changes VM semantics must touch all four of the
following in the same MR:

  1. spec/xqvm/*.md -- any normative spec file: the change documented.
    SPEC.md, ISA.md, HLF.md, ENCODING.md and VERIFIER.md all
    satisfy this layer; the guard matches the directory, not one file.
  2. xqvm/src/**/*.rs -- the Rust production impl updated.
  3. xqvm_py/{executor,opcodes,xqmx,state,vector,tracer,errors}.py --
    the Python reference impl updated.
  4. conformance/vectors/** or conformance/opcodes.yaml -- a new
    or modified conformance vector that exercises the change.

Why

  • No drift-tracking middle ground. Before the merge, drift
    between xq-rs and xq-py was tracked as a running list. Ten
    drift points accumulated before we stopped accepting it and merged
    the repos (QUI-412). The atomic rule makes divergence impossible by
    construction: you can't commit a spec change without updating both
    impls in the same diff.
  • Reviewable as one story. A reviewer sees the spec delta next
    to the two impl deltas next to the test that proves they agree.
    The mental model is contained in one MR.
  • CI coverage that scales. The conformance harness runs every
    vector against both runtimes. Adding a vector at the same time as
    the semantics change means the harness is as up-to-date as the
    spec on the day of the merge.

Enforcement

A CI guard -- scripts/check-atomic-spec-mr.sh
-- runs as part of verify:policy on every merge request. It classifies
changed files into the four layers and fails the pipeline if an MR
touches 1-3 layers but not all four. Touching 0 layers (pure
docs / CI / tooling MRs) or all 4 passes.

Run locally the same way CI does:

sh
scripts/check-atomic-spec-mr.sh origin/main HEAD

Exemptions

Some legitimate changes only touch one or two layers -- the guard
would flag them as drift even though they're alignment fixes. The
exempt cases:

  • One-sided alignment fix. One impl already matches the spec;
    the other impl is brought in line without changing the canonical
    behaviour. Example: QUI-453 changed only xqvm_py/xqmx.py to pre-
    populate a spin sample with -1 per position, matching what Rust
    had always done -- no Rust change, no opcode table change.
  • Spec clarification. The spec text gains precision without
    changing the normative rules. No impl updates needed.
  • Conformance-only coverage addition. A new vector exercises
    existing semantics. No spec / impl changes.
  • Cross-implementation alignment with no normative change. Both
    impls move, and sometimes the harness with them, to converge on
    behaviour the spec already states -- so the spec layer has nothing to
    add and the guard's four-layer test cannot be satisfied honestly.
    This is the case the v0.4.0 series used most: QUI-1147's step-budget
    export touched xqvm/src/ and xqvm_py/ together, and QUI-1032's
    opcode-signature check touched those two plus conformance/, neither
    of them changing what the spec says.

To take an exemption, add a commit-message trailer of the form
Atomic-Spec-Exempt: QUI- <reason>.
It goes in the message's last
paragraph, at column 0, beside the sign-off:

text
Align Python SSMX default to Rust's [-1, -1, ...] initialisation.

Python was shipping empty-dict samples where Rust used vec![-1; size];
this aligns the Python side. No Rust change needed.

Fixes QUI-453
Atomic-Spec-Exempt: QUI-453 one-sided Python fix, no semantics change
Signed-off-by: You <[email protected]>

The layout is not stylistic. git reads trailers out of the last
paragraph only
, and a paragraph break above the trailer hides it from
git log --format='%(trailers)', from GitLab's commit view, and from
everything else built on git's trailer parsing. Every exempt trailer
written before QUI-1030 sat in a paragraph of its own above the ticket
footer, so not one of them parses -- which is how eleven exemptions were
taken without any of them being findable.

Two more rules follow from how git treats that paragraph:

  • The whole reason goes on the trailer line. A continuation line is
    not a trailer, and git either drops it -- silently truncating the
    reason to its first line -- or, with no sign-off in the paragraph,
    stops reading the paragraph altogether.
  • A Fixes QUI-NNN footer may share the paragraph, but not the line
    directly below the trailer.
    It has no colon, so it is not a trailer
    either; the mandatory sign-off is what makes git tolerate it. Directly
    below the trailer it is indistinguishable from a wrapped reason, so the
    guard rejects that position rather than guess. Put it above the
    trailer, or below the sign-off.

A trailer that breaks any of this fails the guard rather than being
ignored by it, because an exemption nobody can find is worse than no
exemption at all. Column 0 is git's rule too, which is why an indented
example in a commit body -- like the ones in this repository's own
documentation commits -- is not mistaken for a real exemption.

The guard scans every commit message in the MR range and bypasses when
it finds at least one well-formed trailer. Reviewers should see the
exemption and confirm the rationale holds; there is no approval process
beyond review.

What does NOT trigger the rule

  • Consumer-side surface work -- the PyO3 bindings under
    xqffi/src/*.rs (Rust, but consumer glue -- they expose the VM to
    Python, they don't define its semantics), plus xquad/program.py
    and the xquad / xqcp / xqsa Python packages. Changes here
    don't need spec or conformance updates.
  • Build glue and tooling -- xqvm/build.rs, xqvm/Cargo.toml,
    xqvm_py/pyproject.toml, Makefile, CI config, scripts. Not
    semantic.
  • Docs -- everything under docs/, READMEs, CHANGELOG. Not
    semantic.
  • Tests -- xqvm/src/**/tests.rs, xqvm_py/tests/**,
    conformance/tests/**. Tests exercise semantics but don't define
    them; the conformance vectors (under conformance/vectors/) are
    the authoritative cross-impl check and that's what the guard
    watches.

The opcode-addition gate

Adding a row to the opcodes! table in
xqvm/src/bytecode/types/table.rs is a change to VM semantics, and the
merge request that adds it argues in its description that the new opcode
clears all six clauses of the on-chain admissibility bar:

  1. No floating point. Integer arithmetic only, with every operation
    range-checked so that it faults rather than wrapping.
  2. 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. Stated normatively in spec/xqvm/SPEC.md under Determinism.
  3. No nondeterministic iteration order. Anything that walks a
    collection walks it in an order the spec fixes.
  4. 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.
  5. 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.
  6. 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.

An opcode that cannot clear all six does not ship. The operation belongs
in xqcp, xqsa, the xquad API or a helper library, where it is
ordinary code rather than something every embedder has to trust.

Why

The bar is a property of the instruction set rather than a decision each
embedder makes, and that is only true while every row on the table has
been held to it. The denied set is empty by construction, so an embedder
has nothing to gate -- but "by construction" names a construction
somebody has to carry out. Each of the six clauses was argued for every
opcode shipped so far, and none of those arguments was recorded where
the next proposal's reviewer would find it. An unwritten rule holds
nothing.

Clause 2 is the one most easily lost, because no test can fail it. An
opcode that read a clock would pass every conformance vector on the
machine that ran them, and split two hosts replaying the same program.
Clauses 4 and 5 are the ones most often half-satisfied: a charge levied
after the work rather than before it, or one that scales with something
other than the data the caller controls, reads as bounded until somebody
submits the worst case.

Enforcement

There is none, deliberately. Every comparable process rule here ships a
scripts/check-*.sh guard that verify:policy runs -- the atomic
spec-MR rule, the commit-message grammar, the merge-request title, the
release notes. This one does not, and the absence is a decision rather
than an oversight.

The reason is what the gate asks for. A guard can prove that a trailer
exists, or that a file was touched. It cannot prove that the argument
the trailer claims was made is sound, and soundness is the whole of what
a reviewer is weighing here. Wiring in a grep would convert a
correctness argument into a compliance ritual, and the ritual is the
half that can be satisfied without doing the work.

So the gate lives in review. A reviewer who cannot find the six-clause
argument in the merge request description asks for it before approving,
the same way they would ask for a test.

Commit and review conventions

See CONTRIBUTING.md for:

  • Sign-off (DCO) requirements.
  • Commit message format.
  • Review turnaround expectations.
  • AGPL license header requirements on new files.

Guide Gitflow Protocol (`docs/guide/gitflow-protocol.md`)

xquad git protocol

Two long-lived branches, main and dev. Every change lands on one of them
according to a single routing question, and every release is cut on a branch
that merges into main.

What the two branches mean depends on whether the project has reached 1.0. The
topology does not change at 1.0: two lines, release branches that merge to
main, one back-merge direction, one invariant. Only the routing question
changes.

The invariant

main is never synchronised with dev. It is contained in it:

sh
git merge-base --is-ancestor origin/main origin/dev

That command must exit 0, and CI checks it rather than leaving it to
convention, because the failure it catches is silent: two long-lived branches
drift apart commit by commit, and nothing reports it until a release is cut
from the branch that fell behind.

scripts/check-branch-containment.sh
runs in verify:policy and judges two kinds of branch:

  • dev, as a signal. Every push to dev runs it, against the highest
    release tag on main rather than main's tip. The protocol back-merges
    after every tag, not after every change to main, so dev goes red when a
    release ships and stays red until its back-merge lands. Non-breaking work
    merging into main between releases does not turn it red. Merge requests
    into dev are not judged, so work continues in parallel.
  • release/*, as a gate. A merge request from a release branch is judged
    on the branch's own tip, so a candidate that does not contain main cannot
    merge -- which is the case that would ship a release missing everything
    main had fixed.

main is never judged. It is the ref being checked against.

Before 1.0

Cargo treats the leftmost non-zero field as the major, so 0.4.0 to 0.5.0 is
breaking and 0.4.0 to 0.4.1 is compatible. The minor is the breaking bump
and the patch carries everything else, fixes and features alike.

The routing question at merge-request time is does this break? Conventional
commits already force an answer to it with !, and the default merge request
template's Compatibility block asks it.

Branch Receives Version
main every non-breaking change 0.4.1-dev
dev every breaking change 0.5.0-dev
release/v0.4.1 cut from main 0.4.1
release/v0.5.0 cut from dev 0.5.0

Rules

  • No hotfix branch exists. A bug fix is a non-breaking change landing on
    main like any other, and it ships in the next patch release.
  • main is a working branch carrying a -dev version between releases.
    What shipped is read from the tag range, never from main's log.
  • main takes merge requests, plus the reopening bump. The one direct
    push main accepts is the version bump that reopens it after a release. CI
    fails any other commit that lands on main without a merge request; see
    Protected branches.
  • dev takes direct pushes for back-merges and version bumps, and nothing
    else.
    Anything authored goes through a merge request. A direct push skips
    the guards scoped to a merge request -- the title check and the atomic
    spec-MR rule -- and breaking work is the work most likely to change VM
    semantics.
  • Both release branches merge to main, so release:auto-tag, which
    looks for merge requests targeting main, needs no change.
  • Each release freezes its own source. A patch freezes main, a minor
    freezes dev. The other line keeps moving.
  • Betas can come off either line.

Edge cases

  • At the cutover, bump main first, then branch dev from it. Branch them
    as siblings off the tag and main's bump is a commit dev cannot reach, so
    containment fails before any work lands.
  • When the minor ships, main becomes the new line. It reopens at
    x.y.1-dev and dev takes x.(y+1).0-dev at its back-merge.
  • A fix landing while a release is open goes to the release branch directly.
    Cascading it through dev reaches the release too, but drags every
    out-of-scope change on dev into the candidate with it.
  • Documentation publishes from tags, not from main. main is unreleased,
    so docs:publish runs on a release tag and nothing else. A reader on an older
    line builds that tag's book locally; see the book's Stability appendix.
  • The old line's end of life is a downstream pin, not a date. 0.4.x stays
    patchable until every consumer that pins it has moved on.

After 1.0

Breaking changes take the major, and the axis returns to the usual one: the
minor is features and the patch is fixes. The routing question becomes is this
a fix for what shipped?

Branch Receives Version
main release and hotfix merges only 1.0.0
dev every feature merge 1.1.0-dev
release/v1.1.0 cut from dev 1.1.0
hotfix/v1.0.1 cut from main 1.0.1

Rules

  • main carries exactly the last tag and takes only release and hotfix
    merges. Features never target it.
  • Hotfixes squash. The branch is cut from main and holds a few commits,
    and the squash subject becomes that patch's only changelog line. Write it as
    the fix, not as fix: 1.0.1.
  • Releases do not squash. See below.
  • Feature branches merge to dev unsquashed, so every commit reaches the
    changelog.

Edge cases

  • "main is sparse" is a --first-parent property. git log main still
    reaches every commit dev ever had. Only the first-parent view is one commit
    per release.
  • A missed back-merge reverts nothing. A true merge keeps changes main
    made that the branch never touched. A missed back-merge costs divergence,
    which is slower and quieter than a revert.
  • release:auto-tag reads only release/vX.Y.Z. A hotfix/* branch is not
    tagged automatically; widen the job or tag by hand when the first one ships.
  • A second release candidate cannot be tagged automatically on a long-lived
    release branch either, because the version comes out of the branch name. Tag
    by hand, or give each candidate its own branch.
  • A hotfix during a release window needs two back-merges, one into the open
    candidate and one into dev. CI checks both: the candidate's merge request is
    gated, and dev goes red.

Merge and squash policy

Merge Squash?
feature or fix into main or dev no
feature or fix into a release/* or hotfix/* branch no
release/* into main no
hotfix/* into main yes

The project setting is "Allow, off by default", which is what makes both rows
possible: the checkbox starts clear and a maintainer ticks it for the one merge
that squashes.

A release branch carries the whole history of the line it was cut from. Squash
it into main and none of those commits are ancestors of main, so the range
between two tags holds only the merge commit and one release: subject. A
changelog generator drops merge commits, and a release: vX.Y.Z entry on the
vX.Y.Z page is a tautology worth dropping too. The release notes come out empty,
and nothing reports it.

A true merge costs nothing here. main's first-parent view stays exactly as
sparse, one commit per release, with the history behind it intact.

A hotfix branch is the opposite case. It is cut from main, so squashing it
discards nothing main did not already have, and one clean subject is the right
granularity for a patch.

Merge commits

A merge commit is made in three places and nowhere else: a merge request
merging into main or dev, a release branch merging into main, and the
back-merge of main into dev. A feature branch never contains one. Every
merge commit a branch carries lands on the line behind the merge's second
parent, so a merge made inside a branch becomes permanent history on dev or
main.

  • Update a branch by rebasing it, never by merging its target in.
    git rebase origin/dev, not git merge origin/dev, and the merge request's
    Rebase button rather than a merge.
  • Stack branches on each other's tips. A branch stacked on another is
    rebased onto that branch's current tip, never synced by merging it in.
    git rebase --update-refs rebases a whole stack in one pass and moves every
    branch in it.
  • Merge a stack from the bottom up. Merge the lowest merge request into its
    line first, with "Delete source branch" ticked, and GitLab retargets the next
    one onto the line. Never merge a stacked merge request into the branch below
    it: that merge commit lands on the lower branch and reaches the line with it.

Release branches are the exception. A minor's release branch carries every
merge dev made, and a fix merge request onto a release branch adds its own,
and both reach main through the release merge.

verify:policy enforces this on merge request pipelines:
scripts/check-commit-messages.sh fails a merge request whose branch carries a
merge commit, unless the branch is a release/* branch.

Cutting a release

Cut the release branch when the milestone's scope is done, not when someone
wants something testable. A branch cut early is not a release candidate; it is a
snapshot that keeps moving, and the freeze it is supposed to impose never
happens. Pre-freeze snapshots are what betas are for.

  1. Cut release/vX.Y.Z from its line -- main for a patch, dev for a minor.
    The source line freezes.

  2. Set the version and regenerate the lockfiles:

    sh
    make set-version VERSION=X.Y.Z
    cargo check && uv lock
    cargo update -p xqvm --manifest-path fixtures/pallet-xqvm/Cargo.toml
    git commit -sam "chore: bump workspace to X.Y.Z"
  3. Cut release candidates as work stabilises, the way a beta is cut: a
    throwaway branch off the release branch, the version set to X.Y.Z-rcN,
    the tag, and the branch discarded. The release branch stays at X.Y.Z.
    Only fixes land on it; a feature arriving here is the rule being broken.

  4. Open the merge request into main with the release template and merge it
    without squashing. release:auto-tag pushes vX.Y.Z and the tag pipeline
    publishes.

  5. Reopen main at the next patch -dev version, by pushing the bump
    straight to main:

    sh
    git switch -c reopen origin/main                 # local only, never pushed
    make set-version VERSION=X.Y.(Z+1)-dev
    cargo check && uv lock
    cargo update -p xqvm --manifest-path fixtures/pallet-xqvm/Cargo.toml
    git commit -sam "chore: reopen main at X.Y.(Z+1)-dev"
    git push origin HEAD:main
    git switch - && git branch -D reopen

    Until it lands, release:validate fails on main's pipelines.

  6. Back-merge main into dev, below. Until it lands, dev carries a
    standing red.

RELEASING.md has the full checklist.

Pre-release snapshots

Use -betaN for anything published before the freeze.

Not -devN: that suffix already means unreleased and never published, and
reusing it makes the same string mean two opposite things.

Not -previewN or -preN: PEP 440 normalises both to rc. A Rust toolchain
keeps 0.5.0-preview1 and 0.5.0-rc1 apart, a Python one renders both as
0.5.0rc1. Publish a preview and then a candidate and the second upload is
rejected as a duplicate, after the first has already published elsewhere and
cannot be withdrawn.

A beta is cut on a throwaway branch, tagged, and never merged:

sh
git switch -c beta/v0.5.0-beta1 origin/dev     # or origin/main, for a 0.4.x beta
make set-version VERSION=0.5.0-beta1
cargo check && uv lock
cargo update -p xqvm --manifest-path fixtures/pallet-xqvm/Cargo.toml
git commit -sam "chore: cut 0.5.0-beta1"
git tag v0.5.0-beta1 && git push origin v0.5.0-beta1
git switch - && git branch -D beta/v0.5.0-beta1

The published version comes from the tree, not from the tag, so the tagged
commit has to carry the beta version -- which is the whole reason a commit
exists at all here. The lockfiles have to agree with it too: release:crates
publishes with --locked. The line itself never moves, so there is no second
bump to remember and nothing to undo, and the procedure is identical off either
line.

The trade is that the beta's commit is reachable only through its tag. A v*
tag is protected and only a Maintainer can delete one, and git does not collect
a tagged commit, so that is enough for something ephemeral by construction.

A beta publishes to crates.io and PyPI and gets no release page and no
documentation, exactly like a release candidate: release:notes and
docs:publish match cliff.toml's tag pattern, which is three numeric fields
and nothing else.

Back-merging

Back-merge main into dev after every tag on main.

The version sites always conflict, because the two branches hold different
versions by design and both have moved since the last back-merge. A plain
git merge stops with every manifest carrying conflict markers, and
make set-version cannot repair that: it reads every site before writing any,
and a manifest with conflict markers does not parse.

So take the version out of the merge before it starts. Carry dev's version
onto a scratch branch off main, and merge that instead. Both sides then made
the same change to every version line, git has nothing to reconcile there, and
whatever still conflicts is a real conflict:

sh
git fetch origin
git switch -c backmerge/vX.Y.Z origin/main       # local only, never pushed
make set-version VERSION=0.5.0-dev               # dev's version, not main's
cargo check && uv lock
cargo update -p xqvm --manifest-path fixtures/pallet-xqvm/Cargo.toml
git commit -sam "chore: carry dev's version into the vX.Y.Z back-merge"

git switch dev && git pull --ff-only
git merge --no-ff backmerge/vX.Y.Z               # only real conflicts remain
git push origin dev
git branch -d backmerge/vX.Y.Z

Everything else main changed in those manifests -- a new dependency, a
changed constraint -- merges in normally, because only the version lines were
taken out of play. If Cargo.lock or uv.lock still conflicts, both sides
changed dependencies: resolve the manifests, take either lockfile, and
regenerate it with cargo check and uv lock.

Before 1.0 expect real conflicts. main is a working line carrying every
non-breaking feature and fix, not a line that only receives releases, so the
two branches diverge in code between tags. A back-merge here is an ordinary
merge with a mechanical prologue, not a rubber stamp.

This stays manual on purpose. The auto-resolvable case is the one where
nothing but the version sites conflict, which is the post-1.0 shape, and
before 1.0 it is the exception. Machinery whose fast path rarely fires is wrong
more often than it is right, and a back-merge that silently resolves a real code
conflict is the worst outcome available here.

Avoid a .gitattributes merge=ours driver too. It needs per-clone
configuration and fails open when someone has not set it up, silently taking the
wrong side.

Protected branches

Protection and push access are separate settings, and the distinction is what
makes this work. Protected is what makes test:wasm, test:substrate and the
hardware:* tier run unconditionally rather than path-gated. Push access is
who may write without a merge request.

Branch Protected Push Merge Why
main yes Code owners Maintainers The line consumers pin. Pushes are open to the code owners for the reopening bump only, and CI enforces that.
dev yes Maintainers Maintainers The back-merge is a push. Closing it would tax the one routine operation.
release/* yes Maintainers Maintainers Protection alone is the point: a release candidate runs the full CI tier.

Force push is off on all three, and code owner approval is off on all three.
v* tags are protected separately, Maintainers only.

Push access cannot say "version bumps only", so a CI check says it for main.
scripts/check-main-direct-push.sh
runs in verify:policy on every push to main and judges each commit the push
put on main's first-parent line. A commit passes if it is a merge request's
merge commit, or if every file it touches is identical before and after once
the old and new versions are masked out. Anything else turns main red. The
check detects and does not prevent: the commit is already on main when it
runs, and the fix is a revert through a merge request.

Code owners means the users listed in
.gitlab/CODEOWNERS, named one by one in the
branch's push setting. GitLab does not link the two, so a change to either one
is made by hand in the other.

Opening pushes to the code owners was a trade. A release costs one merge
request fewer, and in exchange a stray git push origin main from a code
owner's clone now lands, where it used to be rejected. The check turns that into a red
pipeline rather than a silent change.


Guide Solverquip Testing (`docs/guide/solverquip-testing.md`)

Testing SolverQuip against a Quip network

SolverQuip (the xqsa "quip" backend, xqsa/quip/)
submits Ising jobs to a live Quip Network chain, waits for a solver fleet to
return a solution, decodes the best one, and runs an energy canary against the
chain's recorded result. The gated live suite
(xqsa/tests/quip/test_live.py, pytest marker
quip) exercises that whole path end to end against a real chain.

This guide covers running that suite in the two environments you can point it
at:

  • a local DevNet -- make localdev in the sibling nodes.quip.network
    repo stands up a self-contained single-validator chain plus a faucet and one
    CPU miner. Fast, offline, and you control the whole stack.
  • the public aglais network -- a real multi-node deployment with a
    third-party GPU solver fleet and a rolling runtime. This is the target for
    validation the DevNet cannot fully stand in for (real solvers, real timing,
    real runtime upgrades). Its faucet is healthy, so the funded tier runs there
    too.

The older public TestNet is retired and is no longer a target. It ran the
H3 suite (sr25519 + ML-DSA-44) that this signing layer no longer speaks, and
its faucet stopped resolving before it was shut down. Nothing in this guide
points at it.

Both are opt-in and gated on QUIP_RPC_URL, so they never run in the default
make test or in CI. The MR template
lists make test-quip as an Optional Check for any change to SolverQuip.

The make targets

The three quip targets live in the Makefile:

Target Runs Needs a chain?
make test-quip-sign signing tests (test_signing.py) -- pure-Python SCALE / keystore / extrinsic no
make test-quip-e2e live suite (test_live.py) against a chain yes
make test-quip both of the above (the MR-template Optional Check) yes (for the e2e leaf)

make test-quip-sign is also what the CI test:quip job runs; it needs no
chain and always executes. make test-quip-e2e hard-errors when QUIP_RPC_URL
is unset (rather than reporting a hollow all-skipped pass), so the aggregate
make test-quip requires the devnet env vars below for its e2e leaf. All three
install the [quip] extra themselves: uv sync --extra quip, which also
rebuilds xqffi when its Rust sources changed, then pytest through
uv run --no-sync.

Environment variables

Variable When Meaning
QUIP_RPC_URL required for e2e chain RPC, ws:// (DevNet) or wss:// (aglais). make test-quip / make test-quip-e2e pass it through explicitly.
QUIP_FAUCET_URL optional faucet base URL; the funded fixture POSTs to <QUIP_FAUCET_URL>/request. SolverQuip reads it too, as the faucet autofund draws from, but only when its RPC URL also comes from QUIP_RPC_URL; the fixtures pass url= and fund directly. Without it only the read-only connectivity tests run.
QUIP_TOPOLOGY optional registered topology hash for SolverQuip to target instead of the chain default. Unlike the rest of this table it configures the solver, not the harness, so it also applies outside the tests. Setting it un-skips the override tier.
QUIP_MINER_PROBE_TIMEOUT optional (default 60) how long the solving_miner probe waits before skipping the end-to-end tier. Raise it on a slow or remote fleet.

Any variable exported in your shell -- or passed as make VAR=val -- reaches
pytest, so you can set QUIP_MINER_PROBE_TIMEOUT alongside the two the
recipe forwards. On macOS, whose python.org and uv Pythons ship no CA
bundle, xqsa.quip.metadata.connect and the suite's faucet requests verify
TLS against certifi's bundle unless SSL_CERT_FILE or SSL_CERT_DIR is
set, or WEBSOCKET_CLIENT_CA_BUNDLE names an existing file or directory, so
aglais needs no CA export.

The test tiers

The live suite runs in three tiers, by what a test needs from the chain:

  • read-only (TestConnectivity): any reachable chain. Without
    QUIP_FAUCET_URL, these are the only tests that run.
  • funded: QUIP_FAUCET_URL set. The funded_keystore fixture generates a
    fresh keystore and draws one faucet drip into it; second_funded_keystore
    draws a second for another account and skips its tests if the funding has not
    landed within 60 s. A full session makes up to three faucet requests.
  • solved: an answer to the order. Some tests wait for the solver fleet
    through the solving_miner fixture, which proposes a throwaway order and
    skips the tier cleanly when nothing answers within QUIP_MINER_PROBE_TIMEOUT.
    Others answer their own orders (self-solve, below) and need no fleet.
Class Covers Needs
TestConnectivity chain constants, the topology decode, the V14 metadata shim, the signed-extension list, the topology override a reachable chain; a few tests fund an account; the override test needs QUIP_TOPOLOGY
TestSubmitPath the balance pre-check, a proposal that times out and is recovered, auto-reclaim of an expired unanswered order funding; patches MIN_DEADLINE_BLOCKS to 1 so an order can expire inside the test
TestJobOrder create_order, set(), quote() and submit(), the stored order read back, set() refused after submit funding
TestReceipt an unanswered order reclaimed through its receipt (Reward reclaimed), list_orders(), a repeated reclaim refused funding; patches MIN_DEADLINE_BLOCKS to 1
TestEndToEnd spin, binary and native round trips; a receipt rebuilt in a fresh session (reclaims and skips if unanswered); the deprecated query() and status() the fleet (solving_miner)
TestLeader a tied order follows our lead (skips without a tie); a four-vector answer comes back whole (skips if MaxSolutions is below 4) funding and self-solve; the tie test also needs the fleet
TestPayoutRules a TopNEqual(2) split matches the claim; TopNWeighted refused below runtime 119 (skips on 119 and later) second_funded_keystore and self-solve
TestAccess an allowlisted account answers; a type allowlist admits by type; an unlisted solver is refused (NotEligibleSolver) self-solve; two tests use second_funded_keystore
TestQualityFloors the ceiling drops the vector above it (self-answered); an order with floors comes back whole (reclaims and skips if no solver meets them) funding; the second test needs the fleet

Self-solve tests answer their own orders. A helper registers the test account as
a Cpu solver if it is not one, then proposes the order and submits its answer
in one Utility.batch_all, predicting the order id from NextOrderId and
retrying up to three times when another proposal takes it; payout tests then
send claim_reward. Registration is permanent: every account a run registers
stays on chain as a Cpu solver. The fixtures use throwaway keystores for that
reason, so a run adds up to two solver accounts to the network.

A fresh order's first answer took 1 to 25 blocks on aglais, measured across
twelve answered orders on 2026-09-18 (median 1 to 2). The suite therefore runs
with deadline_blocks=100 and block_wait=10 (SOLVER_KWARGS in
test_live.py), so a slow answer still lands before the deadline.

Common prerequisites

  • uv sync --extra quip in this repo (installs substrate-interface + the
    quip-signer extension). The make targets do it for you. uv run keeps the
    extra, but a plain uv sync, including the one in make deps-py,
    make test-py and make repl, removes it again; re-run
    uv sync --extra quip after any of them, or SolverQuip fails with
    substrate-interface is not installed.
  • Apple Silicon: quip-signer 0.3.0 publishes no macOS wheel, so the first
    sync after the pin moved builds it from the sdist and needs a local Rust
    toolchain. Expect minutes, not seconds. Slow here is expected; a failure is
    not, and almost always means no cargo on PATH.
  • A hybrid keystore (sr25519 + FN-DSA-512). The funded test fixture generates
    one on demand; for a manual solve, load_or_generate_keystore(path) creates a
    0600 seed file. A keystore written under the old H3 suite is worthless: the
    format check rejects it, and H4 derives a different account id from the same
    master seed, so the balance does not carry over either. Generate a fresh one
    and fund that.

Option A -- local DevNet

The DevNet is the fast, offline way to exercise the full propose -> discover ->
solve -> submit path without a public chain. It runs the same node image the
public stack runs, so a spec-matched DevNet is a good pre-flight before an
aglais run.

There are no pinned :v0.2 image tags any more. make localdev resolves the
newest published tag per image at run time (scripts/newest-tags.py writes
data/localdev.tags.env), because CI does not move latest for rc builds and
tracking latest would silently hold localdev on an old build. The public
stack selects images by CHANNEL instead, defaulting to beta; stable and
beta both run aglais and differ only in how far ahead of the release line
they sit. A QUIP_*_TAG pinned in .env passes through either path untouched.

Sibling repos under ~/code/gitlab.com/quip.network/:

  • nodes.quip.network -- DevNet orchestration (compose, Makefile, coordinator
    invocation); this is where you run make localdev.
  • quip-miner -- the mining stack: the quip-coordinator, quip-miner-exec
    and quip-mock-miner crates. Only needed to read miner internals, such as
    whether a miner registers itself as a mempool solver. Renamed from
    quip-protocol, and no longer a Python image source; it is a Rust workspace
    as of the v0.3 line.
  • quip-validator -- the substrate node (runtime, pallets) + the quip_signer
    binding. Renamed from quip-protocol-rs.

Bring-up

Prerequisites: Docker Desktop running; nodes.quip.network on the latest
main. The quip.network GitLab container registry is public, so the
make localdev image pulls resolve anonymously: docker login is NOT
required (logging in only raises registry rate limits). If you do want to
log in, use docker login registry.gitlab.com with a read_registry PAT.

From nodes.quip.network:

text
cp env.example .env          # then set PUID=0 / PGID=0 (see macOS notes)
make localdev                # wipe + pull + seed topology + bring up the stack

make localdev is idempotent and destructive by design: it tears down, wipes
the chain, resolves and pulls the newest published image tags, brings up the
validator + faucet, seeds the advantage2_system1 topology + difficulty via
//Alice sudo, then starts the miner. Seeding now lives inside the coordinator
binary (quip-coordinator seed-chain); the old
scripts/seed-advantage2-topology.py is gone. Success prints
localdev stack up with the dashboard / RPC / faucet URLs. The miner
self-bootstraps: it faucet-funds its account, registers as a PoW miner, and
auto-registers as a mempool solver (mempool solver guard: registered (cpu)).
Bootstrap completes ~60-90s after the miner container starts.

macOS (Apple Silicon): set PUID=0 and PGID=0 in .env. Do NOT use
PGID=$(id -g) -- on macOS that is 20 (staff), which collides with the
miner image's GID 20 (dialout) and crash-loops the entrypoint's group
remap. The miner and faucet images are amd64-only and run under emulation, so
the first pull is slow and CPU is higher than native -- expected, not a hang.

Verify the stack

  • Blocks + runtime version: read state_getRuntimeVersion over the RPC and
    record what it says. Nothing is pinned here on purpose -- localdev tracks the
    newest published image, so its runtime moves whenever one is published, and a
    figure written down in this guide would be stale within the week. Read
    aglais the same way; a localdev stack brought up from current images should
    be at or ahead of it.
  • Containers: docker ps -- all *-localdev Up, quip-cpu-localdev not
    Restarting.
  • Miner healthy: docker logs quip-cpu-localdev | grep -iE "solver guard: registered|topology .from chain.|mempool=on"
    -- expect the solver registered, the DevNet DefaultTopology hash, and
    mempool on. make localdev seeds the coordinator's built-in
    advantage2-system1 preset (allowed_h = [-1000, 0, 1000], hashing to
    0xfb91813b...), which is a different topology from the allowed_h = [0]
    one aglais runs. SolverQuip reads whichever the chain reports, so nothing
    in xquad needs to know which you are on -- but never carry a hash across.

Host access

The stack exposes everything through Caddy on :20049:

  • RPC: ws://localhost:20049/rpc (use http://localhost:20049/rpc for HTTP).
  • Faucet: base http://localhost:20049/api/faucet (the suite POSTs
    .../request).

If you prefer raw ports, add a ports: mapping for quip-validator
(9944:9944) and quip-faucet (8087:8087) -- the localdev stack does not
publish them by default. Note that docker-compose.override.yml is no longer
the file to put that in: the localdev overrides moved to the explicit
docker-compose.localdev.yml, which plain docker compose does not auto-load,
so use a differently-named override and pass it with -f.

Run the suite

text
make test-quip \
    QUIP_RPC_URL=ws://localhost:20049/rpc \
    QUIP_FAUCET_URL=http://localhost:20049/api/faucet

A fresh order solves in ~2-4 blocks at localdev block time (~14-21s propose to
on-chain solution under emulation).

Teardown

From nodes.quip.network: make down (stop), make clean-chain (wipe chain
only), make clean (full reset). Re-run make localdev after any wipe -- state
is non-persistent and order ids reset, so never hard-code one.

Troubleshooting

  • Miner crash-loops "at least one validator URL is required": the persisted
    data/config.toml has an explicit empty validators = [] (old-schema
    leftover) that defeats the built-in fallback. On latest main,
    make localdev overwrites the config; if you hand-edited it, delete the
    validators line or set validators = ["ws://quip-validator:9944"].
  • Stale checkout of nodes.quip.network: a checkout still on the v0.2
    line does not merely lag, it fails. The miner images moved to a separate
    v0.3 repository line, data/config.toml changed schema (make updateconfig
    migrates it), and chain seeding moved into the coordinator. Pull main before
    blaming the stack.
  • 0 solutions with a healthy-looking miner: confirm the node's runtime with
    state_getRuntimeVersion and that the miner logs the mempool_producer /
    work_scheduler stack. make localdev resolves the newest published tags on
    every run, so a stale image is far less likely than it used to be; a stale
    .env pinning QUIP_*_TAG is the remaining way to get one.
  • macOS miner crash-loop before any app logs: PGID=20 group collision --
    set PUID=0 / PGID=0.
  • Faucet: on a fresh bring-up the miner's first requests hit
    Connection refused for ~30s until the faucet is up, then fund on retry
    (expected). After that the faucet's two gates apply, as described
    for Option B below: a repeat request inside the ~5s window is a 429
    that fund_from_faucet retries once, and an account already holding
    more than one drip gets a 403 that is not retried. Start from an
    empty keystore, or spend the account below one drip.

Option B -- the public aglais network

aglais is a real, multi-node deployment with a registered solver fleet. You do
not control the solvers or the topology -- you propose jobs and a third-party
fleet solves them. It runs the H4 hybrid suite (sr25519 + FN-DSA-512), which is
what the signing layer speaks.

Coordinates (verify before a run -- these move)

  • RPC: the aglais preset in xqsa/quip/networks.py is the source of
    truth; it points at bootnode-1's validator RPC (bootnode-2 and bootnode-3
    serve the same chain). The commands below spell out its current RPC and
    faucet values; if they disagree with the preset, the preset wins. Any
    node can fall behind the chain tip and serve a stale, frozen view -- balances read as 0 and freshly submitted extrinsics look
    like they never land, even though the chain is live. Confirm the node is
    caught up before trusting reads (see the liveness check below), and point
    QUIP_RPC_URL at a different RPC if the one you are on is not syncing.
  • Faucet: the aglais preset's faucet coordinate in
    xqsa/quip/networks.py is the source of truth, healthy as of 2026-09-15
    (/health returns {"status":"ok"}; the bare root returns 404, which is not a
    fault). Set QUIP_FAUCET_URL to it and the funded tier runs. The faucet
    gates requests two ways. A 429 with
    retry_after_seconds is the per-destination rate limit, a window of
    about 5s. A 429 without retry_after_seconds is a
    duplicate request still in flight for the
    same destination. Both are retryable, and fund_from_faucet retries
    a 429 once, waiting 5s when no retry_after_seconds is given; a
    429 asking for more than 30 s raises QuipFaucetError instead of
    waiting (MAX_RETRY_AFTER_SECONDS). A 403 destination already funded with
    free_balance_plancks is the balance ceiling: the faucet refuses any
    account holding more than one drip (10 AGLS), and it is not retryable.
    Spending below the ceiling makes the account eligible again, so there is
    no lifetime quota; a fresh keystore works because it starts empty.
  • Block time: ~6s.
  • Runtime: advances over time; read it with state_getRuntimeVersion
    rather than assuming a pinned value. The signer reads transactionVersion
    from the chain, so a bump to that number alone does not break submission.
    The signed-extension list and its order are fixed client-side
    (SIGNED_EXTENSIONS in xqsa/quip/signing.py), so a runtime that adds,
    removes, reorders or reshapes an extension breaks signing. After an upgrade,
    run test_live.py::TestConnectivity, which checks that list against the
    chain.
  • Metadata: the runtime serves Metadata V16, which scalecodec cannot
    decode. Build clients through xqsa.quip.metadata.connect, which pulls V14
    from the versioned runtime API; a stock SubstrateInterface fails with
    Index '16' not present in Enum type mapping (see the gotcha below).
  • Spec id: DEFAULT_ISING_SPEC_ID = 0x8f46f3a3... (== chain
    DefaultIsingSpecId); MinReward = 1e12 planck (1 UNIT).

Quick version + liveness check. A synced node reports isSyncing == false and
currentBlock == highestBlock; if it is behind, its state reads are stale:

text
uv run python -c "from xqsa.quip.metadata import connect; \
    from xqsa.quip.networks import NETWORKS; \
    s=connect(NETWORKS['aglais'].rpc); \
    print('runtime', s.rpc_request('state_getRuntimeVersion',[])['result']); \
    print('sync   ', s.rpc_request('system_syncState',[])['result']); \
    print('health ', s.rpc_request('system_health',[])['result'])"

Topology

The same advantage2_system1 graph hashes differently per deployment (each
network's allowed-value specs fold into the hash), so SolverQuip resolves the
topology at construction from topology=, then QUIP_TOPOLOGY, then chain
QuantumPow.DefaultTopology. There is no pinned fallback in the codebase, and a
topology that resolves from none of the three raises rather than selecting a
hash no chain would accept. On aglais DefaultTopology is
0xcbec1eb4... over 4577 nodes / 41514 edges, read live on 2026-09-15. Never
assume the DevNet hash on aglais or vice versa.

solve() does not consult MineableTopologies. That set is the chain's active
mining set: it gates submit_proof, and so block production, not the compute
mempool. Nothing binds an order to a topology at propose_job -- 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
mineability cannot affect whether an order is admitted or answered.

To exercise that, aglais carries a permanently registered non-mineable topology:
a complete graph on 16 nodes (K16), hash
0x830abc16c0b28b26f119f3a1279d07811bdeb9c0234d7f442ae46d6698d9a797, registered
at block 232673. It is registered, not mineable, and not the default. Point
QUIP_TOPOLOGY at it to run the whole suite against it:

text
QUIP_MINER_PROBE_TIMEOUT=180 \
  QUIP_TOPOLOGY=0x830abc16c0b28b26f119f3a1279d07811bdeb9c0234d7f442ae46d6698d9a797 \
  make test-quip \
    QUIP_RPC_URL=wss://bootnode-1.aglais.quip.network:20049/rpc \
    QUIP_FAUCET_URL=https://faucet.aglais.quip.network

Reuse K16; do not register another. There is no unregister_topology, so every
registration is permanent.

Run the suite

text
QUIP_MINER_PROBE_TIMEOUT=180 \
  make test-quip \
    QUIP_RPC_URL=wss://bootnode-1.aglais.quip.network:20049/rpc \
    QUIP_FAUCET_URL=https://faucet.aglais.quip.network

The submit-path tests always run; the TestEndToEnd cases are gated behind the
solving_miner probe. If the probe skips (no solution within the window), retry
once when the fleet is active.

Learnings / gotchas

  • Discovery gap (important): the fleet discovers jobs ONLY from live
    JobProposed events -- there is NO storage backfill of pre-existing open
    orders. An order proposed BEFORE a solver subscribed is invisible to it
    forever. Always test with a freshly proposed order; a fresh one usually gets
    its first answer within 1 to 2 blocks while the fleet is live (the slowest
    measured took 25), but historical orders that predate the current fleet stay
    unsolved, which looks like "nothing solves" if you only inspect old orders. (Known miner-side follow-up, not a client bug.)
  • Runtime can advance under you: re-read state_getRuntimeVersion each
    session; do not hard-code pinned facts. The signer adapts transactionVersion
    from the chain, but the signed-extension list is fixed client-side, so an
    upgrade that changes it breaks submission. Run
    test_live.py::TestConnectivity after every known upgrade.
  • Metadata V16 blocks a stock client: substrate-interface decodes through
    scalecodec, whose MetadataAll enum ends at V14, so
    SubstrateInterface.init_runtime() raises ValueError: Index '16' not present in Enum type mapping against any runtime from specVersion 116 on. No
    release of either library decodes V16. xqsa.quip.metadata fetches V14
    through state_call("Metadata_metadata_at_version", 14) instead, and
    SolverQuip builds its client that way; ad-hoc scripts must do the same. The
    shim falls back to state_getMetadata for older DevNet images, and raises
    QuipMetadataError naming the served version when neither path decodes.
  • Watch for a lagging RPC node: a node that has fallen behind the chain tip
    serves a frozen snapshot, and every read then looks wrong -- balances at 0,
    funded accounts "missing", submitted extrinsics that never land -- while the
    chain is actually fine. A faucet request returning 200 + extrinsic_hash is NOT
    proof of funding. Check isSyncing == false and currentBlock == highestBlock;
    if a node is not syncing, point QUIP_RPC_URL at a different RPC URL.
  • Lifecycle: an order's effective expiry is created_at + deadline_blocks,
    tightened to first_solution_at + block_wait once a first solution lands. An
    order that expires with zero solutions can be auto-reclaimed (SolverQuip does
    this and raises a job-failed error, releasing the reserved reward).
  • Fleet answers can be valid but suboptimal: the aglais fleet runs
    heuristic GPU solvers and may return a correct, chain-consistent assignment
    that is not the global optimum -- observed: a 3-var binary model whose true
    optimum is -2 came back as -1, while the controlled DevNet CPU fleet returns
    -2 deterministically. Do not assert global optimality against a live fleet.
    TestEndToEnd asserts only pipeline correctness (energy_matches_chain, a
    returned solution, domain feasibility); encoding and optimality correctness
    are guarded deterministically offline by
    test_client.py::test_encoded_problem_argmin_decodes_to_optimum.
  • Direct pytest invocation: export QUIP_RPC_URL / QUIP_FAUCET_URL as
    environment variables before the command. Passing them as trailing KEY=VAL
    args is a make-ism (make VAR=val); pytest reads trailing KEY=VAL tokens
    as file paths and errors with "file or directory not found".

Manual smoke solve (optional)

Beyond the pytest suite, a single small model round-trips the propose + decode +
energy-canary path. Set QUIP_KEYSTORE and fund the keystore, then:

text
export QUIP_KEYSTORE=/tmp/quip-ks.json
uv run python - <<'PY'
from xqsa.quip import SolverQuip
from xqvm_py.xqmx import XQMX
m = XQMX.spin_model(2)
m.set_linear(0, 1); m.set_quadratic(0, 1, -1)   # brute-force optimum = -2
r = SolverQuip.for_network("aglais").solve(m)   # or "devnet"
print("energy:", r.energy, "| matches chain:", r.metadata["energy_matches_chain"])
PY

Expect energy_matches_chain: True; energy is typically -2.0, though a
heuristic fleet may return a valid but suboptimal value. Get the account id to
fund via
load_or_generate_keystore('/tmp/quip-ks.json').account_id.hex() and POST
{"dest":"0x<accountid>","amount":10000000000000} to the faucet's /request
endpoint.

Inspecting chain state

Independent confirmation lives in QuantumComputeMempool storage: JobOrders
(status / solution_count / first_solution_at), OrderSolutions,
OrderFrontRunner (winning solver + energy_milli, the natural energy x1000),
OrderResults (populated at finalization), and Solvers (each with
solutions_submitted). Topology lives under
QuantumPow.{DefaultTopology,MineableTopologies,RegisteredTopologies}. Strip
the large ising_params arrays when dumping orders.

Which to use when

  • DevNet for fast, deterministic, offline iteration and as a spec-matched
    pre-flight before an aglais run. You control the fleet, so a fresh order
    always solves quickly.
  • aglais for the real-solver, real-timing, real-runtime validation the
    DevNet cannot reproduce -- run it before landing a SolverQuip change that
    touches submission, decoding, or the lifecycle. It is the only public target;
    the older TestNet is retired.

Book Src SUMMARY (`docs/book/src/SUMMARY.md`)

Summary

Introduction


Glossary
Stability
Spec Index
Contributing to These Docs


Design Plans 2026 03 24 Xqvm Spec Migration (`docs/design/plans/2026-03-24-xqvm-spec-migration.md`)

Editor's note: the domain this plan calls "discrete" was renamed to "integer" in QUI-1345. The text below is left as written on 2026-03-24.

XQVM Spec Migration Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Align the Rust XQVM implementation (68 -> 76 opcodes) with XQVM_SPEC.md via a layer-by-layer migration that keeps make lint && make test green after each layer.

Architecture: Four independent crates updated in dependency order: bytecode (opcode table + builder) -> disasm (recompile-only) -> asm (assembler sugar updates + TSP examples) -> vm (new instruction handlers + stack depth enforcement). JUMP/JUMPI keep i16 relative byte offsets; loop semantics stay lazy.

Tech Stack: Rust 2024 edition, pastey (paste! macro), pest (grammar -- no changes), thiserror, miette, cargo nextest.

Spec: docs/design/specs/2026-03-24-xqvm-spec-migration-design.md


File Map

File Change
crates/bytecode/src/types/table.rs Rewrite: all recodings + removal + 9 new opcodes
crates/bytecode/src/builder.rs Update minimal_pushc() to use new variant names; update doc comments referencing PUSHC_N; auto-generated methods (copy, one_hot_r, drop, sclr, etc.) come for free from the macro
crates/bytecode/src/codec.rs Update doc examples and tests that hard-code old variant names (PushC0, PushC1, etc.) or opcode bytes (0x10, 0x0F)
crates/bytecode/src/stream.rs Update doc examples and unit tests referencing old variant names or opcode bytes
crates/bytecode/src/lib.rs Update doc examples referencing Instruction::PushC0, Opcode::PushC0, byte 0x10
crates/disasm/ No code changes -- recompile from updated table; update any snapshot tests
crates/asm/src/assembler.rs Update PUSH/PUSHC special-case to also accept PUSH1..PUSH8; update doc comment opcode references
crates/vm/src/error.rs Add StackOverflow variant
crates/vm/src/vm.rs Introduce module-level STACK_LIMIT const; introduce push_stack helper (replaces push_val); rename exec handlers; add 9 new exec_* handlers
crates/vm/examples/tsp/encoder.xqasm DUPL->COPY, ONEHOT->ONEHOTR
crates/vm/examples/tsp/decoder.xqasm Inspect and update if affected
crates/vm/examples/tsp/verifier.xqasm Inspect and update if affected

Layer 1: `crates/bytecode`

Task 1: Rewrite the opcode table

Files:

  • Modify: crates/bytecode/src/types/table.rs

Apply all recodings, removal, and new opcodes in a single edit to avoid transient code collisions.

  • Step 1: Write a failing compile check

Run cargo check -p aglais-xqvm-bytecode to confirm it currently passes.

sh
cargo check -p aglais-xqvm-bytecode

Expected: exits 0 (green baseline).

  • Step 2: Replace the full opcode table

Replace the contents of crates/bytecode/src/types/table.rs with the version below. The diff is: (1) update the doc comment count from 76 to 76 post-migration, (2) apply all opcode code/variant/mnemonic changes, (3) remove PushC0, (4) add 9 new entries.

Complete new table:

rust
// Copyright (C) 2026 Postquant Labs Incorporated
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program.  If not, see <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: AGPL-3.0-or-later

/// Invoke `$mac!` with the complete XQVM opcode table.
///
/// The callback macro receives the full comma-separated list of 76 opcode
/// entries. Each entry has the form:
///
/// ```text
/// (code, Variant, "MNEMONIC", "doc", {field_name: FieldType, ...})
/// ```
///
/// | Position | Type | Description |
/// |---|---|---|
/// | `code` | `u8` literal | Wire-encoding byte |
/// | `Variant` | `ident` | `PascalCase` Rust enum variant name |
/// | `"MNEMONIC"` | `str` literal | Uppercase assembly mnemonic |
/// | `"doc"` | `str` literal | Single-sentence description |
/// | `{field: Type, ...}` | named-field list | Zero or more named operand fields |
///
/// # Examples
///
/// ```rust
/// macro_rules! collect_mnemonics {
///     ( $( ($code:literal, $var:ident, $mnem:literal, $doc:literal, {$($f:tt)*}) ),* $(,)? ) => {
///         &[ $( $mnem ),* ]
///     }
/// }
///
/// let mnemonics: &[&str] = aglais_xqvm_bytecode::opcodes!(collect_mnemonics);
/// assert!(mnemonics.contains(&"HALT"));
/// assert!(mnemonics.contains(&"ENERGY"));
/// ```
/* Detailed source-code truncated for AI context efficiency. */
  • Step 3: Run compile check
sh
cargo check -p aglais-xqvm-bytecode 2>&1 | head -40

Expected: compilation errors in crates that depend on bytecode (the downstream crates reference old variant names). The bytecode crate itself should compile. If bytecode fails, fix it before proceeding.


Task 2: Update `builder.rs`

Files:

  • Modify: crates/bytecode/src/builder.rs

The impl_builder_methods! macro auto-generates methods by converting PascalCase variant names to snake_case (via pastey::paste!). The new variants produce these auto-generated names:

  • Drop -> fn drop(&mut self, reg: Register) -- drop is not a Rust keyword; it is safe as a method name
  • Sclr -> fn sclr(&mut self)
  • Sqr -> fn sqr(&mut self)
  • Abs -> fn abs(&mut self)
  • Min -> fn min(&mut self)
  • Max -> fn max(&mut self)
  • Inc -> fn inc(&mut self)
  • Dec -> fn dec(&mut self)
  • OneHotC -> fn one_hot_c(&mut self, reg: Register)
  • OneHotR -> fn one_hot_r(&mut self, reg: Register) (renamed from one_hot)
  • Copy -> fn copy(&mut self) (renamed from dupl)

Grammar note: The pest grammar rule mnemonic = @{ ASCII_ALPHA ~ (ASCII_ALPHA | ASCII_DIGIT)* } already matches PUSH1..PUSH8 and all other new/renamed mnemonics (they are all alphanumeric). No changes to grammar.pest are needed.

The macro handles all of the above automatically. The only manual changes needed in builder.rs are:

  1. Update the push() method -- it currently references PushC1..PushC8; update to Push1..Push8.
  2. Update the impl_builder_methods! skip arm for push variants -- currently matches {val: ...}; remains correct since the field name is still val.
  3. Update doc comments -- find and replace PUSHC_N references in doc strings with PUSH_N.
  4. Remove or update any method that was hand-written for old variants -- search for fn dupl, fn onehot, fn push_c in the file and update.
  • Step 1: Find all hand-written references to old variant names in builder.rs
sh
grep -n "dupl\|push_c\|one_hot\b\|PushC\|PUSHC" crates/bytecode/src/builder.rs
  • Step 2: Update minimal_pushc and doc comments in builder.rs

The push() method delegates to minimal_pushc(val: i64) -> Instruction (a private helper near the bottom of the file). Update minimal_pushc to use the new variant names. The logic (sign-extension range checks) is unchanged; only the Instruction::PushCN names change to Instruction::PushN:

rust
fn minimal_pushc(val: i64) -> Instruction {
    let be = val.to_be_bytes();
    for n in 1usize..=7 {
        let bits = (n * 8) as u32;
        let shift = 64 - bits;
        if (val << shift) >> shift == val {
            return match n {
                1 => Instruction::Push1 { val: [be[7]] },
                2 => Instruction::Push2 { val: [be[6], be[7]] },
                3 => Instruction::Push3 { val: [be[5], be[6], be[7]] },
                4 => Instruction::Push4 { val: [be[4], be[5], be[6], be[7]] },
                5 => Instruction::Push5 { val: [be[3], be[4], be[5], be[6], be[7]] },
                6 => Instruction::Push6 { val: [be[2], be[3], be[4], be[5], be[6], be[7]] },
                7 => Instruction::Push7 {
                    val: [be[1], be[2], be[3], be[4], be[5], be[6], be[7]],
                },
                _ => unreachable!(),
            };
        }
    }
    Instruction::Push8 { val: be }
}

Note: the old function had a special case if val == 0 { return Instruction::PushC0 {} } at the top. Remove that branch -- val == 0 now encodes as Push1 { val: [0x00] } (the loop handles it since 0 fits in 1 byte).

Also update all doc comments in builder.rs that reference PUSHC_N:

  • Struct-level doc comment: "Use push() to emit the smallest PUSHC_N instruction" -> "Use push() to emit the smallest PUSH1..PUSH8 instruction"
  • Any /// PUSHC_0, /// PUSHC_1..PUSHC_8 references in examples

Search with: grep -n "PUSHC\|PushC" crates/bytecode/src/builder.rs

  • Step 3: Update codec.rs, stream.rs, and lib.rs doc examples and tests

These files contain doc examples and unit tests that hard-code old variant names and opcode bytes. Find them all:

sh
grep -rn "PushC\|0x10\b\|0x0F\b\|0x14\b\|0x15\b\|0x16\b\|0x17\b" \
  crates/bytecode/src/codec.rs \
  crates/bytecode/src/stream.rs \
  crates/bytecode/src/lib.rs

For each hit:

  • Instruction::PushC0 {} -> Instruction::Push1 { val: [0x00] }

  • Instruction::PushCN { val: [...] } -> Instruction::PushN { val: [...] } (same bytes, new name)

  • Opcode::PushC0 -> Opcode::Push1

  • 0x10 // PUSHC_0 -> 0x11 // PUSH1

  • 0x0F // HALT -> 0x09 // HALT

  • 0x14 // LOAD -> 0x0A // LOAD

  • etc.

  • Step 4: Verify the builder compiles and tests pass

sh
cargo test -p aglais-xqvm-bytecode 2>&1 | tail -20

Expected: all pass. If tests fail on hard-coded byte values, fix them as described in Step 3.

  • Step 5: Run full lint on bytecode crate
sh
cargo clippy -p aglais-xqvm-bytecode --all-targets --all-features -- -D warnings 2>&1 | head -40
RUSTDOCFLAGS="-D warnings" cargo doc -p aglais-xqvm-bytecode 2>&1 | head -20

Expected: zero warnings from both clippy and rustdoc.


Task 3: Commit Layer 1

  • Step 1: Run full bytecode test suite
sh
make test-unit 2>&1 | tail -30
  • Step 2: Commit
sh
git add crates/bytecode/
git commit -s -m "Recode and extend XQVM opcode table to match spec (68 -> 76 opcodes)"

Layer 2: `crates/disasm`

Task 4: Verify disasm

The disasm crate is X-macro driven and should recompile cleanly from the updated table. No source changes are expected.

Files:

  • Verify: crates/disasm/src/ (no edits expected)

  • Step 1: Build disasm

sh
cargo build -p aglais-xqvm-disasm 2>&1 | head -20

Expected: compiles cleanly. If there are errors, they are due to hard-coded opcode references -- fix them.

  • Step 2: Run disasm tests
sh
cargo test -p aglais-xqvm-disasm 2>&1 | tail -20

If snapshot tests exist, update them: INSTA_UPDATE=new cargo test -p aglais-xqvm-disasm or equivalent.

  • Step 3: Spot-check output format

First check the actual disassembler binary name in crates/disasm/Cargo.toml:

sh
grep 'name' crates/disasm/Cargo.toml | head -5

Then run a round-trip:

sh
printf 'PUSH 0\nHALT\n' | cargo run --bin xqasm -- /dev/stdin \
  | cargo run --bin <disasm-bin-name> -- /dev/stdin 2>/dev/null || true

Verify rendered mnemonics use PUSH1, not PUSHC_1, and HALT appears.

  • Step 4: Commit
sh
git add crates/disasm/
git commit -s -m "Verify disasm recompiles cleanly against updated opcode table"

Layer 3: `crates/asm`

Task 5: Update assembler special-case handling

Files:

  • Modify: crates/asm/src/assembler.rs

The build_instr x-macro match already handles all renamed opcodes automatically (it matches on $mnem string literals from the table). The only manual changes needed are:

  1. The "PUSH" | "PUSHC" special case -- update the match arm to also accept explicit PUSH1..PUSH8.
  2. Update the doc comment in assemble() that references PUSHC_0.
  3. Update the doc-test assertion program.code()[0] == 0x10 // PUSHC_0 to reflect the new encoding (PUSH1 = 0x11).
  • Step 1: Locate and update the push special case

Find the arm:

rust
"PUSH" | "PUSHC" => {
    assemble_push(instr, &mut b, src)?;
}

Replace with:

rust
"PUSH" | "PUSHC" | "PUSH1" | "PUSH2" | "PUSH3" | "PUSH4"
| "PUSH5" | "PUSH6" | "PUSH7" | "PUSH8" => {
    assemble_push(instr, &mut b, src)?;
}

This makes PUSH1 42, PUSH2 256, etc. valid in assembly. All of these accept a single integer operand and route through assemble_push, which calls b.push(value) and lets the builder pick the minimal encoding. The explicit width suffix is a readability hint only -- PUSH2 42 emits PUSH1 because 42 fits in 1 byte.

Not supported: The multi-byte literal form PUSH2 0x01 0x00 (two separate hex operands) is not valid in this assembler. check_operand_count(instr, 1, src)? will reject it with WrongOperandCount. Document this limitation in the function's doc comment.

  • Step 2: Update doc comments

In assembler.rs, find all references to PUSHC_0, PUSHC_N, 0x10 // PUSHC_0, 0x0F // HALT and update:

  • PUSHC_0 -> PUSH1 (sugar for value 0 uses PUSH1 0x00)

  • 0x10 // PUSHC_0 -> 0x11 // PUSH1 (PUSH1 opcode = 0x11)

  • 0x0F // HALT -> 0x09 // HALT

  • Step 3: Build and test asm crate

sh
cargo test -p aglais-xqvm-asm 2>&1 | tail -30

Expected: all pass.

  • Step 4: Lint
sh
cargo clippy -p aglais-xqvm-asm --all-targets --all-features -- -D warnings 2>&1 | head -20

Task 6: Update TSP example `.xqasm` files

Files:

  • Modify: crates/vm/examples/tsp/encoder.xqasm
  • Check: crates/vm/examples/tsp/decoder.xqasm
  • Check: crates/vm/examples/tsp/verifier.xqasm

grep found two usages in encoder.xqasm: DUPL (line 29) and ONEHOT r4 (line 106).

  • Step 1: Update encoder.xqasm
sh
sed -i 's/\bDUPL\b/COPY/g; s/\bONEHOT\b/ONEHOTR/g' crates/vm/examples/tsp/encoder.xqasm

Verify with:

sh
grep -n "DUPL\|ONEHOT\b\|PUSHC" crates/vm/examples/tsp/encoder.xqasm

Expected: no matches.

  • Step 2: Check decoder and verifier
sh
grep -n "DUPL\|ONEHOT\b\|PUSHC" crates/vm/examples/tsp/decoder.xqasm crates/vm/examples/tsp/verifier.xqasm

Apply same replacements if any matches are found.

  • Step 3: Commit Layer 3
sh
git add crates/asm/ crates/vm/examples/tsp/
git commit -s -m "Update assembler sugar and TSP examples for renamed opcodes"

Layer 4: `crates/vm`

Task 7: Add `StackOverflow` error and `push_stack` helper

Files:

  • Modify: crates/vm/src/error.rs

  • Modify: crates/vm/src/vm.rs

  • Step 1: Write a failing test for stack overflow

Add to crates/vm/src/vm.rs in the #[cfg(test)] block:

rust
#[test]
fn stack_overflow_is_reported() {
    // RANGE loop with count=8193: pushes 1 value per iteration without
    // popping. After 8192 successful pushes (stack len = 8192), the 8193rd
    // push triggers `stack.len() >= STACK_LIMIT` (8192 >= 8192) -> overflow.
    let mut b = InstructionBuilder::new();
    b.push(0).push(8193).range();
    b.push(1);
    b.next();
    b.halt();
    let program = b.build().unwrap();

    let mut vm = Vm::new();
    let err = vm.run(&program).unwrap_err();
    assert!(
        matches!(err, Error::StackOverflow { .. }),
        "expected StackOverflow, got {err:?}"
    );
}

Run to confirm it fails (StackOverflow variant does not exist yet):

sh
cargo test -p aglais-xqvm-vm stack_overflow 2>&1 | tail -10
  • Step 2: Add StackOverflow to error.rs

In crates/vm/src/error.rs, add after the StepLimitExceeded variant:

rust
/// The value stack exceeded the maximum allowed depth of 8192.
#[error("stack overflow at byte {pos:#06x}: depth exceeded 8192")]
StackOverflow { pos: usize },

Also add pos to the byte_pos() match arm:

rust
Self::StackOverflow { pos } => Some(*pos),
  • Step 3: Introduce STACK_LIMIT const and push_stack helper in vm.rs

Add a module-level const immediately after DEFAULT_STEP_LIMIT (line ~111):

rust
/// Maximum stack depth enforced by [`Vm`].
const STACK_LIMIT: usize = 8192;

Replace the existing push_val helper:

rust
fn push_val(&mut self, v: i64) {
    self.stack.push(v);
}

With (note: module-level const, not Self::STACK_LIMIT):

rust
fn push_stack(&mut self, v: i64, pos: usize) -> Result<(), Error> {
    if self.stack.len() >= STACK_LIMIT {
        return Err(Error::StackOverflow { pos });
    }
    self.stack.push(v);
    Ok(())
}
  • Step 4: Update all call sites of push_val -> push_stack

Every self.push_val(v) call must become self.push_stack(v, pos)?. Use:

sh
grep -n "push_val" crates/vm/src/vm.rs

Important: some handlers declare their pos parameter with a leading underscore (_pos: usize) to suppress the unused-variable lint. When these handlers are updated to call push_stack(v, pos), the parameter must be renamed from _pos to pos -- otherwise -D warnings will fire for unused_variables. Check every handler that pushes. For example, exec_push_c1 currently has _pos: usize; after renaming to exec_push1 and calling push_stack, change the signature to pos: usize.

  • Step 5: Run the overflow test
sh
cargo test -p aglais-xqvm-vm stack_overflow 2>&1 | tail -10

Expected: PASS.


Task 8: Rename changed VM match arms

Files:

  • Modify: crates/vm/src/vm.rs

The impl_dispatch! macro generates calls to self.exec_<variant_snake>(). Renamed variants automatically call the new function name. We must rename the handler functions to match.

  • Step 1: Rename push handler functions

Find and rename:

  • exec_push_c0 (delete -- PushC0 variant is gone)
  • exec_push_c1 -> exec_push1
  • exec_push_c2 -> exec_push2
  • ... through exec_push_c8 -> exec_push8

Each handler body is identical (calls sign_extend_be and push_stack). Use a bulk rename:

sh
sed -i \
  's/fn exec_push_c0/fn exec_push_c0_DELETED/g;
   s/fn exec_push_c1/fn exec_push1/g;
   s/fn exec_push_c2/fn exec_push2/g;
   s/fn exec_push_c3/fn exec_push3/g;
   s/fn exec_push_c4/fn exec_push4/g;
   s/fn exec_push_c5/fn exec_push5/g;
   s/fn exec_push_c6/fn exec_push6/g;
   s/fn exec_push_c7/fn exec_push7/g;
   s/fn exec_push_c8/fn exec_push8/g' \
  crates/vm/src/vm.rs

Then immediately remove the exec_push_c0_DELETED function body from the file before proceeding to Step 4 (the push_val -> push_stack migration). The deleted function calls self.push_val(0); if it is still present when push_val is renamed, the file will fail to compile.

  • Step 2: Rename exec_dupl -> exec_copy
sh
sed -i 's/fn exec_dupl/fn exec_copy/g' crates/vm/src/vm.rs
  • Step 3: Rename exec_one_hot -> exec_one_hot_r
sh
sed -i 's/fn exec_one_hot\b/fn exec_one_hot_r/g' crates/vm/src/vm.rs
  • Step 4: Compile check
sh
cargo check -p aglais-xqvm-vm 2>&1 | head -30

Expected: errors only for missing exec_drop, exec_sclr, exec_sqr, exec_abs, exec_min, exec_max, exec_inc, exec_dec, exec_one_hot_c (not yet written). Fix any other errors first.


Task 9: Add new instruction handlers

Files:

  • Modify: crates/vm/src/vm.rs

  • Modify: crates/vm/src/model.rs

  • Step 1: Add arithmetic handlers

Add after exec_neg:

rust
fn exec_sqr(&mut self, pos: usize) -> Result<StepResult, Error> {
    let a = self.pop(pos)?;
    self.push_stack(a.wrapping_mul(a), pos)?;
    Ok(StepResult::Continue)
}

fn exec_abs(&mut self, pos: usize) -> Result<StepResult, Error> {
    let a = self.pop(pos)?;
    self.push_stack(a.wrapping_abs(), pos)?;
    Ok(StepResult::Continue)
}

fn exec_min(&mut self, pos: usize) -> Result<StepResult, Error> {
    let b = self.pop(pos)?;
    let a = self.pop(pos)?;
    self.push_stack(a.min(b), pos)?;
    Ok(StepResult::Continue)
}

fn exec_max(&mut self, pos: usize) -> Result<StepResult, Error> {
    let b = self.pop(pos)?;
    let a = self.pop(pos)?;
    self.push_stack(a.max(b), pos)?;
    Ok(StepResult::Continue)
}

fn exec_inc(&mut self, pos: usize) -> Result<StepResult, Error> {
    let a = self.pop(pos)?;
    self.push_stack(a.wrapping_add(1), pos)?;
    Ok(StepResult::Continue)
}

fn exec_dec(&mut self, pos: usize) -> Result<StepResult, Error> {
    let a = self.pop(pos)?;
    self.push_stack(a.wrapping_sub(1), pos)?;
    Ok(StepResult::Continue)
}
  • Step 2: Add stack/register handlers
rust
fn exec_drop(&mut self, _pos: usize, reg: Register) -> Result<StepResult, Error> {
    *self.reg_mut(reg) = RegVal::Int(0);
    Ok(StepResult::Continue)
}

fn exec_sclr(&mut self, _pos: usize) -> Result<StepResult, Error> {
    self.stack.clear();
    Ok(StepResult::Continue)
}
  • Step 3: Add exec_one_hot_c handler

The column one-hot mirrors exec_one_hot_r with row/col axes swapped. Add after exec_one_hot_r:

rust
fn exec_one_hot_c(&mut self, pos: usize, reg: Register) -> Result<StepResult, Error> {
    let penalty = self.pop(pos)?;
    let col = self.pop(pos)?;
    let m = self
        .reg_mut(reg)
        .as_model_mut()
        .map_err(|got| Error::RegisterType {
            reg: reg.slot(),
            expected: "model",
            got,
        })?;
    let col_usize = col as usize;
    // H = penalty * (sum(x_i for i in col) - 1)^2
    // Linear: -penalty per variable in column
    // Quadratic: 2*penalty per pair in column
    for r in 0..m.rows {
        m.add_linear(r * m.cols + col_usize, -penalty);
    }
    for ri in 0..m.rows {
        for rj in (ri + 1)..m.rows {
            m.add_quad(ri * m.cols + col_usize, rj * m.cols + col_usize, 2 * penalty);
        }
    }
    Ok(StepResult::Continue)
}
  • Step 4: Write unit tests for new instructions

Add to the #[cfg(test)] block:

text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 5: Run new tests
sh
cargo test -p aglais-xqvm-vm sqr_abs_min_max drop_clears sclr_empties 2>&1 | tail -15

Expected: all PASS.


Task 10: Final validation and commit

  • Step 1: Run full workspace lint and test
sh
make lint && make test

Expected: zero warnings, all tests pass. If the TSP example is a test target, it must pass end-to-end.

  • Step 2: Commit Layer 4
sh
git add crates/vm/
git commit -s -m "Add 9 new VM instruction handlers and enforce 8192-element stack limit"
  • Step 3: Final integration check
sh
make all

Expected: exits 0.


Design Plans 2026 03 27 Tracing Vm (`docs/design/plans/2026-03-27-tracing-vm.md`)

Tracing VM Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add zero-cost execution tracing to the XQVM interpreter with text and JSON output formats.

Architecture: Generic Tracer trait injected via type parameter into Vm::run_trace. Monomorphization with NoopTracer produces identical code to the current run. Concrete TextTracer / JsonTracer formatters live behind #[cfg(feature = "std")]. CLI gets --trace, --trace-format, --trace-file flags.

Tech Stack: Rust, existing workspace (aglais-xqvm-bytecode, aglais-xqvm-vm), clap, no new dependencies.

Spec: docs/design/specs/2026-03-27-tracing-vm-design.md


File Structure

New files

File Responsibility
crates/bytecode/src/types/register_effect.rs RegisterEffect type + Instruction::read_registers / written_registers methods
crates/vm/src/tracer/mod.rs Tracer trait, StepState, NoopTracer, re-exports
crates/vm/src/tracer/text.rs TextTracer<W> (std-only)
crates/vm/src/tracer/json.rs JsonTracer<W> (std-only)

Modified files

File Change
crates/bytecode/src/types/mod.rs Add mod register_effect; + re-export RegisterEffect
crates/bytecode/src/types/instruction.rs Add Display impl, read_registers, written_registers
crates/bytecode/src/lib.rs Re-export RegisterEffect
crates/vm/src/lib.rs Add mod tracer; + re-exports
crates/vm/src/error.rs Add TraceFailed variant + update byte_pos()
crates/vm/src/vm.rs Add run_trace, refactor run to delegate
crates/vm/src/bin/xqvm.rs Add --trace, --trace-format, --trace-file CLI flags

Task 1: Register Effect Type (bytecode crate)

Files:

  • Create: crates/bytecode/src/types/register_effect.rs

  • Modify: crates/bytecode/src/types/mod.rs

  • Modify: crates/bytecode/src/lib.rs

  • Step 1: Write the failing test

Add to the bottom of crates/bytecode/src/types/register_effect.rs (we create the file with the test first):

text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 2: Wire up the module

In crates/bytecode/src/types/mod.rs, add after mod operand;:

rust
mod register_effect;

And update the re-export block:

rust
pub use self::{
    instruction::Instruction,
    opcode::{DecodeError, Opcode},
    operand::Register,
    register_effect::RegisterEffect,
};

In crates/bytecode/src/lib.rs, add to the re-export line:

rust
pub use types::{Instruction, Opcode, Register, RegisterEffect};
  • Step 3: Run tests to verify they pass

Run: cargo nextest run -p aglais-xqvm-bytecode --lib -- register_effect
Expected: 3 PASS

  • Step 4: Commit
text
Add RegisterEffect type for register introspection

Implements QUI-314

Task 2: Register Introspection Methods (bytecode crate)

Files:

  • Modify: crates/bytecode/src/types/instruction.rs

  • Step 1: Write the failing tests

Add tests to the existing #[cfg(test)] mod tests block in crates/bytecode/src/types/instruction.rs:

text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 2: Run tests to verify they fail

Run: cargo nextest run -p aglais-xqvm-bytecode --lib -- read_registers
Expected: FAIL โ€” method read_registers not found

  • Step 3: Implement read_registers and written_registers

Add a new impl Instruction block in crates/bytecode/src/types/instruction.rs, before #[cfg(test)]:

text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 4: Run tests to verify they pass

Run: cargo nextest run -p aglais-xqvm-bytecode --lib
Expected: all PASS (existing + new)

  • Step 5: Commit
text
Add register introspection to Instruction

Implements QUI-314

Task 3: Display Impl for Instruction (bytecode crate)

Files:

  • Modify: crates/bytecode/src/types/instruction.rs

  • Step 1: Write the failing test

Add to the #[cfg(test)] mod tests in crates/bytecode/src/types/instruction.rs:

rust
use alloc::format;

    #[test]
    fn display_no_operands() {
        assert_eq!(format!("{}", Instruction::Add {}), "ADD");
        assert_eq!(format!("{}", Instruction::Halt {}), "HALT");
        assert_eq!(format!("{}", Instruction::Nop {}), "NOP");
    }

    #[test]
    fn display_register_operand() {
        assert_eq!(
            format!("{}", Instruction::Load { reg: Register(5) }),
            "LOAD r5",
        );
        assert_eq!(
            format!("{}", Instruction::Stow { reg: Register(0) }),
            "STOW r0",
        );
    }

    #[test]
    fn display_push() {
        assert_eq!(
            format!("{}", Instruction::Push8 { val: 42i64.to_be_bytes() }),
            "PUSH8 42",
        );
        assert_eq!(
            format!("{}", Instruction::Push1 { val: [0xFF] }),
            "PUSH1 -1",
        );
    }

    #[test]
    fn display_jump() {
        assert_eq!(
            format!("{}", Instruction::Jump { label: 3 }),
            "JUMP .3",
        );
    }

    #[test]
    fn display_energy() {
        assert_eq!(
            format!(
                "{}",
                Instruction::Energy { model: Register(0), sample: Register(1) }
            ),
            "ENERGY r0 r1",
        );
    }
  • Step 2: Run tests to verify they fail

Run: cargo nextest run -p aglais-xqvm-bytecode --lib -- display
Expected: FAIL โ€” Instruction doesn't implement fmt::Display

  • Step 3: Implement Display

Add to crates/bytecode/src/types/instruction.rs, before the #[cfg(test)] block. Use the existing sign_extend_be-style helper from vm.rs โ€” but since this is the bytecode crate, define a local helper:

text
/* Detailed source-code truncated for AI context efficiency. */

Note: the final _ catch-all covers Nop, Target, Next, Range, Halt, Pop, Sclr, Swap, Copy, and all arithmetic/comparison/logic/bitwise/index instructions (all zero-operand).

  • Step 4: Run tests

Run: cargo nextest run -p aglais-xqvm-bytecode --lib
Expected: all PASS

  • Step 5: Commit
text
Add Display impl for Instruction

Implements QUI-314

Task 4: Tracer Trait and NoopTracer (VM crate)

Files:

  • Create: crates/vm/src/tracer/mod.rs

  • Modify: crates/vm/src/lib.rs

  • Modify: crates/vm/src/error.rs

  • Step 1: Create the tracer module

Create crates/vm/src/tracer/mod.rs:

text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 2: Add TraceFailed error variant

In crates/vm/src/error.rs, add at the top of the file after the existing #[cfg(feature = "std")] imports:

rust
#[cfg(not(feature = "std"))]
use alloc::string::String;

Add new variant to the Error enum, after InvalidGridDimensions:

rust
/// A tracer callback returned an error (e.g. I/O write failure).
    #[error("trace failed at byte {pos:#06x}: {message}")]
    TraceFailed {
        pos: usize,
        message: String,
    },

Update byte_pos() โ€” add Self::TraceFailed { pos, .. } to the Some(*pos) arm:

rust
| Self::TraceFailed { pos, .. } => Some(*pos),
  • Step 3: Wire up in lib.rs

In crates/vm/src/lib.rs, add mod tracer; after mod vm;:

rust
mod vm;
pub mod tracer;

Add re-exports after the existing re-export block:

rust
pub use tracer::{NoopTracer, StepState, Tracer};
#[cfg(feature = "std")]
pub use tracer::{JsonTracer, TextTracer};
  • Step 4: Create placeholder files for TextTracer and JsonTracer

Create crates/vm/src/tracer/text.rs with the license header and a placeholder struct that compiles:

rust
// [license header]

//! Human-readable text tracer.

use std::io::Write;

use crate::tracer::{StepState, Tracer};

/// Writes human-readable trace output with aligned columns.
#[derive(Debug)]
pub struct TextTracer<W: Write> {
    out: W,
}

impl<W: Write> Tracer for TextTracer<W> {
    type Error = std::io::Error;

    fn on_step(&mut self, _state: &StepState<'_>) -> Result<(), Self::Error> {
        todo!("implemented in Task 6")
    }
}

Create crates/vm/src/tracer/json.rs with same pattern:

rust
// [license header]

//! JSONL tracer.

use std::io::Write;

use crate::tracer::{StepState, Tracer};

/// Writes one JSON object per line (JSONL format).
#[derive(Debug)]
pub struct JsonTracer<W: Write> {
    out: W,
}

impl<W: Write> Tracer for JsonTracer<W> {
    type Error = std::io::Error;

    fn on_step(&mut self, _state: &StepState<'_>) -> Result<(), Self::Error> {
        todo!("implemented in Task 7")
    }
}
  • Step 5: Verify compilation

Run: cargo check -p aglais-xqvm-vm --all-features
Expected: compiles cleanly

Run: cargo check -p aglais-xqvm-vm --no-default-features
Expected: compiles cleanly (no_std)

  • Step 6: Commit
text
Add Tracer trait, StepState, and NoopTracer

Implements QUI-314

Task 5: VM Integration โ€” `run_trace` Method

Files:

  • Modify: crates/vm/src/vm.rs

  • Step 1: Write the failing test for RecordingTracer

Add at the very bottom of crates/vm/src/vm.rs, a test module:

text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 2: Run tests to verify they fail

Run: cargo nextest run -p aglais-xqvm-vm --lib -- vm::tests
Expected: FAIL โ€” method run_trace not found

  • Step 3: Implement run_trace

In crates/vm/src/vm.rs, add imports at the top (after existing imports):

rust
#[cfg(not(feature = "std"))]
use alloc::{format, vec::Vec};

use crate::tracer::{NoopTracer, StepState, Tracer};

Replace the existing run method (lines 268-304) with:

text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 4: Run tests

Run: cargo nextest run -p aglais-xqvm-vm --lib
Expected: all PASS

Run: cargo nextest run -p aglais-xqvm-vm --test '*'
Expected: all existing integration tests PASS (run delegates to run_trace)

  • Step 5: Commit
text
Add run_trace method with register snapshotting

Implements QUI-314

Task 6: TextTracer (VM crate, std-only)

Files:

  • Modify: crates/vm/src/tracer/text.rs

This is a design and formatting task where your choices shape the output. See the spec for the desired column layout. The RegVal display formatting (compact representation for complex types) is a key decision.

  • Step 1: Write the integration test

Create crates/vm/tests/trace_text.rs:

text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 2: Implement TextTracer

Replace the placeholder in crates/vm/src/tracer/text.rs with the full implementation. Key decisions:

  • Fixed column widths: step(6), offset(8), instruction(22), stack(26), read-regs(18), written-regs(18).
  • Write a header line first, then one row per step.
  • RegVal compact display:
    • Int(v) โ†’ "{v}"
    • VecInt(v) โ†’ "vec(len={v.len()})"
    • VecXqmx(v) โ†’ "vec<model>(len={v.len()})"
    • Model(m) โ†’ "model({m.rows}x{m.cols})"
    • Sample(s) โ†’ "sample(len={s.values.len()})"
  • Stack display: show all if โ‰ค8 elements, otherwise ...N more, top8

The compact RegVal formatting, stack formatting, and column layout are design choices โ€” implement them per the spec's example output.

  • Step 3: Run tests

Run: cargo nextest run -p aglais-xqvm-vm --test trace_text
Expected: PASS

  • Step 4: Commit
text
Add TextTracer for human-readable trace output

Implements QUI-314

Task 7: JsonTracer (VM crate, std-only)

Files:

  • Modify: crates/vm/src/tracer/json.rs

  • Step 1: Write the integration test

Create crates/vm/tests/trace_json.rs:

text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 2: Implement JsonTracer

Replace the placeholder in crates/vm/src/tracer/json.rs. Hand-write JSON serialization using write! calls โ€” no serde dependency. One JSON object per line.

Format:

json
{"step":1,"pos":0,"instruction":"PUSH1 3","stack":[3],"read_regs":{},"written_regs":{}}

Register objects use the register index as key:

json
{"step":4,"pos":5,"instruction":"STOW r0","stack":[],"read_regs":{},"written_regs":{"0":{"type":"int","value":7}}}

Complex types:

json
{"0":{"type":"model","rows":8,"cols":8}}
{"0":{"type":"vec<int>","len":12}}
{"0":{"type":"sample","len":5}}
  • Step 3: Run tests

Run: cargo nextest run -p aglais-xqvm-vm --test trace_json
Expected: PASS

  • Step 4: Commit
text
Add JsonTracer for JSONL trace output

Implements QUI-314

Task 8: CLI Integration

Files:

  • Modify: crates/vm/src/bin/xqvm.rs

  • Step 1: Add CLI flags

In crates/vm/src/bin/xqvm.rs, add the new imports and types:

rust
use std::fs::File;
use std::io::{BufWriter, Write};

use aglais_xqvm_vm::{TextTracer, JsonTracer};

Add the TraceFormat enum:

rust
/// Trace output format.
#[derive(Clone, Debug, clap::ValueEnum)]
enum TraceFormat {
    /// Human-readable aligned columns.
    Text,
    /// One JSON object per line (JSONL).
    Json,
}

Add new fields to Args:

rust
/// Enable execution tracing.
    #[arg(long)]
    trace: bool,

    /// Trace output format.
    #[arg(long, default_value = "text", requires = "trace")]
    trace_format: TraceFormat,

    /// Write trace output to a file instead of stderr.
    #[arg(long, requires = "trace")]
    trace_file: Option<PathBuf>,
  • Step 2: Wire up trace dispatch

Replace the vm.run(&program) call in main() (line 94-95) with:

rust
if args.trace {
        let writer: Box<dyn Write> = match &args.trace_file {
            Some(path) => {
                let file = File::create(path)
                    .into_diagnostic()
                    .wrap_err_with(|| format!("failed to create '{}'", path.display()))?;
                Box::new(BufWriter::new(file))
            }
            None => Box::new(BufWriter::new(std::io::stderr())),
        };
        match args.trace_format {
            TraceFormat::Text => {
                let mut tracer = TextTracer::new(writer);
                vm.run_trace(&mut tracer, &program)
                    .map_err(|e| e.into_diagnostic(&program, &args.file.to_string_lossy()))?;
            }
            TraceFormat::Json => {
                let mut tracer = JsonTracer::new(writer);
                vm.run_trace(&mut tracer, &program)
                    .map_err(|e| e.into_diagnostic(&program, &args.file.to_string_lossy()))?;
            }
        }
    } else {
        vm.run(&program)
            .map_err(|e| e.into_diagnostic(&program, &args.file.to_string_lossy()))?;
    };
  • Step 3: Verify compilation and help output

Run: cargo build -p aglais-xqvm-vm --bin xqvm
Expected: compiles

Run: cargo run -p aglais-xqvm-vm --bin xqvm -- --help
Expected: output includes --trace, --trace-format, --trace-file

  • Step 4: Commit
text
Add --trace, --trace-format, --trace-file CLI flags

Implements QUI-314

Task 9: Final Verification

  • Step 1: Run full lints

Run: make lint
Expected: 0 warnings, 0 errors

  • Step 2: Run all tests

Run: make test
Expected: all PASS

  • Step 3: Run doc tests

Run: make lint-doc
Expected: 0 warnings

  • Step 4: Fix any issues found above, then commit fixes

  • Step 5: Mark QUI-314 as Done in Linear


Design Plans 2026 06 18 Quantum Gate Isa (`docs/design/plans/2026-06-18-quantum-gate-isa.md`)

Quantum Gate ISA Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add a gate-based quantum ISA as a separate .xqg binary format with CPU, IBM Quantum, and IonQ backends, enabling XQuad to target real quantum hardware alongside the existing QUBO optimization path.

Architecture: A new Rust crate xqgbc defines the gate opcode table (X-macro), .xqg binary format (15-byte header + instruction stream), codec, and static verifier. A new Python package xqgb provides a pure-Python decoder for .xqg files, a GateSolver ABC mirroring xqsa's Solver, a NumPy statevector CPU simulator, and backend adapters for IBM Quantum (Qiskit) and IonQ (ionq SDK), all tied together by a registry factory.

Tech Stack: Rust 2024 / no_std+alloc (core types), crc32fast (CRC), thiserror; Python 3.13+ / numpy>=2.0, optional qiskit>=1.0 + qiskit-aer>=0.14 + qiskit-ibm-runtime>=0.20, optional ionq>=0.3

Consolidated work items (W1โ€“W7)

The Linear epic (QUI-724) groups these tasks into seven work items. The granular tasks below are the implementation sub-steps; this table maps each to its W-item.

Work item Composed of Linear ticket
W1 -- xqgbc Rust crate W1 Parts 1โ€“3 (former Tasks 1โ€“3) QUI-725
W2 -- xqgb Python core W2 Parts 1โ€“2 (former Tasks 4โ€“5) QUI-728
W3 -- xqgb hardware backends W3 Parts 1โ€“2 (former Tasks 6โ€“7) QUI-730
W4 -- Conformance harness former Task 8 QUI-732
W5 -- Gate primitives former Task 10 QUI-739
W6 -- Classical control flow + register file W6 Parts 1โ€“3 (former Tasks 9, 11, 13) QUI-738
W7 -- Parameterized circuits / header v2 former Task 12 QUI-741

Section order in this document predates the consolidation: W6's three parts appear below as the former Task 9 (Part 1), Task 11 (Part 2), and Task 13 (Part 3), interleaved with W5 and W7. Follow the W-numbering, not the document order.

W4 should assert that the Rust opcode set, the Python opcode set, and gate_opcodes.yaml are all equal, rather than checking a hardcoded opcode count -- this lets W5, W6, and W7 proceed in parallel once W4 lands.

Global Constraints

  • xqgbc must be no_std + alloc with a std feature gate, matching xqvm's portability posture
  • All Rust code must pass cargo clippy --all-features -- -D warnings with the workspace clippy.toml
  • Python must pass ruff check and ruff format --check at the repo root
  • Google-style docstrings on all public Python APIs
  • Gate opcode bytes live in 0x80โ€“0xE8 (W1 defines 0x80โ€“0xC2; W5โ€“W6 extend it up to 0xE8), inside the range the XQVM spec reserves as illegal for .xqb
  • Gate angles are stored as u32 binary-angular units (full turn = 2^32); conversion to radians happens only at the simulator/backend boundary, never on-chain -- this eliminates NaN/โˆ’0 hazards and halves angle storage cost vs f64
  • No modifications to xqvm, xqasm, or xqvm_py -- gate ISA is purely additive in new crates/packages
  • Add xqgbc to root Cargo.toml members; add xqgb to root pyproject.toml [tool.uv.workspace] members

File Map

New: `xqgbc/` (Rust crate)

File Purpose
xqgbc/Cargo.toml Crate manifest with crc32fast, thiserror, pastey deps
xqgbc/src/lib.rs Public facade re-exporting all public types
xqgbc/src/table.rs gate_opcodes! X-macro (25 gate instructions)
xqgbc/src/types/mod.rs Re-exports from sub-modules
xqgbc/src/types/operand.rs Qubit(u8), Cbit(u8), GateAngle(u32) newtypes (full turn = 2^32 units)
xqgbc/src/types/opcode.rs GateOpcode #[repr(u8)] enum derived from X-macro
xqgbc/src/types/instruction.rs GateInstruction enum derived from X-macro
xqgbc/src/codec.rs EncodeOperand/DecodeOperand traits + impl_gate_codec! macro
xqgbc/src/program.rs GateCircuit struct + .xqg encode/decode with 15-byte header
xqgbc/src/verifier.rs static_verify() single-pass qubit bounds + MEAS presence check
xqgbc/tests/roundtrip.rs Integration: encode โ†’ decode roundtrips for every opcode

New: `xqgb/` (Python package)

File Purpose
xqgb/pyproject.toml Package manifest; extras [ibm], [ionq]
xqgb/__init__.py Re-exports public API
xqgb/decoder.py Pure-Python .xqg header + instruction decoder
xqgb/solver.py GateSolverResult, GateSolver ABC
xqgb/transpiler.py GateCircuitTranspiler[T] ABC; QiskitTranspiler; IonQTranspiler
xqgb/cpu_sim.py StatevectorSimulator + SolverCPUSim(GateSolver)
xqgb/ibm_quantum.py SolverIBMQuantum(GateSolver)
xqgb/ionq.py SolverIonQ(GateSolver)
xqgb/registry.py GATE_SOLVERS dict + build_gate_solver(name, **kwargs)
xqgb/tests/test_decoder.py Decoder unit tests (no hardware)
xqgb/tests/test_cpu_sim.py Statevector sim: Bell state, GHZ, Pauli correctness
xqgb/tests/test_registry.py Registry smoke tests

New: conformance additions

File Purpose
conformance/gate_opcodes.yaml Canonical source of truth for all 25 gate opcodes: code, mnemonic, operand types and wire widths
conformance/vectors/gate/<name>/circuit.xqg Pre-encoded .xqg binary blobs (one per operand shape); committed ground truth
conformance/vectors/gate/<name>/expected_parse.json Decoded instruction list each blob must parse to
conformance/vectors/gate/bell_state/circuit.xqg 2-qubit Bell circuit; simulation conformance vector
conformance/vectors/gate/bell_state/expected_counts.json Measurement distribution bounds (min fraction per outcome)
scripts/check-gate-opcode-parity.py CI script: validates Rust gate_opcodes! table and Python _OPCODE_TABLE both match gate_opcodes.yaml
scripts/gen-gate-vectors.py Generator: reads gate_opcodes.yaml, emits codec vectors under conformance/vectors/gate/

W1 ยท Part 1 -- `xqgbc` Rust Crate: Scaffold + Opcode Table

Files:

  • Create: xqgbc/Cargo.toml
  • Create: xqgbc/src/lib.rs
  • Create: xqgbc/src/table.rs
  • Create: xqgbc/src/types/mod.rs
  • Create: xqgbc/src/types/operand.rs
  • Modify: Cargo.toml (root) -- add xqgbc to members

Interfaces:

  • Produces: gate_opcodes! X-macro usable by later tasks to generate GateOpcode, GateInstruction, and codec

  • Step 1: Add xqgbc to root workspace

In Cargo.toml (root), add "xqgbc" to the members array:

toml
members = [
    "xqvm",
    "xqasm",
    "xqcli",
    "conformance",
    "xqffi",
    "xqgbc",             # <-- add this
    "fixtures/xqvm-wasm",
]
  • Step 2: Write xqgbc/Cargo.toml
toml
[package]
name        = "xqgbc"
version     = "0.1.0"
edition     = "2021"
description = "Gate-based quantum bytecode (XQG) format, codec, and verifier."
repository.workspace = true
license.workspace    = true

[features]
default = ["std"]
std     = ["crc32fast/std", "thiserror/std"]

[dependencies]
crc32fast = { version = "1.5", default-features = false }
thiserror  = { version = "2", default-features = false }
pastey     = { workspace = true }

[dev-dependencies]
std::f64::consts = []   # accessed in tests directly; no extra dep needed
  • Step 3: Write xqgbc/src/types/operand.rs
text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 4: Write xqgbc/src/table.rs (the X-macro -- single source of truth)
rust
// Copyright (C) 2026 Postquant Labs Incorporated
// SPDX-License-Identifier: AGPL-3.0-or-later

/// Invoke `$mac!` with the complete XQG gate opcode table.
///
/// Each entry is an enum variant with a `#[gate(...)]` attribute and an
/// optional named-field body, mirroring Rust enum variant syntax:
///
/// ```text
/// /// Doc comment.
/// #[gate(code, "MNEMONIC", qubit_arity)]
/// VariantName { field: Type, ... },
/// ```
///
/// | Attribute position | Type | Description |
/// |---|---|---|
/// | `code` | `u8` literal | Wire-encoding byte (0x80โ€“0xE8) |
/// | `"MNEMONIC"` | `str` literal | Uppercase assembly mnemonic |
/// | `qubit_arity` | `u8` literal | Number of qubit operands; used by the static verifier |
///
/// `GateAngle` fields are not counted toward `qubit_arity`.
/// All opcodes live in 0x80โ€“0xE8, inside the range `.xqb` reserves as illegal.
///
/// Consumer macros match the pattern:
/// ```rust,ignore
/// macro_rules! my_consumer {
///     (
///         $(
///             $(#[doc = $doc:literal])*
///             #[gate($code:literal, $mnem:literal, $arity:literal)]
///             $var:ident $({ $($fields:tt)* })?
///         ),* $(,)?
///     ) => { /* ... */ }
/// }
/// ```
/* Detailed source-code truncated for AI context efficiency. */
  • Step 5: Write xqgbc/src/types/mod.rs (placeholder for W1 Part 2 types)
rust
pub mod operand;
pub use operand::{Cbit, GateAngle, Qubit};
// opcode and instruction modules added in W1 Part 2
  • Step 6: Write xqgbc/src/lib.rs
rust
// Copyright (C) 2026 Postquant Labs Incorporated
// SPDX-License-Identifier: AGPL-3.0-or-later

#![cfg_attr(not(feature = "std"), no_std)]
#[cfg(not(feature = "std"))]
extern crate alloc;

#[macro_use]
mod table;

mod types;
pub use types::{Cbit, GateAngle, Qubit};
pub use gate_opcodes;
  • Step 7: Verify it compiles
bash
cargo check -p xqgbc

Expected: 0 errors, 0 warnings.

  • Step 8: Commit
bash
git add xqgbc/ Cargo.toml Cargo.lock
git commit -m "feat(xqgbc): scaffold crate with gate_opcodes! X-macro and operand types"

W1 ยท Part 2 -- `xqgbc`: Opcode Enum, Instruction Enum, and Codec

Files:

  • Create: xqgbc/src/types/opcode.rs
  • Create: xqgbc/src/types/instruction.rs
  • Create: xqgbc/src/codec.rs
  • Modify: xqgbc/src/types/mod.rs
  • Modify: xqgbc/src/lib.rs

Interfaces:

  • Consumes: gate_opcodes! from W1 Part 1; Qubit, Cbit, GateAngle from W1 Part 1

  • Produces: GateOpcode, GateInstruction, codec::encode(&GateInstruction) -> Vec, codec::decode(&[u8]) -> Result<(GateInstruction, usize), DecodeError>

  • Step 1: Write failing roundtrip tests in xqgbc/tests/roundtrip.rs

rust
use xqgbc::{GateInstruction, Qubit, Cbit, GateAngle};
use xqgbc::codec;

#[test]
fn roundtrip_h() {
    let instr = GateInstruction::H { q: Qubit(0) };
    let bytes = codec::encode(&instr);
    assert_eq!(bytes, [0x90, 0x00]);
    let (decoded, consumed) = codec::decode(&bytes).unwrap();
    assert_eq!(decoded, instr);
    assert_eq!(consumed, 2);
}

#[test]
fn roundtrip_cnot() {
    let instr = GateInstruction::Cnot { ctrl: Qubit(0), tgt: Qubit(1) };
    let bytes = codec::encode(&instr);
    assert_eq!(bytes, [0xB0, 0x00, 0x01]);
    let (decoded, consumed) = codec::decode(&bytes).unwrap();
    assert_eq!(decoded, instr);
    assert_eq!(consumed, 3);
}

#[test]
fn roundtrip_rx() {
    use std::f64::consts::PI;
    let instr = GateInstruction::Rx { q: Qubit(2), theta: GateAngle::from_radians(PI / 2.0) };
    let bytes = codec::encode(&instr);
    assert_eq!(bytes.len(), 6); // 1 opcode + 1 qubit + 4 angle (u32, not f64)
    let (decoded, consumed) = codec::decode(&bytes).unwrap();
    assert_eq!(decoded, instr);
    assert_eq!(consumed, 6);
}

#[test]
fn decode_unknown_opcode() {
    let err = codec::decode(&[0xDE]).unwrap_err();
    assert!(matches!(err, xqgbc::codec::DecodeError::UnknownOpcode { byte: 0xDE }));
}

Run: cargo test -p xqgbc โ†’ expected: fails to compile (types not yet defined).

  • Step 2: Write xqgbc/src/types/opcode.rs

Use the pastey crate (already in workspace) for paste-based identifier construction:

text
/* Detailed source-code truncated for AI context efficiency. */

Note: GateOpcode is written by hand rather than macro-generated to avoid needing pastey for a simple enum. The X-macro is the source of truth for GateInstruction and the codec.

  • Step 3: Write xqgbc/src/types/instruction.rs
text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 4: Write xqgbc/src/codec.rs
text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 5: Update xqgbc/src/types/mod.rs and lib.rs

types/mod.rs:

rust
pub mod operand;
pub mod opcode;
pub mod instruction;
pub use operand::{Cbit, GateAngle, Qubit};
pub use opcode::GateOpcode;
pub use instruction::GateInstruction;

lib.rs:

rust
#![cfg_attr(not(feature = "std"), no_std)]
#[cfg(not(feature = "std"))]
extern crate alloc;

#[macro_use]
mod table;

mod types;
pub use types::{Cbit, GateAngle, GateInstruction, GateOpcode, Qubit};
pub use gate_opcodes;

pub mod codec;
  • Step 6: Run the roundtrip tests
bash
cargo test -p xqgbc

Expected: all tests pass.

  • Step 7: Commit
bash
git add xqgbc/
git commit -m "feat(xqgbc): gate instruction enum, opcode enum, and binary codec"

W1 ยท Part 3 -- `xqgbc`: `.xqg` Program Format and Static Verifier

Files:

  • Create: xqgbc/src/program.rs
  • Create: xqgbc/src/verifier.rs
  • Modify: xqgbc/src/lib.rs

Interfaces:

  • Consumes: GateInstruction, codec::encode/decode from W1 Part 2
  • Produces: GateCircuit::encode() -> Vec, GateCircuit::decode(&[u8]) -> Result<GateCircuit, _>, static_verify(&GateCircuit) -> Result<(), GateVerifyError>

`.xqg` Binary Header (15 bytes)

The gate header is structurally identical to the QUBO (.xqb) header -- same size, same
field positions, same CRC scope. The format is distinguished by the magic bytes.

XQ bytecode family discriminant: Both formats share the two-byte prefix XQ. Bytes
2โ€“3 identify the specific format: BC โ†’ QUBO circuit; GB โ†’ gate circuit. A
generic XQ reader can dispatch on bytes [2:4] without parsing the rest of the header.

text
Offset  Width  Field             (.xqb / QUBO)        (.xqg / Gate)
------  -----  -----             ------------         -------------
 0..4     4    Magic             b"XQBC"              b"XQGB"
    4     1    version: u8       = 1                  = 1
    5     1    domain_a: u8      input_slots           n_qubits
    6     1    domain_b: u8      output_slots          n_cbits
 7..11    4    domain_c: u32 BE  code_len              n_shots
11..15    4    crc32: u32 BE     CRC-32 over stream   CRC-32 over stream
15+       *    instruction stream

domain_a/domain_b/domain_c are format-specific interpretations of the same
structural slots. CRC is stream-only in both formats: header fields are independently
validated by the verifier (qubit/cbit bounds checks; length match), so a corrupt header
byte that disagrees with the stream fails structurally rather than silently.

  • Step 1: Write failing tests in xqgbc/tests/roundtrip.rs (append):
rust
use xqgbc::program::GateCircuit;
use xqgbc::verifier::static_verify;

#[test]
fn encode_decode_bell_circuit() {
    use xqgbc::GateInstruction::{H, Cnot, Meas, QHalt};
    let instructions = vec![
        H { q: Qubit(0) },
        Cnot { ctrl: Qubit(0), tgt: Qubit(1) },
        Meas { q: Qubit(0), c: Cbit(0) },
        Meas { q: Qubit(1), c: Cbit(1) },
        QHalt,
    ];
    let circuit = GateCircuit::new(2, 2, 1024, instructions);
    let bytes = circuit.encode();
    let decoded = GateCircuit::decode(&bytes).unwrap();
    assert_eq!(decoded.n_qubits(), 2);
    assert_eq!(decoded.n_cbits(), 2);
    assert_eq!(decoded.n_shots(), 1024);
    assert_eq!(decoded.instructions(), circuit.instructions());
    static_verify(&decoded).unwrap();
}

#[test]
fn verify_rejects_missing_meas() {
    use xqgbc::GateInstruction::{H, QHalt};
    let circuit = GateCircuit::new(1, 0, 100, vec![H { q: Qubit(0) }, QHalt]);
    assert!(static_verify(&circuit).is_err());
}

#[test]
fn verify_rejects_qubit_out_of_bounds() {
    use xqgbc::GateInstruction::{Meas, QHalt};
    // Circuit declares 1 qubit but instruction references qubit 5
    let circuit = GateCircuit::new(1, 1, 100, vec![
        Meas { q: Qubit(5), c: Cbit(0) },
        QHalt,
    ]);
    assert!(static_verify(&circuit).is_err());
}

Run: cargo test -p xqgbc โ†’ expected: compile error (module not found).

  • Step 2: Write xqgbc/src/program.rs
text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 3: Write xqgbc/src/verifier.rs
text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 4: Update xqgbc/src/lib.rs
rust
#![cfg_attr(not(feature = "std"), no_std)]
#[cfg(not(feature = "std"))]
extern crate alloc;

#[macro_use]
mod table;

mod types;
pub use types::{Cbit, GateAngle, GateInstruction, GateOpcode, Qubit};
pub use gate_opcodes;

pub mod codec;
pub mod program;
pub mod verifier;
pub use program::{GateCircuit, GateCircuitDecodeError};
pub use verifier::{static_verify, GateVerifyError};
  • Step 5: Run all tests
bash
cargo test -p xqgbc

Expected: all pass including the 3 new program/verifier tests.

  • Step 6: Commit
bash
git add xqgbc/
git commit -m "feat(xqgbc): .xqg binary program format and static verifier"

W2 ยท Part 1 -- `xqgb` Python Package: Decoder and `GateSolver` Base

Files:

  • Create: xqgb/pyproject.toml
  • Create: xqgb/__init__.py
  • Create: xqgb/decoder.py
  • Create: xqgb/solver.py
  • Create: xqgb/tests/__init__.py
  • Create: xqgb/tests/test_decoder.py
  • Modify: root pyproject.toml -- add xqgb to [tool.uv.workspace] members

Interfaces:

  • Produces: GateCircuit (Python dataclass), decode_xqg(data: bytes) -> GateCircuit, GateSolverResult, GateSolver ABC

  • Step 1: Write xqgb/tests/test_decoder.py (failing tests first)

text
/* Detailed source-code truncated for AI context efficiency. */

Run: cd /home/konrad/Quip/xquad && uv run pytest xqgb/tests/test_decoder.py โ†’ expected: import error.

  • Step 2: Write xqgb/pyproject.toml
toml
[build-system]
requires      = ["hatchling"]
build-backend = "hatchling.build"

[project]
name            = "xqgb"
version         = "0.1.0"
description     = "Gate-circuit backends for the XQuad toolchain (CPU sim, IBM Quantum, IonQ)."
requires-python = ">=3.13"
license         = { text = "AGPL-3.0-or-later" }
dependencies    = ["numpy>=2.0"]

[project.optional-dependencies]
ibm  = ["qiskit>=1.0", "qiskit-aer>=0.14", "qiskit-ibm-runtime>=0.20"]
ionq = ["ionq>=0.3"]

[tool.hatch.build.targets.wheel]
packages = ["."]
exclude  = ["tests/**", "pyproject.toml"]
  • Step 3: Add xqgb to root pyproject.toml workspace

Locate [tool.uv.workspace] in root pyproject.toml and add "xqgb":

toml
[tool.uv.workspace]
members = ["xqffi", "xqvm_py", "xqcp", "xqsa", "xquad", "xqgb"]

Also add "xqgb/tests" to [tool.pytest.ini_options] testpaths if that list exists.

  • Step 4: Write xqgb/decoder.py
text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 5: Write xqgb/solver.py
text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 6: Write xqgb/__init__.py
python
# Copyright (C) 2026 Postquant Labs Incorporated
# SPDX-License-Identifier: AGPL-3.0-or-later

"""XQuad gate-circuit backend package."""

from xqgb.decoder import GateCircuit, XqgHeader, RawGateInstruction, decode_xqg
from xqgb.solver import GateSolver, GateSolverResult

__all__ = [
    "GateCircuit",
    "XqgHeader",
    "RawGateInstruction",
    "decode_xqg",
    "GateSolver",
    "GateSolverResult",
]
  • Step 7: Run decoder tests
bash
cd /home/konrad/Quip/xquad && uv run pytest xqgb/tests/test_decoder.py -v

Expected: all 4 tests pass.

  • Step 8: Commit
bash
git add xqgb/ pyproject.toml
git commit -m "feat(xqgb): Python package scaffold with .xqg decoder and GateSolver ABC"

W2 ยท Part 2 -- `xqgb`: NumPy Statevector CPU Simulator

Files:

  • Create: xqgb/cpu_sim.py
  • Create: xqgb/tests/test_cpu_sim.py
  • Modify: xqgb/__init__.py

Interfaces:

  • Consumes: GateCircuit, GateSolver, GateSolverResult from W2 Part 1

  • Produces: SolverCPUSim(GateSolver) with run(circuit) -> GateSolverResult

  • Step 1: Write failing tests in xqgb/tests/test_cpu_sim.py

text
/* Detailed source-code truncated for AI context efficiency. */

Run: uv run pytest xqgb/tests/test_cpu_sim.py โ†’ expected: import error.

  • Step 2: Write xqgb/cpu_sim.py
text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 3: Run the CPU sim tests
bash
uv run pytest xqgb/tests/test_cpu_sim.py -v

Expected: all 5 tests pass.

  • Step 4: Commit
bash
git add xqgb/cpu_sim.py xqgb/tests/test_cpu_sim.py
git commit -m "feat(xqgb): NumPy statevector CPU simulator with full gate set"

W3 ยท Part 1 -- `xqgb`: Transpilers and IBM Quantum Backend

Files:

  • Create: xqgb/transpiler.py
  • Create: xqgb/ibm_quantum.py

Interfaces:

  • Consumes: GateCircuit, GateSolver, GateSolverResult from W2 Part 1

  • Produces: QiskitTranspiler, IonQTranspiler, SolverIBMQuantum(GateSolver)

  • Step 1: Write xqgb/transpiler.py

text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 2: Write xqgb/ibm_quantum.py
text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 3: Lint check
bash
uv run ruff check xqgb/transpiler.py xqgb/ibm_quantum.py

Expected: 0 issues.

  • Step 4: Commit
bash
git add xqgb/transpiler.py xqgb/ibm_quantum.py
git commit -m "feat(xqgb): Qiskit/IonQ transpilers and IBM Quantum backend"

W3 ยท Part 2 -- `xqgb`: IonQ Backend and Registry

Files:

  • Create: xqgb/ionq.py
  • Create: xqgb/registry.py
  • Create: xqgb/tests/test_registry.py
  • Modify: xqgb/__init__.py

Interfaces:

  • Consumes: all solver classes from W2 Part 2 and W3 Part 1, IonQTranspiler from W3 Part 1

  • Produces: SolverIonQ(GateSolver), GATE_SOLVERS dict, build_gate_solver(name, **kwargs) -> GateSolver

  • Step 1: Write failing tests in xqgb/tests/test_registry.py

python
"""Registry unit tests -- no cloud credentials needed."""

import pytest

from xqgb.registry import GATE_SOLVERS, DEFAULT_GATE_SOLVER, build_gate_solver
from xqgb.cpu_sim import SolverCPUSim


def test_all_solvers_registered():
    expected = {"cpu-sim", "ibm-aer", "ibm-quantum", "ionq-sim", "ionq"}
    assert expected == set(GATE_SOLVERS.keys())


def test_default_solver_is_cpu_sim():
    assert DEFAULT_GATE_SOLVER == "cpu-sim"


def test_build_cpu_sim():
    solver = build_gate_solver("cpu-sim", seed=42)
    assert isinstance(solver, SolverCPUSim)


def test_build_unknown_raises():
    with pytest.raises(ValueError, match="Unknown gate solver"):
        build_gate_solver("not-a-real-solver")

Run: uv run pytest xqgb/tests/test_registry.py โ†’ expected: import error.

  • Step 2: Write xqgb/ionq.py
text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 3: Write xqgb/registry.py
text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 4: Update xqgb/__init__.py to expose registry
python
# Copyright (C) 2026 Postquant Labs Incorporated
# SPDX-License-Identifier: AGPL-3.0-or-later

"""XQuad gate-circuit backend package."""

from xqgb.decoder import GateCircuit, XqgHeader, RawGateInstruction, decode_xqg
from xqgb.solver import GateSolver, GateSolverResult
from xqgb.registry import GATE_SOLVERS, DEFAULT_GATE_SOLVER, build_gate_solver

__all__ = [
    "GateCircuit",
    "XqgHeader",
    "RawGateInstruction",
    "decode_xqg",
    "GateSolver",
    "GateSolverResult",
    "GATE_SOLVERS",
    "DEFAULT_GATE_SOLVER",
    "build_gate_solver",
]
  • Step 5: Run all tests
bash
uv run pytest xqgb/ -v

Expected: all 12 tests pass (4 decoder + 5 CPU sim + 3 registry).

  • Step 6: Run Rust tests
bash
cargo test -p xqgbc

Expected: all pass.

  • Step 7: Lint
bash
uv run ruff check xqgb/
cargo clippy -p xqgbc -- -D warnings

Expected: 0 issues each.

  • Step 8: Commit
bash
git add xqgb/ionq.py xqgb/registry.py xqgb/tests/test_registry.py xqgb/__init__.py
git commit -m "feat(xqgb): IonQ backend and gate solver registry"

W4 -- Conformance Hardening: Gate ISA Parity

Files:

  • Create: conformance/gate_opcodes.yaml
  • Create: scripts/check-gate-opcode-parity.py
  • Create: scripts/gen-gate-vectors.py
  • Create: conformance/vectors/gate/ (generated then committed)
  • Modify: conformance/build.rs -- add gate vector category runner
  • Modify: conformance/Cargo.toml -- add xqgbc dependency
  • Modify: conformance/src/lib.rs -- add run_rust_gate and run_python_gate

Interfaces:

  • Consumes: xqgbc::codec::{encode, decode} from W1 Part 2; xqgb.decoder.decode_xqg from W2 Part 1

  • Produces: CI checks that fail if Rust and Python gate ISA tables or wire codecs diverge

  • Step 1: Write conformance/gate_opcodes.yaml

This is the canonical source of truth. Every gate opcode appears once; both the Rust parity check and Python parity check are validated against it.

text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 2: Run the parity check (will fail -- files not yet matched)
bash
uv run python scripts/check-gate-opcode-parity.py

Expected: ERROR -- Python _OPCODE_TABLE and/or Rust gate_opcodes! not yet validated against the YAML. This confirms the script works.

  • Step 3: Write scripts/check-gate-opcode-parity.py
text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 4: Run the parity check against live code
bash
uv run python scripts/check-gate-opcode-parity.py

Expected: OK -- 25 gate opcodes match across YAML, Rust, and Python.

Fix any differences reported before proceeding.

  • Step 5: Write scripts/gen-gate-vectors.py

This generates one codec vector per distinct operand shape (not per opcode -- that would be 25 near-identical files). Each vector is a minimal valid .xqg with sentinel operand values, encoded by Python to guarantee the expected bytes are independent of the Rust encoder.

text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 6: Generate and commit the vectors
bash
uv run python scripts/gen-gate-vectors.py

Expected output lists each generated directory. Verify the files look sane:

bash
ls conformance/vectors/gate/
xxd conformance/vectors/gate/codec_rx/circuit.xqg
# Should show: 58 51 47 42 (XQGB magic), then header bytes, then A0 02 40 00 00 00 81
cat conformance/vectors/gate/codec_rx/expected_parse.json

Commit:

bash
git add conformance/gate_opcodes.yaml conformance/vectors/gate/ scripts/
git commit -m "feat(conformance): gate_opcodes.yaml + codec/simulation conformance vectors"
  • Step 7: Extend conformance/Cargo.toml with xqgbc dep
toml
[dependencies]
xqvm.workspace   = true
xqasm.workspace  = true
xqgbc.workspace  = true          # add this line
serde            = { version = "1", features = ["derive"] }
serde_json       = "1"
tempfile         = "3"
  • Step 8: Add gate vector runners to conformance/src/lib.rs

Add these two functions at the bottom of conformance/src/lib.rs:

text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 9: Add xqgb decode CLI entry point to xqgb/__main__.py

The Python gate runner above calls python -m xqgb decode . Add this module:

python
# Copyright (C) 2026 Postquant Labs Incorporated
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Minimal CLI for conformance harness: `python -m xqgb decode <path.xqg>`."""

from __future__ import annotations

import json
import sys
from pathlib import Path

from xqgb.decoder import decode_xqg


def main() -> None:
    if len(sys.argv) < 3 or sys.argv[1] != "decode":
        print(
            "Usage: python -m xqgb decode <path.xqg>",
            file=sys.stderr,
        )
        sys.exit(2)

    data = Path(sys.argv[2]).read_bytes()
    circuit = decode_xqg(data)
    result = [
        {"mnemonic": instr.mnemonic, "operands": list(instr.operands)}
        for instr in circuit.instructions
    ]
    print(json.dumps(result))


if __name__ == "__main__":
    main()
  • Step 10: Extend conformance/build.rs to generate gate vector tests

In build.rs, the discover_vectors function already scans vectors/<category>/<name>/. Gate codec vectors are discovered automatically because they live under vectors/gate/. Add a secondary discovery pass for the expected_parse.json sentinel:

rust
// In discover_vectors(), change the required-files check to accept either
// the classic triple OR a gate codec vector:
if (dir.join("program.xqasm").exists()
    && dir.join("inputs.json").exists()
    && dir.join("expected.json").exists())
    || (dir.join("circuit.xqg").exists()
        && dir.join("expected_parse.json").exists())
{
    out.push((category.clone(), name));
}

And in write_test_file, dispatch on the vector kind:

rust
// Replace the single call to xquad_conformance::check_vector with:
writeln!(
    body,
    "#[test]\nfn {fn_name}() {{\n    xquad_conformance::check_any_vector(\"{category}\", \"{name}\", xquad_conformance::Impl::{imp});\n}}\n"
)

Add check_any_vector to lib.rs:

rust
/// Dispatch to the correct check function based on which files exist.
/// Classic vectors (program.xqasm + expected.json) use check_vector.
/// Gate codec vectors (circuit.xqg + expected_parse.json) use check_gate_codec_vector.
#[expect(clippy::panic, reason = "test-harness entry point")]
pub fn check_any_vector(category: &str, name: &str, imp: Impl) {
    let dir = conformance_root().join("vectors").join(category).join(name);
    if dir.join("circuit.xqg").exists() {
        check_gate_codec_vector(category, name, imp);
    } else {
        check_vector(category, name, imp);
    }
}
  • Step 11: Run the full conformance suite
bash
cargo test -p xquad-conformance

Expected: all prior vectors still pass, plus new vector_gate_codec_* tests appear and pass for both Rust and Python.

  • Step 12: Add parity check to CI

In .github/workflows/ (whichever workflow runs Python checks), add:

yaml
- name: Gate opcode parity
  run: uv run python scripts/check-gate-opcode-parity.py
  • Step 13: Commit
bash
git add \
  conformance/Cargo.toml \
  conformance/src/lib.rs \
  conformance/build.rs \
  xqgb/__main__.py \
  scripts/check-gate-opcode-parity.py \
  scripts/gen-gate-vectors.py
git commit -m "feat(conformance): gate ISA parity check + codec conformance harness"

Verification

End-to-end (CPU sim -- no cloud credentials needed)

python
import math, struct, zlib
from xqgb import decode_xqg, build_gate_solver

# Build a 2-qubit Bell circuit .xqg in memory
# Note: rotation gates (RX, RY, RZ, P, U, CP, RZZ, CRX) encode angles as
# u32 binary-angular units (full turn = 2^32). Helper for those:
#   def encode_angle(r): return struct.pack(">I", round(r % (2*math.pi) / (2*math.pi) * 4_294_967_296) & 0xFFFFFFFF)
# Bell circuit has no rotation gates, so no angle encoding needed here.

stream = (
    b"\x90\x00"          # H q=0
    b"\xB0\x00\x01"      # CNOT ctrl=0 tgt=1
    b"\x83\x00\x00"      # MEAS q=0 c=0
    b"\x83\x01\x01"      # MEAS q=1 c=1
    b"\x81"              # QHALT
)
crc = zlib.crc32(stream) & 0xFFFFFFFF
header = b"XQGB" + bytes([1, 2, 2]) + struct.pack(">I", 1024) + struct.pack(">I", crc)
data = header + stream

circuit = decode_xqg(data)
solver = build_gate_solver("cpu-sim", seed=42)
result = solver.run(circuit)

print(result.counts)          # e.g. {"00": 510, "11": 514}
assert set(result.counts) <= {"00", "11"}
assert result.total_shots == 1024
print("Bell state verification passed.")

Rust roundtrip

bash
cargo test -p xqgbc -- --nocapture

IBM Aer (requires `pip install xqgb[ibm]`)

python
from xqgb import decode_xqg, build_gate_solver
# same data bytes as above
circuit = decode_xqg(data)
solver = build_gate_solver("ibm-aer")
result = solver.run(circuit)
print(result.counts)
assert set(result.counts) <= {"00", "11"}

IonQ sim (requires `pip install xqgb[ionq]` and `IONQ_API_KEY`)

python
import os
os.environ["IONQ_API_KEY"] = "your-key-here"
solver = build_gate_solver("ionq-sim")
result = solver.run(circuit)
print(result.counts)

W5 -- Missing Gate Primitives: `RESET`, `CCX`, `CSWAP`

Background: Three standard OpenQASM 3 primitives are absent from the v1 gate table:
reset (qubit mid-circuit reset to |0โŸฉ), ccx (Toffoli / doubly-controlled-X), and
cswap (Fredkin / controlled-SWAP). Both IBM and IonQ support all three natively.
Without RESET, qubit reuse in error correction is impossible. Without CCX/CSWAP,
classical-reversible sub-circuits require decomposition into 1- and 2-qubit gates,
inflating circuit depth unnecessarily.

Partial BARRIER with a qubit list is deferred -- it requires variable-length operands,
which would break the codec invariant that the opcode byte alone determines operand width.
The global BARRIER (0x82) remains sufficient.

Opcode placement:

Byte Mnemonic Operands Group
0x88 RESET q: Qubit Circuit control (gap after CF opcodes)
0xB4 CCX ctrl0: Qubit, ctrl1: Qubit, tgt: Qubit Three-qubit fixed gates
0xB5 CSWAP ctrl: Qubit, q0: Qubit, q1: Qubit Three-qubit fixed gates

0xB4โ€“0xBF extends the existing two-qubit fixed-gate group (0xB0โ€“0xB3).

Files:

  • Modify: conformance/gate_opcodes.yaml -- 3 new entries
  • Modify: xqgbc/src/table.rs -- 3 entries in gate_opcodes!
  • Modify: xqgbc/src/types/opcode.rs -- 3 variants
  • Modify: xqgbc/src/types/instruction.rs -- 3 variants + opcode()/mnemonic() arms
  • Modify: xqgbc/src/codec.rs -- encode/decode arms
  • Modify: xqgbc/src/verifier.rs -- extend check_qubits for 3-qubit instructions
  • Modify: xqgb/decoder.py -- 3 entries in _OPCODE_TABLE ("qqq" / "qq" formats)
  • Modify: xqgb/cpu_sim.py -- RESET collapses statevector to |0โŸฉ on qubit q; CCX
    and CSWAP applied via _apply_three() using 8ร—8 unitaries
  • Modify: xqgb/transpiler.py -- add QiskitTranspiler and IonQTranspiler mappings

Interfaces:

  • Consumes: W1 (xqgbc), W2โ€“W3 (xqgb), W4 (conformance)
  • Produces: RESET/CCX/CSWAP in Rust codec and Python decoder; CPU sim _apply_three()
    helper for 8ร—8 unitaries; parity check updated to 28 opcodes (32 if W6 Part 1's four CF opcodes have already landed)

  • Step 1: Update conformance/gate_opcodes.yaml
yaml
- code: 0x88
    mnemonic: RESET
    operands:
      - {name: q, type: qubit, width: 1}

  - code: 0xB4
    mnemonic: CCX
    operands:
      - {name: ctrl0, type: qubit, width: 1}
      - {name: ctrl1, type: qubit, width: 1}
      - {name: tgt,   type: qubit, width: 1}

  - code: 0xB5
    mnemonic: CSWAP
    operands:
      - {name: ctrl, type: qubit, width: 1}
      - {name: q0,   type: qubit, width: 1}
      - {name: q1,   type: qubit, width: 1}
  • Step 2: Add entries to xqgbc/src/table.rs

After Barrier/Meas group, add RESET in the circuit-control section; add CCX/CSWAP after ISwap in the two-qubit fixed group:

rust
/// Reset qubit to |0โŸฉ (mid-circuit).
            #[gate(0x88, "RESET", 1)]
            Reset { q: $crate::Qubit },

            // (in two-qubit fixed group, after ISwap)
            /// Toffoli (doubly-controlled-X) gate.
            #[gate(0xB4, "CCX", 3)]
            Ccx { ctrl0: $crate::Qubit, ctrl1: $crate::Qubit, tgt: $crate::Qubit },

            /// Fredkin (controlled-SWAP) gate.
            #[gate(0xB5, "CSWAP", 3)]
            Cswap { ctrl: $crate::Qubit, q0: $crate::Qubit, q1: $crate::Qubit },
  • Step 3: Extend opcode.rs, instruction.rs, codec.rs following the same
    pattern as existing single- and two-qubit gates. check_qubits in verifier.rs gains
    two new arms returning &[ctrl0.0, ctrl1.0, tgt.0] and &[ctrl.0, q0.0, q1.0].

  • Step 4: Add _apply_three helper to xqgb/cpu_sim.py

python
def _apply_three(sv, n, q0, q1, q2, u):
    """Apply 8x8 unitary to (q0, q1, q2) in an n-qubit statevector."""
    sv = sv.reshape([2] * n)
    axes = [q0, q1, q2] + [i for i in range(n) if i not in (q0, q1, q2)]
    sv = sv.transpose(axes).reshape(8, -1)
    sv = u @ sv
    sv = sv.reshape([2, 2, 2] + [2] * (n - 3))
    inv_axes = [0] * n
    for new, old in enumerate(axes):
        inv_axes[old] = new
    return sv.transpose(inv_axes).reshape(-1)

RESET collapses the statevector: project onto q == 0, renormalise.

python
if m == "RESET":
    q = int(ops[0])
    sv = sv.reshape([2] * n_qubits)
    # Zero out all amplitudes where qubit q == 1
    idx = [slice(None)] * n_qubits
    idx[q] = 1
    sv[tuple(idx)] = 0.0
    sv = sv.reshape(-1)
    norm = np.sqrt((sv * sv.conj()).real.sum())
    if norm > 0:
        sv /= norm
    pos += 1
    continue
  • Step 5: Run tests and parity check
bash
cargo test -p xqgbc
uv run pytest xqgb/ -v
uv run python scripts/check-gate-opcode-parity.py
# Expected: OK -- N gate opcodes match, where N = 25 + 3 (W5) + any of W6 Part 1's 4 CF opcodes already landed
  • Step 6: Commit
bash
git commit -m "feat(xqgbc,xqgb): RESET, CCX (Toffoli), CSWAP (Fredkin) gate primitives"

W6 ยท Part 2 -- Wide-Form CF Jumps

Background: CF_JZ/CF_JNZ/CF_JUMP use u8 label IDs (max 255 distinct labels
per circuit). Circuits compiled from unrolled for loops or deep conditional chains can
exceed this limit. Wide-form counterparts with u16 IDs follow the Jump1/Jump2
precedent from the QUBO ISA exactly, and require no header changes -- the 15-byte header
is kept intact, matching the QUBO format's unchanging 15-byte structure.

The header CRC remains stream-only (consistent with the QUBO format's design: header
fields are validated structurally by the verifier, not by the checksum). No feature-flags
byte is added; format evolution is handled by version bumps.

Wide-form CF opcodes (in gap 0x8Aโ€“0x8C):

Byte Mnemonic Operands
0x8A CF_JUMP2 id: u16
0x8B CF_JZ2 c: Cbit, id: u16
0x8C CF_JNZ2 c: Cbit, id: u16

Files:

  • Modify: conformance/gate_opcodes.yaml -- 3 new CF wide-form entries (type label16, width 2)
  • Modify: xqgbc/src/table.rs -- 3 new CF wide-form entries in gate_opcodes!
  • Modify: xqgbc/src/types/opcode.rs -- 3 new variants
  • Modify: xqgbc/src/types/instruction.rs -- 3 new variants
  • Modify: xqgbc/src/codec.rs -- encode/decode arms (u16 already has EncodeOperand/DecodeOperand)
  • Modify: xqgbc/src/verifier.rs -- wide-form jumps participate in label-consistency check
  • Modify: xqgb/decoder.py -- _OPCODE_TABLE entries; 'L' format char for u16 labels
  • Modify: xqgb/cpu_sim.py -- handle CF_JUMP2/CF_JZ2/CF_JNZ2 in dispatch loop

Interfaces:

  • Consumes: W1, W4, W6 Part 1
  • Produces: 3 wide-form CF opcodes; 'L' format char in _OPCODE_TABLE decoder

  • Step 1: Add 3 entries to conformance/gate_opcodes.yaml
yaml
- code: 0x8A
    mnemonic: CF_JUMP2
    operands:
      - {name: id, type: label16, width: 2}

  - code: 0x8B
    mnemonic: CF_JZ2
    operands:
      - {name: c,  type: cbit,    width: 1}
      - {name: id, type: label16, width: 2}

  - code: 0x8C
    mnemonic: CF_JNZ2
    operands:
      - {name: c,  type: cbit,    width: 1}
      - {name: id, type: label16, width: 2}

Also add label16 to the type maps in check-gate-opcode-parity.py.

  • Step 2: Add entries to xqgbc/src/table.rs
rust
#[gate(0x8A, "CF_JUMP2", 0)]
            CfJump2 { id: u16 },
            #[gate(0x8B, "CF_JZ2", 0)]
            CfJz2 { c: $crate::Cbit, id: u16 },
            #[gate(0x8C, "CF_JNZ2", 0)]
            CfJnz2 { c: $crate::Cbit, id: u16 },
  • Step 3: Extend xqgbc/src/verifier.rs -- the label-consistency check already
    uses pattern matching; extend all three match arms to also cover the 2 variants:
rust
let referenced = match instr {
                GateInstruction::CfJump  { id }
                | GateInstruction::CfJump2 { id }
                | GateInstruction::CfJz   { id, .. }
                | GateInstruction::CfJz2  { id, .. }
                | GateInstruction::CfJnz  { id, .. }
                | GateInstruction::CfJnz2 { id, .. } => Some(*id as u16),
                _ => None,
            };

Note: CF_TARGET label IDs remain u8 (the target declaration), but CF_JUMP2 can
reference any u16. This means a CF_JUMP2 referencing id > 255 will always fail the
consistency check -- which is correct since no CF_TARGET can have id > 255. Wide-form
is useful only when you have many distinct labels, not larger individual label IDs. If
label IDs > 255 are needed, CF_TARGET2 would be required (deferred).

  • Step 4: Update xqgb/decoder.py -- add 'L' format character for u16
    label operands and add 3 opcode entries:
python
elif char == "L":   # u16 label (wide-form CF)
    if pos + consumed + 2 > len(stream):
        raise ValueError(f"Stream truncated reading u16 label at offset {pos + consumed}")
    (val,) = struct.unpack_from(">H", stream, pos + consumed)
    values.append(val)
    consumed += 2
python
0x8A: ("CF_JUMP2", "L"),
    0x8B: ("CF_JZ2",   "cL"),
    0x8C: ("CF_JNZ2",  "cL"),
  • Step 5: Extend xqgb/cpu_sim.py -- in the CF dispatch block:
python
if m in ("CF_JUMP2", "CF_JUMP"):
    pos = _find_label(instructions, ops[0])
    continue
if m in ("CF_JZ2", "CF_JZ"):
    pos = _find_label(instructions, ops[1]) if cbits[ops[0]] == 0 else pos + 1
    continue
if m in ("CF_JNZ2", "CF_JNZ"):
    pos = _find_label(instructions, ops[1]) if cbits[ops[0]] != 0 else pos + 1
    continue
  • Step 6: Write tests -- encode a circuit with 260 CF_TARGET + CF_JUMP2
    labels (impossible with u8), round-trip through Rust codec, verify Python decoder
    reconstructs all jump targets correctly.

  • Step 7: Run suite and parity check

bash
cargo test -p xqgbc && uv run pytest xqgb/ -v
uv run python scripts/check-gate-opcode-parity.py
# Expected: OK -- 35 gate opcodes match (32 = W1 + W5 + W6 Part 1, plus 3 wide-form CF; counts assume that landing order)
  • Step 8: Commit
bash
git commit -m "feat(xqgbc,xqgb): wide-form CF_JUMP2/JZ2/JNZ2 with u16 label IDs"

W7 -- Parameterized Circuits

Background: VQE, QAOA, and similar variational algorithms execute the same circuit
structure hundreds or thousands of times with different rotation angles. Currently every
parameter sweep requires re-encoding the full .xqg file.

Design: Format version 2 adds a parameter preamble immediately after the 15-byte
header. The header itself is unchanged (same magic, same field layout as v1). The version
byte distinguishes the two:

text
v1 file (15 bytes header, then instruction stream):
  [15-byte XQGB header, version=1] [instruction stream]

v2 file (15 bytes header + parameter preamble, then instruction stream):
  [15-byte XQGB header, version=2] [n_params: u8] [u32 BE ร— n_params] [instruction stream]

The CRC in the header covers the stream bytes only (i.e. the instruction stream excluding
the parameter preamble). The parameter preamble is validated structurally: the decoder
reads n_params and then exactly 4 ร— n_params bytes before handing the stream to the
instruction decoder.

Angle operands that reference a parameter slot use the high bit of their u32 value as
a flag: bit 31 = 0 โ†’ literal value; bit 31 = 1 โ†’ parameter-slot reference
(bits 0โ€“7 = slot index). In v2 files a literal angle therefore has 31 bits of precision
and a full turn is 2^31 units (v1: 2^32). Without this change every literal angle of pi
or more would set bit 31 and be misread as a slot reference. The two versions are not
interchangeable: a v1 decoder reading a v2 file misreads every angle, so decoders must
reject unknown versions rather than guess, and angle-to-radian conversion must key off
the file version.

Files:

  • Modify: xqgbc/src/program.rs -- add params: Vec<GateAngle> to GateCircuit;
    encode v2 preamble when params is non-empty; decode v2 preamble when version == 2;
    add GateAngle::is_param_ref(), GateAngle::param_slot(), GateAngle::from_param_slot()
  • Modify: xqgbc/src/verifier.rs -- every is_param_ref() angle must have slot index < n_params
  • Modify: xqgb/decoder.py -- read v2 preamble when data[4] == 2; expose circuit.params;
    add circuit.bind(values: list[float]) -> GateCircuit
  • Modify: xqgb/solver.py -- GateSolver.run() gains optional params: list[float] kwarg

Interfaces:

  • Consumes: W1 (codec, program format); W6 Part 2 (no dependency -- header unchanged)
  • Produces: GateCircuit.params, GateCircuit.bind(), GateSolver.run(params=...)

  • Step 1: Add is_param_ref and param_slot to GateAngle in
    xqgbc/src/types/operand.rs:
rust
impl GateAngle {
    /// True when this value encodes a parameter-slot reference (bit 31 set).
    pub fn is_param_ref(self) -> bool { self.0 & 0x8000_0000 != 0 }

    /// The slot index when `is_param_ref()` is true.
    pub fn param_slot(self) -> u8 { (self.0 & 0xFF) as u8 }

    /// Construct a parameter-slot reference.
    pub fn from_param_slot(slot: u8) -> Self { Self(0x8000_0000 | slot as u32) }
}
  • Step 2: Extend GateCircuit::encode() -- if self.params is non-empty, set
    version byte = 2 and emit preamble (n_params: u8, then each param as u32 BE) before
    the instruction stream. CRC is still computed over the instruction stream bytes only.

GateCircuit::decode() branches on bytes[4]: 1 โ†’ stream starts at byte 15; 2 โ†’ read
preamble starting at byte 15, stream starts at 15 + 1 + 4 * n_params.

  • Step 3: Extend xqgbc/src/verifier.rs -- after the qubit/cbit bounds check
    loop, add a second pass: every angle operand with is_param_ref() set must have
    param_slot() < circuit.params().len().

  • Step 4: Implement bind() in xqgb/decoder.py

python
def bind(self, values: list[float]) -> "GateCircuit":
    """Return a new GateCircuit with all parameter references replaced by concrete angles."""
    _PARAM_FLAG = 0x8000_0000
    if len(values) != len(self.params):
        raise ValueError(f"expected {len(self.params)} parameter values, got {len(values)}")

    def resolve(op):
        if isinstance(op, int) and op & _PARAM_FLAG:
            slot = op & 0xFF
            # v2 literal angles are 31-bit: full turn = 2^31 units.
            return round(values[slot] % (2 * math.pi) / (2 * math.pi) * 2_147_483_648) & 0x7FFF_FFFF
        return op
    new_instrs = [
        RawGateInstruction(i.mnemonic, tuple(resolve(o) for o in i.operands))
        for i in self.instructions
    ]
    # Bound circuits carry no preamble; re-encoding emits a v2 file with n_params = 0.
    return GateCircuit(header=self.header, params=[], instructions=new_instrs)
  • Step 5: Write tests -- a v2 parameterized RX circuit with ฮธ = slot 0,
    default 0. Encode, round-trip decode, bind ฮธ = ฯ€, assert same distribution as literal
    RX(ฯ€). Also: v1 files still decode identically, a v2 literal RX(3*pi/2) round-trips as a literal (not a slot reference), and bind() with the wrong number of values raises ValueError.

  • Step 6: Commit

bash
git commit -m "feat(xqgbc,xqgb): v2 format -- parameterized circuits with preamble parameter table"

W6 ยท Part 3 -- Classical Integer Registers

Background: The CF opcodes (CF_JZ/CF_JNZ) can only branch on a single Cbit
(one measurement bit). OpenQASM 3 also supports int classical registers that accumulate
values, support arithmetic, and can be compared in multi-bit conditions
(if (syndrome == 3)). Without this, multi-qubit error syndrome decoding must happen
off-chip.

Design: A classical register file of 8 u16 slots (CR0โ€“CR7). Format version 3
adds an n_cregs: u8 preamble byte immediately after the parameter preamble (or directly
after the 15-byte header if no parameter preamble is present). The 15-byte header is
unchanged; the version byte (= 3) signals the preamble. New opcode group 0xE0โ€“0xEF:

Byte Mnemonic Operands Description
0xE0 CSET reg: u8, val: u8 Load 8-bit immediate into classical register
0xE1 CADD dst: u8, src: u8 CR[dst] += CR[src]
0xE2 CSUB dst: u8, src: u8 CR[dst] -= CR[src]
0xE3 CINC reg: u8 CR[reg] += 1
0xE4 CDEC reg: u8 CR[reg] -= 1
0xE5 CMBIT reg: u8, c: Cbit Copy Cbit value into CR[reg] bit 0
0xE6 CJEQ r0: u8, r1: u8, id: u8 Jump to label if CR[r0] == CR[r1]
0xE7 CJLT r0: u8, r1: u8, id: u8 Jump to label if CR[r0] < CR[r1]
0xE8 CJGT r0: u8, r1: u8, id: u8 Jump to label if CR[r0] > CR[r1]

CF_LOOP/CF_NEXT (deliberately omitted from W6 Part 1) can now be expressed as
CSET + CF_TARGET + (body) + CDEC + CJGT reg 0 label, which is explicit and
auditable.

CMBIT bridges the Cbit/classical-register boundary: after a MEAS, copy the result
into a register accumulator for syndrome arithmetic.

Files:

  • Modify: conformance/gate_opcodes.yaml -- 9 new entries (type creg, width 1)
  • Modify: xqgbc/src/table.rs, opcode.rs, instruction.rs, codec.rs -- 9 new variants
  • Modify: xqgbc/src/verifier.rs -- CJEQ/CJLT/CJGT participate in label-consistency
    check; CSET/CMBIT register-index bounds check (< n_cregs)
  • Modify: xqgb/decoder.py -- 9 entries with 'r' format char for creg operands
  • Modify: xqgb/cpu_sim.py -- add cregs = [0] * 8 to simulate(); dispatch all C*
    opcodes; CJEQ/CJLT/CJGT call _find_label
  • No transpiler changes -- backends receive the classical registers as opaque; IBM and IonQ
    do not support this natively and QiskitTranspiler/IonQTranspiler should raise
    ValueError on any 0xE* opcode

Interfaces:

  • Consumes: W6 Part 1 (CF label infrastructure), W6 Part 2 (header v2 flags/n_cregs)
  • Produces: 8-slot u16 classical register file; arithmetic/comparison opcodes; loop
    counter idiom via CSET+CDEC+CJGT; CMBIT bridge from Cbit to register

  • Step 1: Implement version 3 preamble -- GateCircuit::encode() emits version = 3
    when n_cregs > 0. The preamble layout after the 15-byte header: optional param block
    (if n_params > 0), then n_cregs: u8. GateCircuit::decode() branches on version:
    1 โ†’ stream only; 2 โ†’ param preamble + stream; 3 โ†’ param preamble + n_cregs byte + stream.

  • Step 2: Add 9 entries to xqgbc/src/table.rs in a new 0xE0โ€“0xE8 group.
    u8 operands already have EncodeOperand/DecodeOperand; no new types needed.

  • Step 3: Extend xqgbc/src/verifier.rs

    • CSET/CMBIT/CINC/CDEC/CADD/CSUB: check all reg operands < n_cregs
    • CJEQ/CJLT/CJGT: check reg operands + check id is in declared_labels
  • Step 4: Extend xqgb/cpu_sim.py simulate() loop

python
cregs = [0] * 8   # u16 classical registers

# In dispatch:
if m == "CSET":  cregs[ops[0]] = ops[1]; pos += 1; continue
if m == "CADD":  cregs[ops[0]] = (cregs[ops[0]] + cregs[ops[1]]) & 0xFFFF; pos += 1; continue
if m == "CSUB":  cregs[ops[0]] = (cregs[ops[0]] - cregs[ops[1]]) & 0xFFFF; pos += 1; continue
if m == "CINC":  cregs[ops[0]] = (cregs[ops[0]] + 1) & 0xFFFF; pos += 1; continue
if m == "CDEC":  cregs[ops[0]] = (cregs[ops[0]] - 1) & 0xFFFF; pos += 1; continue
if m == "CMBIT": cregs[ops[0]] = cbits[ops[1]]; pos += 1; continue
if m == "CJEQ":  pos = _find_label(instructions, ops[2]) if cregs[ops[0]] == cregs[ops[1]] else pos + 1; continue
if m == "CJLT":  pos = _find_label(instructions, ops[2]) if cregs[ops[0]] <  cregs[ops[1]] else pos + 1; continue
if m == "CJGT":  pos = _find_label(instructions, ops[2]) if cregs[ops[0]] >  cregs[ops[1]] else pos + 1; continue
  • Step 5: Write tests -- a syndrome accumulation circuit: measure 3 qubits into
    cbits, use CMBIT to accumulate into CR0, branch on CJEQ CR0 CR1 to apply a
    correction gate. Verify CPU sim produces the expected counts.

  • Step 6: Run full suite

bash
cargo test -p xqgbc && uv run pytest xqgb/ -v
uv run python scripts/check-gate-opcode-parity.py
# Expected: OK -- 44 gate opcodes match
  • Step 7: Commit
bash
git commit -m "feat(xqgbc,xqgb): classical integer register file (CSET/CADD/CINC/CMBIT/CJEQโ€ฆ)"

W6 ยท Part 1 -- Classical Control Flow: `if`/`else` via Flat Jumps

Background: OpenQASM 3 adds classical control flow that conditions gate application on
bit register values (measurement results). The only construct requiring genuine runtime
hardware support is if/else -- for and while are always unrolled by the compiler
before encoding, and break/continue resolve to unconditional jumps within the
unrolled body.

Design: Four opcodes in the gap 0x84โ€“0x87, mirroring the TARGET/JUMP1/JUMPI1
pattern from the QUBO ISA. CF_TARGET is a passive label marker -- the verifier collects
all declared IDs and checks that every jump references one, exactly as TARGET does in
xqvm. The executor and transpiler advance past CF_TARGET without action.

text
0x84  CF_TARGET  { id: u8 }              -- passive jump-destination label
0x85  CF_JUMP    { id: u8 }              -- unconditional jump to label
0x86  CF_JZ      { c: Cbit, id: u8 }    -- jump to label if cbit c == 0
0x87  CF_JNZ     { c: Cbit, id: u8 }    -- jump to label if cbit c == 1

How OpenQASM 3 constructs lower to these (compiler responsibility):

text
if (c[0] == 1) { X q[1]; }
โ†’  CF_JZ  c=0 id=1
   X q=1
   CF_TARGET id=1

if (c[0] == 1) { X q[1]; } else { H q[1]; }
โ†’  CF_JZ  c=0 id=1
   X q=1
   CF_JUMP id=2
   CF_TARGET id=1
   H q=1
   CF_TARGET id=2

for i in [0:N] { G; }   โ†’  G ยท G ยท โ€ฆ (N copies; unrolled before encoding)
while (c) { โ€ฆ }         โ†’  compile error; no hardware runtime support
break                   โ†’  CF_JUMP <loop_exit_label>  (resolved by compiler)
continue                โ†’  CF_JUMP <loop_top_label>   (resolved by compiler)

Files:

  • Modify: conformance/gate_opcodes.yaml -- add 4 CF entries
  • Modify: xqgbc/src/table.rs -- add 4 entries to gate_opcodes!
  • Modify: xqgbc/src/types/opcode.rs -- add 4 variants to GateOpcode
  • Modify: xqgbc/src/types/instruction.rs -- add 4 variants to GateInstruction
  • Modify: xqgbc/src/codec.rs -- extend encode/decode match arms
  • Modify: xqgbc/src/verifier.rs -- add label-consistency pass (mirrors XQVM)
  • Modify: xqgb/decoder.py -- add 4 entries to _OPCODE_TABLE
  • Modify: xqgb/cpu_sim.py -- index-based loop + cbit register + forward label scan
  • Modify: xqgb/transpiler.py -- forward-scan body for QiskitTranspiler; raise for IonQ

Interfaces:

  • Consumes: all of W1โ€“W4
  • Produces: CF_TARGET/CF_JUMP/CF_JZ/CF_JNZ in Rust codec and Python decoder;
    verifier label-consistency check; CPU sim branches on Cbit values; Qiskit transpiler
    collects body by scanning forward to the next CF_TARGET

  • Step 1: Update conformance/gate_opcodes.yaml

Append after the CRX entry. Note the new operand type label (width 1, encodes as
u8); add it to check-gate-opcode-parity.py's _PY_FORMAT_WIDTHS and
_RUST_TYPE_WIDTH maps as an alias for a raw u8:

yaml
# -------------------------------------------------------------------------
  # Classical control flow  (0x84โ€“0x87; gap between circuit-control and H)
  # Mirrors TARGET/JUMP1/JUMPI1 from the QUBO ISA.
  # -------------------------------------------------------------------------
  - code: 0x84
    mnemonic: CF_TARGET
    operands:
      - {name: id, type: label, width: 1}

  - code: 0x85
    mnemonic: CF_JUMP
    operands:
      - {name: id, type: label, width: 1}

  - code: 0x86
    mnemonic: CF_JZ
    operands:
      - {name: c,  type: cbit,  width: 1}
      - {name: id, type: label, width: 1}

  - code: 0x87
    mnemonic: CF_JNZ
    operands:
      - {name: c,  type: cbit,  width: 1}
      - {name: id, type: label, width: 1}

  • Step 2: Write failing tests in xqgbc/tests/roundtrip.rs (append)
text
/* Detailed source-code truncated for AI context efficiency. */

Run: cargo test -p xqgbc โ†’ expected: compile error (variants not yet defined).


  • Step 3: Add 4 entries to xqgbc/src/table.rs

Inside gate_opcodes!, after the Meas entry (0x83) and before H (0x90):

rust
// --- Classical control flow (mirrors QUBO ISA TARGET/JUMP/JUMPI) ---
            /// Passive jump-destination label; no runtime effect.
            #[gate(0x84, "CF_TARGET", 0)]
            CfTarget { id: u8 },

            /// Unconditional jump to label.
            #[gate(0x85, "CF_JUMP", 0)]
            CfJump { id: u8 },

            /// Jump to label if classical bit c is 0.
            #[gate(0x86, "CF_JZ", 0)]
            CfJz { c: $crate::Cbit, id: u8 },

            /// Jump to label if classical bit c is 1.
            #[gate(0x87, "CF_JNZ", 0)]
            CfJnz { c: $crate::Cbit, id: u8 },

qubit_arity is 0 for all four -- check_qubits() already ignores them via the _ => &[] fallthrough.


  • Step 4: Extend xqgbc/src/types/opcode.rs

Add to the GateOpcode enum:

rust
CfTarget = 0x84,
    CfJump   = 0x85,
    CfJz     = 0x86,
    CfJnz    = 0x87,

Add to TryFrom:

rust
0x84 => Ok(Self::CfTarget),
    0x85 => Ok(Self::CfJump),
    0x86 => Ok(Self::CfJz),
    0x87 => Ok(Self::CfJnz),

  • Step 5: Extend xqgbc/src/types/instruction.rs

Add variants to GateInstruction:

rust
CfTarget { id: u8 },
    CfJump   { id: u8 },
    CfJz     { c: Cbit, id: u8 },
    CfJnz    { c: Cbit, id: u8 },

Extend opcode():

rust
Self::CfTarget { .. } => GateOpcode::CfTarget,
    Self::CfJump   { .. } => GateOpcode::CfJump,
    Self::CfJz     { .. } => GateOpcode::CfJz,
    Self::CfJnz    { .. } => GateOpcode::CfJnz,

Extend mnemonic():

rust
GateOpcode::CfTarget => "CF_TARGET",
    GateOpcode::CfJump   => "CF_JUMP",
    GateOpcode::CfJz     => "CF_JZ",
    GateOpcode::CfJnz    => "CF_JNZ",

  • Step 6: Extend xqgbc/src/codec.rs

In encode():

rust
GateInstruction::CfTarget { id }    => { id.encode_into(&mut buf); }
GateInstruction::CfJump   { id }    => { id.encode_into(&mut buf); }
GateInstruction::CfJz  { c, id }    => { c.encode_into(&mut buf); id.encode_into(&mut buf); }
GateInstruction::CfJnz { c, id }    => { c.encode_into(&mut buf); id.encode_into(&mut buf); }

In decode():

rust
GateOpcode::CfTarget => GateInstruction::CfTarget { id: read!(u8) },
GateOpcode::CfJump   => GateInstruction::CfJump   { id: read!(u8) },
GateOpcode::CfJz     => GateInstruction::CfJz  { c: read!(Cbit), id: read!(u8) },
GateOpcode::CfJnz    => GateInstruction::CfJnz { c: read!(Cbit), id: read!(u8) },

u8 already implements EncodeOperand/DecodeOperand.


  • Step 7: Extend xqgbc/src/verifier.rs -- label-consistency pass

Add a second pass after the existing qubit/cbit bounds check. Structure mirrors XQVM's
verifier: collect declared labels first, then verify every jump references one.

rust
// Collect all declared CF_TARGET label IDs.
let declared_labels: std::collections::HashSet<u8> = instrs
    .iter()
    .filter_map(|i| {
        if let GateInstruction::CfTarget { id } = i { Some(*id) } else { None }
    })
    .collect();

// Every jump must reference a declared CF_TARGET.
for instr in instrs {
    let referenced = match instr {
        GateInstruction::CfJump { id }
        | GateInstruction::CfJz  { id, .. }
        | GateInstruction::CfJnz { id, .. } => Some(*id),
        _ => None,
    };
    if let Some(id) = referenced {
        if !declared_labels.contains(&id) {
            return Err(GateVerifyError::UndeclaredLabel { id });
        }
    }
}

Add the new error variant to GateVerifyError:

rust
#[error("jump references undeclared CF_TARGET label id {id}")]
UndeclaredLabel { id: u8 },

  • Step 8: Run Rust tests
bash
cargo test -p xqgbc

Expected: all prior tests pass plus the 6 new CF roundtrip and verifier tests.


  • Step 9: Update xqgb/decoder.py _OPCODE_TABLE

Add a 'l' format character for label/u8 operands (distinct from 'q' and 'c'
semantically; decoded identically as a raw u8):

python
0x84: ("CF_TARGET", "l"),
    0x85: ("CF_JUMP",   "l"),
    0x86: ("CF_JZ",     "cl"),
    0x87: ("CF_JNZ",    "cl"),

Add 'l' to _decode_operands:

python
elif char == "l":
            if pos + consumed >= len(stream):
                raise ValueError(
                    f"Truncated label operand for opcode 0x{opcode:02X} "
                    f"at offset {pos + consumed}"
                )
            values.append(stream[pos + consumed])
            consumed += 1

Add 'l' to _PY_FORMAT_WIDTHS in check-gate-opcode-parity.py:

python
_PY_FORMAT_WIDTHS = {
    "q": ("qubit", 1), "c": ("cbit", 1), "a": ("angle", 4), "l": ("label", 1)
}

Also add "label" to _RUST_TYPE_WIDTH and _RUST_TYPE_NAME (mapped from u8):

python
_RUST_TYPE_WIDTH = {"Qubit": 1, "Cbit": 1, "GateAngle": 4, "u8": 1}
_RUST_TYPE_NAME  = {"Qubit": "qubit", "Cbit": "cbit", "GateAngle": "angle", "u8": "label"}

  • Step 10: Update xqgb/cpu_sim.py

Replace the for instr in instructions loop in simulate() with an index-based loop
that maintains a cbits array and branches on label lookup. CF_TARGET is a no-op
(executor advances past it, same as XQVM's TARGET).

python
def simulate(self, n_qubits, n_cbits, n_shots, instructions):
    sv = np.zeros(2**n_qubits, dtype=np.complex128)
    sv[0] = 1.0
    cbits = [0] * n_cbits   # updated by MEAS during execution
    meas_map: list[tuple[int, int]] = []

    pos = 0
    while pos < len(instructions):
        instr = instructions[pos]
        m = instr.mnemonic
        ops = instr.operands

        if m in ("QNOP", "BARRIER", "QHALT", "CF_TARGET"):
            pos += 1
            continue
        if m == "MEAS":
            meas_map.append((ops[0], ops[1]))
            pos += 1
            continue
        if m == "CF_JUMP":
            pos = _find_label(instructions, ops[0])
            continue
        if m == "CF_JZ":
            pos = _find_label(instructions, ops[1]) if cbits[ops[0]] == 0 else pos + 1
            continue
        if m == "CF_JNZ":
            pos = _find_label(instructions, ops[1]) if cbits[ops[0]] != 0 else pos + 1
            continue

        sv = self._apply(sv, n_qubits, instr)
        pos += 1

    probs = (sv * sv.conj()).real
    probs = np.maximum(probs, 0.0)
    probs /= probs.sum()
    sampled = self._rng.choice(2**n_qubits, size=n_shots, p=probs)
    return self._build_histogram(sampled, meas_map, n_cbits)

Add the label-lookup helper (O(n) scan; acceptable since CPU sim is PoC-only):

python
def _find_label(instructions: list, label_id: int) -> int:
    """Return the index of CF_TARGET with the given id."""
    for i, instr in enumerate(instructions):
        if instr.mnemonic == "CF_TARGET" and instr.operands[0] == label_id:
            return i
    raise ValueError(f"CF_TARGET id={label_id} not found -- verifier should have caught this")

  • Step 11: Update xqgb/transpiler.py

In QiskitTranspiler.transpile(), handle CF opcodes before the generic _QISKIT_MAP
lookup. CF_TARGET is skipped (no Qiskit equivalent needed). For CF_JZ/CF_JNZ,
collect the body by scanning forward to the matching CF_TARGET, then emit using
if_test:

python
if instr.mnemonic == "CF_TARGET":
                continue
            if instr.mnemonic == "CF_JUMP":
                # unconditional jump: only legal as else-skip in compiled if/else;
                # the surrounding if_test context handles this implicitly -- skip.
                continue
            if instr.mnemonic in ("CF_JZ", "CF_JNZ"):
                c_idx, label_id = instr.operands[0], instr.operands[1]
                # Collect body: instructions between here and CF_TARGET label_id.
                body, else_body = _collect_cf_body(circuit.instructions, idx, label_id)
                creg = qc.cregs[0]
                condition_val = 0 if instr.mnemonic == "CF_JZ" else 1
                with qc.if_test((creg[c_idx], condition_val)) as else_ctx:
                    for body_instr in body:
                        _emit_single(qc, body_instr)
                    if else_body:
                        with else_ctx:
                            for body_instr in else_body:
                                _emit_single(qc, body_instr)
                # Advance idx past the whole if/else block.
                idx = _find_cf_target(circuit.instructions, label_id)
                continue

Add helpers _collect_cf_body and _find_cf_target as module-level functions. Their
implementation is straightforward forward scans; they are not shown here to keep the plan
concise -- implement them alongside the transpiler update.

In IonQTranspiler.transpile(), raise on any CF opcode -- IonQ requires full unrolling:

python
if instr.mnemonic.startswith("CF_"):
                raise ValueError(
                    f"IonQ does not support runtime classical control. "
                    f"Unroll {instr.mnemonic!r} before encoding to .xqg."
                )

  • Step 12: Write Python tests in xqgb/tests/test_cf.py
text
/* Detailed source-code truncated for AI context efficiency. */

Run: uv run pytest xqgb/tests/test_cf.py -v

Expected: all 4 tests pass.


  • Step 13: Run the full test suite and parity check
bash
cargo test -p xqgbc
uv run pytest xqgb/ -v
uv run ruff check xqgb/
cargo clippy -p xqgbc -- -D warnings
uv run python scripts/check-gate-opcode-parity.py

Expected: 0 failures, 0 warnings, parity check prints OK -- 29 gate opcodes match.


  • Step 14: Commit
bash
git add \
  conformance/gate_opcodes.yaml \
  xqgbc/src/table.rs \
  xqgbc/src/types/opcode.rs \
  xqgbc/src/types/instruction.rs \
  xqgbc/src/codec.rs \
  xqgbc/src/verifier.rs \
  xqgb/decoder.py \
  xqgb/cpu_sim.py \
  xqgb/transpiler.py \
  xqgb/tests/test_cf.py \
  scripts/check-gate-opcode-parity.py
git commit -m "feat(xqgbc,xqgb): classical control-flow opcodes CF_TARGET/JUMP/JZ/JNZ"

Design Plans 2026 07 13 Qui 167 Scale Sweep (`docs/design/plans/2026-07-13-qui-167-scale-sweep.md`)

QUI-167 Scale Sweep Harness Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build scripts/scale_sweep.py, a two-mode harness that runs the shipped MaxCut/TSP example encodings at growing sizes across VM backends and SA solvers, recording per-stage timing, memory, correctness, and parity โ€” then run it and report findings to QUI-167.

Architecture: One self-contained script. Orchestrator mode builds a pruned (problem ร— backend ร— solver ร— n ร— seed) matrix and spawns itself in point mode as a child process per point (clean peak-RSS, crash isolation). Point mode re-runs the example's exact compile โ†’ encode โ†’ solve โ†’ verify โ†’ decode sequence with per-stage timers. Pure analysis/report helpers get a small pytest file.

Tech Stack: Python 3.13 (uv-managed venv), xquad workspace packages (xquad.cp/.sa/.vm/.types), importlib loading of examples/*/runner.py, subprocess, resource. No new dependencies.

Spec: docs/design/specs/2026-07-13-qui-167-scale-sweep-design.md (approved 2026-07-13).

Global Constraints

  • Branch feature/qui-167 (worktree at .claude/worktrees/feature+qui-167); never touch release/v0.3.0.
  • Run everything with uv run --no-sync (plain uv run reverts the maturin-built xqffi).
  • Solver names are exactly dwave-cpu and cuda-gpu (keys of xquad.sa.SOLVERS).
  • CPU lanes: both VM backends. GPU lanes: Rust VM only.
  • Ladders โ€” CPU: MaxCut 8,16,32,64,128,256,512, TSP 4,6,8,12,16,24,32,48. GPU adds MaxCut 1024, TSP 64.
  • Seeds 42,43,44; per-point timeout default 300 s; a lane stops escalating at the first non-ok point.
  • The orchestrator never aborts the sweep on a lane failure; every outcome becomes a JSONL record.
  • โ‰ค100 lines/function, โ‰ค5 positional params, 100-char lines, absolute imports, ruff clean (make lint-python equivalent: uv run --no-sync ruff check scripts/).
  • Commit after each task (imperative, โ‰ค72-char subject, no co-author bylines).

File Structure

  • Create scripts/scale_sweep.py โ€” the harness (adapters, point mode, orchestrator, analysis, report, CLI).
  • Create scripts/tests/test_scale_sweep.py โ€” pytest for the pure helpers (matrix building, structural checks, analysis, report rendering). Measurement plumbing (subprocess, GPU) is verified by running the harness, not mocked.

Task 1: Adapter registry and example-module loader

Files:

  • Create: scripts/scale_sweep.py
  • Create: scripts/tests/test_scale_sweep.py

Interfaces:

  • Produces: load_runner(example: str) -> ModuleType; Adapter frozen dataclass with fields name, ladder, gpu_ladder, runner_module, build_calldata, structurally_valid, objective, canonicalize, model_vars, approx_quad_terms; make_adapters() -> dict[str, Adapter] with keys "maxcut", "tsp".

  • Step 1: Write the failing test

Create scripts/tests/test_scale_sweep.py:

text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 2: Run test to verify it fails

Run: uv run --no-sync pytest scripts/tests/test_scale_sweep.py -v
Expected: FAIL with ModuleNotFoundError: No module named 'scale_sweep'

  • Step 3: Write the implementation

Create scripts/scale_sweep.py:

text
/* Detailed source-code truncated for AI context efficiency. */

Note: maxcut_objective re-implements the two-line cut_weight instead of
loading the runner module (an import of the full xquad stack) inside a hot
helper; tsp_objective must delegate because tour_distance depends on
triu indexing. Both are exercised against runner goldens in Task 2's
verification.

  • Step 4: Run tests to verify they pass

Run: uv run --no-sync pytest scripts/tests/test_scale_sweep.py -v
Expected: 4 passed

  • Step 5: Ruff check and commit
bash
uv run --no-sync ruff check scripts/scale_sweep.py scripts/tests/test_scale_sweep.py
git add scripts/scale_sweep.py scripts/tests/test_scale_sweep.py
git commit -m "feat(scripts): add scale sweep adapter registry (QUI-167)"

Task 2: Point mode โ€” measure one (problem, backend, solver, n, seed)

Files:

  • Modify: scripts/scale_sweep.py (append; also extend imports)

Interfaces:

  • Consumes: Adapter, make_adapters(), load_runner() from Task 1.

  • Produces: measure_point(adapter: Adapter, backend_name: str, solver_name: str, n: int, seed: int) -> dict returning a record with keys schema, problem, backend, solver, n, seed, status and, when status == "ok": stages (dict of 5 stage-seconds), model ({"vars": int, "approx_quad_terms": int}), energy: int, valid: int, structural_ok: bool, objective: int, solution: list[int], peak_rss_kib: int. When a stage raises: status="error", stage, message. Also main(argv) -> int handling --point CLI.

  • Step 1: Write the implementation

Append to scripts/scale_sweep.py (add import argparse, json, resource, sys, time to the imports block):

text
/* Detailed source-code truncated for AI context efficiency. */

Add a temporary stub so point mode is runnable before Task 3 (replaced there):

python
def _orchestrate(args: argparse.Namespace) -> int:
    raise NotImplementedError("orchestrator lands in Task 3")
  • Step 2: Verify point mode against the runner goldens
bash
uv run --no-sync python scripts/scale_sweep.py --point --problem maxcut --backend rust --n 8 --seed 42 | python3 -m json.tool
uv run --no-sync python examples/maxcut/runner.py --n 8 --seed 42 --interpreter rust

Expected: point record has status: "ok", valid: 1, structural_ok: true, five positive stage timings, peak_rss_kib > 0, and its energy/objective/solution equal the runner's energy/cut_weight/partition for the same n and seed. Repeat for TSP:

bash
uv run --no-sync python scripts/scale_sweep.py --point --problem tsp --backend python --n 4 --seed 42 | python3 -m json.tool
uv run --no-sync python examples/tsp/runner.py --n 4 --seed 42 --interpreter python

Expected: tour/tour_distance match solution/objective.

  • Step 3: Verify the error path
bash
uv run --no-sync python scripts/scale_sweep.py --point --problem maxcut --backend rust --solver dwave-qpu --n 8 --seed 42; echo "exit=$?"

Expected: JSON with status: "error", stage: "solve", non-empty message; exit=1.

  • Step 4: Ruff check and commit
bash
uv run --no-sync ruff check scripts/scale_sweep.py
git add scripts/scale_sweep.py
git commit -m "feat(scripts): add scale sweep point measurement mode (QUI-167)"

Task 3: Orchestrator โ€” matrix, child processes, lane escalation

Files:

  • Modify: scripts/scale_sweep.py (replace the _orchestrate stub; extend imports with import os, subprocess)

Interfaces:

  • Consumes: make_adapters(), build_parser(), point-mode CLI from Task 2; CPU_SOLVER, GPU_SOLVER, SEEDS from Task 1.

  • Produces: cuda_available() -> bool; build_matrix(adapters: dict[str, Adapter], solvers: list[str], smoke: bool) -> list[Lane] where Lane is a dataclass (problem: str, backend: str, solver: str, ladder: tuple[int, ...]); run_point_child(lane: Lane, n: int, seed: int, timeout: float) -> dict; _orchestrate(args) -> int writing JSONL to args.out and a report via Task 4's render_report(records) -> str.

  • Step 1: Write the failing test

Append to scripts/tests/test_scale_sweep.py:

python
def test_build_matrix_cpu_only():
    adapters = scale_sweep.make_adapters()
    lanes = scale_sweep.build_matrix(adapters, solvers=["dwave-cpu"], smoke=False)
    keys = {(lane.problem, lane.backend, lane.solver) for lane in lanes}
    assert keys == {
        ("maxcut", "python", "dwave-cpu"),
        ("maxcut", "rust", "dwave-cpu"),
        ("tsp", "python", "dwave-cpu"),
        ("tsp", "rust", "dwave-cpu"),
    }
    assert all(lane.ladder == adapters[lane.problem].ladder for lane in lanes)


def test_build_matrix_gpu_lanes_rust_only_with_extended_ladder():
    adapters = scale_sweep.make_adapters()
    lanes = scale_sweep.build_matrix(adapters, solvers=["dwave-cpu", "cuda-gpu"], smoke=False)
    gpu = [lane for lane in lanes if lane.solver == "cuda-gpu"]
    assert {(lane.problem, lane.backend) for lane in gpu} == {("maxcut", "rust"), ("tsp", "rust")}
    assert all(lane.ladder == adapters[lane.problem].gpu_ladder for lane in gpu)


def test_build_matrix_smoke_truncates_ladders():
    adapters = scale_sweep.make_adapters()
    lanes = scale_sweep.build_matrix(adapters, solvers=["dwave-cpu"], smoke=True)
    assert all(len(lane.ladder) == 1 for lane in lanes)
    assert all(lane.ladder[0] == adapters[lane.problem].ladder[0] for lane in lanes)
  • Step 2: Run tests to verify they fail

Run: uv run --no-sync pytest scripts/tests/test_scale_sweep.py -v -k matrix
Expected: 3 FAIL with AttributeError: module 'scale_sweep' has no attribute 'build_matrix'

  • Step 3: Write the implementation

Replace the Task 2 _orchestrate stub with:

text
/* Detailed source-code truncated for AI context efficiency. */

Task 4 defines render_report; until then add this stub right above _orchestrate (replaced in Task 4):

python
def render_report(records: list[dict]) -> str:
    return f"# Scale sweep\n\n{len(records)} records (report lands in Task 4)\n"
  • Step 4: Run tests to verify they pass

Run: uv run --no-sync pytest scripts/tests/test_scale_sweep.py -v
Expected: 7 passed

  • Step 5: Verify end-to-end with the smoke sweep

Run: uv run --no-sync python scripts/scale_sweep.py --smoke --out scratch/scale_sweep/smoke.jsonl
Expected: 6 lanes print (4 CPU + 2 GPU on this machine), every point ok; scratch/scale_sweep/smoke.jsonl holds 6 records with status: "ok". Spot-check one:
python3 -c "import json; rs=[json.loads(l) for l in open('scratch/scale_sweep/smoke.jsonl')]; print(len(rs), all(r['status']=='ok' for r in rs))"

  • Step 6: Ruff check and commit
bash
uv run --no-sync ruff check scripts/
git add scripts/scale_sweep.py scripts/tests/test_scale_sweep.py
git commit -m "feat(scripts): add scale sweep orchestrator with lane escalation (QUI-167)"

Task 4: Analysis and markdown report

Files:

  • Modify: scripts/scale_sweep.py (replace render_report stub; add helpers)
  • Modify: scripts/tests/test_scale_sweep.py (append tests)

Interfaces:

  • Consumes: record dicts as produced by Tasks 2โ€“3 (status, stages, energy, solution, peak_rss_kib, ...).

  • Produces: lane_ceilings(records) -> dict[tuple[str, str, str], int] (max ok n per lane); parity_failures(records) -> list[dict] (CPU-solver python-vs-rust solution mismatches, keyed by problem/n/seed); solver_deltas(records) -> list[dict] (rust-VM cuda-vs-cpu energy deltas per problem/n/seed); render_report(records: list[dict]) -> str (markdown).

  • Step 1: Write the failing tests

Append to scripts/tests/test_scale_sweep.py:

text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 2: Run tests to verify they fail

Run: uv run --no-sync pytest scripts/tests/test_scale_sweep.py -v -k "ceilings or parity or deltas or report"
Expected: 4 FAIL with AttributeError (lane_ceilings undefined)

  • Step 3: Write the implementation

Replace the render_report stub with:

text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 4: Run all tests

Run: uv run --no-sync pytest scripts/tests/test_scale_sweep.py -v
Expected: 11 passed

  • Step 5: Re-run the smoke sweep and read the real report

Run: uv run --no-sync python scripts/scale_sweep.py --smoke --out scratch/scale_sweep/smoke.jsonl
Expected: scratch/scale_sweep/report.md renders all sections; parity section says OK; energy-delta table appears (GPU lane present on this machine).

  • Step 6: Ruff check and commit
bash
uv run --no-sync ruff check scripts/
git add scripts/scale_sweep.py scripts/tests/test_scale_sweep.py
git commit -m "feat(scripts): add scale sweep analysis and markdown report (QUI-167)"

Task 5: Full sweep run and findings

Files:

  • Create: scratch/scale_sweep/results.jsonl (run output; scratch/ is not committed)
  • Create: docs/design/specs/2026-07-13-qui-167-findings.md (committed findings summary)

Interfaces:

  • Consumes: the complete harness from Tasks 1โ€“4.

  • Step 1: Sanity-check runtime bounds, then launch the full sweep

bash
uv run --no-sync python scripts/scale_sweep.py --timeout 300 --out scratch/scale_sweep/results.jsonl

Run in the background (it may take an hour+ if CPU lanes climb high). Expected: 6 lanes, each climbing until timeout/failure/ladder-end; JSONL grows incrementally, so progress is checkable mid-run.

  • Step 2: Review the report for anomalies

Read scratch/scale_sweep/report.md. Check specifically: (a) parity section OK โ€” any mismatch is a QUI-465-class bug and blocks findings; (b) verifier-invalid count 0 at small n (invalid TSP solutions at large n are expected annealer behavior, worth noting, not a harness bug); (c) ceilings are timeout-shaped, not crash-shaped โ€” crashes need a follow-up ticket.

  • Step 3: Write the findings doc

Create docs/design/specs/2026-07-13-qui-167-findings.md containing: the lane-ceilings table, the largest-common-rung stage breakdown, the parity verdict, the GPU-vs-CPU energy/throughput comparison, and a short "implications for v0.3.1+" section (candidate follow-ups: bit-packing QUI-704 relevance, encoder hot spots). Copy tables from the generated report; do not hand-edit numbers.

  • Step 4: Commit
bash
git add docs/design/specs/2026-07-13-qui-167-findings.md
git commit -m "docs: record QUI-167 scale sweep findings"
  • Step 5: Close the loop on Linear

Post a comment on QUI-167 with the findings summary (ceilings table + parity verdict + GPU delta headline), tick the ticket's MaxCut and TSP checkboxes, and move the issue to In Review after the MR is opened (finishing-a-development-branch skill handles the MR).


Self-Review

  • Spec coverage: adapters/registry (Task 1), five instrumented stages + structural/objective/valid checks + peak RSS (Task 2), matrix pruning + child isolation + timeout/crash/error records + lane stop + JSONL (Task 3), parity + cross-solver deltas + ceilings + markdown report (Task 4), smoke flag (Tasks 3/5 via --smoke), findings + Linear closeout (Task 5). GPU env setup already done in-session and documented in the spec.
  • Placeholder scan: the two intentional stubs (_orchestrate in Task 2, render_report in Task 3) are each replaced by a named later task โ€” acceptable forward references, both explicit.
  • Type consistency: record schema keys used by Task 4 match Task 2/3 producers (stages, solution, energy, peak_rss_kib, status, stderr_tail, timeout_s); Lane fields match build_matrix tests; STAGES tuple shared.

Design Plans 2026 07 13 Qui 687 Gpu Validation (`docs/design/plans/2026-07-13-qui-687-gpu-validation.md`)

QUI-687 GPU Medium-Model Validation Tests Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add xqsa/tests/test_gpu_validation.py โ€” hardware-gated medium-model (20โ€“100 var) validation for SolverCudaGPU and SolverMetalGPU with exact-optimum and CPU cross-check assertions.

Architecture: One new test module. Four module-level model builders return (model, ground_energy). An ungated reference class proves SolverDWaveCPU hits each known optimum on every CI run. Two gated classes (cuda / metal markers, same skip-guard idiom as test_xqsa.py) assert the GPU solvers hit the exact optima on structured models and land within 2% of the CPU reference on a seeded spin glass.

Tech Stack: pytest, xqvm_py.xqmx.XQMX, xqsa solvers. No new dependencies.

Spec: docs/design/specs/2026-07-13-qui-687-gpu-validation-design.md (approved 2026-07-13).

Global Constraints

  • Work ONLY in a dedicated worktree on branch feature/qui-687 (base 15918ed post-v0.3.0 main).
  • Run Python via uv run --no-sync ... (plain uv run reverts the maturin-built xqffi).
  • Development machine assumption: a CUDA GPU with cupy installed โ€” cuda-marked tests run for real; no Metal device โ€” metal-marked tests must be observed to skip.
  • Guard idiom copied from xqsa/tests/test_xqsa.py: _IN_CI = os.environ.get("CI") is not None; probe _has_cupy/_has_metal; import solver under if _has_X or _IN_CI:; class decorated @pytest.mark.cuda|metal + @pytest.mark.skipif(not _IN_CI and not _has_X, reason=...).
  • Assertion policy: structured models exact on both solvers; spin glass gpu <= cpu + 0.02 * abs(cpu).
  • GPU calls pin num_reads=64, num_sweeps=2000, seed=42 (never rely on defaults).
  • Style: โ‰ค100 lines/function, โ‰ค5 positional params, 100-char lines (repo ruff allows 120 โ€” the 100 limit still binds; check with awk 'length > 100'), ruff clean, full 16-line AGPL header + SPDX.
  • Commits: imperative, โ‰ค72-char subject, no co-author bylines/trailers.

File Structure

  • Create xqsa/tests/test_gpu_validation.py โ€” builders, TestMediumModelReferences (ungated), TestCudaGPUMediumModels (cuda), TestMetalGPUMediumModels (metal). Nothing else changes.

Task 1: Model builders + ungated CPU reference tests

Files:

  • Create: xqsa/tests/test_gpu_validation.py

Interfaces:

  • Produces (used verbatim by Tasks 2โ€“3): ferro_chain(n: int = 64) -> tuple[XQMX, int], frustrated_triangles(n: int = 51) -> tuple[XQMX, int], bipartite_maxcut(n: int = 20) -> tuple[XQMX, int], random_spin_glass(n: int = 100, seed: int = 7) -> tuple[XQMX, None]; module constants _IN_CI, _has_cupy, _has_metal; GPU_PARAMS = {"num_reads": 64, "num_sweeps": 2000, "seed": 42}; cpu_reference(model) -> int returning SolverDWaveCPU(seed=42).solve(model).energy.

  • Step 1: Create the file with header, guards, builders left unimplemented, and the reference tests

Create xqsa/tests/test_gpu_validation.py with the full 16-line AGPL header exactly as at the top of xqsa/tests/test_xqsa.py (Copyright 2026 Postquant Labs Incorporated โ€ฆ SPDX-License-Identifier: AGPL-3.0-or-later), then:

text
/* Detailed source-code truncated for AI context efficiency. */

Then the four builders as stubs raising NotImplementedError (implemented in Step 3), followed by the reference tests:

python
def cpu_reference(model: XQMX) -> int:
    """Best energy SolverDWaveCPU finds on ``model`` (fixed seed)."""
    return SolverDWaveCPU(seed=42).solve(model).energy


class TestMediumModelReferences:
    """Ungated: builders and reference energies hold on every CI run."""

    def test_ferro_chain_cpu_hits_ground_state(self) -> None:
        model, ground = ferro_chain()
        assert ground == -63
        assert cpu_reference(model) == ground

    def test_frustrated_triangles_cpu_hits_ground_state(self) -> None:
        model, ground = frustrated_triangles()
        assert ground == -17
        assert cpu_reference(model) == ground

    def test_bipartite_maxcut_cpu_hits_ground_state(self) -> None:
        model, ground = bipartite_maxcut()
        assert ground == -100
        assert cpu_reference(model) == ground

    def test_random_spin_glass_is_deterministic(self) -> None:
        model_a, ground_a = random_spin_glass()
        model_b, _ = random_spin_glass()
        assert ground_a is None
        assert model_a.size == model_b.size == 100
        assert cpu_reference(model_a) == cpu_reference(model_b)
  • Step 2: Run to verify the reference tests fail on the stubs

Run: uv run --no-sync pytest xqsa/tests/test_gpu_validation.py -q -k References
Expected: 4 FAIL with NotImplementedError

  • Step 3: Implement the builders

Replace the stubs with:

text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 4: Run to verify the reference tests pass

Run: uv run --no-sync pytest xqsa/tests/test_gpu_validation.py -q -k References
Expected: 4 passed. If a structured-model reference assert fails (CPU misses the optimum at defaults), STOP and report โ€” that invalidates the spec's reference assumption; do not loosen the assertion.

  • Step 5: Ruff, line-length check, commit
bash
uv run --no-sync ruff check xqsa/tests/test_gpu_validation.py
awk 'length > 100 {print FNR" ("length")"}' xqsa/tests/test_gpu_validation.py
git add xqsa/tests/test_gpu_validation.py
git commit -m "test(xqsa): add medium-model builders with CPU reference tests"

awk must print nothing.


Task 2: CUDA gated class (runs for real on a CUDA development machine)

Files:

  • Modify: xqsa/tests/test_gpu_validation.py (append)

Interfaces:

  • Consumes: builders, cpu_reference, GPU_PARAMS, GLASS_TOLERANCE, _IN_CI, _has_cupy, SolverCudaGPU from Task 1.

  • Step 1: Append the CUDA class

python
@pytest.mark.cuda
@pytest.mark.skipif(
    not _IN_CI and not _has_cupy,
    reason="cupy not installed (skipped locally; runs and hard-fails in CI)",
)
class TestCudaGPUMediumModels:
    """SolverCudaGPU on 20-100 var models with known/CPU-referenced optima."""

    def test_ferro_chain_exact(self) -> None:
        model, ground = ferro_chain()
        result = SolverCudaGPU(**GPU_PARAMS).solve(model)
        assert result.energy == ground

    def test_frustrated_triangles_exact(self) -> None:
        model, ground = frustrated_triangles()
        result = SolverCudaGPU(**GPU_PARAMS).solve(model)
        assert result.energy == ground

    def test_bipartite_maxcut_exact(self) -> None:
        model, ground = bipartite_maxcut()
        result = SolverCudaGPU(**GPU_PARAMS).solve(model)
        assert result.energy == ground

    def test_random_spin_glass_within_tolerance_of_cpu(self) -> None:
        model, _ = random_spin_glass()
        cpu = cpu_reference(model)
        gpu = SolverCudaGPU(**GPU_PARAMS).solve(model).energy
        assert gpu <= cpu + GLASS_TOLERANCE * abs(cpu), (
            f"GPU best energy {gpu} more than {GLASS_TOLERANCE:.0%} above CPU reference {cpu}"
        )
  • Step 2: Run the CUDA class for real

Run: uv run --no-sync pytest xqsa/tests/test_gpu_validation.py -q -k Cuda
Expected: 4 passed (on a machine with a CUDA GPU + cupy).

If an exact assert fails: first re-run once to distinguish flake from miss. If the GPU consistently misses a structured optimum at num_sweeps=2000, raise num_sweeps in GPU_PARAMS stepwise (4000 โ†’ 10000 โ†’ 20000) until all structured models pass reliably, and record the final value + observed energies in your report. If 20000 still fails, STOP and report the observed vs expected energies โ€” that is a solver-quality finding (consistent with QUI-167's CUDA observations), not a test bug to paper over. Apply the same escalation if the glass misses the 2% band.

  • Step 3: Stability check โ€” 3 consecutive runs

Run: for i in 1 2 3; do uv run --no-sync pytest xqsa/tests/test_gpu_validation.py -q -k Cuda 2>&1 | tail -1; done
Expected: 4 passed three times. Record the three lines in your report. If any run flakes, escalate num_sweeps per Step 2 and repeat until 3/3 stable.

  • Step 4: Ruff, line-length, commit
bash
uv run --no-sync ruff check xqsa/tests/test_gpu_validation.py
awk 'length > 100 {print FNR" ("length")"}' xqsa/tests/test_gpu_validation.py
git add xqsa/tests/test_gpu_validation.py
git commit -m "test(xqsa): add CUDA GPU medium-model validation tests"

Task 3: Metal gated class + whole-file verification

Files:

  • Modify: xqsa/tests/test_gpu_validation.py (append)

Interfaces:

  • Consumes: builders, cpu_reference, GPU_PARAMS, GLASS_TOLERANCE, _IN_CI, _has_metal, SolverMetalGPU from Task 1. Metal's constructor additionally takes strategy ("sa" or "gibbs").

  • Step 1: Append the Metal class

text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 2: Verify the Metal class SKIPS locally (guard correctness)

Run: uv run --no-sync pytest xqsa/tests/test_gpu_validation.py -rs -q -k Metal
Expected: 6 skipped with reason "Metal GPU not available (skipped locally; runs and hard-fails in CI)". Record the skip lines in your report โ€” observing the skip IS the local verification for this class.

  • Step 3: Whole-file + regression verification
bash
uv run --no-sync pytest xqsa/tests/test_gpu_validation.py -q
uv run --no-sync pytest xqsa/tests/ -q -m "not qpu and not metal and not quip"

Expected: file run = 8 passed, 6 skipped (4 references + 4 cuda pass; 6 metal skip). Full xqsa run: all pass (76 pre-existing + new), no new warnings.

  • Step 4: Ruff, line-length, commit
bash
uv run --no-sync ruff check xqsa/tests/test_gpu_validation.py
awk 'length > 100 {print FNR" ("length")"}' xqsa/tests/test_gpu_validation.py
git add xqsa/tests/test_gpu_validation.py
git commit -m "test(xqsa): add Metal GPU medium-model validation tests"

Self-Review

  • Spec coverage: guard idiom (Task 1 Step 1), four builders with exact energies (Task 1 Step 3), ungated reference class (Task 1), CUDA class incl. glass tolerance (Task 2), Metal sa + gibbs incl. the โ‰ฅ3-colour triangles (Task 3), observed-skip verification (Task 3 Step 2), AC mapping complete.
  • Placeholder scan: the only stubs are Task 1 Step 1's NotImplementedError builders, replaced in Step 3 of the same task โ€” explicit TDD, not a placeholder.
  • Type consistency: builders return tuple[XQMX, int|None] everywhere; GPU_PARAMS dict unpacks into both solver constructors (both accept num_reads, num_sweeps, seed; Metal adds strategy passed explicitly); cpu_reference used identically in Tasks 1โ€“3.
  • Escalation rule for stochastic misses is defined once (Task 2 Step 2) and referenced by the stability step; Metal cannot be escalated locally (skips) โ€” CI will exercise it, and the budgets are shared via GPU_PARAMS, already validated on CUDA.

Design Plans 2026 07 13 Qui 704 Spin Packing Eval (`docs/design/plans/2026-07-13-qui-704-spin-packing-eval.md`)

QUI-704 Spin-Packing Evaluation Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build scripts/bench_spin_packing.py, benchmark dense int32 vs bit-packed spin storage for SolverCudaGPU on the RTX 4090 at n = 512โ€ฆ4096, and produce a findings doc with an adopt/reject recommendation.

Architecture: One benchmark script. It builds seeded synthetic spin instances (an XQMX for the stock solver plus matching h/J numpy arrays for the prototype), runs the shipped SolverCudaGPU, and runs a bench-local bit-packed variant of the same kernel that consumes an identical RNG stream, identical init samples, and identical fixed beta_range โ€” so trajectories are bit-identical and the prototype's faithfulness is asserted by exact best-sample equality, not statistics. Metrics: per-buffer analytic bytes, measured mempool peak, median wall time.

Tech Stack: cupy (RawKernel), numpy, xqvm_py.xqmx.XQMX, xqsa.cuda_gpu.SolverCudaGPU. No new dependencies. Metal is analytic-only (no hardware) โ€” handled in the findings doc, not the script.

Spec: docs/design/specs/2026-07-13-qui-704-spin-packing-eval-design.md (approved 2026-07-13).

Global Constraints

  • Work ONLY in the worktree /home/konrad/Quip/xquad/.claude/worktrees/feature+qui-704 (branch feature/qui-704, base 15918ed).
  • Run Python via uv run --no-sync ... (plain uv run reverts the maturin-built xqffi).
  • Bench parameters (fixed): num_reads=64, num_sweeps=500, seed=42, beta_range=(0.1, 10.0) passed explicitly to BOTH variants; sizes n โˆˆ (512, 1024, 2048, 4096); 3 timing repetitions, median reported.
  • No changes to xqsa/ source. The packed kernel lives only in the bench script.
  • Results JSON goes under scratch/spin_packing/ (not committed); the script and findings doc are committed.
  • Style: โ‰ค100 lines/function, โ‰ค5 positional params, 100-char lines (awk 'length > 100' โ€” repo ruff allows 120 but 100 binds), ruff clean, full 16-line AGPL header + SPDX (copy from scripts/scale_sweep.py if present on this branch's base โ€” otherwise from xqsa/tests/test_xqsa.py).
  • Commits: imperative, โ‰ค72-char subject, no co-author bylines/trailers.

File Structure

  • Create scripts/bench_spin_packing.py โ€” instance builder, stock runner, packed prototype (kernel + driver), matrix runner, JSON output.
  • Create docs/design/specs/2026-07-13-qui-704-findings.md โ€” Task 3, from measured data.

Task 1: Bench scaffold โ€” instances, stock runner, CLI

Files:

  • Create: scripts/bench_spin_packing.py

Interfaces:

  • Produces (Task 2 depends on these exact signatures): build_instance(n: int, seed: int) -> tuple[XQMX, np.ndarray, np.ndarray] returning (model, h, j_matrix) where h is float64[n] zeros and j_matrix is the full symmetric float64[n, n] coupling matrix; run_stock(model: XQMX, n: int) -> dict returning {"variant": "stock", "n": n, "wall_s": [3 floats], "energy": int, "sample": list[int], "mempool_peak_bytes": int}; constants NUM_READS = 64, NUM_SWEEPS = 500, SEED = 42, BETA_RANGE = (0.1, 10.0), SIZES = (512, 1024, 2048, 4096), REPS = 3.

  • Step 1: Create the script

Full 16-line AGPL header + SPDX first, then:

text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 2: Verify with a single-size run

Run: uv run --no-sync python scripts/bench_spin_packing.py --sizes 512 --out scratch/spin_packing/smoke1.json
Expected: one line n=512 stock: median ...s energy ... (energy is a deterministic negative int; the same command re-run prints the identical energy), then the JSON path. If SolverCudaGPU.solve rejects any argument or the energies differ between two runs with the same seed, STOP and report โ€” determinism is a prerequisite for Task 2's exact check.

  • Step 3: Ruff, line length, commit
bash
uv run --no-sync ruff check scripts/bench_spin_packing.py
awk 'length > 100 {print FNR" ("length")"}' scripts/bench_spin_packing.py
git add scripts/bench_spin_packing.py
git commit -m "bench(xqsa): add spin-packing benchmark scaffold (QUI-704)"

awk must print nothing.


Task 2: Bit-packed prototype โ€” kernel, driver, exact-trajectory check

Files:

  • Modify: scripts/bench_spin_packing.py

Interfaces:

  • Consumes: everything Task 1 produced.
  • Produces: run_packed(h: np.ndarray, j_matrix: np.ndarray, n: int) -> dict (same row shape, "variant": "packed"); PACKED_KERNEL (CUDA source string, entry sa_spin_packed).

Background you need: the shipped spin kernel is _SA_SPIN_KERNEL in xqsa/cuda_gpu.py:108-161 โ€” read it first. The driver logic to replicate is _run_sa in the same file (xqsa/cuda_gpu.py:318-392): RNG stream order is (1) rng.integers(0, 2, size=(num_reads, n), dtype=cupy.int32) mapped *2-1 to spins, then (2) rng.random(size=(num_reads, num_sweeps, n), dtype=cupy.float64). Reproduce that order exactly with cupy.random.default_rng(SEED) so the packed run consumes the identical stream. Also read how solve() builds h/j_matrix from the XQMX (the code just above _run_sa) โ€” if it builds J symmetric-full as Task 1 assumes, the exact-trajectory check will pass; if the check fails, compare your bench j_matrix against the solver's construction and fix build_instance to match (report what you found).

  • Step 1: Append the packed kernel + driver
text
/* Detailed source-code truncated for AI context efficiency. */

Note the timing asymmetry this creates: run_stock times the whole solve() call (includes RNG generation + host transfers), while the packed timer brackets only the kernel. Fix the asymmetry by moving the packed variant's t0 = time.perf_counter() to BEFORE the rng = ... line so both walls cover RNG + kernel + sync. Keep the row's wall_s semantics identical between variants; state remaining differences (XQMX extraction happens only in stock) in the findings doc.

  • Step 2: Wire the packed run + faithfulness assert into main

Replace the loop body in main with:

python
model, h, j_matrix = build_instance(n, SEED)
        stock = run_stock(model, n)
        packed = run_packed(h, j_matrix, n)
        if packed["sample"] != stock["sample"]:
            raise SystemExit(
                f"TRAJECTORY DIVERGENCE at n={n}: packed prototype is not faithful "
                f"(first mismatch at index "
                f"{next(i for i, (a, b) in enumerate(zip(packed['sample'], stock['sample'])) if a != b)})"
            )
        analytic = analytic_bytes(n)
        rows.append({**stock, "analytic": analytic})
        rows.append({**packed, "analytic": analytic})
        med_s = sorted(stock["wall_s"])[1]
        med_p = sorted(packed["wall_s"])[1]
        print(
            f"n={n}: stock {med_s:.2f}s / packed {med_p:.2f}s "
            f"(x{med_p / med_s:.2f}), energy {stock['energy']}, samples identical"
        )
  • Step 3: Verify faithfulness at n=512

Run: uv run --no-sync python scripts/bench_spin_packing.py --sizes 512 --out scratch/spin_packing/smoke2.json
Expected: samples identical in the output line. If TRAJECTORY DIVERGENCE fires: first check J-layout (read solve()'s array construction in xqsa/cuda_gpu.py and align build_instance), then RNG order. If it still diverges after both, STOP and report the mismatch details โ€” do not weaken the check to a tolerance.

  • Step 4: Ruff, line length, commit
bash
uv run --no-sync ruff check scripts/bench_spin_packing.py
awk 'length > 100 {print FNR" ("length")"}' scripts/bench_spin_packing.py
git add scripts/bench_spin_packing.py
git commit -m "bench(xqsa): add bit-packed kernel prototype with exact-trajectory check"

Task 3: Full matrix run + findings doc

Files:

  • Create: docs/design/specs/2026-07-13-qui-704-findings.md

  • Output (not committed): scratch/spin_packing/results.json

  • Step 1: Full run

Run: uv run --no-sync python scripts/bench_spin_packing.py
Expected: four n=... lines, each ending samples identical; up to ~15 min at n=4096 (single-thread-per-block kernel is slow โ€” that slowness is itself a documented finding from QUI-167). If n=4096 exceeds 30 min, record the sizes that completed and note the abort in the findings; do not kill smaller sizes.

  • Step 2: Write the findings doc

Create docs/design/specs/2026-07-13-qui-704-findings.md with exactly these sections, filling every number from scratch/spin_packing/results.json (never hand-estimate):

  1. Header: ticket link, date, machine (RTX 4090 / WSL2 / cupy version), bench parameters.
  2. Device-memory breakdown table โ€” per n: J bytes, randoms bytes, dense-samples bytes, packed-samples bytes, spins' % of total (analytic), measured mempool peaks for both variants.
  3. Throughput table โ€” per n: stock median wall, packed median wall, ratio.
  4. Faithfulness โ€” one line: exact best-sample equality held at every size (or details).
  5. Metal analysis (no hardware) โ€” analytic: no randoms buffer (on-device xorshift32, metal_gpu.py docstring), J float32 nยฒร—4 dominates; spins' % of Metal footprint at the same sizes; conclusion carries a fortiori.
  6. Recommendation โ€” adopt/reject against the ticket's criterion ("material footprint reduction without unacceptable throughput regression"), stated plainly with the measured shares.
  7. Follow-up candidates (documented, not implemented): CUDA on-device RNG (eliminates the dominant randoms buffer, re-aligns solvers, lifts the 80%-VRAM guard); kernel occupancy (block=(1,) single-thread launch, consistent with QUI-167's 5ร— CPU-vs-GPU finding).
  • Step 3: Commit
bash
git add docs/design/specs/2026-07-13-qui-704-findings.md
git commit -m "docs: record QUI-704 spin-packing evaluation findings"

Self-Review

  • Spec coverage: instances + stock runner (Task 1), packed prototype + faithfulness (Task 2), full matrix + findings incl. Metal analytic section and follow-up candidates (Task 3). Linear comment/closeout is controller-side, per spec Deliverable 3.
  • Placeholder scan: none โ€” every code step is complete; findings sections enumerate exact required content sourced from the results JSON.
  • Type consistency: row dict shape shared by run_stock/run_packed; constants used identically across tasks; build_instance tuple order (model, h, j_matrix) consistent.
  • Known risk, mitigated: Task 1's assumption that solve() builds J symmetric-full is verified end-to-end by Task 2's exact-trajectory check, with an explicit diagnose-and-align instruction if it fires.

Design Plans 2026 07 16 Qui 852 Gpu Sa Occupancy (`docs/design/plans/2026-07-16-qui-852-gpu-sa-occupancy.md`)

QUI-852: GPU SA Kernel Occupancy (CUDA + Metal) โ€” Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Parallelize the O(n) local-field reduction inside the GPU SA kernels across the threads of each replica's block/threadgroup (currently 1 thread per replica), keeping spin updates strictly sequential โ€” same proposal order, same acceptance rule, same RNG consumption order (Linear QUI-852).

Architecture: CUDA: launch (num_reads,) blocks ร— (256,) threads; each thread computes a strided partial sum of ฮฃโฑผ J[i,j]ยทx[j], a shared-memory tree reduction combines them, and thread 0 alone applies Metropolis + flips the spin, with __syncthreads() barriers around the sequential section. Metal: identical shape with threadgroup memory and threadgroup_barrier, threadgroup width clamped to a power of two โ‰ค maxTotalThreadsPerThreadgroup (โ‰ค 256). The Metal gibbs pipeline keeps 1 thread/threadgroup (its parallelization interacts with the coloring โ€” out of scope). Verification uses a committed benchmark harness: on integer-coefficient instances, float64/float32 sums are exact in any order, so the new kernels must be bit-identical to the shipped ones โ€” a hard gate; float instances get distribution-parity checks; n=4096 and n=512 timings are re-measured against a baseline captured at the branch base.

Tech Stack: CUDA via CuPy RawKernel, Metal Shading Language via pyobjc, numpy, pytest, uv run.

Global Constraints

  • Branch is STACKED on feature/qui-854 (MR !91): branch feature/qui-852 from feature/qui-854, NOT from main. The kernels being modified already take the beta buffer at arg index 5 (CUDA) / buffer 2 (Metal).
  • Markov chain definition unchanged: proposal order (i = 0..n-1 sequential), acceptance rule, and RNG consumption order must be untouched. Only thread 0 reads RNG (CUDA: rand_ptr[(long long)sweep * n + i]; Metal: rand_unit(rng) on thread 0's state). Only floating-point summation order inside the local field may change.
  • Hard verification gate: on integer-coefficient instances the new CUDA kernel must produce byte-identical results (raw_energy, energy, sample hash) to the baseline captured at the branch base. Not approximately โ€” exactly.
  • Metal hardware validation is DEFERRED (no Apple Silicon on this machine): MSL changes are verified structurally via the mocked suite (dispatch shape, ABI); the MR must state that real-hardware validation needs an M-series run (post-merge CI job test:metal or a reviewer machine). Do not claim Metal numerical verification.
  • Thread counts: CUDA fixed _THREADS_PER_REPLICA = 256. Metal _SA_THREADGROUP_WIDTH = 256, clamped at dispatch to the largest power of two โ‰ค pipeline.maxTotalThreadsPerThreadgroup(); the tree reduction requires a power-of-two width.
  • Repo rules: ruff format/lint at line-length 120; uv run --no-sync pytest ...; commits signed off (git commit -s), imperative โ‰ค72-char subject, NO co-author bylines/Claude attribution. Commit after every green test cycle.
  • Mocked CUDA test command: uv run --no-sync pytest xqsa/tests/test_xqsa.py -k "CudaGPUMocked" -v
  • Mocked Metal test command: uv run --no-sync pytest xqsa/tests/test_xqsa.py -k "MetalGPUMocked" -v
  • Real-GPU command: uv run --extra cuda pytest xqsa/tests/test_xqsa.py -m cuda -v (RTX 4090 present; first run installs cupy โ€” allow 600s timeout)
  • Bench harness runs use uv run --extra cuda python scripts/bench_occupancy.py ... โ€” long timeouts (up to 1800000 ms for the timing baseline; run in foreground with that timeout).

Task 1: Stacked branch + benchmark harness + baseline capture (runs the SHIPPED kernel)

Files:

  • Create: scripts/bench_occupancy.py
  • Create (uncommitted, scratch): scratch/qui-852/baseline-trajectories.json, scratch/qui-852/baseline-parity.json, scratch/qui-852/baseline-timing.json

Interfaces:

  • Produces: scripts/bench_occupancy.py with subcommands trajectories, parity, timing, each writing JSON via --out, and compare --mode {trajectories,parity,timing} BASELINE CURRENT which exits 1 on trajectory mismatch and prints stats otherwise. Task 3 re-runs the same subcommands on the new kernel and compares.

  • CRITICAL ORDERING: baselines MUST be captured in this task, before any kernel change.

  • Step 1: Verify branch state (the controller prepared the worktree โ€” confirm, don't create):

Run: git branch --show-current && git log --oneline -1 && git merge-base --is-ancestor $(git rev-parse origin/feature/qui-854 2>/dev/null || git rev-parse feature/qui-854) HEAD && echo STACKED-OK
Expected: feature/qui-852, and STACKED-OK.

  • Step 2: Write scripts/bench_occupancy.py (complete file):
text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 3: Smoke-check the harness (fast, real GPU):

Run: uv run --extra cuda python -c "import scripts.bench_occupancy as b; m=b.build_maxcut(16,0,'binary'); r,_=b._solve(m,1,50,0); print(r)" โ€” expect a record dict printed, no traceback. (If scripts is not importable as a package, run the equivalent through python scripts/bench_occupancy.py trajectories --out /tmp/smoke.json after temporarily reducing nothing โ€” instead just proceed to Step 4, which exercises the same code.)

  • Step 4: Capture baselines (SHIPPED kernel โ€” this is why this step precedes all kernel edits):
bash
mkdir -p scratch/qui-852
uv run --extra cuda python scripts/bench_occupancy.py trajectories --out scratch/qui-852/baseline-trajectories.json
uv run --extra cuda python scripts/bench_occupancy.py parity --out scratch/qui-852/baseline-parity.json
uv run --extra cuda python scripts/bench_occupancy.py timing --out scratch/qui-852/baseline-timing.json

Timeouts: 600000 ms, 900000 ms, 1800000 ms respectively (the n=4096 timing case alone runs ~10 minutes on the shipped kernel). Record the printed timing numbers in your report.

  • Step 5: Lint + commit the harness (scratch JSONs stay uncommitted; scratch/ is gitignored):
bash
uv run --no-sync ruff format --check scripts/bench_occupancy.py && uv run --no-sync ruff check scripts/bench_occupancy.py
git add scripts/bench_occupancy.py
git commit -s -m "bench(xqsa): add QUI-852 occupancy trajectory/parity/timing harness"

If ruff format --check fails, run uv run --no-sync ruff format scripts/bench_occupancy.py first.


Task 2: CUDA kernels โ€” parallel local-field reduction

Files:

  • Modify: xqsa/cuda_gpu.py โ€” _SA_BINARY_KERNEL, _SA_SPIN_KERNEL, the module constants block, and the kernel launch in _run_sa
  • Modify: xqsa/tests/test_xqsa.py โ€” _capture_kernel_args helper (record grid/block), one new mocked test

Interfaces:

  • Consumes: kernel ABI from !91 โ€” args (h, J, samples, energies, randoms, betas, n, num_sweeps); the mocked fake kernel receives (grid, block, args) and simulates on CPU (it ignores block, so it needs NO functional change).

  • Produces: launch shape (num_reads,), (_THREADS_PER_REPLICA,) with _THREADS_PER_REPLICA = 256; kernel arg list UNCHANGED. Task 3 verifies numerics; Task 4 mirrors the design in MSL.

  • Step 1: Add the failing mocked test. Extend the existing _capture_kernel_args staticmethod in TestSolverCudaGPUMocked โ€” change its body to also record the launch shape:

python
@staticmethod
    def _capture_kernel_args(solver) -> dict:
        """Shadow the cached binary kernel with a wrapper that records call args."""
        captured: dict = {}
        real_kernel = solver._binary_kernel

        def wrapper(grid, block, args):
            captured["grid"] = grid
            captured["block"] = block
            captured["args"] = args
            return real_kernel(grid, block, args)

        solver.__dict__["_binary_kernel"] = wrapper
        return captured

Then append inside TestSolverCudaGPUMocked:

python
def test_launch_shape_parallel_reduction_mocked(self, mock_cupy_env) -> None:
        """Each replica's block launches _THREADS_PER_REPLICA threads (QUI-852)."""
        from xqsa.cuda_gpu import _THREADS_PER_REPLICA, SolverCudaGPU as _Solver

        model = XQMX.binary_model(2)
        model.set_linear(0, 1.0)

        solver = _Solver(num_reads=7, num_sweeps=10, seed=1)
        captured = self._capture_kernel_args(solver)
        solver.solve(model)

        assert captured["grid"] == (7,)
        assert captured["block"] == (_THREADS_PER_REPLICA,)
        assert _THREADS_PER_REPLICA == 256
  • Step 2: Run it โ€” expect FAIL (ImportError: cannot import name '_THREADS_PER_REPLICA'):

Run: uv run --no-sync pytest xqsa/tests/test_xqsa.py -k "launch_shape" -v

  • Step 3: Rewrite _SA_BINARY_KERNEL. Replace the full kernel source string with:
text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 4: Rewrite _SA_SPIN_KERNEL โ€” identical structure; the three differences from the binary kernel are the initial-energy loop, delta_E, and the flip:
text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 5: Host-side launch. Under _SUPPORTED_SCHEDULES = ... add:
python
# One CUDA block per replica; this many threads cooperate on the local-field
# reduction (QUI-852). Must be a power of two (tree reduction) and match the
# kernels' `__shared__ double sdata[256]`.
_THREADS_PER_REPLICA = 256

In _run_sa, change the launch:

python
kernel(
            (num_reads,),  # grid: one block per replica
            (_THREADS_PER_REPLICA,),  # block: cooperative local-field reduction
            (

(the args tuple is unchanged). Also update the comment line # block: single thread per replica if it survives anywhere.

  • Step 6: Run mocked suite โ€” all pass, including the new launch-shape test (the fake kernel ignores block, so pre-existing tests are unaffected):

Run: uv run --no-sync pytest xqsa/tests/test_xqsa.py -k "CudaGPUMocked" -v
Expected: 22 passed (21 existing + 1 new).

  • Step 7: Run the real-GPU suite (NVRTC-compiles the new kernels; trivial-model results are exact-sum cases and must be unchanged):

Run: uv run --extra cuda pytest xqsa/tests/test_xqsa.py -m cuda -v (timeout 600000)
Expected: 18 passed.

  • Step 8: Lint + commit:
bash
uv run --no-sync ruff format --check xqsa/cuda_gpu.py xqsa/tests/test_xqsa.py && uv run --no-sync ruff check xqsa/cuda_gpu.py xqsa/tests/test_xqsa.py
git add xqsa/cuda_gpu.py xqsa/tests/test_xqsa.py
git commit -s -m "perf(xqsa): parallelize CUDA SA local-field reduction across block"

Task 3: CUDA verification against baselines (RTX 4090)

Files:

  • Create (uncommitted, scratch): scratch/qui-852/new-trajectories.json, scratch/qui-852/new-parity.json, scratch/qui-852/new-timing.json

Interfaces:

  • Consumes: scripts/bench_occupancy.py (Task 1), the baselines in scratch/qui-852/baseline-*.json, and the new kernels (Task 2).

  • Produces: the compare outputs (paste into your report verbatim); Task 5 copies the numbers into the findings doc.

  • Step 1: Re-run the three captures on the new kernel:

bash
uv run --extra cuda python scripts/bench_occupancy.py trajectories --out scratch/qui-852/new-trajectories.json
uv run --extra cuda python scripts/bench_occupancy.py parity --out scratch/qui-852/new-parity.json
uv run --extra cuda python scripts/bench_occupancy.py timing --out scratch/qui-852/new-timing.json

(timeouts 600000 / 900000 / 900000 โ€” the new kernel should be much faster; if a run exceeds its timeout, that is itself a finding, report it).

  • Step 2: The trajectory gate (MUST exit 0):

Run: uv run --no-sync python scripts/bench_occupancy.py compare --mode trajectories scratch/qui-852/baseline-trajectories.json scratch/qui-852/new-trajectories.json
Expected: OK: all 120 trajectory records bit-identical, exit 0. If ANY record differs, the kernel violates the sequential-semantics contract โ€” STOP, report BLOCKED with the mismatch list. Do not proceed and do not relax the gate.

  • Step 3: Parity + timing comparisons (informational, paste output):
bash
uv run --no-sync python scripts/bench_occupancy.py compare --mode parity scratch/qui-852/baseline-parity.json scratch/qui-852/new-parity.json
uv run --no-sync python scripts/bench_occupancy.py compare --mode timing scratch/qui-852/baseline-timing.json scratch/qui-852/new-timing.json

Flag in your report if any parity dmean exceeds 2%, or if any timing case shows < 2x speedup (the reduction should be a large win at n=4096; a small win means the launch change didn't take effect).

  • Step 4: No commit (scratch only). Write all numbers into your report file โ€” they are the evidence Task 5 publishes.

Task 4: Metal โ€” MSL threadgroup reduction + per-strategy dispatch width

Files:

  • Modify: xqsa/metal_gpu.py โ€” _SA_KERNEL MSL source, buffer-ABI comment block (~lines 63-73), _dispatch (~line 521), the sa dispatch call site inside _run_sa, module constants
  • Modify: xqsa/tests/test_xqsa.py โ€” mock_metal_env's _Encoder.dispatchThreadgroups_threadsPerThreadgroup_ (record the width), two new mocked tests

Interfaces:

  • Consumes: MSL sa_metal buffer ABI (0:h 1:J 2:beta 3:samples 4:energies 5:n 6:num_sweeps 7:base_seed 8:is_spin) โ€” UNCHANGED; _dispatch(pipeline, encoder, num_reads) currently hardcodes MTLSizeMake(1, 1, 1).

  • Produces: _dispatch(pipeline, encoder, num_reads, threads_per_group=1); sa dispatches with the clamped power-of-two width (โ‰ค _SA_THREADGROUP_WIDTH = 256), gibbs stays at 1. The gibbs MSL kernel is untouched.

  • Step 1: Extend the Metal mock to record the threadgroup width. In mock_metal_env's _Encoder, replace:

python
def dispatchThreadgroups_threadsPerThreadgroup_(self, grid, per_group):
            self.num_reads = int(grid[0])

with:

python
def dispatchThreadgroups_threadsPerThreadgroup_(self, grid, per_group):
            self.num_reads = int(grid[0])
            self.threads_per_group = tuple(int(v) for v in per_group)
  • Step 2: Add the failing mocked tests (inside TestSolverMetalGPUMocked, reusing that class's existing encoder-capture pattern โ€” see test_num_sweeps_per_beta_run_mocked for the capturing_queue scaffold, and reuse it verbatim):
text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 3: Run โ€” expect the sa test to FAIL (ImportError: cannot import name '_SA_THREADGROUP_WIDTH'), the gibbs test to FAIL (AttributeError: threads_per_group) until Step 1's mock change lands (do Step 1 first, then gibbs fails only if the width is wrong):

Run: uv run --no-sync pytest xqsa/tests/test_xqsa.py -k "dispatch_width" -v

  • Step 4: Rewrite the sa_metal MSL kernel body inside _SA_KERNEL (the _RNG_PRELUDE and the buffer parameter list are UNCHANGED; add the two thread-position parameters and restructure the body):
text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 5: Host-side dispatch. Under _SUPPORTED_SCHEDULES = ... in metal_gpu.py add:
python
# Threadgroup width for the sa pipeline's cooperative local-field reduction
# (QUI-852). Clamped at dispatch to the largest power of two that the
# compiled pipeline supports; must match the kernel's `threadgroup float
# sdata[256]`. The gibbs pipeline stays at one thread per replica.
_SA_THREADGROUP_WIDTH = 256

Change _dispatch:

python
def _dispatch(self, pipeline, encoder, num_reads: int, threads_per_group: int = 1) -> None:
        """Encode and dispatch one threadgroup per replica."""
        encoder.setComputePipelineState_(pipeline)
        grid = self._metal.MTLSizeMake(num_reads, 1, 1)
        per_group = self._metal.MTLSizeMake(threads_per_group, 1, 1)
        encoder.dispatchThreadgroups_threadsPerThreadgroup_(grid, per_group)
        encoder.endEncoding()

At the sa dispatch call site in _run_sa, compute the clamped width and pass it (the gibbs call site is left untouched, defaulting to 1):

python
max_threads = int(pipeline.maxTotalThreadsPerThreadgroup())
        width = min(_SA_THREADGROUP_WIDTH, 1 << (max_threads.bit_length() - 1))
        self._dispatch(pipeline, encoder, num_reads, threads_per_group=width)

(Locate the existing self._dispatch(pipeline, encoder, num_reads) inside the sa path; pipeline is the variable already in scope there โ€” keep its existing name if it differs.)

  • Step 6: Update the buffer-ABI comment block (metal_gpu.py ~lines 63-73): change the line # Each threadgroup (one thread) owns one replica via threadgroup_position_in_grid. to:
python
# Each threadgroup owns one replica via threadgroup_position_in_grid.
# sa_metal runs _SA_THREADGROUP_WIDTH cooperating threads per replica
# (local-field reduction, QUI-852); gibbs_metal remains single-thread.
  • Step 7: Run the mocked Metal suite โ€” all pass (the CPU reference in the mock is a semantic simulation keyed on the buffer ABI, which is unchanged):

Run: uv run --no-sync pytest xqsa/tests/test_xqsa.py -k "MetalGPUMocked" -v
Expected: all pass, including the two new dispatch-width tests.

  • Step 8: Full-file mocked regression + lint + commit:
bash
uv run --no-sync pytest xqsa/tests/test_xqsa.py -q
uv run --no-sync ruff format --check xqsa/metal_gpu.py xqsa/tests/test_xqsa.py && uv run --no-sync ruff check xqsa/metal_gpu.py xqsa/tests/test_xqsa.py
git add xqsa/metal_gpu.py xqsa/tests/test_xqsa.py
git commit -s -m "perf(xqsa): parallelize Metal SA local-field reduction across threadgroup"

Expected pytest: same pass/skip counts as before this task plus 2 (real-GPU/Metal classes skipped on this machine).


Task 5: Findings doc + docstring updates

Files:

  • Create: docs/design/specs/2026-07-16-qui-852-findings.md
  • Modify: xqsa/cuda_gpu.py (class docstring, ~line 170), xqsa/metal_gpu.py (class docstring, ~line 228)

Interfaces:

  • Consumes: the Task 1 baseline numbers and Task 3 compare outputs (in .superpowers/sdd/task-1-report.md and .superpowers/sdd/task-3-report.md โ€” read both).

  • Step 1: Write the findings doc with this structure (fill the bracketed numbers from the task reports โ€” every number must come from an actual run, never invent one):

text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 2: Docstring touch-ups. In xqsa/cuda_gpu.py, the class docstring sentence "Each replica (controlled by num_reads) executes independently in its own CUDA thread block." โ€” append: "Within a block, _THREADS_PER_REPLICA threads cooperate on the local-field reduction while spin updates remain sequential (QUI-852)." In xqsa/metal_gpu.py, find the equivalent per-replica sentence in the class docstring and append the analogous sentence naming _SA_THREADGROUP_WIDTH and noting gibbs stays single-threaded.

  • Step 3: Lint + commit:

bash
uv run --no-sync ruff format --check xqsa/cuda_gpu.py xqsa/metal_gpu.py && uv run --no-sync ruff check xqsa/cuda_gpu.py xqsa/metal_gpu.py
git add docs/design/specs/2026-07-16-qui-852-findings.md xqsa/cuda_gpu.py xqsa/metal_gpu.py
git commit -s -m "docs(xqsa): QUI-852 occupancy findings and solver docstrings"

Task 6: Preflight and merge request (controller-executed)

  • Step 1: make preflight-py โ€” all green.
  • Step 2: Push feature/qui-852; glab mr create with target branch feature/qui-854 (stacked on MR !91 โ€” GitLab retargets to main when !91 merges). Description covers: the reduction design, the trajectory gate result, parity stats, the timing table, the explicit Metal deferred-validation caveat, gibbs out-of-scope note, "Implements QUI-852", and the stacked-on-!91 note.
  • Step 3: Verify pipeline starts; move QUI-852 to In Review on Linear with the MR link.

Self-review (done at plan time)

  • Ticket coverage: parallel reduction both solvers (Tasks 2, 4); sequential spins + RNG order preserved (kernel design + trajectory gate, Task 3); near-identical trajectory check โ†’ strengthened to bit-identical on integer instances (Task 3 Step 2); energy-distribution parity across seedsร—instances (Tasks 1/3); best-energy + CUDA-vs-CPU gap context (timing cases n=512/4096, findings ยง3); gibbs/checkerboard out of scope (findings ยง5). โœ“
  • No placeholders: all kernels, harness, tests, and host changes shown in full; findings doc numbers are explicitly sourced from run reports. โœ“
  • Type consistency: _THREADS_PER_REPLICA/_SA_THREADGROUP_WIDTH = 256 everywhere; harness subcommand names match between Tasks 1 and 3; capture-helper keys (grid/block/args) match between helper and test. โœ“

Design Plans 2026 07 16 Qui 854 Cuda Num Sweeps Per Beta (`docs/design/plans/2026-07-16-qui-854-cuda-num-sweeps-per-beta.md`)

QUI-854: `num_sweeps_per_beta` + `beta_schedule_type` for SolverCudaGPU โ€” Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Extend SolverCudaGPU with the num_sweeps_per_beta equilibration knob and beta_schedule_type ("geometric"/"linear"), replacing the in-kernel hardcoded linear beta ramp with a host-built per-sweep schedule buffer, so all three SA solvers share the same API (Linear ticket QUI-854, "fuller option").

Architecture: The CUDA kernels (_SA_BINARY_KERNEL, _SA_SPIN_KERNEL in xqsa/cuda_gpu.py) currently compute beta = beta_start + (beta_end โˆ’ beta_start) ยท (sweep / (num_sweeps โˆ’ 1)) inline. We replace the two double scalar kernel params with a const double* betas buffer of length num_sweeps, built host-side: num_betas = num_sweeps // num_sweeps_per_beta schedule points expanded via np.repeat(..., num_sweeps_per_beta) โ€” mirroring SolverMetalGPU. The mocked test fixture mock_cupy_env contains a numpy reimplementation of the kernel (fake RawKernel) that must change in lockstep with the kernel signature.

Tech Stack: Python 3.12, CuPy (cupy-cuda12x), numpy, pytest. Test runner: uv run pytest. Mocked CUDA tests run without a GPU; real-GPU tests run locally on the RTX 4090.

Global Constraints

  • Bit-identical default (user decision): with num_sweeps_per_beta=1 and beta_schedule_type="linear" (the new default), the schedule must reproduce the retired in-kernel formula bit-for-bit in float64. Do NOT use np.linspace for the linear schedule โ€” use the exact expression beta_start + (beta_end โˆ’ beta_start) * (np.arange(num_betas) / np.float64(num_betas โˆ’ 1)).
  • Divide semantics (QUI-685 parity): num_sweeps stays the total sweep count; num_betas = num_sweeps // num_sweeps_per_beta; num_sweeps must be divisible by num_sweeps_per_beta, else ValueError. Random-buffer size (num_reads * num_sweeps * n) and its OOM pre-flight are unchanged.
  • CUDA default schedule type is "linear" (the historical CUDA ramp), NOT "geometric" (Metal's default) โ€” changing it would break the reproduce-current-output acceptance criterion. Document the divergence.
  • num_betas == 1 semantics: single-element schedule at beta_start (hottest) โ€” matches both the retired kernel's num_sweeps <= 1 branch and SolverMetalGPU.
    Superseded (2026-07-20, after !90 review): the schedule is a single element at beta_end (coldest) โ€” a greedy quench matching SolverDWaveCPU and the revised SolverMetalGPU (!90 commit c9358c9). This intentionally departs from the retired kernel's num_sweeps <= 1 branch, so the bit-identity guarantee above holds only for num_sweeps >= 2. Cross-backend consistency was preferred over bit-identity on a level that performs no annealing.
  • Repo rules: 100-char lines, ruff format + lint clean, Google-style docstrings on public APIs, AGPL headers untouched. Commits: imperative mood, โ‰ค72-char subject, signed off (git commit -s), NO co-author bylines. Commit after every green test cycle (user preference: frequent commits, not per-task).
  • All work on branch feature/qui-854 (Linear's gitBranchName), branched from origin/main. Do not touch xqsa/metal_gpu.py or xqsa/dwave_cpu.py (MR !90 owns those).
  • Mocked test command: uv run --no-sync pytest xqsa/tests/test_xqsa.py -k "CudaGPUMocked" -v
  • Real-GPU test command: uv run --extra cuda pytest xqsa/tests/test_xqsa.py -m cuda -v (if cupy is already importable in the venv, uv run --no-sync works too)

Task 1: Branch + `_compute_beta_schedule` on SolverCudaGPU

Files:

  • Modify: xqsa/cuda_gpu.py (imports ~line 35, module constants ~line 44, new method after _auto_beta_range ~line 315)
  • Test: xqsa/tests/test_xqsa.py (append to class TestSolverCudaGPUMocked, before the real-GPU section ~line 686)

Interfaces:

  • Consumes: nothing new.

  • Produces: SolverCudaGPU._compute_beta_schedule(num_betas: int, beta_range: tuple[float, float], schedule_type: str) -> npt.NDArray[np.float64]; module constant _SUPPORTED_SCHEDULES = frozenset({"geometric", "linear"}). Task 2 calls this from _run_sa and validates against _SUPPORTED_SCHEDULES.

  • Step 1: Create the branch

bash
cd /home/konrad/Quip/xquad && git checkout -b feature/qui-854 origin/main
  • Step 2: Write the failing tests โ€” append inside TestSolverCudaGPUMocked (after test_oom_guard):
python
def test_compute_beta_schedule_mocked(self, mock_cupy_env) -> None:
        """_compute_beta_schedule returns num_betas float64 points for both types."""
        from xqsa.cuda_gpu import SolverCudaGPU as _Solver

        solver = _Solver()
        for schedule_type in ("geometric", "linear"):
            schedule = solver._compute_beta_schedule(20, (0.05, 5.0), schedule_type)
            assert schedule.shape == (20,)
            assert schedule.dtype == np.float64

    def test_compute_beta_schedule_linear_bit_identical_mocked(self, mock_cupy_env) -> None:
        """The linear schedule reproduces the retired in-kernel ramp bit-for-bit."""
        from xqsa.cuda_gpu import SolverCudaGPU as _Solver

        solver = _Solver()
        schedule = solver._compute_beta_schedule(200, (0.05, 5.0), "linear")
        expected = 0.05 + (5.0 - 0.05) * (np.arange(200, dtype=np.float64) / np.float64(199))
        np.testing.assert_array_equal(schedule, expected)

    def test_compute_beta_schedule_single_level_mocked(self, mock_cupy_env) -> None:
        """A single temperature level anneals at beta_start (hottest)."""
        from xqsa.cuda_gpu import SolverCudaGPU as _Solver

        solver = _Solver()
        for schedule_type in ("geometric", "linear"):
            schedule = solver._compute_beta_schedule(1, (0.05, 5.0), schedule_type)
            assert schedule.shape == (1,)
            assert schedule[0] == np.float64(0.05)
  • Step 3: Run tests to verify they fail

Run: uv run --no-sync pytest xqsa/tests/test_xqsa.py -k "compute_beta_schedule" -v
Expected: 3 FAILED with AttributeError: 'SolverCudaGPU' object has no attribute '_compute_beta_schedule'

  • Step 4: Implement. In xqsa/cuda_gpu.py:

(a) Under import numpy as np (line 35) add:

python
import numpy.typing as npt

(b) Under _SUPPORTED_STRATEGIES = frozenset({"sa"}) (line 44) add:

python
_SUPPORTED_SCHEDULES = frozenset({"geometric", "linear"})

(c) After _auto_beta_range (ends line 315) add:

python
def _compute_beta_schedule(
        self, num_betas: int, beta_range: tuple[float, float], schedule_type: str
    ) -> npt.NDArray[np.float64]:
        """Build the per-temperature-level inverse-temperature schedule.

        Returns ``num_betas`` beta points; ``_run_sa`` expands this to the
        per-sweep kernel buffer via ``np.repeat(..., num_sweeps_per_beta)``.

        The ``"linear"`` branch reproduces the retired in-kernel ramp
        ``beta_start + (beta_end - beta_start) * (sweep / (num_sweeps - 1))``
        term-for-term in float64, so the default configuration
        (``num_sweeps_per_beta=1``) is bit-identical to the pre-buffer
        kernel output. Do not replace it with ``np.linspace``, which
        computes linear spacing differently in the last ulp.
        """
        beta_start, beta_end = beta_range
        if num_betas == 1:
            # A single temperature level anneals at beta_start (hottest),
            # matching the retired kernel's num_sweeps <= 1 branch and
            # SolverMetalGPU's single-level convention.
            return np.array([beta_start], dtype=np.float64)
        if schedule_type == "geometric":
            start = max(beta_start, 1e-12)
            return np.geomspace(start, beta_end, num_betas).astype(np.float64)
        steps = np.arange(num_betas, dtype=np.float64) / np.float64(num_betas - 1)
        return np.float64(beta_start) + (np.float64(beta_end) - np.float64(beta_start)) * steps
  • Step 5: Run tests to verify they pass

Run: uv run --no-sync pytest xqsa/tests/test_xqsa.py -k "compute_beta_schedule" -v
Expected: 3 PASSED

  • Step 6: Commit
bash
git add xqsa/cuda_gpu.py xqsa/tests/test_xqsa.py
git commit -s -m "feat(xqsa): add host-side beta schedule builder to SolverCudaGPU"

Task 2: Kernel ABI change, parameter plumbing, and fixture lockstep

Files:

  • Modify: xqsa/cuda_gpu.py โ€” kernel sources (lines 50-162), __init__ (193-217), solve (229-281), _run_sa (317-391), class docstring (166-191)
  • Modify: xqsa/tests/test_xqsa.py โ€” mock_cupy_env fake kernel (_make_sa_kernel, ~lines 495-556)
  • Test: xqsa/tests/test_xqsa.py (append inside TestSolverCudaGPUMocked)

Interfaces:

  • Consumes: _compute_beta_schedule and _SUPPORTED_SCHEDULES from Task 1.

  • Produces: new kernel ABI โ€” args (h, J, samples, energies, randoms, betas, n, num_sweeps) where betas is a float64 device array of length num_sweeps at index 5; new public params num_sweeps_per_beta: int = 1 and beta_schedule_type: str = "linear" on __init__ and solve kwargs; metadata params keys {strategy, num_sweeps, num_sweeps_per_beta, num_betas, beta_range, beta_schedule_type, raw_energy}. Task 3's capture tests read the buffer at args index 5.

  • Step 1: Write the failing validation/metadata tests โ€” append inside TestSolverCudaGPUMocked:

text
/* Detailed source-code truncated for AI context efficiency. */

Also extend the existing test_metadata_schema_mocked (test_xqsa.py:629) by adding after its current assertion:

python
assert set(result.metadata["params"].keys()) == {
            "strategy",
            "num_sweeps",
            "num_sweeps_per_beta",
            "num_betas",
            "beta_range",
            "beta_schedule_type",
            "raw_energy",
        }
  • Step 2: Run tests to verify they fail

Run: uv run --no-sync pytest xqsa/tests/test_xqsa.py -k "CudaGPUMocked" -v
Expected: the 3 new tests FAIL (no ValueError raised / KeyError num_sweeps_per_beta); test_metadata_schema_mocked FAILS on the key-set; all other mocked tests still PASS.

  • Step 3: Change both kernel sources. In _SA_BINARY_KERNEL (lines 50-106) and _SA_SPIN_KERNEL (lines 108-162), apply the identical two edits:

(a) Replace the parameter list tail

c
int    n,
    int    num_sweeps,
    double beta_start,
    double beta_end

with

c
const double* __restrict__ betas,
    int    n,
    int    num_sweeps

placing const double* __restrict__ betas, directly after the randoms parameter (so the pointer params stay grouped, ABI order: h, J, samples, energies, randoms, betas, n, num_sweeps).

(b) Replace the in-loop beta computation

c
double beta;
        if (num_sweeps <= 1) {
            beta = beta_start;
        } else {
            beta = beta_start
                + (beta_end - beta_start)
                    * ((double)sweep / (double)(num_sweeps - 1));
        }

with

c
double beta = betas[sweep];
  • Step 4: Plumb the parameters through the host side.

(a) __init__ (line 193) โ€” new signature and validation (mirror SolverMetalGPU's ordering; add the schedule-type check after the strategy check at line 210, and store the new attributes):

python
def __init__(
        self,
        strategy: str = "sa",
        num_reads: int = 100,
        num_sweeps: int = 1000,
        num_sweeps_per_beta: int = 1,
        beta_range: tuple[float, float] | None = None,
        beta_schedule_type: str = "linear",
        seed: int | None = None,
    ) -> None:

after the existing strategy check:

python
if beta_schedule_type not in _SUPPORTED_SCHEDULES:
            raise ValueError(
                f"Unsupported beta_schedule_type {beta_schedule_type!r}. "
                f"Supported: {sorted(_SUPPORTED_SCHEDULES)}"
            )

and with the other attribute assignments:

python
self.num_sweeps_per_beta = num_sweeps_per_beta
        self.beta_schedule_type = beta_schedule_type

(b) solve (line 229) โ€” read the kwargs alongside the existing ones:

python
num_sweeps_per_beta = kwargs.get("num_sweeps_per_beta", self.num_sweeps_per_beta)
        beta_schedule_type = kwargs.get("beta_schedule_type", self.beta_schedule_type)

after the existing num_sweeps < 1 check add:

python
if beta_schedule_type not in _SUPPORTED_SCHEDULES:
            raise ValueError(
                f"Unsupported beta_schedule_type {beta_schedule_type!r}. "
                f"Supported: {sorted(_SUPPORTED_SCHEDULES)}"
            )
        if num_sweeps_per_beta < 1:
            raise ValueError(f"num_sweeps_per_beta must be >= 1, got {num_sweeps_per_beta}")
        num_betas, rem = divmod(num_sweeps, num_sweeps_per_beta)
        if rem != 0:
            raise ValueError(
                f"num_sweeps ({num_sweeps}) must be divisible by "
                f"num_sweeps_per_beta ({num_sweeps_per_beta})"
            )

pass both through the _run_sa call:

python
best_sample_dict, raw_energy = self._run_sa(
            model=model,
            h=h,
            j_matrix=j_matrix,
            num_reads=num_reads,
            num_sweeps=num_sweeps,
            num_sweeps_per_beta=num_sweeps_per_beta,
            beta_schedule_type=beta_schedule_type,
            beta_range=beta_range,
            seed=seed,
        )

and extend the metadata params dict (line 274) to:

python
"params": {
                    "strategy": strategy,
                    "num_sweeps": num_sweeps,
                    "num_sweeps_per_beta": num_sweeps_per_beta,
                    "num_betas": num_betas,
                    "beta_range": beta_range,
                    "beta_schedule_type": beta_schedule_type,
                    "raw_energy": raw_energy,
                },

(c) _run_sa (line 317) โ€” make params keyword-only (it is already called with keywords) and add the two new ones:

python
def _run_sa(
        self,
        model: XQMX,
        *,
        h: cp.ndarray,
        j_matrix: cp.ndarray,
        num_reads: int,
        num_sweeps: int,
        num_sweeps_per_beta: int,
        beta_schedule_type: str,
        beta_range: tuple[float, float] | None,
        seed: int | None,
    ) -> tuple[dict[int, int], float]:

replace the two lines beta_start, beta_end = beta_range (line 339) with schedule construction (immediately after the beta_range is None auto-range block):

python
# Host-built per-sweep schedule: num_betas levels, each held for
        # num_sweeps_per_beta consecutive sweeps (divisibility validated
        # in solve). Replaces the retired in-kernel linear ramp.
        num_betas = num_sweeps // num_sweeps_per_beta
        betas_host = self._compute_beta_schedule(num_betas, beta_range, beta_schedule_type)
        beta_schedule = cupy.asarray(np.repeat(betas_host, num_sweeps_per_beta))

and change the kernel invocation args tuple (lines 370-380) to:

python
(
                h,
                j_matrix,
                samples,
                energies,
                randoms,
                beta_schedule,
                np.int32(n),
                np.int32(num_sweeps),
            ),
  • Step 5: Update the fake kernel in mock_cupy_env in lockstep. In _make_sa_kernel (test_xqsa.py ~line 495), change the unpack line
python
h, J, samples, energies, randoms, n_val, ns_val, bs, be = args

to

python
h, J, samples, energies, randoms, betas, n_val, ns_val = args

delete the lines beta_start = float(bs) and beta_end = float(be), and replace the in-loop block

python
for sweep in range(num_sweeps):
                    if num_sweeps <= 1:
                        beta = beta_start
                    else:
                        beta = beta_start + (beta_end - beta_start) * (sweep / (num_sweeps - 1))

with

python
for sweep in range(num_sweeps):
                    beta = float(betas[sweep])
  • Step 6: Run the full mocked class

Run: uv run --no-sync pytest xqsa/tests/test_xqsa.py -k "CudaGPUMocked" -v
Expected: ALL PASS โ€” the 3 new tests, the extended test_metadata_schema_mocked, and every pre-existing mocked test (default path regression: same energies, since the default schedule is bit-identical).

  • Step 7: Commit
bash
git add xqsa/cuda_gpu.py xqsa/tests/test_xqsa.py
git commit -s -m "feat(xqsa): add num_sweeps_per_beta + beta_schedule_type to SolverCudaGPU"

Task 3: Behavioral schedule-buffer tests (mocked)

Files:

  • Test: xqsa/tests/test_xqsa.py (append inside TestSolverCudaGPUMocked)

Interfaces:

  • Consumes: kernel ABI from Task 2 โ€” the beta buffer is args[5] of the fake-kernel call; _binary_kernel is a functools.cached_property, so an instance-dict override shadows it.

  • Produces: nothing for later tasks (leaf task).

  • Step 1: Write the tests (they exercise already-implemented behavior; expected to pass โ€” their value is regression):

text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 2: Run the tests

Run: uv run --no-sync pytest xqsa/tests/test_xqsa.py -k "CudaGPUMocked" -v
Expected: ALL PASS (6 new + all prior). If any new test fails, the implementation from Task 2 has a real bug โ€” debug it, do not weaken the test.

  • Step 3: Commit
bash
git add xqsa/tests/test_xqsa.py
git commit -s -m "test(xqsa): verify SolverCudaGPU beta buffer semantics via mocked kernel ABI"

Task 4: Real-GPU tests, docstring, changelog

Files:

  • Modify: xqsa/tests/test_xqsa.py โ€” class TestSolverCudaGPU (real hardware, starts ~line 699): extend test_default_params / test_custom_params, append 3 tests
  • Modify: xqsa/cuda_gpu.py โ€” class docstring (lines 166-191)
  • Modify: CHANGELOG.md

Interfaces:

  • Consumes: public API from Task 2 (num_sweeps_per_beta, beta_schedule_type, metadata keys).

  • Produces: nothing for later tasks.

  • Step 1: Extend the real-GPU param tests. In test_default_params add after the num_sweeps assertion:

python
assert solver.num_sweeps_per_beta == 1
        assert solver.beta_schedule_type == "linear"

In test_custom_params, change the constructor call and add assertions:

python
solver = SolverCudaGPU(
            strategy="sa", num_reads=50, num_sweeps=500, num_sweeps_per_beta=5, seed=42
        )
        assert solver.num_reads == 50
        assert solver.num_sweeps == 500
        assert solver.num_sweeps_per_beta == 5
        assert solver.seed == 42
  • Step 2: Append the real-GPU behavior tests (inside TestSolverCudaGPU, mirroring the Metal class pattern at test_xqsa.py:1663+ conventions):
text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 3: Run the real-GPU suite locally (RTX 4090)

Run: uv run --extra cuda pytest xqsa/tests/test_xqsa.py -m cuda -v
(If cupy is already in the venv, uv run --no-sync pytest xqsa/tests/test_xqsa.py -m cuda -v.)
Expected: ALL PASS, including every pre-existing real-GPU test (this compiles the modified kernels with NVRTC โ€” it is the only step that proves the new CUDA source is valid). If NVRTC compilation fails, the kernel edit has a syntax error; fix xqsa/cuda_gpu.py, not the test.

  • Step 4: Update the class docstring. In xqsa/cuda_gpu.py, after the sentence ending "its own CUDA thread block." (line 170) insert:
text
``num_sweeps_per_beta`` holds each inverse-temperature (beta) for that
    many consecutive sweeps, decoupling the number of temperature levels
    from the total sweep count: ``num_betas = num_sweeps //
    num_sweeps_per_beta`` (``num_sweeps`` must be divisible by it). The
    default of 1 keeps one beta per sweep, bit-identical to the previous
    behaviour. When ``num_betas == 1`` the whole run anneals at
    ``beta_start`` (hottest), matching ``SolverMetalGPU``.

    ``beta_schedule_type`` selects the schedule shape: ``"linear"`` (the
    default, preserving the historical CUDA ramp) or ``"geometric"``.
    Note: ``SolverMetalGPU`` defaults to ``"geometric"``.

and extend the Raises: section line for ValueError to:

text
ValueError: if ``strategy`` or ``beta_schedule_type`` is not supported.
  • Step 5: Add the changelog entry. In CHANGELOG.md, insert directly above ## [0.3.0] - 2026-07-13:
markdown
## [Unreleased]

### Added
- **xqsa**: Add `num_sweeps_per_beta` and `beta_schedule_type` to SolverCudaGPU (QUI-854)

(If an ## [Unreleased] section already exists by then, add the bullet to its ### Added list instead.)

  • Step 6: Re-run mocked suite + commit

Run: uv run --no-sync pytest xqsa/tests/test_xqsa.py -k "CudaGPU" -v
Expected: ALL PASS.

bash
git add xqsa/cuda_gpu.py xqsa/tests/test_xqsa.py CHANGELOG.md
git commit -s -m "test(xqsa): cover SolverCudaGPU schedule params on real GPU; document"

Task 5: Preflight and merge request

Files:

  • No new files; runs checks and pushes.

Interfaces:

  • Consumes: everything above, all committed on feature/qui-854.

  • Step 1: Full Python preflight

Run: make preflight-py (from /home/konrad/Quip/xquad)
Expected: ruff format clean, ruff lint clean, pytest green (~725 passed, hardware-marked tests deselected). Fix any formatting the tooling flags, amend nothing โ€” new commit style(xqsa): appease ruff only if needed.

  • Step 2: Push and open the MR
bash
git push -u origin feature/qui-854
glab mr create --title "feat(xqsa): add num_sweeps_per_beta and beta_schedule_type to SolverCudaGPU" \
  --description-file <path-to-description> --assignee kleczkowski

MR description must state: divide semantics matching QUI-685; kernel ABI change (scalars โ†’ per-sweep betas buffer at arg index 5); bit-identical default (linear schedule computed with the retired formula's exact float64 expression, verified by test_default_schedule_bit_identical_mocked); CUDA keeps "linear" default vs Metal's "geometric" and why; random buffer + OOM guard unchanged; testing evidence (mocked suite + real RTX 4090 run counts); "Implements QUI-854". Fill the repo's MR checklist honestly (preflight-py checked; preflight-rs/parity N/A; real-GPU -m cuda run results).

  • Step 3: Verify pipeline starts

Run: glab mr view feature/qui-854 and confirm the MR exists and CI is running.


Self-review (done at plan time)

  • Spec coverage: all five QUI-854 acceptance criteria have tasks (plumbing โ†’ Task 2; default reproduces output โ†’ Tasks 1-3 bit-identical tests; metadata โ†’ Task 2; mocked + real tests โ†’ Tasks 2-4; fuller option beta_schedule_type โ†’ Tasks 2-4). โœ“
  • No placeholders; every code step shows the code. โœ“
  • Type consistency: _compute_beta_schedule signature identical across Tasks 1/2; buffer at args index 5 consistent across Tasks 2/3; metadata keys consistent across Tasks 2/3/4. โœ“

Design Plans 2026 07 17 Qui 705 Cuda On Device Rng (`docs/design/plans/2026-07-17-qui-705-cuda-on-device-rng.md`)

QUI-705: CUDA On-Device RNG Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace SolverCudaGPU's pre-generated (num_reads, num_sweeps, n) float64 acceptance-randoms buffer with an on-device per-replica xorshift64 RNG (splitmix64-seeded), removing the buffer allocation and its 80%-of-VRAM OOM guard, matching SolverMetalGPU's strategy.

Architecture: A CUDA prelude (splitmix64 + seed_for_replica + xorshift64 + 53-bit rand_unit) is prepended to both SA kernels, mirroring Metal's _RNG_PRELUDE structure but with 64-bit state so acceptance stays float64 (per the MR !88 review decision โ€” Metal's float32 is an MSL platform limit, not a precision choice). Kernels drop the randoms pointer and take a base_seed scalar; the host drops the buffer allocation and OOM guard. Since the sampling stream changes, validation is distributional (energy-distribution parity + best-energy regression), never exact-trajectory.

Tech Stack: Python 3 + CuPy RawKernel (CUDA C), numpy-mocked CuPy for CI tests, pytest.

Global Constraints

  • Base branch: origin/feature/qui-852 (NOT main). QUI-705 touches the same kernels as QUI-852 (MR !92, in review, stacked on !91โ†’!90). Branch name: feature/qui-705. MR target: feature/qui-852. GitLab will retarget as the stack merges.
  • Worktree: create via superpowers:using-git-worktrees at .claude/worktrees/qui-705. Subagents pin to their launch dir โ€” every Bash command in a subagent must start with cd /home/konrad/Quip/xquad/.claude/worktrees/qui-705 && (path may differ if the skill picks another location; use the actual one).
  • Worktree env setup (known gotchas from QUI-852): run make deps-py first (writes xq-rs-workspace.pth; bare uv sync does not). Then uv sync --extra cuda && make deps-py (re-sync installs cupy but clobbers the workspace install; the second deps-py repairs it). After that, ALWAYS uv run --no-sync ... โ€” a plain uv run re-syncs and removes cupy.
  • GPU: local RTX 4090 (WSL2). Verify with uv run --no-sync python -c "import cupy; print(cupy.cuda.runtime.getDeviceCount())" โ†’ 1.
  • Commit after every file-modification step (user's standing preference), imperative mood, โ‰ค72-char subject, no co-author bylines.
  • New/edited Python files: AGPL header block (copy verbatim from scripts/bench_spin_packing.py lines 1โ€“16), 100-char lines, โ‰ค100-line functions, Google-style docstrings on public APIs. make fmt-py lint-py must be clean before each commit.
  • Decisions already settled in the Linear ticket (do not re-litigate): float64 acceptance on-device; splitmix64 per-replica seeding over (base_seed, rid); distributional success criterion.
  • Decisions taken in this plan (flag in MR description): xorshift64 as the stream generator (simplest 64-bit analogue of Metal's xorshift32; statistical quality is ample for SA acceptance); seed=None draws a random base_seed from np.random.default_rng(None); parity thresholds KS ฮฑ=0.01, mean-shift โ‰ค2%, best-energy โ‰ค2% (2% mirrors GLASS_TOLERANCE in xqsa/tests/test_gpu_validation.py:68).

Task 1: Distributional-parity harness

Files:

  • Create: scripts/validate_rng_parity.py

Interfaces:

  • Produces: CLI run --out <json> [--smoke] and compare <candidate.json> (exit 0 = parity holds, 1 = failed). Task 2 runs run on the unmodified solver; Task 6 runs run + compare on the candidate.

  • Consumes: build_instance(n, seed) from scripts/bench_spin_packing.py (returns (XQMX, h, J); only the model is used).

  • Step 1: Write the harness

Create scripts/validate_rng_parity.py with the AGPL header (copy lines 1โ€“16 of scripts/bench_spin_packing.py), then:

text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 2: Sanity-check the harness on the unmodified solver (needs the GPU)

Run: uv run --no-sync python scripts/validate_rng_parity.py run --smoke --out scratch/qui-705/smoke_a.json
Expected: one n=64 instance=7: best=... mean=... line, wrote scratch/qui-705/smoke_a.json, exit 0.

Run it a second time to scratch/qui-705/smoke_b.json, then:
uv run --no-sync python scripts/validate_rng_parity.py compare scratch/qui-705/smoke_a.json scratch/qui-705/smoke_b.json
Expected: PASS: 1 cells within KS/mean/best-energy thresholds, exit 0. (Identical seeds on the same code give identical energies โ€” D=0.)

  • Step 3: Lint and commit

Run: make fmt-py lint-py
Expected: no diffs, no warnings.

bash
git add scripts/validate_rng_parity.py
git commit -m "test(xqsa): add RNG distributional-parity harness for QUI-705"

Task 2: Capture the pre-change baseline (GPU, before ANY solver edit)

The baseline must come from the unmodified solver. Do not start Task 3/4 until this task's JSON files exist.

Files:

  • Create (not committed; scratch/ is untracked): scratch/qui-705/baseline.json, scratch/qui-705/sweep_baseline.jsonl

  • Step 1: Full parity baseline

Run: uv run --no-sync python scripts/validate_rng_parity.py run --out scratch/qui-705/baseline.json
Expected: 6 cell lines (2 sizes ร— 3 instances, 24 solves each) and wrote scratch/qui-705/baseline.json. Order minutes on the RTX 4090 with the QUI-852 kernels.

  • Step 2: QUI-167 sweep baseline (best-energy regression reference)
bash
mkdir -p scratch/qui-705
for n in 128 512 1024; do for s in 1 2 3; do
  uv run --no-sync python scripts/scale_sweep.py --point --problem maxcut \
    --backend rust --solver cuda-gpu --n $n --seed $s >> scratch/qui-705/sweep_baseline.jsonl
done; done
for n in 16 32; do for s in 1 2 3; do
  uv run --no-sync python scripts/scale_sweep.py --point --problem tsp \
    --backend rust --solver cuda-gpu --n $n --seed $s >> scratch/qui-705/sweep_baseline.jsonl
done; done

Expected: 15 JSON lines, each with "status": "ok" and an energy field. Verify: wc -l scratch/qui-705/sweep_baseline.jsonl โ†’ 15; rg -c '"status": "ok"' scratch/qui-705/sweep_baseline.jsonl โ†’ 15.


Task 3: Update the mocked-CuPy tests to the new kernel contract (TDD red)

Files:

  • Modify: xqsa/tests/test_xqsa.py (fixture mock_cupy_env ~line 458; TestSolverCudaGPUMocked ~line 562)

Interfaces:

  • Consumes: nothing from other tasks (this is the failing-test step).

  • Produces: the kernel-call contract Task 4 implements โ€” args tuple (h, J, samples, energies, betas, n, num_sweeps, base_seed) with base_seed an np.uint64; Python RNG mirrors _splitmix64, _seed_for_replica, _xorshift64, _rand_unit whose constants Task 4's CUDA prelude must match bit-for-bit.

  • Step 1: Add Python mirrors of the device RNG above the mock_cupy_env fixture

python
# Python mirrors of the CUDA device RNG in xqsa.cuda_gpu._RNG_PRELUDE
# (QUI-705). Constants and update order must match the kernel bit-for-bit;
# the fake kernel below consumes these so the mocked solve reproduces the
# device sampling stream exactly.
_MASK64 = (1 << 64) - 1


def _splitmix64(x: int) -> int:
    x = (x + 0x9E3779B97F4A7C15) & _MASK64
    x = ((x ^ (x >> 30)) * 0xBF58476D1CE4E5B9) & _MASK64
    x = ((x ^ (x >> 27)) * 0x94D049BB133111EB) & _MASK64
    return x ^ (x >> 31)


def _seed_for_replica(base_seed: int, rid: int) -> int:
    s = _splitmix64(base_seed ^ ((0x9E3779B97F4A7C15 * (rid + 1)) & _MASK64))
    return s if s != 0 else 1


def _xorshift64(state: int) -> int:
    x = state
    x ^= (x << 13) & _MASK64
    x ^= x >> 7
    x ^= (x << 17) & _MASK64
    return x


def _rand_unit(state: int) -> tuple[float, int]:
    state = _xorshift64(state)
    return (state >> 11) / 9007199254740992.0, state
  • Step 2: Rewrite the fake kernel to the new signature and stream

In _make_sa_kernel's inner _kernel, replace the unpack line

python
h, J, samples, energies, randoms, betas, n_val, ns_val = args

with

python
h, J, samples, energies, betas, n_val, ns_val, base_seed = args

Inside the for rid in range(num_reads): loop, immediately after x = samples[rid], add:

python
rng_state = _seed_for_replica(int(base_seed), rid)

and replace the acceptance draw

python
r = randoms[rid, sweep, i]

with

python
r, rng_state = _rand_unit(rng_state)
  • Step 3: Delete test_oom_guard

Remove the whole test_oom_guard method (currently xqsa/tests/test_xqsa.py:660-680 on the base branch) โ€” the guard it exercises is deleted in Task 4. Also remove the now-unused _TinyDevice-free leftovers if any (the class is local to the test; deleting the method removes it all).

  • Step 4: Re-index the captured-args tests from args[5] to args[4]

With randoms gone, the betas buffer moves from index 5 to index 4. In these four tests, change captured["args"][5] โ†’ captured["args"][4]:

  • test_num_sweeps_per_beta_schedule_buffer_mocked

  • test_default_schedule_bit_identical_mocked

  • test_single_beta_level_uses_beta_start_mocked

  • test_geometric_schedule_shape_mocked

  • Step 5: Add seed-semantics tests to TestSolverCudaGPUMocked

python
def test_same_seed_reproducible_mocked(self, mock_cupy_env) -> None:
        """Identical seeds give identical best energy and sample (QUI-705)."""
        from xqsa.cuda_gpu import SolverCudaGPU as _Solver

        model = XQMX.spin_model(6)
        model.set_quadratic(0, 1, 1.0)
        model.set_quadratic(1, 2, -1.0)
        model.set_quadratic(2, 3, 1.0)
        model.set_quadratic(3, 4, -1.0)
        model.set_quadratic(4, 5, 1.0)
        model.set_quadratic(0, 5, 1.0)

        r1 = _Solver(num_reads=4, num_sweeps=30, seed=7).solve(model)
        r2 = _Solver(num_reads=4, num_sweeps=30, seed=7).solve(model)

        assert r1.energy == r2.energy
        assert [r1.sample.get_linear(i) for i in range(6)] == [
            r2.sample.get_linear(i) for i in range(6)
        ]

    def test_seed_none_solves_mocked(self, mock_cupy_env) -> None:
        """seed=None draws a random base seed and still solves (QUI-705)."""
        from xqsa.cuda_gpu import SolverCudaGPU as _Solver

        model = XQMX.binary_model(2)
        model.set_linear(0, 1.0)
        model.set_linear(1, 1.0)

        result = _Solver(num_reads=10, num_sweeps=100, seed=None).solve(model)

        assert result.energy == 0
        assert result.metadata["seed"] is None
  • Step 6: Run the CUDA-mocked tests to verify they fail against the old implementation

Run: uv run --no-sync pytest xqsa/tests/test_xqsa.py -k "Cuda" -x -q
Expected: FAIL. The old _run_sa still passes 8 old-layout args, so the fake kernel binds the 3-D randoms array to betas and float(betas[sweep]) raises TypeError (or similar unpack/shape error). The point is red-before-green, not the specific exception.

  • Step 7: Commit the red tests
bash
git add xqsa/tests/test_xqsa.py
git commit -m "test(xqsa): retarget mocked CUDA tests at on-device RNG contract"

(Repo hooks permitting a red suite; if a pre-commit hook runs pytest and blocks, commit Tasks 3+4 together at the end of Task 4 instead and note it in the MR.)


Task 4: Implement the on-device RNG in `SolverCudaGPU` (TDD green)

Files:

  • Modify: xqsa/cuda_gpu.py (kernel sources ~lines 57โ€“192; _run_sa ~lines 415โ€“496; class docstring ~line 196)

Interfaces:

  • Consumes: the args-tuple contract and RNG constants from Task 3.

  • Produces: SolverCudaGPU with unchanged Python API (seed= still reproducible, seed=None still random); no randoms buffer, no OOM guard.

  • Step 1: Add the RNG prelude and rewrite both kernel headers

In xqsa/cuda_gpu.py, above _SA_BINARY_KERNEL, add:

python
# Device RNG (QUI-705): mirrors SolverMetalGPU's on-device strategy
# (xqsa/metal_gpu.py _RNG_PRELUDE) with 64-bit state so acceptance draws
# stay float64 (53-bit uniform). Metal's float32 is an MSL platform limit,
# not a precision decision. Python mirrors live in tests/test_xqsa.py;
# keep constants in sync bit-for-bit.
_RNG_PRELUDE = r"""
typedef unsigned long long u64;

__device__ __forceinline__ u64 splitmix64(u64 x) {
    x += 0x9E3779B97F4A7C15ULL;
    x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9ULL;
    x = (x ^ (x >> 27)) * 0x94D049BB133111EBULL;
    return x ^ (x >> 31);
}

/* Per-replica stream seed: Metal's seed_for_replica shape (base ^ odd
   constant * (rid+1)) widened to 64 bits, finalized through splitmix64
   for stream decorrelation. xorshift64 has a zero fixed point, so never
   return 0. */
__device__ __forceinline__ u64 seed_for_replica(u64 base_seed, int rid) {
    u64 s = splitmix64(base_seed ^ (0x9E3779B97F4A7C15ULL * (u64)(rid + 1)));
    return (s == 0ULL) ? 1ULL : s;
}

__device__ __forceinline__ u64 xorshift64(u64 &state) {
    u64 x = state;
    x ^= x << 13;
    x ^= x >> 7;
    x ^= x << 17;
    state = x;
    return x;
}

/* Uniform double in [0, 1): top 53 bits / 2^53. */
__device__ __forceinline__ double rand_unit(u64 &state) {
    return (double)(xorshift64(state) >> 11) * (1.0 / 9007199254740992.0);
}
"""

Change both kernel definitions to prepend it:

python
_SA_BINARY_KERNEL = _RNG_PRELUDE + r"""
extern "C" __global__
void sa_binary(
...

(same for _SA_SPIN_KERNEL).

In both kernel signatures, delete the line

c
const double* __restrict__ randoms,

and add after int num_sweeps:

c
unsigned long long base_seed

(mind the comma on the previous line).

  • Step 2: Swap the in-kernel draw (both kernels)

In sa_binary, replace

c
const double* rand_ptr = randoms + (long long)rid * num_sweeps * n;

with

c
u64 rng = seed_for_replica(base_seed, rid);

and replace

c
double r = rand_ptr[(long long)sweep * n + i];

with

c
double r = rand_unit(rng);

Same two edits in sa_spin (its pointer line reads randoms + (long long)rid * num_sweeps * n with const double* rand_ptr = as well). Note the state variable is declared per-thread but only thread 0 ever draws from it (the acceptance block is if (tid == 0)), so the stream is strictly sequential per replica โ€” same property the randoms indexing had. Update the comment block at the top of sa_binary that mentions "same RNG consumption order": the guarantee is now "thread 0 owns the per-replica RNG stream".

  • Step 3: Host-side _run_sa changes

Delete the OOM guard and buffer allocation (the whole block):

python
# Pre-generate all random acceptance thresholds.
        # Memory: num_reads * num_sweeps * n * 8 bytes (float64).
        random_bytes = num_reads * num_sweeps * n * 8
        gpu_free = cupy.cuda.Device().mem_info[0]
        if random_bytes > gpu_free * 0.8:
            raise ValueError(
                f"Random buffer requires {random_bytes / 1e9:.1f} GB "
                f"GPU memory ({gpu_free / 1e9:.1f} GB free). "
                f"Reduce num_reads or num_sweeps."
            )
        randoms = rng.random(size=(num_reads, num_sweeps, n), dtype=cupy.float64)

Replace it with:

python
# Acceptance randomness is generated on-device per replica
        # (QUI-705); only a 64-bit base seed crosses the host boundary.
        # Derived from an independent numpy stream so the cupy `rng` used
        # for replica init keeps its historical consumption order.
        base_seed = np.uint64(np.random.default_rng(seed).integers(0, 2**64, dtype=np.uint64))

Update the kernel launch args from

python
(
                h,
                j_matrix,
                samples,
                energies,
                randoms,
                beta_schedule,
                np.int32(n),
                np.int32(num_sweeps),
            ),

to

python
(
                h,
                j_matrix,
                samples,
                energies,
                beta_schedule,
                np.int32(n),
                np.int32(num_sweeps),
                base_seed,
            ),
  • Step 4: Docstring/comment sweep in cuda_gpu.py

  • Class docstring: add one sentence after the QUI-852 paragraph: "Acceptance randomness is generated on-device per replica (xorshift64 seeded via splitmix64 from a 64-bit base seed), so no (num_reads, num_sweeps, n) buffer is allocated (QUI-705)."

  • solve() Raises section: the OOM ValueError is gone; the remaining ValueError causes (domain, parameter validation) still hold โ€” no text change needed unless it mentions memory. Check with rg -n "GPU memory|Reduce num_reads" xqsa/cuda_gpu.py โ†’ no hits after the edit.

  • Step 5: Run the mocked suite to green

Run: uv run --no-sync pytest xqsa/tests/test_xqsa.py -q
Expected: PASS, zero failures, zero warnings. If a mocked-solve energy assertion fails (e.g. test_solve_trivial_binary_mocked no longer reaches ground state), the mirrors are out of sync with the prelude โ€” diff the constants/shift orders between _RNG_PRELUDE and the test mirrors before touching anything else; trivial 2-var models converge under any healthy uniform stream.

  • Step 6: Lint, then commit

Run: make fmt-py lint-py
Expected: clean.

bash
git add xqsa/cuda_gpu.py
git commit -m "refactor(xqsa): move CUDA SA acceptance RNG on-device (QUI-705)"

Task 5: Real-GPU test validation

Files: none modified (verification only).

  • Step 1: Real-kernel compile + medium-model validation on the RTX 4090

Run: CI=1 uv run --no-sync pytest xqsa/tests/test_gpu_validation.py -m cuda -q
(CI=1 forces hard-fail instead of silent skip if the env is broken.)
Expected: TestCudaGPUMediumModels โ€” 4 passed. These are distributional assertions (exact known optima + 2% glass tolerance), so they must pass unchanged; a failure here is a real quality regression, not an expected stream change.

  • Step 2: Confirm the buffer is actually gone

Run:

bash
uv run --no-sync python - <<'EOF'
import cupy
from scripts_path_shim import *  # noqa: F401,F403  (not needed; see below)
EOF

Use this instead (single command):

bash
uv run --no-sync python -c "
import sys; from pathlib import Path
sys.path.insert(0, 'scripts')
import cupy
from bench_spin_packing import build_instance
from xqsa.cuda_gpu import SolverCudaGPU
model, _, _ = build_instance(1024, 7)
cupy.get_default_memory_pool().free_all_blocks()
SolverCudaGPU(num_reads=64, num_sweeps=500, beta_range=(0.1, 10.0), seed=42).solve(model)
print('mempool peak MiB:', cupy.get_default_memory_pool().total_bytes() / 2**20)
"

Expected: peak well under 100 MiB. The retired randoms buffer alone was 64ยท500ยท1024ยท8 B = 250 MiB at these parameters (and QUI-704 measured it at 88.6โ€“98.3% of the footprint), so a sub-100 MiB peak proves the allocation is gone. Record the number for the MR description.


Task 6: Distributional parity + QUI-167 regression (the ticket's success criterion)

Files:

  • Create: scratch/qui-705/candidate.json, scratch/qui-705/sweep_candidate.jsonl (not committed)

  • Create: docs/design/specs/2026-07-17-qui-705-findings.md

  • Step 1: Candidate parity run

Run: uv run --no-sync python scripts/validate_rng_parity.py run --out scratch/qui-705/candidate.json
Expected: 6 cells, exit 0.

  • Step 2: Compare against the Task 2 baseline

Run: uv run --no-sync python scripts/validate_rng_parity.py compare scratch/qui-705/baseline.json scratch/qui-705/candidate.json
Expected: PASS: 6 cells within KS/mean/best-energy thresholds, exit 0.
If a cell fails: this is the ticket's gate โ€” do NOT loosen thresholds to pass. Diagnose with superpowers:systematic-debugging (suspects, in order: mirror/prelude constant drift, per-replica seed collisions across rid, biased rand_unit).

  • Step 3: QUI-167 sweep candidate points + delta check
bash
for n in 128 512 1024; do for s in 1 2 3; do
  uv run --no-sync python scripts/scale_sweep.py --point --problem maxcut \
    --backend rust --solver cuda-gpu --n $n --seed $s >> scratch/qui-705/sweep_candidate.jsonl
done; done
for n in 16 32; do for s in 1 2 3; do
  uv run --no-sync python scripts/scale_sweep.py --point --problem tsp \
    --backend rust --solver cuda-gpu --n $n --seed $s >> scratch/qui-705/sweep_candidate.jsonl
done; done
uv run --no-sync python -c "
import json
def best(path):
    rows = [json.loads(l) for l in open(path)]
    return {(r['problem'], r['n'], r['seed']): r['energy'] for r in rows}
b, c = best('scratch/qui-705/sweep_baseline.jsonl'), best('scratch/qui-705/sweep_candidate.jsonl')
bad = []
for k in sorted(b):
    tol = 0.02 * abs(b[k])
    flag = '  REGRESSION' if c[k] > b[k] + tol else ''
    print(k, 'base', b[k], 'cand', c[k], flag)
    if flag: bad.append(k)
raise SystemExit(1 if bad else 0)
"

Expected: 15 comparison lines, no REGRESSION flags, exit 0. (If a --point record lacks an energy key, inspect one line of the baseline file and adjust the key in this snippet โ€” the baseline from Task 2 defines the schema; both files share it.)

  • Step 4: Write the findings doc

Create docs/design/specs/2026-07-17-qui-705-findings.md recording: the mempool peak before/after (Task 5 step 2 number vs QUI-704's 250 MiB-at-n=1024 / 1 GiB-at-n=4096 figures), the 6-cell parity table (baseline vs candidate mean/best per cell, KS D values), the 15-point sweep delta table, and the seed-semantics statement ("seed= reproducible, streams decorrelated per replica; sampling stream intentionally differs from the pre-QUI-705 solver"). Follow the structure of docs/design/specs/2026-07-16-qui-852-findings.md.

  • Step 5: Commit
bash
git add docs/design/specs/2026-07-17-qui-705-findings.md
git commit -m "docs(xqsa): record QUI-705 RNG parity validation findings"

Task 7: Changelog, MR, Linear

Files:

  • Modify: CHANGELOG.md (follow the existing unreleased-section format โ€” check git log -p -1 --follow CHANGELOG.md for the house style)

  • Step 1: Changelog entry

Add under the unreleased/v0.3.1 section, matching surrounding entry style:

markdown
- `SolverCudaGPU` now generates acceptance randomness on-device (per-replica
  xorshift64, splitmix64-seeded), removing the `(num_reads, num_sweeps, n)`
  float64 buffer and its GPU-memory guard. Seeded solves remain reproducible;
  the sampling stream differs from previous releases (QUI-705).
  • Step 2: Full preflight + commit

Run: make preflight-py
Expected: clean.

bash
git add CHANGELOG.md
git commit -m "docs: changelog for QUI-705 on-device CUDA RNG"
  • Step 3: Push and open the MR (stacked)
bash
git push -u origin feature/qui-705
glab mr create --source-branch feature/qui-705 --target-branch feature/qui-852 \
  --title "refactor(xqsa): move CUDA SA acceptance RNG on-device (QUI-705)" \
  --description "<body>"

MR body content (plain factual language, no superlatives): what changed (kernel prelude, signature, host allocation removal), the validation evidence (parity PASS output, sweep delta table, mempool peak numbers), the intentional behavior change (sampling stream differs; seed= reproducibility preserved), and the stacking note (targets feature/qui-852; retarget to main as !91/!92 merge). Note the plan-level decisions (xorshift64 choice, thresholds) for reviewer sign-off.

  • Step 4: Linear

Move QUI-705 to "In Review" and comment with the MR link and the parity/regression verdicts (use save_issue/save_comment via the Linear MCP tools).


Self-Review Notes

  • Ticket requirement โ†’ task mapping: on-device per-replica RNG (Task 4), remove OOM guard + allocation (Task 4 step 3, proven in Task 5 step 2), float64 acceptance kept (prelude rand_unit returns 53-bit doubles), splitmix64 per-replica seeding (prelude seed_for_replica), seed semantics confirmed (Task 3 step 5 tests + Task 6 step 4 statement), CUDA tests updated (Task 3), energy-distribution parity across seeds ร— instances with the bench generator (Tasks 1, 2, 6), QUI-167 best-energy regression (Tasks 2, 6), sequencing with QUI-852 (Global Constraints: stacked branch).
  • Type/order consistency: kernel args order (h, J, samples, energies, betas, n, num_sweeps, base_seed) is identical in Task 3 step 2 (mock unpack), Task 4 step 3 (launch), and Task 3 step 4 (betas at index 4). RNG constants appear in exactly two places (prelude, test mirrors) and are byte-identical.
  • Known risk: the mocked fake kernel is O(readsยทsweepsยทnยฒ) pure Python โ€” the new tests use tiny models (nโ‰ค6, โ‰ค30 sweeps) to stay fast.

Design Plans 2026 08 24 Qui 1056 Step Metering (`docs/design/plans/2026-08-24-qui-1056-step-metering.md`)

QUI-1056 Step Metering Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Make one XQVM step a bounded amount of work, so pallet-xqvm's WeightPerStep * steps price is honest for programs that touch a model.

Architecture: The VM keeps two counters: instructions (the ordinal a tracer reports) and steps (metered cost units, what step_limit bounds and what the pallet prices). Every instruction charges a base cost of 1 before dispatch, exactly as today; the handlers whose work scales with program-controlled data charge additional units before doing that work, through a charge_steps helper that mirrors the existing memory charge. Cost constants and the pure cost functions live in a new xqvm/src/metering.rs, mirrored in xqvm_py/metering.py, kept in lockstep by a parity script, and specified normatively in spec/xqvm/METERING.md.

Tech Stack: Rust 2024 (xqvm, xqffi, xquad-conformance), Python 3.13+ (xqvm_py), cargo-nextest, pytest, mdbook.

Spec: QUI-1056 plus the Design section below. Task 9 lands the normative specification at spec/xqvm/METERING.md; until then this document is the spec.

Base: the tree this plan was written against, before QUI-1056 landed. Every line number in this plan is from that tree and is not maintained as those files move; resolve a reference by the name it cites rather than by its line.


Design

The problem

xqvm/src/vm.rs:515 charges one step per instruction:

rust
if self.steps >= self.step_limit {
    return Err(Error::StepLimitExceeded { limit: self.step_limit });
}
self.steps += 1;

ENERGY (vm.rs:2414) clones the sample vector and walks every coefficient in the model, and pays one step. ONEHOTR writes cols + cols*(cols-1)/2 coefficients in one step; ONEHOTC the same in rows; EQUALITY, ATLEAST and ATLEASTW the same in the term count. ITER, LVAL, INPUT and OUTPUT clone whole registers, and a register can hold a model. So the ratio between the cheapest step (NOP) and the dearest has no bound below the memory limit, and a flat per-step price is wrong by that factor.

The memory budget (charge, charge_variables, charge_coefficient, charge_equality_expansion at vm.rs:670ff, mirrored in xqvm_py/executor.py:338ff) already bounds the space those opcodes take, with the charge-before-you-allocate discipline and a Rust/Python-identical formula. This plan applies the same shape to time.

The model

  • Two counters. instructions counts dispatches and is what a Tracer sees. steps counts cost units, is what set_step_limit bounds, and is what an embedder prices. Today they are the same field; splitting them keeps traces readable once costs stop being 1.
  • Base cost 1. Charged before dispatch, unchanged, so a straight-line arithmetic program's step count does not move.
  • Extra units charged before the work. A handler computes the size of the work it is about to do, calls charge_steps, and only then does it. A program that cannot pay performs none of the work, exactly like an allocation it cannot afford.
  • Units are relative to NOP. One unit is one NOP dispatch, rounded so that a unit is never cheaper than the operation it stands for. Task 2 measures the ratios; Task 3 freezes them as named constants.
  • Integer arithmetic, saturating throughout. The counts are consensus-visible, so no floats and no platform-dependent rounding. usize widths are converted with u64::try_from(..).unwrap_or(u64::MAX), matching charge_variables.

Cost table

B = base cost 1, charged for every instruction. Everything below is in addition to B.

Opcode(s) Extra units Constant(s)
ENERGY SAMPLE_COPY_STEPS * sample.len() + MODEL_TERM_STEPS * (linear_len + quadratic_len) both
SETLINE, ADDLINE, SETQUAD, ADDQUAD COEFF_WRITE_STEPS, only when the register holds a model COEFF_WRITE_STEPS
ONEHOTR equality_expansion_steps(cols) COEFF_WRITE_STEPS
ONEHOTC equality_expansion_steps(rows) COEFF_WRITE_STEPS
EQUALITY equality_expansion_steps(n) where n = idx_vec.len() COEFF_WRITE_STEPS
ATLEAST, ATLEASTW equality_expansion_steps(n + num_slacks) COEFF_WRITE_STEPS
BSMX, SSMX, XSMX SAMPLE_COPY_STEPS * size (the buffer is filled) SAMPLE_COPY_STEPS
BQMX, SQMX, XQMX none: an empty model allocates no coefficients --
RANGE ELEMENT_COPY_STEPS * count ELEMENT_COPY_STEPS
ITER over vec ELEMENT_COPY_STEPS * slice.len() ELEMENT_COPY_STEPS
ITER over vec<xqmx> value_copy_steps summed over the sliced models both
LVAL, INPUT, OUTPUT value_copy_steps(value) both
SLACK ELEMENT_COPY_STEPS * 2 * entries ELEMENT_COPY_STEPS
everything else none --

with

text
equality_expansion_steps(n) = (n + n*(n-1)/2) * COEFF_WRITE_STEPS

value_copy_steps(Unset | Int)   = 0
value_copy_steps(VecInt(v))     = ELEMENT_COPY_STEPS * v.len()
value_copy_steps(Sample(s))     = SAMPLE_COPY_STEPS * s.values.len()
value_copy_steps(Model(m))      = COEFF_WRITE_STEPS * (m.linear_len() + m.quadratic_len())
value_copy_steps(VecXqmx(v))    = sum of value_copy_steps over the models

equality_expansion_steps counts the same worst case as equality_expansion_bytes (vm.rs:242): n linear terms and one quadratic term per unordered pair. Repeated indices collide on one map key, so an expansion can write fewer entries than it is charged for -- the count bounds the work, it does not measure it.

Consequences

  • Step counts become observable and normative: xqvm and xqvm_py must produce identical counts for identical programs, and conformance asserts it.
  • Existing programs that touch a model get larger step counts. This is a behavioural break; it lands in 0.4.0 with QUI-1019.
  • Error::StepLimitExceeded gains the position and the refused charge, so a fault is diagnosable the way MemoryLimitExceeded already is.
  • QUI-1054 can then calibrate a flat WeightPerStep against NOP plus a safety multiplier, because a step is now bounded work.

Out of scope

  • Changing the default step limit or set_step_limit(0) semantics (QUI-1053, already fixed).
  • Pallet-side weight calibration (QUI-1054, QUI-1013) -- this plan only makes the unit meaningful.
  • The stale set_step_limit(0) prose in docs/book/src/xqvm/limits-and-errors.md:36 and execution.md:53, which predates QUI-1053.

Global Constraints

  • Every new source file starts with the AGPL header from AGENTS.md (// for Rust, # for Python).
  • Conventional Commits, imperative, lowercase subject, max 72 chars, scope = crate/package name. Sign off with git commit -s. Never add AI co-author trailers.
  • No emojis. No em-dashes in prose -- use --. Four spaces, no tabs.
  • Rust: no unwrap(), no indexing/slicing (.get()/.get_mut()), handle or let _ = every result (unused-results is blocking), document every public item.
  • After edits run make fmt; before declaring a task done run the task's stated checks.
  • xqvm is no_std-capable: metering.rs must not use std.

File Structure

Created:

  • xqvm/src/metering.rs -- cost constants and the pure cost functions. Small and dependency-free so the parity script can read it and so no_std builds are unaffected.
  • xqvm/examples/metering_calibration.rs -- one-off measurement harness that prints the ns/operation ratios the constants are derived from.
  • xqvm_py/metering.py -- the Python mirror of metering.rs.
  • scripts/check-metering-parity.py -- three-way guard: Rust constants, Python constants, and the spec/xqvm/METERING.md table must agree.
  • spec/xqvm/METERING.md -- the normative cost table.
  • conformance/vectors/metering/* -- vectors that assert exact step counts.

Modified:

  • xqvm/src/vm.rs -- counter split, charge_steps, the charge call sites.
  • xqvm/src/error.rs:150 -- StepLimitExceeded fields, and the pos() arm at :262.
  • xqvm/src/lib.rs -- re-export the metering module.
  • xqvm/src/tracer/* -- StepState.step fed from instructions.
  • xqvm/tests/integration.rs -- metering tests.
  • xqffi/src/vm.rs:288 -- add instructions() next to steps().
  • xqvm_py/executor.py -- counter split and charge call sites.
  • conformance/src/lib.rs -- steps on Expected/Outcome/PyOut.
  • Makefile -- metering-parity target wired into check-parity.
  • spec/xqvm/SPEC.md (Runtime Limits), spec/xqvm/ISA.md, docs/book/src/xqvm/execution.md, docs/book/src/xqvm/limits-and-errors.md.

Task 1: The metering primitive

Splits the counters and adds the charge helper. No opcode changes yet, so no step count moves.

Files:

  • Modify: xqvm/src/vm.rs:272 (field), :461-471 (accessors), :506-520 (run loop), :670-711 (charge helpers)
  • Modify: xqvm/src/error.rs:149-151, :246-255
  • Test: xqvm/tests/integration.rs

Interfaces:

  • Consumes: nothing.

  • Produces:

    • Vm::steps(&self) -> u64 -- metered cost units (existing name, new meaning).
    • Vm::instructions(&self) -> u64 -- dispatch ordinal (new).
    • Vm::charge_steps(&mut self, pos: usize, units: u64) -> Result<(), Error> -- private; every later task calls this.
    • Error::StepLimitExceeded { pos: Option, requested: u64, used: u64, limit: u64 }.
  • Step 1: Write the failing tests

Append to xqvm/tests/integration.rs:

rust
// ---------------------------------------------------------------------------
// Metering (QUI-1056)
// ---------------------------------------------------------------------------

#[test]
fn instructions_and_steps_agree_when_every_opcode_costs_base() {
    // 100 NOPs and a HALT: nothing here charges beyond the base cost, so the
    // metered count and the dispatch ordinal are the same number.
    let mut b = InstructionBuilder::new();
    for _ in 0..100 {
        b.emit_nop();
    }
    b.emit_halt();
    let bytecode = b.build().expect("builder build");

    let mut vm = Vm::new();
    vm.set_unlimited_steps();
    vm.run(&bytecode).expect("runs to HALT");
    assert_eq!(vm.steps(), 101);
    assert_eq!(vm.instructions(), 101);
}

#[test]
fn step_limit_error_reports_the_refused_charge() {
    let mut b = InstructionBuilder::new();
    b.emit_push(1).emit_push(2).emit_add();
    let bytecode = b.build().expect("builder build");

    let mut vm = Vm::new();
    vm.set_step_limit(2);
    let err = vm.run(&bytecode).expect_err("two steps is one too few");
    let Error::StepLimitExceeded {
        requested,
        used,
        limit,
        ..
    } = err
    else {
        panic!("expected StepLimitExceeded, got {err:?}");
    };
    assert_eq!(requested, 1, "the base charge is what was refused");
    assert_eq!(used, 2);
    assert_eq!(limit, 2);
}
  • Step 2: Run the tests to verify they fail
bash
cargo nextest run -p xqvm -E 'test(instructions_and_steps_agree_when_every_opcode_costs_base) + test(step_limit_error_reports_the_refused_charge)'

Expected: FAIL -- no method named instructions, and StepLimitExceeded has no requested field.

  • Step 3: Widen the error

In xqvm/src/error.rs, replace the StepLimitExceeded variant:

rust
/// Execution exceeded the configured step budget.
    ///
    /// `requested` is the charge that could not be paid: `1` for the base
    /// cost every instruction pays before dispatch, or the extra units an
    /// opcode asked for before doing work whose size the program controls.
    /// `pos` is absent only for the base charge, which is levied before the
    /// instruction is decoded.
    #[error(
        "step charge of {requested} exceeds the step limit of {limit} \
         ({used} steps already charged)"
    )]
    StepLimitExceeded {
        pos: Option<usize>,
        requested: u64,
        used: u64,
        limit: u64,
    },

and move it out of the None arm of pos() into its own arm beside ArithmeticOverflow:

rust
Self::ArithmeticOverflow { pos } | Self::StepLimitExceeded { pos, .. } => *pos,
  • Step 4: Split the counters and add the helper

In xqvm/src/vm.rs, add the field next to steps (:272) and reset it wherever steps is reset (:297, :471, :506):

rust
/// Instructions dispatched by the last run. Distinct from `steps`, which
    /// counts metered cost units: an opcode whose work scales with the
    /// program's own data charges more than one step for one dispatch.
    instructions: u64,

Add the accessor beside steps() (:461):

rust
/// Return the number of instructions dispatched by the last
    /// [`run`](Self::run) call.
    ///
    /// Distinct from [`steps`](Self::steps): a tracer numbers instructions,
    /// an embedder prices steps. See `spec/xqvm/METERING.md`.
    pub fn instructions(&self) -> u64 {
        self.instructions
    }

Replace the run-loop charge (:515-520) with a call to the helper, and count the dispatch:

rust
self.charge_steps_base()?;
            self.instructions += 1;

Add both helpers next to charge (:670):

rust
/// Charge `units` of execution against the step budget.
    ///
    /// Callers charge *before* they do the work, so an instruction that
    /// cannot pay does none of it -- the same discipline as [`charge`], and
    /// for the same reason: the budget is a bound on what a program can make
    /// the host do, which is worth nothing if the work happens first.
    fn charge_steps(&mut self, pos: usize, units: u64) -> Result<(), Error> {
        self.charge_steps_at(Some(pos), units)
    }

    /// Charge the base cost every instruction pays before dispatch. Levied
    /// before the instruction is decoded, so it carries no position.
    fn charge_steps_base(&mut self) -> Result<(), Error> {
        self.charge_steps_at(None, 1)
    }

    fn charge_steps_at(&mut self, pos: Option<usize>, units: u64) -> Result<(), Error> {
        let total = self.steps.saturating_add(units);
        if total > self.step_limit {
            return Err(Error::StepLimitExceeded {
                pos,
                requested: units,
                used: self.steps,
                limit: self.step_limit,
            });
        }
        self.steps = total;
        Ok(())
    }
  • Step 5: Feed the tracer from the ordinal

In xqvm/src/vm.rs, the StepState construction at :556 reads step: self.steps. Change it to step: self.instructions so a trace stays numbered 1, 2, 3, ... once opcodes charge more than one unit.

  • Step 6: Run the tests
bash
cargo nextest run -p xqvm && cargo test --doc -p xqvm

Expected: PASS, including the pre-existing zero_step_limit_executes_nothing, unlimited_steps_runs_to_completion, step_limit_equal_to_instruction_count_succeeds and step_limit_one_below_instruction_count_fails, none of which change value.

  • Step 7: Check the rest of the workspace still builds
bash
make fmt && cargo clippy --workspace --all-targets --all-features -- -D warnings

Expected: clean. conformance/src/lib.rs:377 matches E::StepLimitExceeded { .. } and is unaffected by the new fields.

  • Step 8: Commit
bash
git add xqvm/src/vm.rs xqvm/src/error.rs xqvm/tests/integration.rs && git commit -s -m "refactor(xqvm): meter steps through a charge helper"

Task 2: Calibrate the unit against NOP

Measures what the constants in Task 3 have to be. Produces numbers, not behaviour.

Files:

  • Create: xqvm/examples/metering_calibration.rs

Interfaces:

  • Consumes: Vm from Task 1 (unchanged public API).

  • Produces: measured ns/operation ratios, recorded in the commit message and reused by Task 3.

  • Step 1: Write the harness

Create xqvm/examples/metering_calibration.rs with the AGPL header, then:

rust
//! Measure the cost of the operations the step meter charges for, relative
//! to a `NOP` dispatch.
//!
//! Not a test and not run by CI: the constants in `xqvm::metering` are frozen
//! from a run of this harness and re-checked by hand when the interpreter
//! changes shape. Run it in release mode, on an otherwise idle machine:
//!
//! ```sh
//! cargo run --release --example metering_calibration
//! ```
/* Detailed source-code truncated for AI context efficiency. */
  • Step 2: Run it
bash
cargo run --release --example metering_calibration

Expected: four lines of output, nop_ns in the neighbourhood of the 6.73 ns recorded in QUI-1056. If nop_ns is an order of magnitude off, the machine is busy or the build is not release -- rerun before trusting the ratios.

  • Step 3: Record the ratios

Round each ratio up to the next integer, with a floor of 1. Those integers are COEFF_WRITE_STEPS, MODEL_TERM_STEPS and ELEMENT_COPY_STEPS in Task 3. Take SAMPLE_COPY_STEPS = ELEMENT_COPY_STEPS: both are one i64 written into a contiguous buffer. Write the four measured ns values and the four integers into the commit message body -- Task 9 copies them into spec/xqvm/METERING.md.

Rounding up is deliberate: a unit that is cheaper than the operation it stands for reintroduces exactly the underpricing this ticket is about.

  • Step 4: Commit
bash
git add xqvm/examples/metering_calibration.rs && git commit -s -m "test(xqvm): add the step-cost calibration harness"

Task 3: The metering module

Files:

  • Create: xqvm/src/metering.rs
  • Modify: xqvm/src/lib.rs

Interfaces:

  • Consumes: the four integers measured in Task 2.
  • Produces (all pub, re-exported from the crate root as xqvm::metering):
    • pub const BASE_STEPS: u64, COEFF_WRITE_STEPS: u64, MODEL_TERM_STEPS: u64, SAMPLE_COPY_STEPS: u64, ELEMENT_COPY_STEPS: u64
    • pub fn equality_expansion_steps(n: u64) -> u64
    • pub fn model_eval_steps(sample_len: u64, terms: u64) -> u64
    • pub fn value_copy_steps(value: &RegVal) -> u64
    • pub fn widen(n: usize) -> u64 -- the shared saturating usize-to-u64 conversion

Later tasks assert against the constants, never against their numeric values, so a recalibration does not rewrite the test suite.

  • Step 1: Write the failing tests

Create xqvm/src/metering.rs with the AGPL header and only its test module for now:

text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 2: Run the tests to verify they fail
bash
cargo nextest run -p xqvm -E 'test(metering::)'

Expected: FAIL -- metering is not a module of the crate.

  • Step 3: Write the module

Above the test module in xqvm/src/metering.rs:

text
/* Detailed source-code truncated for AI context efficiency. */

Replace the three non-BASE_STEPS constant values with the integers measured in Task 2 if they differ from the ones above.

  • Step 4: Wire the module into the crate

In xqvm/src/lib.rs, add pub mod metering; beside the other module declarations, in the same alphabetical position the existing list uses.

  • Step 5: Run the tests
bash
cargo nextest run -p xqvm -E 'test(metering::)' && cargo clippy -p xqvm --all-targets --all-features -- -D warnings

Expected: PASS, clean clippy.

  • Step 6: Check the no_std build still works
bash
cargo build -p xqvm --no-default-features

Expected: success. metering.rs uses only core and crate::RegVal.

  • Step 7: Commit
bash
git add xqvm/src/metering.rs xqvm/src/lib.rs && git commit -s -m "feat(xqvm): add calibrated execution cost units"

Task 4: Charge `ENERGY`

The ticket's headline case: an unbounded model walk for one step.

Files:

  • Modify: xqvm/src/vm.rs:2414-2449 (exec_energy)
  • Test: xqvm/tests/integration.rs

Interfaces:

  • Consumes: charge_steps (Task 1), metering::model_eval_steps, metering::MODEL_TERM_STEPS, metering::SAMPLE_COPY_STEPS (Task 3).

  • Produces: ENERGY charges BASE_STEPS + model_eval_steps(sample.len(), linear_len + quadratic_len).

  • Step 1: Write the failing test

Append to xqvm/tests/integration.rs:

rust
#[test]
fn energy_charges_for_the_model_it_evaluates() {
    // Two models of different size, evaluated identically. If ENERGY still
    // cost one step the two runs would cost the same, which is the bug.
    fn steps_for(terms: i64) -> u64 {
        let mut b = InstructionBuilder::new();
        b.emit_push(terms).emit_bqmx(Register(0));
        for i in 0..terms {
            b.emit_push(i).emit_push(1).emit_set_line(Register(0));
        }
        b.emit_push(terms).emit_bsmx(Register(1));
        b.emit_energy(Register(0), Register(1)).emit_halt();
        let bytecode = b.build().expect("builder build");

        let mut vm = Vm::new();
        vm.set_unlimited_steps();
        vm.run(&bytecode).expect("vm run");
        vm.steps()
    }

    let small = steps_for(4);
    let large = steps_for(8);
    assert!(
        large > small,
        "a bigger model must cost more: {large} vs {small}"
    );

    // And by exactly the modelled amount: four more sample elements to copy
    // and four more linear terms to accumulate.
    assert_eq!(
        large - small,
        // four more instructions of setup (PUSH, PUSH, SETLINE) plus their
        // coefficient writes, four more sample elements, four more terms.
        4 * (3 * xqvm::metering::BASE_STEPS + xqvm::metering::COEFF_WRITE_STEPS)
            + 4 * xqvm::metering::SAMPLE_COPY_STEPS
            + 4 * xqvm::metering::MODEL_TERM_STEPS
    );
}

Note this test also depends on Task 5 charging SETLINE; run it again at the end of Task 5.

  • Step 2: Run the test to verify it fails
bash
cargo nextest run -p xqvm -E 'test(energy_charges_for_the_model_it_evaluates)'

Expected: FAIL -- both runs cost the same, so large > small is false.

  • Step 3: Charge before evaluating

In exec_energy (vm.rs:2414), the charge goes after both register types are established and before m.energy(..) is called -- and, importantly, before the sample is cloned. Restructure the head of the function so the sizes are read first:

rust
// Both halves of this are O(model): the sample is copied out of its
        // register and every coefficient is accumulated. Charge for both
        // before either happens, or a program can buy an arbitrarily large
        // model walk for one step (QUI-1056).
        let sample_len = match self.reg(sample) {
            RegVal::Sample(s) => s.values.len(),
            other => {
                return Err(Error::RegisterType {
                    reg: sample.slot(),
                    expected: "sample",
                    got: other.kind().kind_name(),
                });
            }
        };
        let terms = match self.reg(model) {
            RegVal::Model(m) => m.linear_len().saturating_add(m.quadratic_len()),
            other => {
                return Err(Error::RegisterType {
                    reg: model.slot(),
                    expected: "model",
                    got: other.kind().kind_name(),
                });
            }
        };
        self.charge_steps(pos, model_eval_steps(widen(sample_len), widen(terms)))?;
        let sample_values: Vec<i64> = match self.reg(sample) {
            RegVal::Sample(s) => s.values.clone(),
            _ => unreachable!("sample register type checked above"),
        };
        let RegVal::Model(m) = self.reg(model) else {
            unreachable!("model register type checked above")
        };
        let energy = m.energy(&sample_values)?;

Keep the existing doc comment about the model-as-sample shortcut: the type checks above preserve that behaviour, including the order in which the two register types are validated (sample first), which existing tests depend on.

Add use crate::metering::{model_eval_steps, widen}; to the imports at the top of vm.rs; later tasks extend that same line.

  • Step 4: Run the tests
bash
cargo nextest run -p xqvm && cargo test --doc -p xqvm

Expected: the new assertion on the exact difference still fails (Task 5 charges SETLINE); the large > small half passes. If any other test fails, it is a real regression -- investigate before continuing.

  • Step 5: Commit
bash
git add xqvm/src/vm.rs xqvm/tests/integration.rs && git commit -s -m "fix(xqvm): charge ENERGY for the model it evaluates"

Task 5: Charge the coefficient writes and constraint expansions

Every site that already charges bytes gets the matching step charge, computed from the same n.

Files:

  • Modify: xqvm/src/vm.rs at :1702, :1731, :1783, :1810 (coefficient writes), :1998 (ONEHOTR), :2051 (ONEHOTC), :2199 (EQUALITY), :2256 (ATLEAST), :2344 (ATLEASTW), :1593 (SLACK)
  • Test: xqvm/tests/integration.rs

Interfaces:

  • Consumes: charge_steps, metering::{equality_expansion_steps, COEFF_WRITE_STEPS, ELEMENT_COPY_STEPS}.

  • Produces: the expansion opcodes charge equality_expansion_steps(n) for the same n their byte charge uses.

  • Step 1: Write the failing test

Append to xqvm/tests/integration.rs:

text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 2: Run the tests to verify they fail
bash
cargo nextest run -p xqvm -E 'test(onehot_charges_for_the_expansion_it_writes) + test(a_coefficient_write_costs_more_than_a_nop)'

Expected: FAIL -- both differences come out at zero extra units.

  • Step 3: Charge each site

Add equality_expansion_steps, COEFF_WRITE_STEPS and ELEMENT_COPY_STEPS to the use crate::metering::{..} line in vm.rs, then insert one line immediately after each existing byte charge, using the same n that charge uses:

Existing line in vm.rs Line to insert directly below
:1702 self.charge_coefficient(pos, reg, LINEAR_ENTRY_BYTES)?; self.charge_coefficient_steps(pos, reg)?;
:1731 same self.charge_coefficient_steps(pos, reg)?;
:1783 self.charge_coefficient(pos, reg, QUAD_ENTRY_BYTES)?; self.charge_coefficient_steps(pos, reg)?;
:1810 same self.charge_coefficient_steps(pos, reg)?;
:1998 self.charge_equality_expansion(pos, cols)?; self.charge_steps(pos, equality_expansion_steps(widen(cols)))?;
:2051 self.charge_equality_expansion(pos, rows)?; self.charge_steps(pos, equality_expansion_steps(widen(rows)))?;
:2199 self.charge_equality_expansion(pos, idx_vec.len())?; self.charge_steps(pos, equality_expansion_steps(widen(idx_vec.len())))?;
:2256 self.charge_equality_expansion(pos, n.saturating_add(num_slacks))?; self.charge_steps(pos, equality_expansion_steps(widen(n.saturating_add(num_slacks))))?;
:2344 same as :2256 self.charge_steps(pos, equality_expansion_steps(widen(n.saturating_add(num_slacks))))?;
:1593 self.charge(pos, entries.saturating_mul(2 * VEC_ELEMENT_BYTES))?; self.charge_steps(pos, entries.saturating_mul(2).saturating_mul(ELEMENT_COPY_STEPS))?;

Add the two private helpers next to charge_coefficient (vm.rs:699):

rust
/// Charge the step cost of one coefficient write into `reg`, but only
    /// when `reg` holds a model. The byte twin is [`charge_coefficient`];
    /// the reasoning about samples and repeated writes is identical.
    fn charge_coefficient_steps(&mut self, pos: usize, reg: Register) -> Result<(), Error> {
        if matches!(self.reg(reg), RegVal::Model(_)) {
            self.charge_steps(pos, COEFF_WRITE_STEPS)?;
        }
        Ok(())
    }

widen in the table above is metering::widen -- add it to the same use crate::metering::{..} line rather than writing a second copy in vm.rs.

EQUALITY and the ATLEAST family clone their index and coefficient vecs a few lines above the charge. That copy is O(n) and momentarily uncharged, but it is bounded by the same n that the expansion charge covers and it happens before the quadratic work, so the instruction still cannot escape its budget by more than a linear term. Leave the ordering as it is rather than restructuring three handlers; note it in METERING.md (Task 9).

  • Step 4: Run the tests
bash
cargo nextest run -p xqvm && cargo test --doc -p xqvm

Expected: PASS, including energy_charges_for_the_model_it_evaluates from Task 4, whose exact-difference assertion needs SETLINE charged.

  • Step 5: Commit
bash
git add xqvm/src/vm.rs xqvm/tests/integration.rs && git commit -s -m "fix(xqvm): charge constraint expansions for the terms they write"

Task 6: Charge the allocators and the register copies

Files:

  • Modify: xqvm/src/vm.rs at :1438, :1447, :1458 (sample allocators), :919 (RANGE/ITER int path), :951 (ITER xqmx path), :864 (LVAL), :1082 (INPUT), :1120 (OUTPUT)
  • Test: xqvm/tests/integration.rs

Interfaces:

  • Consumes: charge_steps, metering::{value_copy_steps, SAMPLE_COPY_STEPS, ELEMENT_COPY_STEPS}.

  • Produces: no register-sized copy costs one step any more.

  • Step 1: Write the failing test

Append to xqvm/tests/integration.rs:

text
/* Detailed source-code truncated for AI context efficiency. */
  • Step 2: Run the tests to verify they fail
bash
cargo nextest run -p xqvm -E 'test(allocating_a_sample_costs_its_length) + test(reading_a_model_out_of_a_register_costs_its_coefficients)'

Expected: FAIL -- both differences are short by the copy cost.

  • Step 3: Charge each site

Add value_copy_steps and SAMPLE_COPY_STEPS to the use crate::metering::{..} line, then:

  • exec_bsmx (:1438), exec_ssmx (:1447), exec_xsmx (:1458): after each self.charge_variables(pos, size)?; add

    rust
    self.charge_steps(pos, widen(size).saturating_mul(SAMPLE_COPY_STEPS))?;

    Leave exec_bqmx, exec_sqmx and exec_xqmx alone: an empty model allocates no coefficients, so the base cost is honest.

  • exec_iter int path (:919): after self.charge_variables(pos, range.len())?; add

    rust
    self.charge_steps(pos, widen(range.len()).saturating_mul(ELEMENT_COPY_STEPS))?;
  • exec_iter xqmx path (:951): the handler already folds the slice to compute cost in bytes. Add a second fold in the same shape, immediately after self.charge(pos, cost)?;:

    rust
    let step_cost = v
                      .get(range.clone())
                      .unwrap_or_else(|| unreachable!("resolve_iter_slice already validated"))
                      .iter()
                      .fold(0u64, |acc, m| {
                          let terms = widen(m.linear_len().saturating_add(m.quadratic_len()));
                          acc.saturating_add(terms.saturating_mul(COEFF_WRITE_STEPS))
                      });
                  self.charge(pos, cost)?;
                  self.charge_steps(pos, step_cost)?;

    Compute step_cost from the same borrow that computes cost, before the charge call, so the two folds share one lookup.

  • exec_l_val (:864), exec_input (:1082), exec_output (:1120): each clones a value. Charge for what is about to be cloned, before the clone:

    rust
    let copy_cost = value_copy_steps(&value_to_clone);
          self.charge_steps(pos, copy_cost)?;

    where value_to_clone is the borrow the handler already resolves. In exec_output (:1120) that means replacing

    rust
    let val = self.reg(reg).clone();

    with

    rust
    // A register can hold a model, and cloning one clones its
          // coefficient maps -- O(model) work for one dispatch (QUI-1056).
          let copy_cost = value_copy_steps(self.reg(reg));
          self.charge_steps(pos, copy_cost)?;
          let val = self.reg(reg).clone();

    Apply the same two-line prologue at :864 and :1082, adjusting the expression that names the value.

  • RANGE (exec_range, whose charge_variables(pos, range.len()) is the call at :919's sibling site): charge widen(count).saturating_mul(ELEMENT_COPY_STEPS) for the generated values, next to the existing byte charge.

  • Step 4: Run the whole suite

bash
cargo nextest run -p xqvm && cargo test --doc -p xqvm && cargo clippy --workspace --all-targets --all-features -- -D warnings

Expected: PASS, clean clippy. Doc tests that assert a step count -- notably the set_memory_limit example at vm.rs:378 -- must still pass; if one now trips a step limit, that is a real finding: report it rather than raising the limit in the example.

  • Step 5: Commit
bash
git add xqvm/src/vm.rs xqvm/tests/integration.rs && git commit -s -m "fix(xqvm): charge allocators and register copies for their size"

Task 7: Mirror the metering in the Python VM

Step counts are now observable, so xqvm_py must produce identical ones.

Files:

  • Create: xqvm_py/metering.py
  • Modify: xqvm_py/executor.py (:314-324 run loop, :338-368 charge helpers, and every self._charge* call site), xqvm_py/state.py:268, xqvm_py/errors.py:158
  • Test: xqvm_py/tests/test_executor.py

Interfaces:

  • Consumes: the constants and formulas frozen in Task 3.

  • Produces: xqvm_py.metering with BASE_STEPS, COEFF_WRITE_STEPS, MODEL_TERM_STEPS, SAMPLE_COPY_STEPS, ELEMENT_COPY_STEPS, equality_expansion_steps(n), model_eval_steps(sample_len, terms), value_copy_steps(value); Executor.instructions property; StepLimitExceeded(limit, requested, used).

  • Step 1: Write the failing tests

Append to xqvm_py/tests/test_executor.py, inside the class that holds the existing step-limit tests (test_step_limit_raises_an_xqvm_error at :2980):

python
def test_energy_charges_for_the_model_it_evaluates(self):
        """ENERGY walks every coefficient; one step for that is QUI-1056."""
        from xqvm_py.metering import (
            BASE_STEPS,
            COEFF_WRITE_STEPS,
            MODEL_TERM_STEPS,
            SAMPLE_COPY_STEPS,
        )

        def steps_for(terms: int) -> int:
            source = [f"PUSH {terms}", "BQMX r0"]
            for i in range(terms):
                source += [f"PUSH {i}", "PUSH 1", "SETLINE r0"]
            source += [f"PUSH {terms}", "BSMX r1", "ENERGY r0 r1", "HALT"]
            ex = Executor()
            ex.execute(assemble("\n".join(source)))
            return ex.steps

        delta = steps_for(8) - steps_for(4)
        assert delta == 4 * (3 * BASE_STEPS + COEFF_WRITE_STEPS) + 4 * (
            SAMPLE_COPY_STEPS + MODEL_TERM_STEPS
        )

    def test_instructions_and_steps_are_separate_counters(self):
        ex = Executor()
        ex.execute(assemble("PUSH 2\nBQMX r0\nPUSH 0\nPUSH 3\nSETLINE r0\nHALT"))
        assert ex.instructions == 6
        assert ex.steps > ex.instructions

Use whatever assembler helper the surrounding tests already use to turn source into a Program; do not introduce a second one.

  • Step 2: Run the tests to verify they fail
bash
uv run --no-sync pytest xqvm_py/tests/test_executor.py -k "charges_for_the_model or separate_counters" -v

Expected: FAIL -- xqvm_py.metering does not exist.

  • Step 3: Write the mirror module

Create xqvm_py/metering.py with the AGPL header and a module docstring that says it mirrors xqvm/src/metering.rs value for value and points at spec/xqvm/METERING.md, then the same five constants and three functions. Python integers are unbounded, so the saturating arithmetic has to be written out:

python
U64_MAX = (2**64) - 1


def _saturating(value: int) -> int:
    """Clamp to the u64 range the Rust VM's counters live in.

    Python integers do not overflow, so a charge that Rust saturates to
    ``u64::MAX`` would otherwise grow past it here and the two VMs would
    disagree about which programs are affordable.
    """
    return min(max(value, 0), U64_MAX)


def equality_expansion_steps(n: int) -> int:
    """Worst-case cost of an equality expansion over ``n`` terms."""
    n = _saturating(n)
    pairs = _saturating(n * (n - 1)) // 2
    return _saturating(_saturating(n + pairs) * COEFF_WRITE_STEPS)

model_eval_steps and value_copy_steps follow the Rust definitions in the Design section. value_copy_steps dispatches on the Python register value types the executor uses (int, list[int], XQMX in model vs sample mode, list[XQMX]); read _get_register_as_xqmx and the XQMX class in xqvm_py/xqmx.py for the accessors that correspond to linear_len() and quadratic_len().

  • Step 4: Split the counters and add the charge helper

In xqvm_py/state.py:268, add self.instructions = 0 beside self.steps = 0 and reset it in the same place.

In xqvm_py/executor.py, replace the run loop at :314-317:

python
while not self.state.halted and self.state.pc < len(program):
            self._charge_steps(BASE_STEPS, step_limit)
            self.state.instructions += 1
            self.step()

and add, beside _charge (:338):

python
def _charge_steps(self, units: int, limit: int | None = None) -> None:
        """Charge ``units`` of execution against the step budget.

        Callers charge *before* they do the work, so an instruction that
        cannot pay does none of it -- the same discipline as ``_charge``.
        """
        limit = self._step_limit if limit is None else limit
        if limit is not None and self.state.steps + units > limit:
            raise StepLimitExceeded(limit, requested=units, used=self.state.steps)
        self.state.steps += units

Store the limit on the executor (self._step_limit = step_limit at the top of execute, beside self._memory_limit) so handlers can charge without threading it through, and add the instructions property next to steps (:322).

Extend StepLimitExceeded in xqvm_py/errors.py:158 with the two new keyword fields, defaulted so existing raise sites keep working, and mirror the Rust message text.

  • Step 5: Charge the same sites the Rust VM charges

Work through the Rust diff from Tasks 4-6 and add the twin call at each Python site. Every one of them is directly below an existing self._charge* call except ENERGY, LVAL, INPUT and OUTPUT:

Python site Charge
_runner_ENERGY (:1145) model_eval_steps over the sample length and the model's term count, before compute_energy
_runner_SETLINE/ADDLINE/SETQUAD/ADDQUAD COEFF_WRITE_STEPS, only when the register holds a model
_runner_ONEHOTR / ONEHOTC equality_expansion_steps(cols) / (rows)
_runner_EQUALITY / ATLEAST / ATLEASTW equality_expansion_steps(n) / (n + num_slacks)
_runner_BSMX / SSMX / XSMX size * SAMPLE_COPY_STEPS
_runner_RANGE / ITER ELEMENT_COPY_STEPS per element, value_copy_steps per model for the xqmx path
_runner_LVAL / INPUT / OUTPUT value_copy_steps(value)
_runner_SLACK 2 * entries * ELEMENT_COPY_STEPS
  • Step 6: Run the Python suite
bash
uv run --no-sync pytest xqvm_py/tests -q && make lint-py && make fmt-check-py

Expected: PASS. xqvm_py/tests/test_cli_run.py:77 exercises the --step-limit flag; if it now trips, the fixture's program got more expensive -- adjust the fixture's limit, not the metering.

  • Step 7: Commit
bash
git add xqvm_py/metering.py xqvm_py/executor.py xqvm_py/state.py xqvm_py/errors.py xqvm_py/tests/test_executor.py && git commit -s -m "fix(xqvm-py): mirror the Rust step metering"

Task 8: Lock the two implementations together

A number both VMs must agree on needs a guard that fails when they drift.

Files:

  • Create: scripts/check-metering-parity.py, conformance/vectors/metering/energy_scales_with_model/{program.xqasm,inputs.json,expected.json}, conformance/vectors/metering/onehot_expansion/{program.xqasm,inputs.json,expected.json}
  • Modify: conformance/src/lib.rs:55-60 (PyOut), :160-205 (Expected), :215-235 (Outcome), :344 and :527 (the two runners), :556 (check); Makefile:91

Interfaces:

  • Consumes: xqvm::metering (Task 3), xqvm_py.metering (Task 7), Vm::steps(), executor.steps (already emitted by xqvm_py/cli/run.py:159).

  • Produces: make metering-parity; an optional steps field on expected.json that both runners assert.

  • Step 1: Write the parity script's failing state

Create scripts/check-metering-parity.py modelled on scripts/check-opcode-parity.py (read it first for the repo's conventions on argument handling, exit codes and diff output). It compares three sources and exits non-zero on any disagreement:

  1. xqvm/src/metering.rs -- regex ^pub const (\w+): u64 = (\d+); over the file.
  2. xqvm_py/metering.py -- import the module and read the same names.
  3. spec/xqvm/METERING.md -- the constants table, parsed as | \NAME` | value | ...`.

Report every mismatched name with all three values, not just the first.

  • Step 2: Run it and watch it fail
bash
uv run --no-sync python scripts/check-metering-parity.py

Expected: FAIL -- spec/xqvm/METERING.md does not exist yet. That file arrives in Task 9; this script is what makes Task 9 non-optional.

  • Step 3: Wire it into the Makefile

In Makefile, add the target beside opcode-parity-py and extend check-parity (:91):

make
check-parity: opcode-parity conformance example-smoke metering-parity

metering-parity: deps-py
	uv run --no-sync python scripts/check-metering-parity.py
  • Step 4: Teach the conformance harness about steps

In conformance/src/lib.rs:

  • Add steps: Option to RawExpected and to Expected::Success; a vector that omits it asserts nothing about cost, so the existing 100-odd vectors keep passing unchanged.
  • Add steps: u64 to Outcome::Success, filled from vm.steps() in run_rust_program (:344) and from a new steps: Option field on PyOut in run_python_file (:527). The Python CLI already emits it.
  • In check (:556), compare steps only when the vector supplies them:
rust
if let Some(exp_steps) = exp_steps {
                if steps != exp_steps {
                    let _ = writeln!(
                        detail,
                        "  steps:\n    expected: {exp_steps}\n    actual:   {steps}"
                    );
                }
            }

Fold it into the existing mismatch-reporting shape rather than adding a second failure path.

  • Update the module doc comment at :25 to describe the new field.

  • Step 5: Add the vectors

conformance/vectors/metering/energy_scales_with_model/program.xqasm: build a 4-variable binary model with four linear coefficients, allocate a matching sample, ENERGY, STOW, OUTPUT, HALT. Head it with a comment naming QUI-1056 and stating what the vector pins: that both implementations charge the same for a model walk.

conformance/vectors/metering/onehot_expansion/program.xqasm: a 4-column model, RESIZE, ONEHOTR with a penalty, HALT. Same comment discipline.

inputs.json for both: {"calldata": [], "output_slots": 1} (or 0 for the ONEHOTR vector, which produces no output).

expected.json: run each vector under both implementations, confirm the two agree, hand-check the number against the cost table in the Design section, and record it:

json
{"outputs": [0], "final_stack": [], "steps": 42}

If the two implementations disagree, that is the guard doing its job -- fix the implementation, never the expectation.

  • Step 6: Run the conformance suite both ways
bash
make conformance

Expected: PASS for both conformance-rs and conformance-py, including the two new vectors.

  • Step 7: Commit
bash
git add scripts/check-metering-parity.py Makefile conformance && git commit -s -m "test(conformance): assert step counts agree across implementations"

Task 9: Specify it

A count the chain prices is consensus-visible and belongs in the spec. spec/xqvm/ currently says nothing about step budgets at all.

Files:

  • Create: spec/xqvm/METERING.md
  • Modify: spec/xqvm/SPEC.md:91-102 (Runtime Limits) and :127 (Related Specifications), spec/xqvm/ISA.md (notation section), spec/xqvm/VERIFIER.md, docs/book/src/xqvm/execution.md:45-56, docs/book/src/xqvm/limits-and-errors.md:26, xqffi/src/vm.rs:288

Interfaces:

  • Consumes: the constants from Task 3 and the measured ns values recorded in Task 2's commit message.

  • Produces: spec/xqvm/METERING.md, the third source scripts/check-metering-parity.py reads; XqVm.instructions() on the FFI surface.

  • Step 1: Write the specification

Create spec/xqvm/METERING.md following the house style of spec/xqvm/HLF.md (normative language, tables, no emojis, -- not em-dash). It must contain:

  • What a step is. A unit of work, not an instruction. One step is one NOP dispatch. Every instruction charges BASE_STEPS before dispatch; opcodes whose work scales with program-controlled data charge more, before doing the work.
  • The constants table, in the exact shape scripts/check-metering-parity.py parses:
markdown
| Constant | Value | Meaning |
|----------|-------|---------|
| `BASE_STEPS` | 1 | One instruction dispatch. |
| `COEFF_WRITE_STEPS` | 8 | One coefficient written into a model's sparse map. |
| `MODEL_TERM_STEPS` | 2 | One model term accumulated into an energy. |
| `SAMPLE_COPY_STEPS` | 1 | One sample element written. |
| `ELEMENT_COPY_STEPS` | 1 | One `i64` copied out of a vec. |

with the values from Task 3 and a note recording the measured nanoseconds each was derived from.

  • The per-opcode cost table from the Design section above, verbatim.

  • The formulas for equality_expansion_steps, model_eval_steps and value_copy_steps, including the statement that all arithmetic is integer and saturating, and that an expansion count is a worst case that bounds the work rather than measuring it.

  • The known slack: EQUALITY and the ATLEAST family clone their index and coefficient vecs before charging, so up to O(n) element copies precede the charge for the O(n^2) expansion that follows.

  • Conformance: step counts are observable; two implementations executing the same program must report the same count.

  • Step 2: Link it from the spec

In spec/xqvm/SPEC.md, replace the Loop nesting and XQMX size notes' silence about time with a metering row in the Runtime Limits table:

markdown
| Step budget | Implementation-defined | A step is a unit of work, not an instruction. Implementations meter execution as specified in [METERING.md](METERING.md) and raise `StepLimitExceeded` when the budget is exhausted. |

Add METERING.md to the Related Specifications list at :127.

In spec/xqvm/ISA.md, add one line to the Notation section: opcode cost is specified in METERING.md, not per-row here.

In spec/xqvm/VERIFIER.md, state that a step count is data-dependent and cannot be predicted statically: the verifier bounds execution, it does not predict its cost. This mirrors what is already true of the memory budget.

  • Step 3: Update the book

  • docs/book/src/xqvm/execution.md:45-56: the paragraph claiming the counter "increments after every instruction dispatch" is now wrong. Rewrite it around the two counters, and link spec/xqvm/METERING.md.

  • docs/book/src/xqvm/limits-and-errors.md:26: the Step count row needs the same correction, plus the new StepLimitExceeded message shape at :114.

Leave the stale set_step_limit(0) prose at execution.md:53 and limits-and-errors.md:36 alone -- it predates QUI-1053 and is a separate fix.

  • Step 4: Expose the ordinal on the FFI

In xqffi/src/vm.rs, beside steps() (:288):

rust
/// Total instructions dispatched by the last run.
    ///
    /// Distinct from `steps()`, which counts metered cost units: an opcode
    /// whose work scales with the program's own data charges more than one
    /// step for one dispatch. See `spec/xqvm/METERING.md`.
    fn instructions(&self) -> u64 {
        self.inner.instructions()
    }

and correct the set_step_limit doc comment at :233, which currently calls it "the instruction-step limit".

  • Step 5: Run every guard
bash
make preflight

Expected: PASS. In particular metering-parity now finds all three sources, check-docs-drift accepts the new page links, and check-mermaid-render is unaffected.

  • Step 6: Commit
bash
git add spec docs xqffi/src/vm.rs && git commit -s -m "docs(xqvm): specify step metering"

Verification

Run before declaring the branch done:

bash
make preflight

That covers preflight-rs (fmt, taplo, clippy, rustdoc, deny, unit/integration/doc tests), preflight-py, preflight-parity (opcode parity, metering parity, conformance both ways, example smoke), preflight-docs and preflight-policy.

Then confirm the ticket's own claim by hand:

bash
cargo run --release --example metering_calibration

The ratio between the dearest charged operation and NOP is the factor by which a flat per-step price was previously wrong. That number belongs in the QUI-1056 close-out comment and is the input QUI-1054 needs for its safety multiplier.

Follow-ups, not this branch

  • QUI-1054 / QUI-1013: recalibrate WeightPerStep against NOP plus a safety multiplier now that a step is bounded work.
  • QUI-1019: this is a behavioural break for any program that touches a model; it ships in 0.4.0.
  • The stale set_step_limit(0) prose in the book (execution.md:53, limits-and-errors.md:36), left over from QUI-1053.

Design Plans 2026 08 25 Qui 1178 Followups (`docs/design/plans/2026-08-25-qui-1178-followups.md`)

QUI-1178 follow-ups

Findings from reviewing xqvm_py/executor.py against xqvm/src/vm.rs while
fixing the pop-before-register-read ordering (QUI-1178). None of these is a
pop-order defect, so none was fixed inside that ticket. Each was verified
against the code in this branch (and, where noted, against a small script
run through xqvm_py) rather than assumed from an earlier description. The
human files these as separate Linear tickets, each linked to QUI-1178.

Finding 10 was added later, during review of !134, and is the only entry
here that was fixed rather than filed and deferred (QUI-1202).

Base: the branch this document was written on, before it was rebased.
Every line number here is from that tree and is not maintained as those
files move; resolve a reference by the name it cites -- a function, a
section, a table row -- rather than by its line.

Status against the merge requests open alongside this one

Re-checked 2026-08-27, after !133 merged into main (e45acd1) and this
branch was rebased onto it. Read this before filing any ticket below.

Verified against main at rebase time: findings 1 and 4 are closed by
!133, and finding 3 is still live in exec_equality. Findings 2 and 8
were re-confirmed by inspecting _get_register_as_vec/_get_register_as_xqmx
on main. Findings 5, 6 and 7 were not re-verified against main โ€” they
were established against this branch's original base and must be re-read
before a ticket is filed.

MR Branch Bearing on this list
!133 feature/qui-1062-1029-1174 **Merged (e45acd1). Closes findin