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 inxqffi; user-facingProgram/Session/RunResultlive inxquad.program.
Install
Rust (from crates.io)
cargo install xqcli # gives you the `xquad` binaryPython (from PyPI)
pip install xquad # umbrella -- full pipeline
# or pick individual pieces:
pip install xqffi xqcp xqsa # FFI bindings + DSL + solversPrebuilt 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)
; add.xqasm -- push two integers and add them
PUSH 10
PUSH 32
ADD
HALTxquad asm add.xqasm -o add.xqb && xquad run add.xqbEnd-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:
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 asNone), residual stack, and step count. See
Running Programs for the full tour.
Low-level / conformance surface
For conformance harnesses and one-shot execution, the rawxqffi.vm.Vm is still available directly:
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:
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-smokeArchitecture
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 andspec/xqvm/SPEC.md for the normative spec.
Development
Prerequisites
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh # stable Rust
make deps # dev toolsLocal setup
make xquadThis 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 canimport xqcp / xqsa / xqvm_py / xqffi / xquad naturally, and
installs the xquad CLI binary under ~/.cargo/bin/.
Open a REPL with everything ready:
make replCI-equivalent locally
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 atcargo build time by build.rs. CI runs both through theverify: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
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 testsFile format
`program.xqasm`
Canonical human-readable source. The assembler is authoritative: whateverxqasm::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`
{
"calldata": [6, 7],
"output_slots": 16,
"step_limit": 50,
"memory_limit": 1048576
}output_slots is optional (defaults to 16, matching xquad run). Thecalldata 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.
{
"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:
{
"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 toxqvm::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 oneLoopError 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 intoNoActiveLoop and UnmatchedLoop, and each identity is pinned by its own
vector: vectors/control-flow/next_outside_loop andvectors/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_calldatatakes anyRegVal, samples
included ("This allows passing models, samples, and vectors between
programs"), andINPUTclones the entry into the register.INPUT
writesRegType::Any, which satisfies every requirement, soPUSH 0 / INPUT r0 / PUSH 0 / PUSH 1 / ONEHOTR r0is verifier-clean and
arrives holding whatever the embedder supplied. The--calldataCLI 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_regmergedModelandSampletoRegType::Any, so a program that wroteBQMXon one branch andBSMX
on the other, joined, and calledONEHOTRverified clean using nothing
but its own instructions. QUI-1160 closed that: the join now yieldsRegType::Grid, which satisfies the grid operations and notR::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 withRegisterType, 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
- Write
program.xqasmwith a minimal scenario that exercises the
behaviour you care about. - Pick the matching vector directory under
vectors/(or create a new
one). These directory names are a separate, coarser vocabulary fromopcodes.yaml's owncategoryfield -- the two do not need to match,
and mostly don't. - Write
inputs.jsonwith the calldata the program reads. - Produce
expected.jsonby 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.rsassertsopcodes.yamlagainst theopcodes!x-macro at compile time;scripts/check-opcode-parity.py
assertsopcodes.yamlagainstxqvm_py/opcodes.pyin 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 thestack_pop/stack_pushpair, because the x-macro
stores only the net delta; the pair is compared on the Python side. - Bytecode encoding -- owned by the
xqasmcrate's own test suite
(xqasm/tests/integration.rsplus assembler unit tests). - Observable behaviour --
check_vectorasserts the
observed{outputs, final_stack}, or the observed fault identity,
matchesexpected.jsonfor both runtimes. - Coverage --
coverage.rscomputes which opcodes
the vectors cover, and CI prints the report on every pipeline as the
last step ofmake check-parity. It does not require completeness,
which would fail today;tests/coverage.rsholds
the current numbers as floors so coverage cannot regress unremarked.
Running locally
# 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-coverageThe 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:
uv run python examples/maxcut/runner.py --seed 42Every runner accepts --seed, --interpreter (python or rust),--solver, and -o/--output. Problem size flags vary; each directory'sREADME.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. Theconformance/ 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.
Seexqvm/README.mdfor the document index. - xqcp/ -- X-Quadratic Constraint Programming DSL. Symbolic
problem description and compilation to XQVM assembly. Seexqcp/README.md. - xqsa/ -- X-Quadratic Solver Adapters. Solver-backend
interface and sample/energy contracts. Seexqsa/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:
- A matching update to
../conformance/opcodes.yaml. - Updated or new conformance vectors under
../conformance/vectors/. - Both
xqvm(Rust) andxqvm_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: apest grammar parses source text into an
AST, the assembler lowers the AST to instructions viaxqvm::InstructionBuilder, and errors are reported as richmiette diagnostics with source spans.
This crate declares no binary; the xquad asm subcommand inxqcli is the
command-line entry point.
Install
cargo add xqasmQuick start
The one-call entry point is assemble_source:
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:
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 .Nand referenced asJUMP .N/JUMPI .N, where.Nis a numeric label ID (.0,.1, and so on)
assigned sequentially during pre-scan; the.Ntext is assembler-only and
never appears in the bytecode. PUSH <value>desugars to the smallestPUSH1-PUSH8opcode that fits
the signed integer literal.- Registers are
r0..r255.
Links out
- Bytecode encoding spec -- assembly syntax and binary format
- Normative VM spec
xqvm-- bytecode types and interpreter this crate targetsxqcli-- thexquadCLI, wraps this crate asxquad asm- Repository
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
cargo install xqcli
xquad --helpSubcommands
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),--stdoutto write bytecode to stdout instead of a file.xquad dism: reads from stdin whenFILEis omitted.xquad run:--textto treatFILEas assembly,--calldata <CALLDATA>(comma-separated integers),--outputs <OUTPUTS>(default 16),--step-limit <STEP_LIMIT>(default 10000000; the value is an exact limit, so0executes nothing),--unlimited-stepsto run without a step limit,--tracewith--trace-format text|jsonand--trace-file <FILE>.xquad verify:--textto treatFILEas assembly before verifying.
Quick start
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.xqbxquad run add.xqb prints the residual stack:
stack (bottom to top):
42Links out
xqvm-- bytecode types and interpreter this CLI drivesxqasm-- text assembler behindxquad asm- Normative VM spec
- Bytecode encoding spec
- Repository
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
pip install xqcpA complete example
Max-Cut for three nodes -- the same graph
Quadratic Models
derives by hand -- built with Problem and compiled to .xqasm:
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 encoderprograms.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 withlo=/hi= for a range XQCP shifts into the model and back out throughsample.value()) and unordered cases (CATEGORICAL with k= andpenalty=, 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 isspec/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
pip install xqffiSurface
`xqffi.vm` -- low-level one-shot
What the conformance harness drives. Small wrapper over xqvm::Vm:
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:
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
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 supportExtras 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:
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)
# -1Every 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 constructingSolverDWaveCPU(...) directly; reach for the class constructor instead ofbuild_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 andsubmit() 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 isspec/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
pip install xquadPulls 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:
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:
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).sampleRe-exports of peer packages are identity, not copies:
import xquad, xqcp
assert xquad.cp.Problem is xqcp.Problem # always TrueSo 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, andxquad.vmsurfaces.
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, andmiette-backed runtime diagnostics.
Install
cargo add xqvmFor a no_std target (WASM runtimes, Substrate pallets, embedded):
cargo add xqvm --no-default-featuresQuick start
Build a program with InstructionBuilder, then run it on Vm:
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.
Links out
- Normative VM spec
- Bytecode encoding spec
xqasm-- text assembler that emitsxqvm::Programxqcli-- thexquadCLI, built on this crate- Repository
License
AGPL-3.0-or-later.
Overview (`xqvm_py/README.md`)
xqvm_py
Status -- transitional.
xqvm_pyis the executable conformance
oracle for the XQuad toolchain: every vector underconformance/must produce identical
observable state on both this Python reference and the Rustxqvm
production runtime. The arrangement is explicitly transitional. Once
the Rust runtime is fully battle-tested,xqvm_pymay 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 onxqvm_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
xqasmcrate is the only
implementation, exposed to Python viaxqffi.asm(pyo3). Tests
and the CLI shim callxqvm_py.program_from_xqasmto turn.xqasmtext
into an executableProgram.
Layout
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:
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 shimProgrammatic use:
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)) # 15Conformance
Behavioural parity with the Rust xqvm crate is enforced by thexquad-conformance Rust test harness atconformance/. 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) * Bbinary variables in an(N + 1) x Bgrid. Rows0..N-1are the assignment cells:x[i,b] = 1if item i is placed in bin b. Row N holds one indicator per bin:y[b] = 1if bin b is open. - Objective:
+BIN_COSTon each indicatory[b], so the energy counts the bins the packing opens. A bias spread over the assignment cells instead would be identically N on every feasible packing, because each item lands in exactly one bin, and could not tell a one-bin packing from a three-bin one. - Constraints:
- Assignment per item i:
sum_b x[i,b] = 1(ONEHOTR, penalty 200) - Linking per (i, b):
x[i,b] -> y[b](IMPLIES, penalty 200) - Capacity per bin b:
sum_i s_i * x[i,b] <= C(SLACK + EQUALITY, penalty 100)
- Assignment per item i:
The capacity inequality is encoded by appending binary slack variable entries
to the column index/coefficient vectors, converting it to a weighted equality.
Each bin gets its own slack block: bin b's entries start at(N + 1) * B + b * bitlen(C), past every model variable and past every
earlier bin's block. Sharing one start index across bins would let one bin's
slack absorb another's overflow, leaving the capacity constraint
under-constrained.
DSL methods used
model.apply_onehot_row(row, penalty)-- ONEHOTR assignment constraintmodel.apply_implies(coord_a, coord_b, penalty)-- IMPLIES linking constraintproblem.vec()-- allocate untyped vector registers for indices and coefficientsproblem.slack(indices, coeffs, start_index, capacity)-- append slack entriesmodel.apply_equality(indices, coeffs, target, penalty)-- EQUALITY constraintxq_bitlen(value)-- BITLEN, used to size each bin's slack block
Pipeline overview
- CP (
xqcp) -- generate random item sizes, declare an(N + 1) x Bbinary grid, and add ONEHOTR assignment constraints per item, IMPLIES links per cell, and SLACK + EQUALITY capacity constraints per bin. - Assemble --
.xqasmtext to bytecode viaxquad.asm - Encode -- run encoder on chosen XQVM to produce the XQMX model
- Sample -- solver runs SA/QPU/GPU over the model
- Verify -- verifier checks the sample is binary, the per-item assignment, the bin-usage links and the per-bin capacity, then computes energy
- Decode -- decoder extracts the bin assignments
Usage
uv run python examples/bin_packing/runner.py --seed 42
uv run python examples/bin_packing/runner.py --n 5 --bins 4 --interpreter rust| Flag | Default | Description |
|---|---|---|
--n |
4 |
Number of items |
--bins |
3 |
Number of bins |
--solver |
dwave-cpu |
Solver backend (see Choosing a solver) |
--interpreter |
python |
XQVM backend: python or rust |
--seed |
42 |
Random seed |
-o |
stdout | Write JSON result to file |
Choosing a solver
| 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
-1per variable rewards selection, creating tension with the positive cubic terms. - Objective:
sum(c_t * x_i * x_j * x_k) - sum(x_v)
Each cubic term (i, j, k, c) is degree-reduced to quadratic via:
REDUCE(i, j, P_AUX) -> w-- allocates auxiliary variable w with Rosenberg enforcementP_AUX*(x_i*x_j - 2*x_i*w - 2*x_j*w + 3*w)ADDQUAD(w, k, c)-- addsc*w*x_k = c*x_i*x_j*x_kto the QUBO
DSL methods used
model.reduce(var_a, var_b, p_aux)-- single-stage HOBO degree reduction
Pipeline overview
- CP (
xqcp) -- generate random cubic interaction terms, declare binary variables with linear bias, and degree-reduce each cubic term via REDUCE. - Assemble --
.xqasmtext to bytecode viaxquad.asm - Encode -- run encoder on chosen XQVM to produce the XQMX model
- Sample -- solver runs SA/QPU/GPU over the model
- Verify -- verifier checks the sample is binary and that each Rosenberg REDUCE auxiliary equals the product it stands for, then computes energy
- Decode -- decoder extracts the variable assignment
Usage
uv run python examples/cubic_opt/runner.py --seed 42
uv run python examples/cubic_opt/runner.py --n 5 --m 4 --interpreter rust| Flag | Default | Description |
|---|---|---|
--n |
4 |
Number of variables |
--m |
3 |
Number of cubic terms |
--solver |
dwave-cpu |
Solver backend (see Choosing a solver) |
--interpreter |
python |
XQVM backend: python or rust |
--seed |
42 |
Random seed |
-o |
stdout | Write JSON result to file |
Choosing a solver
| 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.CATEGORICALrecords that as an N x C binary grid,x[v,c] = 1if node v gets color c, with a one-hot row per node. - Objective: none. The problem is pure constraint satisfaction: a colouring is scored only by its constraint violations. A valid C-colouring scores
-penalty * N, not0, because XQMX has no constant-term field and each satisfied ONEHOTR stores-penaltyrather than0. That is-1000at the--n 5, penalty200defaults. The colour count defaults to4because a G(5, 0.5) graph routinely contains a 4-clique -- the default seed's does -- and a 4-clique has no 3-colouring, so--colors 3would make the canonical instance unsatisfiable. See Constraints. - Constraints:
- One-hot per node:
sum_c x[v,c] = 1(ONEHOTR, penalty 200) -- implied by the domain, not written - Exclusion per (edge, color):
x[u,c] + x[v,c] <= 1(EXCLUDE, penalty 200)
- One-hot per node:
Encoding strategy
The one-hot row per node is what makes a categorical variable categorical, sodefine_model(domain=Domain.CATEGORICAL, k=C, penalty=200) emits it: the grid
and one ONEHOTR per row, through the same record paths a hand-written loop
would use. The compiled program is what this example emitted before the
domain existed.
EXCLUDE is applied per (edge (u,v), color c) pair via a nested range loop.
The 2D coordinates (u, c) and (v, c) are resolved to flat indices using
IDXGRID: u * num_colors + c and v * num_colors + c.
Reading the answer back is sample.case(v), a ROWFIND for the 1 in node v's
row. ROWFIND returns -1 where a row holds no 1, which is the uncoloured
sentinel this example used to produce in host code.
DSL methods used
problem.define_model(size=N, domain=Domain.CATEGORICAL, k=C, penalty=200)-- the grid and its one-hot rowsmodel.apply_exclude((u, c), (v, c), penalty)-- EXCLUDE per edge per colorsample.case(node)-- the case a node took, or-1
Pipeline overview
- CP (
xqcp) -- generate a random graph, declare N categorical variables over C cases, and add EXCLUDE constraints per (edge, color) pair. - Assemble --
.xqasmtext to bytecode viaxquad.asm - Encode -- run encoder on chosen XQVM to produce the XQMX model
- Sample -- solver runs SA/QPU/GPU over the model
- Verify -- verifier checks the sample is binary, the one-hot row sums, and the per-edge exclusions, then computes energy
- Decode -- decoder reads each node's case with ROWFIND, giving one color index per node
Usage
uv run python examples/graph_coloring/runner.py --seed 42
uv run python examples/graph_coloring/runner.py --n 6 --colors 4 --interpreter rust| Flag | Default | Description |
|---|---|---|
--n |
5 |
Number of nodes |
--colors |
4 |
Number of colors |
--solver |
dwave-cpu |
Solver backend (see Choosing a solver) |
--interpreter |
python |
XQVM backend: python or rust |
--seed |
42 |
Random seed |
-o |
stdout | Write JSON result to file |
Choosing a solver
| 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 = 1means item i is selected. - Objective: minimise
-sum(v_i * x_i) - Constraints: capacity
sum(w_i * x_i) <= W(SLACK + EQUALITY)
The inequality is encoded via SLACK + EQUALITY. SLACK appends binary slack
variable entries (s_j with coefficients 2^j) to the index and coefficient
vectors, converting the inequality to the equality sum(w_i*x_i) + sum(s_j*2^j) = W.
EQUALITY then adds the penalty term P*(sum(a_k*x_k) - W)^2 to the QUBO.
DSL methods used
problem.vec()-- allocate untyped vector registers for indices and coefficientsproblem.slack(indices, coeffs, start_index, capacity)-- append slack entriesmodel.apply_equality(indices, coeffs, target, penalty)-- EQUALITY constraint
Pipeline overview
- CP (
xqcp) -- generate random item weights and values, declare binary variables, and encode the capacity inequality via SLACK + EQUALITY. - Assemble --
.xqasmtext to bytecode viaxquad.asm - Encode -- run encoder on chosen XQVM to produce the XQMX model
- Sample -- solver runs SA/QPU/GPU over the model
- Verify -- verifier checks the sample is binary and that the capacity constraint holds, then computes energy
- Decode -- decoder extracts the item selection
Usage
uv run python examples/knapsack/runner.py --seed 42
uv run python examples/knapsack/runner.py --n 6 --interpreter rust| Flag | Default | Description |
|---|---|---|
--n |
5 |
Number of items |
--solver |
dwave-cpu |
Solver backend (see Choosing a solver) |
--interpreter |
python |
XQVM backend: python or rust |
--seed |
42 |
Random seed |
-o |
stdout | Write JSON result to file |
Choosing a solver
| 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):
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 enforcementP_AUX*(x_i*x_j - 2*x_i*w - 2*x_j*w + 3*w). The cubic term becomes the
quadratic term -P*w*x_k.
DSL methods used
model.reduce(var_a, var_b, p_aux)-- HOBO degree reduction; returns a
RegLoad holding the auxiliary variable index for chaining into quadratic terms
Pipeline overview
- CP (
xqcp) -- generate random 3-literal clauses, declare binary variables, and degree-reduce the cubic violation terms via REDUCE. - Assemble --
.xqasmtext to bytecode viaxquad.asm - Encode -- run encoder on chosen XQVM to produce the XQMX model
- Sample -- solver runs SA/QPU/GPU over the model
- Verify -- verifier checks the sample is binary and that each Rosenberg REDUCE auxiliary equals the product it stands for, then computes energy
- Decode -- decoder extracts the variable assignment
Usage
uv run python examples/max3sat/runner.py --seed 42
uv run python examples/max3sat/runner.py --n 8 --m 10 --interpreter rust| Flag | Default | Description |
|---|---|---|
--n |
6 |
Number of Boolean variables |
--m |
8 |
Number of clauses |
--solver |
dwave-cpu |
Solver backend (see Choosing a solver) |
--interpreter |
python |
XQVM backend: python or rust |
--seed |
42 |
Random seed |
-o |
stdout | Write JSON result to file |
Choosing a solver
| 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 = 1if node i is in the independent set. - Objective: minimise
-sum(x_i)(maximise set size) - Constraints: per edge (i,j):
x_i + x_j <= 1(SLACK + EQUALITY)
Each edge inequality is encoded via SLACK + EQUALITY. A single binary slack
variable s (capacity = 1) converts x_i + x_j <= 1 into the equalityx_i + x_j + s = 1, and EQUALITY adds the penalty P*(x_i + x_j + s - 1)^2.
Slack variable indices start at num_nodes and are allocated one per edge.
DSL methods used
problem.vec()-- allocate untyped vector registers for indices and coefficientsproblem.slack(indices, coeffs, start_index, capacity)-- append one slack entry per edgemodel.apply_equality(indices, coeffs, target, penalty)-- EQUALITY constraint
Pipeline overview
- CP (
xqcp) -- generate a random graph, declare binary variables (one per node), and encode each edge independence constraint via SLACK + EQUALITY. - Assemble --
.xqasmtext to bytecode viaxquad.asm - Encode -- run encoder on chosen XQVM to produce the XQMX model
- Sample -- solver runs SA/QPU/GPU over the model
- Verify -- verifier checks the sample is binary and that the independence constraints hold, then computes energy
- Decode -- decoder extracts the selected nodes
Usage
uv run python examples/max_independent_set/runner.py --seed 42
uv run python examples/max_independent_set/runner.py --n 7 --interpreter rust| Flag | Default | Description |
|---|---|---|
--n |
5 |
Number of nodes |
--solver |
dwave-cpu |
Solver backend (see Choosing a solver) |
--interpreter |
python |
XQVM backend: python or rust |
--seed |
42 |
Random seed |
-o |
stdout | Write JSON result to file |
Choosing a solver
| 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:
nbinary variables, one per node.x[v] in {0, 1}selects the side of the cut. - Objective: for each edge
(i, j, w), add-w*(x_i + x_j)and+2w*x_i*x_j. Minimising this minimises-sum w*[x_i != x_j], i.e. maximises the cut.
DSL methods used
problem.input()-- declare typed calldata inputsproblem.define_model()-- allocate binary XQMX modelproblem.stow()-- bind intermediate computations to named registersproblem.range()-- emit RANGE loopsmodel.linear[i].add()-- accumulate linear bias on variable imodel.quadratic[i, j].add()-- accumulate quadratic coupling between variables i and jproblem.output()-- declare typed output slotsproblem.sample.getline()-- read a row from the sample bitstring
Pipeline overview
- CP (
xqcp) -- build a random weighted complete graph, declare binary variables (one per node), and add linear/quadratic QUBO terms per edge. - Assemble --
.xqasmtext to bytecode viaxquad.asm - Encode -- run encoder on chosen XQVM to produce the XQMX model
- Sample -- solver runs SA/QPU/GPU over the model
- Verify -- verifier checks the sample is binary, then computes energy; this problem declares no constraints for it to check
- Decode -- decoder extracts the 2-colour partition
Usage
uv run python examples/maxcut/runner.py --seed 42
uv run python examples/maxcut/runner.py --n 6 --seed 7 -o /tmp/mc.json| Flag | Default | Description |
|---|---|---|
--n |
5 |
Number of nodes in the complete graph |
--solver |
dwave-cpu |
Solver backend (see Choosing a solver) |
--interpreter |
python |
XQVM backend: python or rust |
--seed |
42 |
Random seed |
-o |
stdout | Write JSON result to file |
Choosing a solver
| 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 = 1puts numbera_iin subset A. - Objective: minimise
P * (sum(a_i * x_i) - S/2)^2whereS = sum(a_i)
An exact partition exists when S is even and the penalty evaluates to zero.
The QUBO minimiser finds the balanced partition when one exists, or the
most balanced split when the total is odd.
DSL methods used
problem.vec()-- allocate untyped vector registers for indices and coefficientsmodel.apply_equality(indices, coeffs, target, penalty)-- EQUALITY constraint
Pipeline overview
- CP (
xqcp) -- generate random positive integers, declare binary variables (one per number), and encode the half-sum equality constraint via EQUALITY. - Assemble --
.xqasmtext to bytecode viaxquad.asm - Encode -- run encoder on chosen XQVM to produce the XQMX model
- Sample -- solver runs SA/QPU/GPU over the model
- Verify -- verifier checks the sample is binary and that the partition constraint holds, then computes energy
- Decode -- decoder extracts the subset assignment
Usage
uv run python examples/number_partition/runner.py --seed 42
uv run python examples/number_partition/runner.py --n 8 --interpreter rust| Flag | Default | Description |
|---|---|---|
--n |
6 |
Number of integers |
--solver |
dwave-cpu |
Solver backend (see Choosing a solver) |
--interpreter |
python |
XQVM backend: python or rust |
--seed |
42 |
Random seed |
-o |
stdout | Write JSON result to file |
Choosing a solver
| 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 = 1if asset i is selected. - Objective:
-sum(r_i * x_i) + sum(sigma_ijk * x_i * x_j * x_k)-- first term maximises return (minimising its negation), second penalises correlated three-asset risk interactions. - Constraints: budget
sum(x_i) = B(EQUALITY with unit coefficients, penalty 200)
Encoding strategy
Return terms are linear: ADDLINE(i, -r_i) per asset.
Cubic risk terms (i, j, k, sigma) are degree-reduced:
REDUCE(i, j, P_AUX) -> w(Rosenberg enforcement forw = x_i * x_j)ADDQUAD(w, k, sigma)(sigma * w * x_k = sigma * x_i * x_j * x_k)
Budget constraint builds uniform-coefficient index/coeff vecs then calls
EQUALITY with target = B and penalty = 200. EQUALITY is emitted after all
objective (body) actions because it lands in the constraint section.
DSL methods used
model.reduce(var_a, var_b, p_aux)-- HOBO degree reduction for cubic risk termsproblem.vec()-- allocate index/coefficient vecs for the budget constraintmodel.apply_equality(indices, coeffs, target, penalty)-- budget EQUALITY
Pipeline overview
- CP (
xqcp) -- generate random returns and cubic risk interactions, declare binary variables, degree-reduce risk terms via REDUCE, and add a budget EQUALITY constraint. - Assemble --
.xqasmtext to bytecode viaxquad.asm - Encode -- run encoder on chosen XQVM to produce the XQMX model
- Sample -- solver runs SA/QPU/GPU over the model
- Verify -- verifier checks the sample is binary, that the budget constraint holds, and that each REDUCE auxiliary equals the product it stands for, then computes energy
- Decode -- decoder extracts the selected assets
Usage
uv run python examples/portfolio_opt/runner.py --seed 42
uv run python examples/portfolio_opt/runner.py --n 6 --budget 3 --interpreter rust| Flag | Default | Description |
|---|---|---|
--n |
5 |
Number of assets |
--budget |
2 |
Number of assets to select |
--solver |
dwave-cpu |
Solver backend (see Choosing a solver) |
--interpreter |
python |
XQVM backend: python or rust |
--seed |
42 |
Random seed |
-o |
stdout | Write JSON result to file |
Choosing a solver
| 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_iin[-5, 5], declared withlo=/hi=. The model holdsy_i = x_i - loin{0, ..., 10}; coefficients are written overxandsample.value()shifts back on the way out. - Objective:
-sum(r_i * x_i) + sum_{i <= j} C_ij * x_i * x_j - Constraint:
sum(x_i) = B, as the penaltyP * (sum_i x_i - B)^2 - Energy: shifted twice over, and not comparable with any other example's. The ranged domain drops the constant that substituting
x = y + loproduces, because XQMX carries no offset field, and the budget square drops its ownP*B^2the way EQUALITY does. Both shifts are uniform across assignments, so argmin is exact even though the number is not the objective's true value.
Encoding strategy
The budget constraint is written out by hand rather than handed toapply_equality. Every high-level constraint in the VM expands underx^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, becausew * x_i * x_j expands to w*y_i*y_j + w*lo*y_i + w*lo*y_j + w*lo^2 oncex = y + lo is substituted. A write to the diagonal lands both corrections on
the one index, giving the 2*w*lo that squaring asks for. None of that is
visible in the model-building code above.
lo and hi are literals. A runtime lo would want the decoder's single
calldata scalar, which the output loop bound already spends, and xqcp raises
naming both rather than picking one.
DSL methods used
problem.define_model(size=N, domain=Domain.INTEGER, lo=-5, hi=5)-- ranged integer weightsmodel.linear[i].add(w)andmodel.quadratic[i, j].add(w)-- the only operations a non-binary model supportssample.value(i)-- the weight in the domain it was declared over, rather than the storedy
What this example proves
valid == 1 proves that the domain check, the record-layer shift and the
decode compose on a chosen assignment. It proves nothing about optimisation:
no solver samples an integer model yet, so --solver is accepted, ignored,
and the pipeline runs against a hand-picked weight vector that sits inside the
domain and sums to the budget. Integer lowering is XQSA v0.5.0 work.
Pipeline overview
- CP (
xqcp) -- generate returns and a symmetric risk matrix, declare N ranged integer weights, and write the objective and the budget square as coefficients - Assemble --
.xqasmtext to bytecode viaxquad.asm - Encode -- run encoder on chosen XQVM to produce the XQMX model
- Sample -- skipped; the runner supplies the assignment itself
- Verify -- verifier checks every weight is inside
{0, ..., 10}against thekreplayed fromdefine_model, then computes energy - Decode -- decoder reads each weight and adds
lo, giving the signed weights back
Usage
uv run python examples/portfolio_rebalance/runner.py --seed 42
uv run python examples/portfolio_rebalance/runner.py --n 6 --interpreter rust| Flag | Default | Description |
|---|---|---|
--n |
5 |
Number of assets |
--solver |
dwave-cpu |
Accepted and ignored |
--interpreter |
python |
XQVM backend: python or rust |
--seed |
42 |
Random seed |
-o |
stdout | Write JSON result to file |
Canonical output
example-smoke validates both interpreters produce valid == 1 with--seed 42 --solver dwave-cpu. The smoke test is invariant-based --
it checks validity, not exact output.
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
-1per variable rewards selection, creating tension with the positive quartic terms. - Objective:
sum(c_t * x_i * x_j * x_k * x_l) - sum(x_v)
Each quartic term (i, j, k, l, c) is encoded via two-stage REDUCE:
w = REDUCE(i, j, P_AUX)-- introduces auxiliary w; w approximatesx_i*x_j.v = REDUCE(w, k, P_AUX)-- introduces auxiliary v; v approximatesw*x_k = x_i*x_j*x_k. Here w is the variable index returned from the first REDUCE.ADDQUAD(v, l, c)-- addsc*v*x_l = c*x_i*x_j*x_k*x_lto the QUBO.
Each quartic term allocates 2 auxiliary variables. With M terms, the model
grows by 2*M variables beyond the original N.
DSL methods used
model.reduce(var_a, var_b, p_aux)-- two chained HOBO degree reductions;
the RegLoad returned by the first REDUCE is passed as var_a to the second
Pipeline overview
- CP (
xqcp) -- generate random quartic interaction terms, declare binary variables with linear bias, and two-stage degree-reduce each quartic term via chained REDUCE. - Assemble --
.xqasmtext to bytecode viaxquad.asm - Encode -- run encoder on chosen XQVM to produce the XQMX model
- Sample -- solver runs SA/QPU/GPU over the model
- Verify -- verifier checks the sample is binary and that each Rosenberg REDUCE auxiliary equals the product it stands for, then computes energy
- Decode -- decoder extracts the variable assignment
Usage
uv run python examples/quartic_opt/runner.py --seed 42
uv run python examples/quartic_opt/runner.py --n 6 --m 3 --interpreter rust| Flag | Default | Description |
|---|---|---|
--n |
5 |
Number of variables |
--m |
2 |
Number of quartic terms |
--solver |
dwave-cpu |
Solver backend (see Choosing a solver) |
--interpreter |
python |
XQVM backend: python or rust |
--seed |
42 |
Random seed |
-o |
stdout | Write JSON result to file |
Choosing a solver
| 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] = 1if set s covers element e) - Model: S binary variables.
x_s = 1if set s is selected. - Objective: minimise
sum(x_s) - Constraints: per element e:
sum_{s: covers[e][s]=1} x_s >= 1(ATLEAST with k=1)
For each element, the encoder iterates over all sets and uses a branch to
conditionally push only covering set indices into the element's index vector.
ATLEAST then enforces that at least one covering set is selected.
DSL methods used
problem.vec()-- allocate a vector register for each element's covering set indicesproblem.branch(cond, arm, default)-- conditional VECPUSH based on coverage membershipmodel.apply_atleast(indices, k, penalty)-- ATLEAST constraint with k=1
Pipeline overview
- CP (
xqcp) -- generate a random coverage matrix, declare binary variables (one per set), and encode per-element coverage constraints via conditional branching and ATLEAST. - Assemble --
.xqasmtext to bytecode viaxquad.asm - Encode -- run encoder on chosen XQVM to produce the XQMX model
- Sample -- solver runs SA/QPU/GPU over the model
- Verify -- verifier checks the sample is binary and that every element is covered, then computes energy
- Decode -- decoder extracts the selected sets
Usage
uv run python examples/set_cover/runner.py --seed 42
uv run python examples/set_cover/runner.py --num-sets 6 --interpreter rust| Flag | Default | Description |
|---|---|---|
--num-elements |
4 |
Number of elements in the universe |
--num-sets |
5 |
Number of sets |
--solver |
dwave-cpu |
Solver backend (see Choosing a solver) |
--interpreter |
python |
XQVM backend: python or rust |
--seed |
42 |
Random seed |
-o |
stdout | Write JSON result to file |
Choosing a solver
| 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)/2entries) - Model: an
n x nbinary grid.x[i, p] = 1means cityiis at tour positionp. - Objective: sum of distances between consecutive positions in the tour.
- Constraints: one-hot row (each city at exactly one position) and one-hot column (each position holds exactly one city), both with penalty 100.
Hamiltonian
The Hamiltonian is the sum of three terms:
$$H = H_{\text{dist}} + H_{\text{row}} + H_{\text{col}}$$
\(H_{\text{dist}}\) is the tour length. For every pair of cities \(i < j\) and
every position \(p\), a term fires whichever direction the tour visits them
in -- city i at p and city j at the next position, or the reverse:
$$H_{\text{dist}} = \sum_{i<j} \sum_{p=0}^{n-1} d_{ij} \left( x_{i,p}, x_{j,(p+1) \bmod n} + x_{j,p}, x_{i,(p+1) \bmod n} \right)$$
\(H_{\text{row}}\) and \(H_{\text{col}}\) are the one-hot constraintsapply_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 the120 valid tours.
DSL methods used
problem.input()-- declare typed calldata inputsproblem.define_model()-- allocate binary 2D grid XQMX modelproblem.stow()-- bind intermediate computations to named registersproblem.range()-- emit RANGE loopsmodel.quadratic[(city_i, pos), (city_j, pos)].add()-- accumulate quadratic coupling using 2D grid coordinatesmodel.apply_onehot_row()-- ONEHOTR constraint per citymodel.apply_onehot_col()-- ONEHOTC constraint per positionproblem.output()-- declare typed output slotsproblem.sample.colfind()-- find the row index with value 1 in a given column
Pipeline overview
- CP (
xqcp) -- build a random symmetric distance matrix, declare ann x nbinary grid, and add quadratic distance terms plus one-hot row/column constraints. - Assemble --
.xqasmtext to bytecode viaxquad.asm - Encode -- run encoder on chosen XQVM to produce the XQMX model
- Sample -- solver runs SA/QPU/GPU over the model
- Verify -- verifier checks the sample is binary and the one-hot row and column constraints, then computes energy
- Decode -- decoder extracts the tour as a sequence of city indices
Steps 3-6 are three independent XQVM programs plus a solver call in between,
sharing data only through calldata and outputs:
flowchart LR
ENC[Encoder] -->|model| SLV[xqsa solver]
ENC -->|model| VER[Verifier]
SLV -->|sample| VER
SLV -->|sample| DEC[Decoder]
VER -->|energy, valid| OUT[Host program]
DEC -->|tour| OUTThe encoder's calldata is num_cities, distance_matrix; the verifier's ismodel, sample, num_cities; the decoder's is sample, num_cities. See
Three Programs for why the
split exists.
Worked example
uv run python examples/tsp/runner.py --n 4 --seed 42 builds this
4-city instance (edge labels are distance_matrix, indexed by the
upper-triangle position of each city pair):
graph LR
0 ---|82| 1
0 ---|15| 2
0 ---|95| 3
1 ---|4| 2
1 ---|36| 3
2 ---|32| 3Both interpreters return the same tour:
{
"_note": "canonical CI golden",
"_seed": 42,
"energy": -650,
"n": 4,
"tour": [0, 2, 1, 3],
"tour_distance": 150,
"valid": 1
}tour_distance is d(0,2) + d(2,1) + d(1,3) + d(3,0) = 15 + 4 + 36 + 95 = 150, matching \(H_{\text{dist}}\) for a valid tour. energy isH_{\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, soenergy = tour_distance - 2 * P * n = 150 - 2 * 100 * 4 = -650, matching
the run.
Usage
uv run python examples/tsp/runner.py --seed 42
uv run python examples/tsp/runner.py --n 5 --seed 7 -o /tmp/tsp.json| Flag | Default | Description |
|---|---|---|
--n |
4 |
Number of cities |
--solver |
dwave-cpu |
Solver backend (see Choosing a solver) |
--interpreter |
python |
XQVM backend: python or rust |
--seed |
42 |
Random seed |
-o |
stdout | Write JSON result to file |
Choosing a solver
| 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 = 1if vertex v is in the cover. - Objective: minimise
sum(x_v) - Constraints: per edge (i,j):
x_i + x_j >= 1(ATLEAST with k=1)
The at-least-1 constraint is encoded directly with ATLEAST. For each edge,
ATLEAST allocates one slack variable at model.size and adds the penaltyP*(x_i + x_j - 1 - s)^2, where s in {0,1} accounts for the case when
both endpoints are selected (sum = 2).
DSL methods used
problem.vec()-- allocate a vector register for the two endpoint indices per edgemodel.apply_atleast(indices, k, penalty)-- ATLEAST constraint with k=1
Pipeline overview
- CP (
xqcp) -- generate a random graph, declare binary variables (one per vertex), and encode per-edge coverage constraints via ATLEAST. - Assemble --
.xqasmtext to bytecode viaxquad.asm - Encode -- run encoder on chosen XQVM to produce the XQMX model
- Sample -- solver runs SA/QPU/GPU over the model
- Verify -- verifier checks the sample is binary and that every edge is covered, then computes energy
- Decode -- decoder extracts the selected vertices
Usage
uv run python examples/vertex_cover/runner.py --seed 42
uv run python examples/vertex_cover/runner.py --n 7 --interpreter rust| Flag | Default | Description |
|---|---|---|
--n |
5 |
Number of nodes |
--solver |
dwave-cpu |
Solver backend (see Choosing a solver) |
--interpreter |
python |
XQVM backend: python or rust |
--seed |
42 |
Random seed |
-o |
stdout | Write JSON result to file |
Choosing a solver
| 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 = 1if set s is selected. - Objective: minimise
sum(cost[s] * x_s) - Constraints: per element e:
sum_{s: covers[e][s]=1} cap[s] * x_s >= demand[e](ATLEASTW)
For each element, a branch conditionally pushes (set index, capacity) pairs
into per-element index/coefficient vectors, then ATLEASTW enforces the
weighted threshold.
DSL methods used
problem.vec()-- allocate vector registers for covering set indices and capacitiesproblem.branch(cond, arm, default)-- conditional VECPUSH based on coverage membershipmodel.apply_atleastw(indices, coeffs, k, penalty)-- ATLEASTW constraint
Pipeline overview
- CP (
xqcp) -- generate a random weighted coverage instance, declare binary variables (one per set), and encode per-element weighted demand constraints via conditional branching and ATLEASTW. - Assemble --
.xqasmtext to bytecode viaxquad.asm - Encode -- run encoder on chosen XQVM to produce the XQMX model
- Sample -- solver runs SA/QPU/GPU over the model
- Verify -- verifier checks the sample is binary and that every demand is met, then computes energy
- Decode -- decoder extracts the selected sets
Usage
uv run python examples/weighted_set_cover/runner.py --seed 42
uv run python examples/weighted_set_cover/runner.py --num-sets 6 --interpreter rust| Flag | Default | Description |
|---|---|---|
--num-elements |
4 |
Number of elements in the universe |
--num-sets |
5 |
Number of sets |
--solver |
dwave-cpu |
Solver backend (see Choosing a solver) |
--interpreter |
python |
XQVM backend: python or rust |
--seed |
42 |
Random seed |
-o |
stdout | Write JSON result to file |
Choosing a solver
| 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.
Related specifications
- 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.
Related specifications
- 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 andITER iteration.
The instruction set comprises 93 instructions, declared once in theopcodes! table atxqvm/src/bytecode/types/table.rs.
The Opcode enum, the Instruction enum, mnemonic strings, and operand
arity are all derived from that single table.
Every .xqb file opens with a fixed 15-byte XQBC header -- magic bytes,
format version, calldata/output-slot counts, instruction-stream length, and
a CRC-32 checksum -- followed by the raw instruction stream: an opcode byte
followed by its operands in big-endian byte order. See
Bytecode Format for the full field layout.
Where to go next
- Getting Started -- install the toolchain and run
your first program. - Concepts -- what a QUBO/Ising model is, the ways
to use XQuad, and the backends it targets. - Modelling with XQCP -- write a problem with the
constraint-programming DSL. - Running Programs and
Solving -- execute a compiled program and hand it
to a solver. - XQVM Reference -- the machine model, assembly
language, instruction set, and bytecode format. - Embedding -- using the Rust crates directly,
no_stdsupport, and cross-implementation conformance. - Examples -- worked problems including a
Travelling Salesman Problem.
License
Licensed under the
GNU Affero General Public License v3.0 or later.
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.
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 asadd.xqasm:
; push two integers and add them
PUSH 10
PUSH 32
ADD
HALT$ xquad asm add.xqasm -o add.xqb
assembled 4 instructions (21 bytes) -> add.xqb
$ xquad run add.xqb
stack (bottom to top):
42Here the pipeline is just two rows: the assembler and the VM. The xquad
CLI plays the host program's role, reading add.xqb from disk and
printing the result, and that already runs the real toolchain end to end.
XQVM Reference walks through this same add.xqasm
again, from the machine's side: what each instruction does to the stack as
it runs.
A problem worth handing to a solver uses every row in the table above.xqcp compiles a Problem into three separate .xqasm programs, not one
-- see Three Programs for what those three are and
why. The first one builds a model; xqsa solves it; the other two check
and decode the answer. A Python host program -- xquad.program,xqffi.vm, or a full xqcp pipeline -- drives it, injecting calldata
before each VM run and reading outputs back after. See
Ways to Use XQuad for which surface plays that host-program
role in each of the six ways to work with XQuad.
Where to Go From Here
- Ways to Use XQuad -- the six surfaces that build a
model and drive execution, and how to pick one. - Quadratic Models -- what the model in the
middle of this pipeline actually is, and why constraints become part of
it. - Three Programs -- why
xqcpcompiles a problem
into three separate programs instead of one. - Backends -- the solvers that sit in the gap between
encoder and verifier.
Past this chapter: Modelling builds a model with
the DSL, Running Programs and
Solving Overview execute it and hand it to a
solver, and XQVM Reference is the machine this whole
pipeline compiles down to.
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 withoutstd:
- Bytecode -- the opcode table,
Instruction,Register,InstructionBuilder,Program, and the wire codec. - Interpreter core --
Vm,Error,RegVal,XqmxModel/XqmxSample, the bytecode verifier, and theTracertrait with its
no-op implementation.
The std feature, enabled by default, adds three things a no_std
embedder gives up: the disassembler (disasm::Disassembly), miette-backed
runtime diagnostics (RuntimeDiagnostic), and the JsonTracer /TextTracer implementations. None of these change what a program computes;
they change how a fault or a trace is reported. Build for a no_std target
with:
cargo add xqvm --no-default-featuresThis is exercised, not just claimed: fixtures/xqvm-wasm is a freestandingwasm32-unknown-unknown test crate depending on xqvm withdefault-features = false, and CI's test:wasm job runs cargo build -p xqvm --target wasm32v1-none --no-default-features followed bywasm-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:
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 forxqvm::InstructionBuilder
instead when a Rust host is generating programs and adding a Python step
to emit them buys nothing.
One Problem, Once
Every page in this chapter builds on the same running example:examples/knapsack/runner.py. It takes item weights, item values, and a
capacity, and chooses the subset of items maximising total value without
exceeding the capacity. build_problem is the whole of it:
def build_problem(n: int, weights: list[int], values: list[int], capacity: int) -> Problem:
problem = Problem("Knapsack")
num_items = problem.input("num_items", type=Types.Int)
weights_in = problem.input("weights", type=Types.Vec)
values_in = problem.input("values", type=Types.Vec)
capacity_in = problem.input("capacity", type=Types.Int)
problem.define_model(size=num_items, domain=Domain.BINARY)
# Objective: minimise -sum(v_i * x_i)
with problem.range(0, num_items) as i:
vi = problem.stow("vi", values_in.get(i))
problem.model.linear[i].add(-vi)
# Constraint: sum(w_i * x_i) <= W
indices = problem.vec()
coeffs = problem.vec()
with problem.range(0, num_items) as i:
indices.push(i)
coeffs.push(weights_in.get(i))
problem.slack(indices, coeffs, num_items, capacity_in)
problem.model.apply_equality(indices, coeffs, capacity_in, 100)
selected = problem.output("selected", type=Types.Vec)
with problem.range(0, num_items) as i:
selected.append(problem.sample.getline(i))
return problemOne 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 andapply_equality encoding the capacity constraint, and a decoder loop
reading the solved sample back into a selected vector. Running it end
to end:
$ uv run python examples/knapsack/runner.py --seed 42
{
"_note": "canonical CI golden",
"_seed": 42,
"capacity": 18,
"energy": -32446,
"n": 5,
"selection": [
1,
1,
1,
0,
1
],
"total_value": 46,
"total_weight": 12,
"valid": 1,
"values": [
5,
4,
18,
3,
19
],
"weights": [
2,
1,
5,
4,
4
]
}_note and _seed are the runner's own bookkeeping, the same as in
Three Programs. energy
is the full model's Hamiltonian, capacity constraint included, so it is
not simply -total_value here the way Max-Cut's energy is -cut_weight
-- Objectives isolates just the objective term's
contribution, and Constraints covers what the rest ofenergy 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 fourproblem.input() calls and the define_model() line above; the rest of
the chapter works through the remaining lines in the order they appear.
Where to Go From Here
- Inputs and Model Shape -- runtime inputs and
the 1D/2D model they fill. - Expressions -- the operators and functions that
build a coefficient or a loop bound. - Objectives -- turning "minimise this" into
linear/quadraticcoefficients, sign included. - Constraints -- folding a rule into the objective
as a penalty. - Control Flow --
range,iter, andbranch. - Outputs and Decoding -- reading a solved
sample back into your problem's own terms. - Compiling -- what
problem.compile()actually
produces.
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:
from xquad.program import Program
src = """
PUSH 0
INPUT r0
PUSH 1
INPUT r1
LOAD r0
LOAD r1
ADD
STOW r2
PUSH 0
OUTPUT r2
HALT
"""
program = Program.from_source(src)
assert program.instruction_count == 11
assert program.source is not None # retained for debugging
session = program.session(output_slots=4)
session.set_calldata([40, 2])
result = session.run()
assert dict(result.outputs) == {0: 42, 1: None, 2: None, 3: None}
assert result.stack == []
assert result.steps == 11
# Re-run with different calldata -- sessions carry no hidden state.
session.set_calldata([100, 200])
assert dict(session.run().outputs) == {0: 300, 1: None, 2: None, 3: None}RunResult.outputs is a dict keyed by slot index; a slot the program never
wrote reads as None rather than raising. Program is immutable and
reusable; Session carries the mutable calldata and output-slot count.
Deriving a second Session from the same Program gives you two runners
that share no state.
Step limits
Session.set_step_limit(n) caps how many instructions a run may execute
before run() raises. The limit is exact: 0 permits no instructions at
all. A fresh Session starts at xquad.vm.DEFAULT_STEP_LIMIT, which is
10,000,000. None is the only unbounded spelling, and a caller has to
write it -- unbounded execution is asked for, never inherited.
session = program.session(output_slots=1)
session.set_calldata([1, 2])
assert "step_limit=10000000" in repr(session)
session.set_step_limit(3)
try:
session.run()
except RuntimeError as e:
assert "StepLimitExceeded" in str(e)
# 0 is not a sentinel for "unlimited" -- it executes nothing, so this
# 11-step program fails at its first instruction.
session.set_step_limit(0)
try:
session.run()
except RuntimeError as e:
assert "StepLimitExceeded" in str(e)
# None removes the bound. A program that never halts will not return,
# and it does not take SIGALRM: the GIL is held inside the Rust run.
session.set_step_limit(None)
assert "step_limit=unlimited" in repr(session)
assert dict(session.run().outputs) == {0: 3}A budget that exactly covers the program succeeds. This one needs 11
steps, so set_step_limit(11) runs it and set_step_limit(10) does not.
Loading bytecode directly
Program.load(bytes) parses raw wire-format bytes -- a .xqb blob produced
by xquad asm or by program.bytecode(). No source is retained:
blob = program.bytecode()
reloaded = Program.load(blob)
assert reloaded.source is None
assert reloaded.instruction_count == program.instruction_countProgram.load never validates its input; a malformed blob decodes
successfully as a Program object and only fails once a Session tries to
run it. A one-byte blob is too short even for the wire-format header:
p = Program.load(b"\x43")
s = p.session()
try:
s.run()
except RuntimeError as e:
print(e)
# decode error: TruncatedHeaderProgram.from_source validates at load time instead: an unassemblable
string raises ValueError immediately, before a Session exists.
Calldata types
A calldata list may mix any of int, list[int], xqffi.vm.XqmxModel,xqffi.vm.XqmxSample, and None (unset). Session.set_calldata checks
every element's type as soon as you call it, not at run time:
from xqffi.vm import XqmxModel, XqmxSample
model = XqmxModel("binary", size=4)
model.set_linear(0, -1)
model.set_quad(0, 1, 2)
sample = XqmxSample("spin", values=[-1, 1, -1, 1])
session.set_calldata([model, sample, [1, 2, 3], 42])
# session.run() now sees four typed input slots.
try:
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 NoneThe constructors also refuse a size overxqffi.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:
try:
model.set_linear(4, 1)
except IndexError as e:
print(e)
# variable index 4 is out of range for a model of size 4Inspecting a model or sample
XqmxModel and XqmxSample are pyo3 objects; there is no dict-conversion
helper in this package today. Read a model's coefficients through its own
accessors:
model = XqmxModel("binary", size=4)
model.set_linear(0, -1)
model.set_linear(2, 3)
model.set_quad(0, 1, 2)
model.domain # "binary"
model.size # 4
list(model.linear_items()) # [(0, -1), (2, 3)]
list(model.quadratic_items()) # [((0, 1), 2)]
model.get_linear(0) # -1
model.get_quad(0, 1) # 2
repr(model) # 'XqmxModel(domain=binary, size=4)'__repr__ is intentionally minimal. Convert to xquad.types.XQMX (the
canonical, backend-independent type covered next) if you want a Python
object you can inspect with normal attribute access, or serialise by
iterating linear_items() / quadratic_items() yourself.
The other two VM surfaces
Two lower-level wrappers sit underneath and alongside xquad.program,
both worth knowing about directly.
xqffi.vm.Vm is the thinnest possible wrapper over the Rust
interpreter: construct it, call set_calldata / set_output_slots, call.run(bytecode), read .outputs() as a positional list. It always targets
the Rust backend. .reset() clears the stack, registers, loop stack, and
step counter, but calldata and output slots are untouched by it -- both
persist across runs until you call set_calldata() or set_output_slots()
again. It is what Session.run() builds internally:
from xqffi.vm import Vm
vm_src = "PUSH 7\nPUSH 5\nADD\nSTOW r0\nPUSH 0\nOUTPUT r0\nHALT\n"
bytecode = Program.from_source(vm_src).bytecode()
vm = Vm()
vm.set_output_slots(1)
vm.run(bytecode)
assert vm.outputs() == [12]Ways to Use XQuad
covers when to reach for it directly instead of Session.
xquad.vm.VM is a separate wrapper that every example runner underexamples/ actually uses (examples/maxcut/runner.py,examples/knapsack/runner.py). It selects between the Rust interpreter and
the pure-Python reference VM via VMBackend, converts FFI
objects to and from the canonical xquad.types.XQMX at the boundary, and
runs .xqasm source text directly rather than pre-assembled bytecode:
from xquad.vm import VM, VMBackend
src = "PUSH 7\nPUSH 5\nADD\nSTOW r0\nPUSH 0\nOUTPUT r0\nHALT\n"
v = VM(backend=VMBackend.RUST) # RUST is also the default with no argument
v.set_output_slots(1)
v.run(src)
assert v.outputs() == [12]
assert v.stack() == []VM.outputs() returns xquad.types.XQMX instances for model and sample
outputs, where xqffi.vm.Vm.outputs() and Session.run().outputs return
the raw pyo3 XqmxModel / XqmxSample objects. Pick VM when you need
Python-backend parity checking or when your calldata and outputs are
already in xquad.types terms; pick Session for the dict-keyed,
slot-sparse ergonomics shown above; pick xqffi.vm.Vm only if you are
building your own layer on top of the raw Rust FFI.
VM.set_calldata() applies the same size, grid and index rules to everyxquad.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 raisesxqvm_py.errors.SampleOutOfDomain, a ValueError subclass, fromset_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 fromxquad.program looks like this, using the exact build_problem functionexamples/knapsack/runner.py defines (see Compiling
for what compile() emits):
from examples.knapsack.runner import build_problem
from xquad.program import Program
from xqffi.vm import XqmxSample
n = 4
weights = [2, 3, 4, 5]
values = [3, 4, 5, 8]
capacity = 8
problem = build_problem(n, weights, values, capacity)
programs = problem.compile()
# Encoder: inputs in, model out.
encoder = Program.from_source(programs.encoder)
enc_session = encoder.session(output_slots=1)
enc_session.set_calldata([n, weights, values, capacity])
model = enc_session.run().outputs[0]
# A hand-picked sample -- items 1, 2, 3 selected, no solver involved.
sample = XqmxSample("binary", values=[0, 1, 1, 1, 0, 0, 0, 0])
# Verifier: the encoder's own inputs, then model and sample; (energy, valid) out.
verifier = Program.from_source(programs.verifier)
ver_session = verifier.session(output_slots=2)
ver_session.set_calldata([n, weights, values, capacity, model, sample])
ver_result = ver_session.run()
energy, valid = ver_result.outputs[0], ver_result.outputs[1]
# Decoder: sample + N in, decoded selection out.
decoder = Program.from_source(programs.decoder)
dec_session = decoder.session(output_slots=1)
dec_session.set_calldata([sample, n])
selected = dec_session.run().outputs[0]
assert (energy, valid, selected) == (-4817, 0, [0, 1, 1, 1])Items 1, 2 and 3 weigh 3 + 4 + 5 = 12 against a capacity of 8, so the
sample violates the capacity constraint and valid comes back 0.energy is still computed: ENERGY recomputes the objective whatever the
sample's feasibility, so the two outputs answer different questions. Swap
in [1, 1, 0, 0, 0, 0, 0, 0] -- items 0 and 1, weighing 5 -- and the same
run gives (-5507, 1).
Verification
says what valid covers.
The verifier takes the encoder's calldata because it replays the encoder to
rebuild the constraint data, then appends the model and the sample. Each
program's own .set_calldata order and .set_output_slots count are fixed
by what it was compiled from -- Compiling
has the full table. A real pipeline replaces the hand-picked sample above
with xqsa.build_solver(...).solve(model).sample; see Solving
Overview.
What this page does not cover
- Tracing. Step-by-step execution inspection is not part of
Session
today. - Keyword calldata (
session.set_calldata(n=4, ...)).Sessiontakes a
positional list only; there is no input-slot labelling to key against. - A
_repr_html_for notebooks.__repr__is the only representationProgram,Session,RunResult,XqmxModelandXqmxSampleprovide. - A
to_numpy()helper. Convert vialinear_items()/quadratic_items()andnumpy.asarray(...)on your own side; this
package does not carry a numpy dependency.
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:
class Solver(ABC):
@abstractmethod
def solve(self, model: XQMX, **kwargs: Any) -> SolverResult: ...model is an XQMX in MODEL mode, binary or spin domain. **kwargs
override that solver's constructor defaults for this call only --solver = SolverDWaveCPU(num_reads=100) then solver.solve(model, num_reads=500) runs 500 reads without building a new solver. solve()
raises on failure (hardware unreachable, no embedding found, connection
lost); it never returns a partial or empty result.
The return value is a frozen dataclass:
@dataclass(frozen=True)
class SolverResult:
sample: XQMX
energy: int
timing: float
metadata: dict[str, Any] = field(default_factory=dict)sample is the solution, an XQMX in SAMPLE mode. energy is the
authoritative Hamiltonian energy. timing is wall-clock seconds spent
solving. metadata defaults to an empty dict; its actual shape is
per-backend, covered below.
energy is never the backend's own reported value taken on faith. Everysolve() 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, andmetal-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 omitsseed (there is no seed on physical hardware) and adds solver andqpu_timing at the top level, alongside reads; see
D-Wave QPU. quip returns a
different set entirely -- no seed, reads, or params at all; see
Quip Network. Check the backend you are
calling before indexing into result.metadata.
Picking a Backend by Name
build_solver constructs any of the five from a short string, so a
caller -- an example runner, a CLI flag, a script -- can stay
backend-agnostic:
from xqsa import SOLVERS, DEFAULT_SOLVER, build_solver
print(sorted(SOLVERS))
# ['cuda-gpu', 'dwave-cpu', 'dwave-qpu', 'metal-gpu', 'quip']
print(DEFAULT_SOLVER)
# dwave-cpu
solver = build_solver("dwave-cpu", seed=42)
result = solver.solve(model)seed reaches the three classical simulated-annealing backends
(dwave-cpu, cuda-gpu, metal-gpu) and is ignored for dwave-qpu
(physical hardware has no seed) and quip (configured from the
environment; the miner's own randomness is out of the caller's control).
build_solver otherwise uses each backend's own constructor defaults,
with one exception: for cuda-gpu and metal-gpu it raises num_reads
to 200 and num_sweeps to 2000, rather than the 100 and 1000 the bare
constructor defaults to. Local Solvers
has the constructor defaults for every backend.
Swapping Backends Without Changing the Model
This is the claim the whole toolchain rests on, so here it is checked
rather than asserted. examples/maxcut/runner.py builds one Max-Cut
model with xqcp, runs the encoder on the Rust XQVM, and samples the
result with whichever solver --solver names, built throughbuild_solver. Run it against two different backends -- dwave-cpu
(CPU simulated annealing, 100 reads and 1000 sweeps) and metal-gpu (an
Apple Silicon GPU kernel, a different process on different hardware, and
-- per build_solver's override above -- 200 reads and 2000 sweeps) --
and nothing about the model or the encoder changes:
uv run python examples/maxcut/runner.py --n 6 --seed 42 --interpreter rust --solver dwave-cpu
uv run python examples/maxcut/runner.py --n 6 --seed 42 --interpreter rust --solver metal-gpuBoth 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 currentxqsa solver's _validate_model() accepts binary and spin and raisesValueError on integer:
from xqsa import SolverDWaveCPU
from xqvm_py.xqmx import XQMX
model = XQMX.integer_model(size=3, k=4)
SolverDWaveCPU().solve(model)
# ValueError: Unsupported domain for solving: INTEGERAn 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 exactlyDEFAULT_SOLVER: every other backend is worth comparing againstdwave-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 thexqffi extension from source, and installing individual Python packages
instead of the umbrella. The two commands most readers need:
cargo install xqcli # gives you the `xquad` binary
pip install xquad # Python umbrella: the full pipelineVerify the Install
xquad --version prints the installed CLI's version, confirming thexqcli binary is on PATH and runs. Importing the umbrella package
confirms the Python half:
$ python -c "import xquad; print('ok')"
okGet 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 andpip install above do not put them on disk. Get one and set up the
Python workspace once:
git clone https://gitlab.com/quip.network/xquad.git
cd xquad
make deps-pymake 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, souv 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'sdwave-cpu backend. Every other solver -- a local GPU or a real D-Wave
QPU -- needs an extra on top, and the extra needs hardware or credentials
this page cannot install for you. xqsa/pyproject.toml defines the
extras, and xquad/pyproject.toml forwards to the ones it re-exports:
cuda--cupy-cuda12x>=13.0,nvidia-cuda-nvrtc-cu12, andnvidia-cuda-runtime-cu12, for a local NVIDIA GPU.dwave--dwave-system>=1.0, for the real D-Wave QPU.metal--pyobjc-framework-Metal>=11.0, markedsys_platform == 'darwin', for a local Apple GPU. The marker meanspip install xquad[metal]succeeds on Linux and installs nothing.quip--substrate-interface>=1.7.4,<2plusquip-signer>=0.3.0,<0.4,
for the Quip network solver, which needs aQUIP_RPC_URLand a
configured signer; see Quip Network for
what that solver does.
xqcp, xqffi, and xqvm_py define no optional dependencies at all.
The only extra this page can fully specify is [dwave]: it needs a D-Wave
Leap account and a DWAVE_API_TOKEN, and dwave ping confirms both. A
local GPU ([cuda] or [metal]) is another option; see Local
Solvers for those two extras
and their driver checks.
pip install xquad[cuda], xquad[metal], xquad[dwave], andxquad[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 apip install xqsa[...] hint naming the extra to add.
Per-solver parameters, driver-level troubleshooting, and what a QPU
result contains that a CPU one does not are covered in
Solving Overview, Local Solvers,
and D-Wave QPU. This section only covers what
to install before you get there.
Where to Go From Here
- First Problem -- run a complete problem end to
end and get a real answer back. - What Happened -- the explanation of what that
run just did. - XQVM Reference -- a minimal hand-written
.xqasmprogram, if you want to see the machine underneath before
running anything larger. - Toolchain Map -- the pieces XQuad is built
from, and how they hand off to each other.
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:
; push two integers and add them
PUSH 10
PUSH 32
ADD
HALT$ xquad asm add.xqasm -o add.xqb
assembled 4 instructions (21 bytes) -> add.xqb
$ xquad run add.xqb
stack (bottom to top):
42The program pushes 10 and 32 onto the value stack, adds them, and halts. The
result remains on the stack and is printed by xquad run. xquad run --text add.xqasm skips the separate assembly step and runs the source directly.
What this section covers
- VM Architecture -- the value stack, the 256-slot
register file, and the loop stack. - Loop Stack --
RANGE/ITER/NEXTiteration. - Calldata and Outputs -- getting values into a program with
INPUTand out withOUTPUT. - Execution -- the fetch-decode-execute cycle and
execution tracing. - Assembly -- the
.xqasmtext syntax. - Assembly Examples -- worked
.xqasmprograms. - Instructions -- all 93 instructions, by
category. - Opcode Reference -- the generated opcode table.
- Bytecode Format -- the
.xqbwire format. - Verifier -- what the pre-execution verifier checks.
- CLI -- the
xquadcommand-line tool. - Limits and Errors -- fixed and configurable
limits, and every runtime and verifier error.
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, notxqcli. Running xqcli --help after installing fails because no such
binary exists.
Installation
cargo install xqcli
xquad --helpOr build from this repository:
cargo build -p xqcli --releaseThe binary is at target/release/xquad.
Subcommands
xquad has four subcommands, one per stage of the toolchain:
| Command | Description |
|---|---|
xquad asm |
Assemble .xqasm source into binary bytecode. |
xquad dism |
Disassemble bytecode into a human-readable listing. |
xquad run |
Execute bytecode or assembly, with optional tracing. |
xquad verify |
Run the bytecode verifier over a program without running it. |
General usage
xquad <COMMAND> [OPTIONS] [ARGS]
xquad --help
xquad <COMMAND> --helpxquad 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 andSourceSpan, 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 asasm's -o/--stdout conflict, are plain clap errors, a third and
unrelated error path.
A minimal round trip
cat > add.xqasm <<'EOF'
; add.xqasm -- push two integers and add them
PUSH 10
PUSH 32
ADD
HALT
EOF
xquad asm add.xqasm -o add.xqb
xquad verify add.xqb
xquad run add.xqbxquad run add.xqb prints the residual stack:
stack (bottom to top):
42Overview (`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, thena")
and summarised on the generated Opcode Reference as aStackcolumn of the form2 โ 1, meaning two values popped and one
pushed. The value stack is last-in-first-out: whichever value was pushed
most recently is popped first, soPUSH a; PUSH b; SUBcomputesa - b, notb - a. Every "popx, theny" operand order on this
section's pages, including the ones on constraints.md and
index-math.md, depends on this rule. reg-- theu8operand encoded in the instruction byte stream,
identifying a register slot (r0--r255).label-- a sequential id assigned to eachTARGETinstruction, in
program order, during the load-time pre-scan described in
Bytecode Format. It is not a byte offset, and not
the.Ntoken used in.xqasmsource -- that token is assembler-only and
is resolved to the sequential id before encoding.JUMP1/JUMPI1encode
the id as a singleu8operand;JUMP2/JUMPI2encode it as au16
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
i64range. A result that would leave it raisesArithmeticOverflow
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 outside0x00-0x7F; every other byte in 0x80-0xFF is illegal. Seespec/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 againstconformance/opcodes.yaml. The implementation is what ships, and the two
can diverge; where a divergence is known, the relevant book page says so.
Conformance
explains why a green run is evidence about the programs a vector covers
rather than a blanket guarantee.
The lists below are not closed. For anything they omit, check the book
chapter covering the component you are working with, or search the spec
files directly.
Top Level
spec/README.md
-- index of the three layers below, and the drift policy that ties spec
to implementation.
XQVM
Normative for the virtual machine: the instruction set, the wire format,
and the bytecode verifier.
spec/xqvm/README.md
-- document index for this layer.spec/xqvm/SPEC.md
-- normative for the machine overview, the three-program architecture,
the state model, determinism, the type system, and runtime limits.spec/xqvm/ISA.md
-- normative for the instruction set: notation, per-category opcode
tables, semantic notes, and reserved opcodes.spec/xqvm/HLF.md
-- normative for the high-level constraint expansion formulas: the QUBO
penalty termsONEHOTR,ONEHOTC,EXCLUDE,IMPLIES,EQUALITY,ATLEAST,ATLEASTW, andREDUCEinject.spec/xqvm/ENCODING.md
-- normative for both file formats:.xqasmassembly syntax and the.xqbbinary encoding, including the XQBC header layout.spec/xqvm/VERIFIER.md
-- normative for the bytecode verifier: its phase pipeline and every
error it can raise.spec/xqvm/METERING.md
-- normative for step metering: what a step is, the cost constants and
how they were measured, the per-opcode charge table, the three formulas,
and the conformance rules for step counts and operand validation order.
XQCP
Normative for the constraint-programming DSL that compiles a Problem
into the three XQVM programs.
spec/xqcp/README.md
-- document index for this layer.spec/xqcp/SPEC.md
-- normative for the DSL overview, the three-program architecture asxqcpimplements it, the problem lifecycle, and the compilation
contract.spec/xqcp/TYPES.md
-- normative for the symbolic value types, the expression tree, the
operator algebra, and the free functions built on top of it.spec/xqcp/CONSTRAINTS.md
-- normative for the constraint taxonomy, each constraint method's
signature, and its cross-reference into the matching HLF expansion.spec/xqcp/COMPILER.md
-- normative for the compilation pipeline: encoder/verifier/decoder
generation, register allocation, and action recording.
XQSA
Normative for the solver-adapter interface between a model and a backend.
spec/xqsa/README.md
-- document index for this layer.spec/xqsa/SPEC.md
-- normative for the architecture overview, where solving sits in the
pipeline, and the plugin model new solvers implement.spec/xqsa/INTERFACE.md
-- normative for theSolverabstract class, thesolve()contract,
theSolverResulttype, and parameter-passing conventions.spec/xqsa/ENERGY.md
-- normative for the energy computation formula, its precision contract,
and the sparse representation solvers exchange it in.spec/xqsa/DOMAINS.md
-- normative for the domain support matrix, sample encoding, grid
metadata, and capability negotiation between a model and a solver.spec/xqsa/SOLVERS.md
-- normative for the solver registry, its naming convention, algorithm
families, and the dependency model behind each optional extra.
Reading a Spec File Like a Reference, Not a Tutorial
Every file above assumes the concepts this book explains from scratch --
what a quadratic model is, why a problem becomes three programs, what a
penalty weight does. Arrive from Concepts or the
relevant chapter first; the spec files are where to go once you need the
exact rule the book page summarised, not where to start.
Guide Development Workflow (`docs/guide/development-workflow.md`)
xquad development workflow
This document describes how changes move from authoring to main anddev 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-generatedmerge: branch '' into 'main'subject. Merges are not squashed.dev-- the breaking line, at the next minor's-devversion.
Protected; Maintainers may push to it, for back-merges and version
bumps only. It containsmainat all times, and CI marksdevred
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 frommainfor a
patch or fromdevfor a minor, carries the version bump, and merges
intomainunsquashed.release:auto-tagcuts 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:
spec/xqvm/*.md-- any normative spec file: the change documented.SPEC.md,ISA.md,HLF.md,ENCODING.mdandVERIFIER.mdall
satisfy this layer; the guard matches the directory, not one file.xqvm/src/**/*.rs-- the Rust production impl updated.xqvm_py/{executor,opcodes,xqmx,state,vector,tracer,errors}.py--
the Python reference impl updated.conformance/vectors/**orconformance/opcodes.yaml-- a new
or modified conformance vector that exercises the change.
Why
- No drift-tracking middle ground. Before the merge, drift
betweenxq-rsandxq-pywas 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:
scripts/check-atomic-spec-mr.sh origin/main HEADExemptions
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 onlyxqvm_py/xqmx.pyto pre-
populate a spin sample with-1per 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 touchedxqvm/src/andxqvm_py/together, and QUI-1032's
opcode-signature check touched those two plusconformance/, neither
of them changing what the spec says.
To take an exemption, add a commit-message trailer of the formAtomic-Spec-Exempt: QUI- <reason>. It goes in the message's last
paragraph, at column 0, beside the sign-off:
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 fromgit 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-NNNfooter 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), plusxquad/program.py
and thexquad/xqcp/xqsaPython 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 (underconformance/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 inxqvm/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:
- No floating point. Integer arithmetic only, with every operation
range-checked so that it faults rather than wrapping. - No host I/O, wall-clock, or ambient state. An instruction's
result is a function of the program, the calldata and the VM's own
state. Stated normatively inspec/xqvm/SPEC.mdunder Determinism. - No nondeterministic iteration order. Anything that walks a
collection walks it in an order the spec fixes. - Bounded allocation. Every allocation is charged against a budget
before it happens, and the budget is not escapable by a value the
submitting account controls. - Bounded per-instruction work. The work one instruction performs
is charged against the step budget before it happens, at a rate that
scales with the data the program controls, so that a step is a unit
of cost rather than a unit of dispatch. - Specified behaviour, pinned by a vector. The result and every
fault the opcode can raise are specified normatively inspec/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 ascripts/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 tomain, one back-merge direction, one invariant. Only the routing question
changes.
The invariant
main is never synchronised with dev. It is contained in it:
git merge-base --is-ancestor origin/main origin/devThat 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 todevruns it, against the highest
release tag onmainrather thanmain's tip. The protocol back-merges
after every tag, not after every change tomain, sodevgoes red when a
release ships and stays red until its back-merge lands. Non-breaking work
merging intomainbetween releases does not turn it red. Merge requests
intodevare 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 containmaincannot
merge -- which is the case that would ship a release missing everythingmainhad 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
mainlike any other, and it ships in the next patch release. mainis a working branch carrying a-devversion between releases.
What shipped is read from the tag range, never frommain's log.maintakes merge requests, plus the reopening bump. The one direct
pushmainaccepts is the version bump that reopens it after a release. CI
fails any other commit that lands onmainwithout a merge request; see
Protected branches.devtakes 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, sorelease:auto-tag, which
looks for merge requests targetingmain, needs no change. - Each release freezes its own source. A patch freezes
main, a minor
freezesdev. The other line keeps moving. - Betas can come off either line.
Edge cases
- At the cutover, bump
mainfirst, then branchdevfrom it. Branch them
as siblings off the tag andmain's bump is a commitdevcannot reach, so
containment fails before any work lands. - When the minor ships,
mainbecomes the new line. It reopens atx.y.1-devanddevtakesx.(y+1).0-devat its back-merge. - A fix landing while a release is open goes to the release branch directly.
Cascading it throughdevreaches the release too, but drags every
out-of-scope change ondevinto the candidate with it. - Documentation publishes from tags, not from
main.mainis unreleased,
sodocs:publishruns 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.xstays
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
maincarries exactly the last tag and takes only release and hotfix
merges. Features never target it.- Hotfixes squash. The branch is cut from
mainand holds a few commits,
and the squash subject becomes that patch's only changelog line. Write it as
the fix, not asfix: 1.0.1. - Releases do not squash. See below.
- Feature branches merge to
devunsquashed, so every commit reaches the
changelog.
Edge cases
- "
mainis sparse" is a--first-parentproperty.git log mainstill
reaches every commitdevever 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-tagreads onlyrelease/vX.Y.Z. Ahotfix/*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 intodev. CI checks both: the candidate's merge request is
gated, anddevgoes 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 ormain.
- Update a branch by rebasing it, never by merging its target in.
git rebase origin/dev, notgit 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-refsrebases 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.
Cut
release/vX.Y.Zfrom its line --mainfor a patch,devfor a minor.
The source line freezes.Set the version and regenerate the lockfiles:
shmake 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"Cut release candidates as work stabilises, the way a beta is cut: a
throwaway branch off the release branch, the version set toX.Y.Z-rcN,
the tag, and the branch discarded. The release branch stays atX.Y.Z.
Only fixes land on it; a feature arriving here is the rule being broken.Open the merge request into
mainwith thereleasetemplate and merge it
without squashing.release:auto-tagpushesvX.Y.Zand the tag pipeline
publishes.Reopen
mainat the next patch-devversion, by pushing the bump
straight tomain:shgit 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 reopenUntil it lands,
release:validatefails onmain's pipelines.Back-merge
mainintodev, below. Until it lands,devcarries 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 as0.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:
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-beta1The 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 anddocs: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 plaingit merge stops with every manifest carrying conflict markers, andmake 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:
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.ZEverything 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 thehardware:* 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 markerquip) 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 localdevin the siblingnodes.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 defaultmake 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 aggregatemake 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 throughuv 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. WithoutQUIP_FAUCET_URL, these are the only tests that run. - funded:
QUIP_FAUCET_URLset. Thefunded_keystorefixture 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 thesolving_minerfixture, which proposes a throwaway order and
skips the tier cleanly when nothing answers withinQUIP_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 intest_live.py), so a slow answer still lands before the deadline.
Common prerequisites
uv sync --extra quipin this repo (installssubstrate-interface+ thequip-signerextension). The make targets do it for you.uv runkeeps the
extra, but a plainuv sync, including the one inmake deps-py,make test-pyandmake repl, removes it again; re-runuv sync --extra quipafter any of them, orSolverQuipfails withsubstrate-interface is not installed.- Apple Silicon:
quip-signer0.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 nocargoonPATH. - 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 a0600seed 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 writesdata/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 andbeta 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 runmake localdev.quip-miner-- the mining stack: thequip-coordinator,quip-miner-exec
andquip-mock-minercrates. Only needed to read miner internals, such as
whether a miner registers itself as a mempool solver. Renamed fromquip-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) + thequip_signer
binding. Renamed fromquip-protocol-rs.
Bring-up
Prerequisites: Docker Desktop running; nodes.quip.network on the latestmain. The quip.network GitLab container registry is public, so themake 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:
cp env.example .env # then set PUID=0 / PGID=0 (see macOS notes)
make localdev # wipe + pull + seed topology + bring up the stackmake 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 oldscripts/seed-advantage2-topology.py is gone. Success printslocaldev 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 usePGID=$(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_getRuntimeVersionover 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*-localdevUp,quip-cpu-localdevnot
Restarting. - Miner healthy:
docker logs quip-cpu-localdev | grep -iE "solver guard: registered|topology .from chain.|mempool=on"
-- expect the solver registered, the DevNetDefaultTopologyhash, and
mempool on.make localdevseeds the coordinator's built-inadvantage2-system1preset (allowed_h = [-1000, 0, 1000], hashing to0xfb91813b...), which is a different topology from theallowed_h = [0]
one aglais runs.SolverQuipreads 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(usehttp://localhost:20049/rpcfor 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 explicitdocker-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
make test-quip \
QUIP_RPC_URL=ws://localhost:20049/rpc \
QUIP_FAUCET_URL=http://localhost:20049/api/faucetA 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.tomlhas an explicit emptyvalidators = [](old-schema
leftover) that defeats the built-in fallback. On latestmain,make localdevoverwrites the config; if you hand-edited it, delete thevalidatorsline or setvalidators = ["ws://quip-validator:9944"]. - Stale checkout of
nodes.quip.network: a checkout still on thev0.2
line does not merely lag, it fails. The miner images moved to a separatev0.3repository line,data/config.tomlchanged schema (make updateconfig
migrates it), and chain seeding moved into the coordinator. Pullmainbefore
blaming the stack. - 0 solutions with a healthy-looking miner: confirm the node's runtime with
state_getRuntimeVersionand that the miner logs themempool_producer/work_schedulerstack.make localdevresolves the newest published tags on
every run, so a stale image is far less likely than it used to be; a stale.envpinningQUIP_*_TAGis the remaining way to get one. - macOS miner crash-loop before any app logs:
PGID=20group collision --
setPUID=0/PGID=0. - Faucet: on a fresh bring-up the miner's first requests hit
Connection refusedfor ~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
thatfund_from_faucetretries 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
aglaispreset inxqsa/quip/networks.pyis 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 pointQUIP_RPC_URLat a different RPC if the one you are on is not syncing. - Faucet: the
aglaispreset's faucet coordinate inxqsa/quip/networks.pyis the source of truth, healthy as of 2026-09-15
(/healthreturns{"status":"ok"}; the bare root returns 404, which is not a
fault). SetQUIP_FAUCET_URLto it and the funded tier runs. The faucet
gates requests two ways. A429withretry_after_secondsis the per-destination rate limit, a window of
about 5s. A429withoutretry_after_secondsis a
duplicate request still in flight for the
same destination. Both are retryable, andfund_from_faucetretries
a429once, waiting 5s when noretry_after_secondsis given; a429asking for more than 30 s raisesQuipFaucetErrorinstead of
waiting (MAX_RETRY_AFTER_SECONDS). A403destination already fundedwithfree_balance_plancksis 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 readstransactionVersion
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_EXTENSIONSinxqsa/quip/signing.py), so a runtime that adds,
removes, reorders or reshapes an extension breaks signing. After an upgrade,
runtest_live.py::TestConnectivity, which checks that list against the
chain. - Metadata: the runtime serves Metadata V16, which
scalecodeccannot
decode. Build clients throughxqsa.quip.metadata.connect, which pulls V14
from the versioned runtime API; a stockSubstrateInterfacefails withIndex '16' not present in Enum type mapping(see the gotcha below). - Spec id:
DEFAULT_ISING_SPEC_ID = 0x8f46f3a3...(== chainDefaultIsingSpecId);MinReward= 1e12 planck (1 UNIT).
Quick version + liveness check. A synced node reports isSyncing == false andcurrentBlock == highestBlock; if it is behind, its state reads are stale:
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 chainQuantumPow.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 is0xcbec1eb4... 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), hash0x830abc16c0b28b26f119f3a1279d07811bdeb9c0234d7f442ae46d6698d9a797, registered
at block 232673. It is registered, not mineable, and not the default. PointQUIP_TOPOLOGY at it to run the whole suite against it:
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.networkReuse K16; do not register another. There is no unregister_topology, so every
registration is permanent.
Run the suite
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.networkThe submit-path tests always run; the TestEndToEnd cases are gated behind thesolving_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
JobProposedevents -- 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_getRuntimeVersioneach
session; do not hard-code pinned facts. The signer adaptstransactionVersion
from the chain, but the signed-extension list is fixed client-side, so an
upgrade that changes it breaks submission. Runtest_live.py::TestConnectivityafter every known upgrade. - Metadata V16 blocks a stock client:
substrate-interfacedecodes throughscalecodec, whoseMetadataAllenum ends at V14, soSubstrateInterface.init_runtime()raisesValueError: Index '16' not present in Enum type mappingagainst any runtime fromspecVersion 116on. No
release of either library decodes V16.xqsa.quip.metadatafetches V14
throughstate_call("Metadata_metadata_at_version", 14)instead, andSolverQuipbuilds its client that way; ad-hoc scripts must do the same. The
shim falls back tostate_getMetadatafor older DevNet images, and raisesQuipMetadataErrornaming 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 returning200 + extrinsic_hashis NOT
proof of funding. CheckisSyncing == falseandcurrentBlock == highestBlock;
if a node is not syncing, pointQUIP_RPC_URLat a different RPC URL. - Lifecycle: an order's effective expiry is
created_at + deadline_blocks,
tightened tofirst_solution_at + block_waitonce 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.TestEndToEndasserts only pipeline correctness (energy_matches_chain, a
returned solution, domain feasibility); encoding and optimality correctness
are guarded deterministically offline bytest_client.py::test_encoded_problem_argmin_decodes_to_optimum. - Direct
pytestinvocation: exportQUIP_RPC_URL/QUIP_FAUCET_URLas
environment variables before the command. Passing them as trailingKEY=VAL
args is a make-ism (make VAR=val); pytest reads trailingKEY=VALtokens
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:
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"])
PYExpect 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 viaload_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 withsolutions_submitted). Topology lives underQuantumPow.{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
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.
cargo check -p aglais-xqvm-bytecodeExpected: 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:
// 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
cargo check -p aglais-xqvm-bytecode 2>&1 | head -40Expected: 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)--dropis not a Rust keyword; it is safe as a method nameSclr->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 fromone_hot)Copy->fn copy(&mut self)(renamed fromdupl)
Grammar note: The pest grammar rule
mnemonic = @{ ASCII_ALPHA ~ (ASCII_ALPHA | ASCII_DIGIT)* }already matchesPUSH1..PUSH8and all other new/renamed mnemonics (they are all alphanumeric). No changes togrammar.pestare needed.
The macro handles all of the above automatically. The only manual changes needed in builder.rs are:
- Update the
push()method -- it currently referencesPushC1..PushC8; update toPush1..Push8. - Update the
impl_builder_methods!skip arm for push variants -- currently matches{val: ...}; remains correct since the field name is stillval. - Update doc comments -- find and replace
PUSHC_Nreferences in doc strings withPUSH_N. - Remove or update any method that was hand-written for old variants -- search for
fn dupl,fn onehot,fn push_cin the file and update.
- Step 1: Find all hand-written references to old variant names in builder.rs
grep -n "dupl\|push_c\|one_hot\b\|PushC\|PUSHC" crates/bytecode/src/builder.rs- Step 2: Update
minimal_pushcand doc comments inbuilder.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:
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_Ninstruction" -> "Use push() to emit the smallestPUSH1..PUSH8instruction" - Any
/// PUSHC_0,/// PUSHC_1..PUSHC_8references 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:
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.rsFor each hit:
Instruction::PushC0 {}->Instruction::Push1 { val: [0x00] }Instruction::PushCN { val: [...] }->Instruction::PushN { val: [...] }(same bytes, new name)Opcode::PushC0->Opcode::Push10x10 // PUSHC_0->0x11 // PUSH10x0F // HALT->0x09 // HALT0x14 // LOAD->0x0A // LOADetc.
Step 4: Verify the builder compiles and tests pass
cargo test -p aglais-xqvm-bytecode 2>&1 | tail -20Expected: 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
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 -20Expected: zero warnings from both clippy and rustdoc.
Task 3: Commit Layer 1
- Step 1: Run full bytecode test suite
make test-unit 2>&1 | tail -30- Step 2: Commit
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
cargo build -p aglais-xqvm-disasm 2>&1 | head -20Expected: compiles cleanly. If there are errors, they are due to hard-coded opcode references -- fix them.
- Step 2: Run disasm tests
cargo test -p aglais-xqvm-disasm 2>&1 | tail -20If 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:
grep 'name' crates/disasm/Cargo.toml | head -5Then run a round-trip:
printf 'PUSH 0\nHALT\n' | cargo run --bin xqasm -- /dev/stdin \
| cargo run --bin <disasm-bin-name> -- /dev/stdin 2>/dev/null || trueVerify rendered mnemonics use PUSH1, not PUSHC_1, and HALT appears.
- Step 4: Commit
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:
- The
"PUSH" | "PUSHC"special case -- update the match arm to also accept explicitPUSH1..PUSH8. - Update the doc comment in
assemble()that referencesPUSHC_0. - Update the doc-test assertion
program.code()[0] == 0x10 // PUSHC_0to reflect the new encoding (PUSH1=0x11).
- Step 1: Locate and update the push special case
Find the arm:
"PUSH" | "PUSHC" => {
assemble_push(instr, &mut b, src)?;
}Replace with:
"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 withWrongOperandCount. 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 usesPUSH1 0x00)0x10 // PUSHC_0->0x11 // PUSH1(PUSH1 opcode = 0x11)0x0F // HALT->0x09 // HALTStep 3: Build and test asm crate
cargo test -p aglais-xqvm-asm 2>&1 | tail -30Expected: all pass.
- Step 4: Lint
cargo clippy -p aglais-xqvm-asm --all-targets --all-features -- -D warnings 2>&1 | head -20Task 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
sed -i 's/\bDUPL\b/COPY/g; s/\bONEHOT\b/ONEHOTR/g' crates/vm/examples/tsp/encoder.xqasmVerify with:
grep -n "DUPL\|ONEHOT\b\|PUSHC" crates/vm/examples/tsp/encoder.xqasmExpected: no matches.
- Step 2: Check decoder and verifier
grep -n "DUPL\|ONEHOT\b\|PUSHC" crates/vm/examples/tsp/decoder.xqasm crates/vm/examples/tsp/verifier.xqasmApply same replacements if any matches are found.
- Step 3: Commit Layer 3
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.rsModify:
crates/vm/src/vm.rsStep 1: Write a failing test for stack overflow
Add to crates/vm/src/vm.rs in the #[cfg(test)] block:
#[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):
cargo test -p aglais-xqvm-vm stack_overflow 2>&1 | tail -10- Step 2: Add
StackOverflowtoerror.rs
In crates/vm/src/error.rs, add after the StepLimitExceeded variant:
/// 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:
Self::StackOverflow { pos } => Some(*pos),- Step 3: Introduce
STACK_LIMITconst andpush_stackhelper invm.rs
Add a module-level const immediately after DEFAULT_STEP_LIMIT (line ~111):
/// Maximum stack depth enforced by [`Vm`].
const STACK_LIMIT: usize = 8192;Replace the existing push_val helper:
fn push_val(&mut self, v: i64) {
self.stack.push(v);
}With (note: module-level const, not Self::STACK_LIMIT):
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:
grep -n "push_val" crates/vm/src/vm.rsImportant: 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
cargo test -p aglais-xqvm-vm stack_overflow 2>&1 | tail -10Expected: 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 --PushC0variant is gone)exec_push_c1->exec_push1exec_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:
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.rsThen 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
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
sed -i 's/fn exec_one_hot\b/fn exec_one_hot_r/g' crates/vm/src/vm.rs- Step 4: Compile check
cargo check -p aglais-xqvm-vm 2>&1 | head -30Expected: 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.rsModify:
crates/vm/src/model.rsStep 1: Add arithmetic handlers
Add after exec_neg:
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
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_chandler
The column one-hot mirrors exec_one_hot_r with row/col axes swapped. Add after exec_one_hot_r:
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:
/* Detailed source-code truncated for AI context efficiency. */- Step 5: Run new tests
cargo test -p aglais-xqvm-vm sqr_abs_min_max drop_clears sclr_empties 2>&1 | tail -15Expected: all PASS.
Task 10: Final validation and commit
- Step 1: Run full workspace lint and test
make lint && make testExpected: zero warnings, all tests pass. If the TSP example is a test target, it must pass end-to-end.
- Step 2: Commit Layer 4
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
make allExpected: 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.rsModify:
crates/bytecode/src/types/mod.rsModify:
crates/bytecode/src/lib.rsStep 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):
/* 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;:
mod register_effect;And update the re-export block:
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:
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
Add RegisterEffect type for register introspection
Implements QUI-314Task 2: Register Introspection Methods (bytecode crate)
Files:
Modify:
crates/bytecode/src/types/instruction.rsStep 1: Write the failing tests
Add tests to the existing #[cfg(test)] mod tests block in crates/bytecode/src/types/instruction.rs:
/* 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_registersandwritten_registers
Add a new impl Instruction block in crates/bytecode/src/types/instruction.rs, before #[cfg(test)]:
/* 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
Add register introspection to Instruction
Implements QUI-314Task 3: Display Impl for Instruction (bytecode crate)
Files:
Modify:
crates/bytecode/src/types/instruction.rsStep 1: Write the failing test
Add to the #[cfg(test)] mod tests in crates/bytecode/src/types/instruction.rs:
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:
/* 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
Add Display impl for Instruction
Implements QUI-314Task 4: Tracer Trait and NoopTracer (VM crate)
Files:
Create:
crates/vm/src/tracer/mod.rsModify:
crates/vm/src/lib.rsModify:
crates/vm/src/error.rsStep 1: Create the tracer module
Create crates/vm/src/tracer/mod.rs:
/* Detailed source-code truncated for AI context efficiency. */- Step 2: Add
TraceFailederror variant
In crates/vm/src/error.rs, add at the top of the file after the existing #[cfg(feature = "std")] imports:
#[cfg(not(feature = "std"))]
use alloc::string::String;Add new variant to the Error enum, after InvalidGridDimensions:
/// 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:
| Self::TraceFailed { pos, .. } => Some(*pos),- Step 3: Wire up in
lib.rs
In crates/vm/src/lib.rs, add mod tracer; after mod vm;:
mod vm;
pub mod tracer;Add re-exports after the existing re-export block:
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:
// [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:
// [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
Add Tracer trait, StepState, and NoopTracer
Implements QUI-314Task 5: VM Integration โ `run_trace` Method
Files:
Modify:
crates/vm/src/vm.rsStep 1: Write the failing test for RecordingTracer
Add at the very bottom of crates/vm/src/vm.rs, a test module:
/* 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):
#[cfg(not(feature = "std"))]
use alloc::{format, vec::Vec};
use crate::tracer::{NoopTracer, StepState, Tracer};Replace the existing run method (lines 268-304) with:
/* 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
Add run_trace method with register snapshotting
Implements QUI-314Task 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:
/* 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.
RegValcompact 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
Add TextTracer for human-readable trace output
Implements QUI-314Task 7: JsonTracer (VM crate, std-only)
Files:
Modify:
crates/vm/src/tracer/json.rsStep 1: Write the integration test
Create crates/vm/tests/trace_json.rs:
/* 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:
{"step":1,"pos":0,"instruction":"PUSH1 3","stack":[3],"read_regs":{},"written_regs":{}}Register objects use the register index as key:
{"step":4,"pos":5,"instruction":"STOW r0","stack":[],"read_regs":{},"written_regs":{"0":{"type":"int","value":7}}}Complex types:
{"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
Add JsonTracer for JSONL trace output
Implements QUI-314Task 8: CLI Integration
Files:
Modify:
crates/vm/src/bin/xqvm.rsStep 1: Add CLI flags
In crates/vm/src/bin/xqvm.rs, add the new imports and types:
use std::fs::File;
use std::io::{BufWriter, Write};
use aglais_xqvm_vm::{TextTracer, JsonTracer};Add the TraceFormat enum:
/// 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:
/// 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:
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
Add --trace, --trace-format, --trace-file CLI flags
Implements QUI-314Task 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.yamlare all equal, rather than checking a hardcoded opcode count -- this lets W5, W6, and W7 proceed in parallel once W4 lands.
Global Constraints
xqgbcmust beno_std + allocwith astdfeature gate, matchingxqvm's portability posture- All Rust code must pass
cargo clippy --all-features -- -D warningswith the workspaceclippy.toml - Python must pass
ruff checkandruff format --checkat 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
u32binary-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, orxqvm_py-- gate ISA is purely additive in new crates/packages - Add
xqgbcto rootCargo.tomlmembers; addxqgbto rootpyproject.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) -- addxqgbctomembers
Interfaces:
Produces:
gate_opcodes!X-macro usable by later tasks to generateGateOpcode,GateInstruction, and codecStep 1: Add
xqgbcto root workspace
In Cargo.toml (root), add "xqgbc" to the members array:
members = [
"xqvm",
"xqasm",
"xqcli",
"conformance",
"xqffi",
"xqgbc", # <-- add this
"fixtures/xqvm-wasm",
]- Step 2: Write
xqgbc/Cargo.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
/* Detailed source-code truncated for AI context efficiency. */- Step 4: Write
xqgbc/src/table.rs(the X-macro -- single source of truth)
// 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)
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
// 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
cargo check -p xqgbcExpected: 0 errors, 0 warnings.
- Step 8: Commit
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,GateAnglefrom W1 Part 1Produces:
GateOpcode,GateInstruction,codec::encode(&GateInstruction) -> Vec,codec::decode(&[u8]) -> Result<(GateInstruction, usize), DecodeError>Step 1: Write failing roundtrip tests in
xqgbc/tests/roundtrip.rs
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:
/* 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
/* Detailed source-code truncated for AI context efficiency. */- Step 4: Write
xqgbc/src/codec.rs
/* Detailed source-code truncated for AI context efficiency. */- Step 5: Update
xqgbc/src/types/mod.rsandlib.rs
types/mod.rs:
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:
#![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
cargo test -p xqgbcExpected: all tests pass.
- Step 7: Commit
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/decodefrom 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.
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 streamdomain_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):
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
/* Detailed source-code truncated for AI context efficiency. */- Step 3: Write
xqgbc/src/verifier.rs
/* Detailed source-code truncated for AI context efficiency. */- Step 4: Update
xqgbc/src/lib.rs
#![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
cargo test -p xqgbcExpected: all pass including the 3 new program/verifier tests.
- Step 6: Commit
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-- addxqgbto[tool.uv.workspace] members
Interfaces:
Produces:
GateCircuit(Python dataclass),decode_xqg(data: bytes) -> GateCircuit,GateSolverResult,GateSolverABCStep 1: Write
xqgb/tests/test_decoder.py(failing tests first)
/* 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
[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
xqgbto rootpyproject.tomlworkspace
Locate [tool.uv.workspace] in root pyproject.toml and add "xqgb":
[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
/* Detailed source-code truncated for AI context efficiency. */- Step 5: Write
xqgb/solver.py
/* Detailed source-code truncated for AI context efficiency. */- Step 6: Write
xqgb/__init__.py
# 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
cd /home/konrad/Quip/xquad && uv run pytest xqgb/tests/test_decoder.py -vExpected: all 4 tests pass.
- Step 8: Commit
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,GateSolverResultfrom W2 Part 1Produces:
SolverCPUSim(GateSolver)withrun(circuit) -> GateSolverResultStep 1: Write failing tests in
xqgb/tests/test_cpu_sim.py
/* 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
/* Detailed source-code truncated for AI context efficiency. */- Step 3: Run the CPU sim tests
uv run pytest xqgb/tests/test_cpu_sim.py -vExpected: all 5 tests pass.
- Step 4: Commit
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,GateSolverResultfrom W2 Part 1Produces:
QiskitTranspiler,IonQTranspiler,SolverIBMQuantum(GateSolver)Step 1: Write
xqgb/transpiler.py
/* Detailed source-code truncated for AI context efficiency. */- Step 2: Write
xqgb/ibm_quantum.py
/* Detailed source-code truncated for AI context efficiency. */- Step 3: Lint check
uv run ruff check xqgb/transpiler.py xqgb/ibm_quantum.pyExpected: 0 issues.
- Step 4: Commit
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,
IonQTranspilerfrom W3 Part 1Produces:
SolverIonQ(GateSolver),GATE_SOLVERSdict,build_gate_solver(name, **kwargs) -> GateSolverStep 1: Write failing tests in
xqgb/tests/test_registry.py
"""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
/* Detailed source-code truncated for AI context efficiency. */- Step 3: Write
xqgb/registry.py
/* Detailed source-code truncated for AI context efficiency. */- Step 4: Update
xqgb/__init__.pyto expose registry
# 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
uv run pytest xqgb/ -vExpected: all 12 tests pass (4 decoder + 5 CPU sim + 3 registry).
- Step 6: Run Rust tests
cargo test -p xqgbcExpected: all pass.
- Step 7: Lint
uv run ruff check xqgb/
cargo clippy -p xqgbc -- -D warningsExpected: 0 issues each.
- Step 8: Commit
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-- addgatevector category runner - Modify:
conformance/Cargo.toml-- addxqgbcdependency - Modify:
conformance/src/lib.rs-- addrun_rust_gateandrun_python_gate
Interfaces:
Consumes:
xqgbc::codec::{encode, decode}from W1 Part 2;xqgb.decoder.decode_xqgfrom W2 Part 1Produces: 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.
/* Detailed source-code truncated for AI context efficiency. */- Step 2: Run the parity check (will fail -- files not yet matched)
uv run python scripts/check-gate-opcode-parity.pyExpected: 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
/* Detailed source-code truncated for AI context efficiency. */- Step 4: Run the parity check against live code
uv run python scripts/check-gate-opcode-parity.pyExpected: 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.
/* Detailed source-code truncated for AI context efficiency. */- Step 6: Generate and commit the vectors
uv run python scripts/gen-gate-vectors.pyExpected output lists each generated directory. Verify the files look sane:
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.jsonCommit:
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.tomlwithxqgbcdep
[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:
/* Detailed source-code truncated for AI context efficiency. */- Step 9: Add
xqgb decodeCLI entry point toxqgb/__main__.py
The Python gate runner above calls python -m xqgb decode . Add this module:
# 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.rsto 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:
// 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:
// 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:
/// 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
cargo test -p xquad-conformanceExpected: 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:
- name: Gate opcode parity
run: uv run python scripts/check-gate-opcode-parity.py- Step 13: Commit
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)
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
cargo test -p xqgbc -- --nocaptureIBM Aer (requires `pip install xqgb[ibm]`)
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`)
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), andcswap (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 ingate_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-- extendcheck_qubitsfor 3-qubit instructions - Modify:
xqgb/decoder.py-- 3 entries in_OPCODE_TABLE("qqq"/"qq"formats) - Modify:
xqgb/cpu_sim.py--RESETcollapses statevector to |0โฉ on qubit q;CCX
andCSWAPapplied via_apply_three()using 8ร8 unitaries - Modify:
xqgb/transpiler.py-- addQiskitTranspilerandIonQTranspilermappings
Interfaces:
- Consumes: W1 (xqgbc), W2โW3 (xqgb), W4 (conformance)
- Produces:
RESET/CCX/CSWAPin 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
- 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:
/// 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.rsfollowing the same
pattern as existing single- and two-qubit gates.check_qubitsinverifier.rsgains
two new arms returning&[ctrl0.0, ctrl1.0, tgt.0]and&[ctrl.0, q0.0, q1.0].Step 4: Add
_apply_threehelper toxqgb/cpu_sim.py
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.
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
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
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 (typelabel16, width 2) - Modify:
xqgbc/src/table.rs-- 3 new CF wide-form entries ingate_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 (u16already hasEncodeOperand/DecodeOperand) - Modify:
xqgbc/src/verifier.rs-- wide-form jumps participate in label-consistency check - Modify:
xqgb/decoder.py--_OPCODE_TABLEentries;'L'format char foru16labels - Modify:
xqgb/cpu_sim.py-- handleCF_JUMP2/CF_JZ2/CF_JNZ2in dispatch loop
Interfaces:
- Consumes: W1, W4, W6 Part 1
- Produces: 3 wide-form CF opcodes;
'L'format char in_OPCODE_TABLEdecoder
- Step 1: Add 3 entries to
conformance/gate_opcodes.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
#[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 threematcharms to also cover the2variants:
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 foru16
label operands and add 3 opcode entries:
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 += 20x8A: ("CF_JUMP2", "L"),
0x8B: ("CF_JZ2", "cL"),
0x8C: ("CF_JNZ2", "cL"),- Step 5: Extend
xqgb/cpu_sim.py-- in the CF dispatch block:
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
continueStep 6: Write tests -- encode a circuit with 260
CF_TARGET+CF_JUMP2
labels (impossible withu8), round-trip through Rust codec, verify Python decoder
reconstructs all jump targets correctly.Step 7: Run suite and parity check
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
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:
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-- addparams: Vec<GateAngle>toGateCircuit;
encode v2 preamble whenparamsis non-empty; decode v2 preamble whenversion == 2;
addGateAngle::is_param_ref(),GateAngle::param_slot(),GateAngle::from_param_slot() - Modify:
xqgbc/src/verifier.rs-- everyis_param_ref()angle must have slot index< n_params - Modify:
xqgb/decoder.py-- read v2 preamble whendata[4] == 2; exposecircuit.params;
addcircuit.bind(values: list[float]) -> GateCircuit - Modify:
xqgb/solver.py--GateSolver.run()gains optionalparams: 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_refandparam_slottoGateAngleinxqgbc/src/types/operand.rs:
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()-- ifself.paramsis non-empty, set
version byte = 2 and emit preamble (n_params: u8, then each param asu32 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 withis_param_ref()set must haveparam_slot() < circuit.params().len().Step 4: Implement
bind()inxqgb/decoder.py
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
RXcircuit withฮธ = slot 0,
default 0. Encode, round-trip decode, bindฮธ = ฯ, assert same distribution as literalRX(ฯ). Also: v1 files still decode identically, a v2 literalRX(3*pi/2)round-trips as a literal (not a slot reference), andbind()with the wrong number of values raisesValueError.Step 6: Commit
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 asCSET + 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 (typecreg, width 1) - Modify:
xqgbc/src/table.rs,opcode.rs,instruction.rs,codec.rs-- 9 new variants - Modify:
xqgbc/src/verifier.rs--CJEQ/CJLT/CJGTparticipate in label-consistency
check;CSET/CMBITregister-index bounds check (< n_cregs) - Modify:
xqgb/decoder.py-- 9 entries with'r'format char forcregoperands - Modify:
xqgb/cpu_sim.py-- addcregs = [0] * 8tosimulate(); dispatch allC*
opcodes;CJEQ/CJLT/CJGTcall_find_label - No transpiler changes -- backends receive the classical registers as opaque; IBM and IonQ
do not support this natively andQiskitTranspiler/IonQTranspilershould raiseValueErroron any0xE*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 viaCSET+CDEC+CJGT;CMBITbridge from Cbit to register
Step 1: Implement version 3 preamble --
GateCircuit::encode()emits version = 3
whenn_cregs > 0. The preamble layout after the 15-byte header: optional param block
(ifn_params > 0), thenn_cregs: u8.GateCircuit::decode()branches on version:
1 โ stream only; 2 โ param preamble + stream; 3 โ param preamble +n_cregsbyte + stream.Step 2: Add 9 entries to
xqgbc/src/table.rsin a new0xE0โ0xE8group.u8operands already haveEncodeOperand/DecodeOperand; no new types needed.Step 3: Extend
xqgbc/src/verifier.rsCSET/CMBIT/CINC/CDEC/CADD/CSUB: check allregoperands< n_cregsCJEQ/CJLT/CJGT: check reg operands + checkidis indeclared_labels
Step 4: Extend
xqgb/cpu_sim.pysimulate()loop
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; continueStep 5: Write tests -- a syndrome accumulation circuit: measure 3 qubits into
cbits, useCMBITto accumulate intoCR0, branch onCJEQ CR0 CR1to apply a
correction gate. Verify CPU sim produces the expected counts.Step 6: Run full suite
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
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 onbit 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 inxqvm. The executor and transpiler advance past CF_TARGET without action.
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 == 1How OpenQASM 3 constructs lower to these (compiler responsibility):
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 togate_opcodes! - Modify:
xqgbc/src/types/opcode.rs-- add 4 variants toGateOpcode - Modify:
xqgbc/src/types/instruction.rs-- add 4 variants toGateInstruction - Modify:
xqgbc/src/codec.rs-- extendencode/decodematch 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 forQiskitTranspiler; raise for IonQ
Interfaces:
- Consumes: all of W1โW4
- Produces:
CF_TARGET/CF_JUMP/CF_JZ/CF_JNZin 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 asu8); add it to check-gate-opcode-parity.py's _PY_FORMAT_WIDTHS and_RUST_TYPE_WIDTH maps as an alias for a raw u8:
# -------------------------------------------------------------------------
# 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)
/* 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):
// --- 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:
CfTarget = 0x84,
CfJump = 0x85,
CfJz = 0x86,
CfJnz = 0x87,Add to TryFrom:
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:
CfTarget { id: u8 },
CfJump { id: u8 },
CfJz { c: Cbit, id: u8 },
CfJnz { c: Cbit, id: u8 },Extend opcode():
Self::CfTarget { .. } => GateOpcode::CfTarget,
Self::CfJump { .. } => GateOpcode::CfJump,
Self::CfJz { .. } => GateOpcode::CfJz,
Self::CfJnz { .. } => GateOpcode::CfJnz,Extend mnemonic():
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():
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():
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.
// 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:
#[error("jump references undeclared CF_TARGET label id {id}")]
UndeclaredLabel { id: u8 },- Step 8: Run Rust tests
cargo test -p xqgbcExpected: 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):
0x84: ("CF_TARGET", "l"),
0x85: ("CF_JUMP", "l"),
0x86: ("CF_JZ", "cl"),
0x87: ("CF_JNZ", "cl"),Add 'l' to _decode_operands:
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 += 1Add 'l' to _PY_FORMAT_WIDTHS in check-gate-opcode-parity.py:
_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):
_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).
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):
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 usingif_test:
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)
continueAdd 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:
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
/* 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
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.pyExpected: 0 failures, 0 warnings, parity check prints OK -- 29 gate opcodes match.
- Step 14: Commit
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 touchrelease/v0.3.0. - Run everything with
uv run --no-sync(plainuv runreverts the maturin-builtxqffi). - Solver names are exactly
dwave-cpuandcuda-gpu(keys ofxquad.sa.SOLVERS). - CPU lanes: both VM backends. GPU lanes: Rust VM only.
- Ladders โ CPU: MaxCut
8,16,32,64,128,256,512, TSP4,6,8,12,16,24,32,48. GPU adds MaxCut1024, TSP64. - 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-pythonequivalent: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;Adapterfrozen dataclass with fieldsname, 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:
/* 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:
/* 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 ontriu 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
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) -> dictreturning a record with keysschema, problem, backend, solver, n, seed, statusand, whenstatus == "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. Alsomain(argv) -> inthandling--pointCLI.Step 1: Write the implementation
Append to scripts/scale_sweep.py (add import argparse, json, resource, sys, time to the imports block):
/* Detailed source-code truncated for AI context efficiency. */Add a temporary stub so point mode is runnable before Task 3 (replaced there):
def _orchestrate(args: argparse.Namespace) -> int:
raise NotImplementedError("orchestrator lands in Task 3")- Step 2: Verify point mode against the runner goldens
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 rustExpected: 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:
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 pythonExpected: tour/tour_distance match solution/objective.
- Step 3: Verify the error path
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
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_orchestratestub; extend imports withimport os, subprocess)
Interfaces:
Consumes:
make_adapters(),build_parser(), point-mode CLI from Task 2;CPU_SOLVER,GPU_SOLVER,SEEDSfrom Task 1.Produces:
cuda_available() -> bool;build_matrix(adapters: dict[str, Adapter], solvers: list[str], smoke: bool) -> list[Lane]whereLaneis 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) -> intwriting JSONL toargs.outand a report via Task 4'srender_report(records) -> str.Step 1: Write the failing test
Append to scripts/tests/test_scale_sweep.py:
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:
/* 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):
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
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(replacerender_reportstub; 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:
/* 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:
/* 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
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
uv run --no-sync python scripts/scale_sweep.py --timeout 300 --out scratch/scale_sweep/results.jsonlRun 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
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 (
_orchestratein Task 2,render_reportin 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);Lanefields matchbuild_matrixtests;STAGEStuple 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 ...(plainuv runreverts 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 underif _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) -> intreturningSolverDWaveCPU(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:
/* 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:
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:
/* 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
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,SolverCudaGPUfrom Task 1.Step 1: Append the CUDA class
@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
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,SolverMetalGPUfrom Task 1. Metal's constructor additionally takesstrategy("sa" or "gibbs").Step 1: Append the Metal class
/* 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
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
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_PARAMSdict unpacks into both solver constructors (both acceptnum_reads,num_sweeps,seed; Metal addsstrategypassed explicitly);cpu_referenceused 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(branchfeature/qui-704, base 15918ed). - Run Python via
uv run --no-sync ...(plainuv runreverts 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; sizesn โ (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 fromscripts/scale_sweep.pyif present on this branch's base โ otherwise fromxqsa/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)wherehisfloat64[n]zeros andj_matrixis the full symmetricfloat64[n, n]coupling matrix;run_stock(model: XQMX, n: int) -> dictreturning{"variant": "stock", "n": n, "wall_s": [3 floats], "energy": int, "sample": list[int], "mempool_peak_bytes": int}; constantsNUM_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:
/* 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
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, entrysa_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
/* 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:
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
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.mdOutput (not committed):
scratch/spin_packing/results.jsonStep 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):
- Header: ticket link, date, machine (RTX 4090 / WSL2 / cupy version), bench parameters.
- 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.
- Throughput table โ per n: stock median wall, packed median wall, ratio.
- Faithfulness โ one line: exact best-sample equality held at every size (or details).
- Metal analysis (no hardware) โ analytic: no randoms buffer (on-device xorshift32,
metal_gpu.pydocstring), J float32nยฒร4dominates; spins' % of Metal footprint at the same sizes; conclusion carries a fortiori. - Recommendation โ adopt/reject against the ticket's criterion ("material footprint reduction without unacceptable throughput regression"), stated plainly with the measured shares.
- 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
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_instancetuple 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): branchfeature/qui-852fromfeature/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:metalor 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.pywith subcommandstrajectories,parity,timing, each writing JSON via--out, andcompare --mode {trajectories,parity,timing} BASELINE CURRENTwhich 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):
/* 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):
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.jsonTimeouts: 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):
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_argshelper (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 ignoresblock, 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_argsstaticmethod inTestSolverCudaGPUMockedโ change its body to also record the launch shape:
@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 capturedThen append inside TestSolverCudaGPUMocked:
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:
/* 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:
/* Detailed source-code truncated for AI context efficiency. */- Step 5: Host-side launch. Under
_SUPPORTED_SCHEDULES = ...add:
# 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 = 256In _run_sa, change the launch:
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:
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 inscratch/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:
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):
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.jsonFlag 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_KERNELMSL source, buffer-ABI comment block (~lines 63-73),_dispatch(~line 521), thesadispatch 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_metalbuffer 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 hardcodesMTLSizeMake(1, 1, 1).Produces:
_dispatch(pipeline, encoder, num_reads, threads_per_group=1);sadispatches with the clamped power-of-two width (โค_SA_THREADGROUP_WIDTH = 256),gibbsstays 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:
def dispatchThreadgroups_threadsPerThreadgroup_(self, grid, per_group):
self.num_reads = int(grid[0])with:
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 โ seetest_num_sweeps_per_beta_run_mockedfor thecapturing_queuescaffold, and reuse it verbatim):
/* 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_metalMSL kernel body inside_SA_KERNEL(the_RNG_PRELUDEand the buffer parameter list are UNCHANGED; add the two thread-position parameters and restructure the body):
/* Detailed source-code truncated for AI context efficiency. */- Step 5: Host-side dispatch. Under
_SUPPORTED_SCHEDULES = ...in metal_gpu.py add:
# 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 = 256Change _dispatch:
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):
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:
# 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:
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.mdand.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):
/* 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 bynum_reads) executes independently in its own CUDA thread block." โ append: "Within a block,_THREADS_PER_REPLICAthreads cooperate on the local-field reduction while spin updates remain sequential (QUI-852)." Inxqsa/metal_gpu.py, find the equivalent per-replica sentence in the class docstring and append the analogous sentence naming_SA_THREADGROUP_WIDTHand noting gibbs stays single-threaded.Step 3: Lint + commit:
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 createwith target branchfeature/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=1andbeta_schedule_type="linear"(the new default), the schedule must reproduce the retired in-kernel formula bit-for-bit in float64. Do NOT usenp.linspacefor the linear schedule โ use the exact expressionbeta_start + (beta_end โ beta_start) * (np.arange(num_betas) / np.float64(num_betas โ 1)). - Divide semantics (QUI-685 parity):
num_sweepsstays the total sweep count;num_betas = num_sweeps // num_sweeps_per_beta;num_sweepsmust be divisible bynum_sweeps_per_beta, elseValueError. 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 == 1semantics:single-element schedule atbeta_start(hottest) โ matches both the retired kernel'snum_sweeps <= 1branch andSolverMetalGPU.
Superseded (2026-07-20, after !90 review): the schedule is a single element atbeta_end(coldest) โ a greedy quench matchingSolverDWaveCPUand the revisedSolverMetalGPU(!90 commitc9358c9). This intentionally departs from the retired kernel'snum_sweeps <= 1branch, so the bit-identity guarantee above holds only fornum_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'sgitBranchName), branched fromorigin/main. Do not touchxqsa/metal_gpu.pyorxqsa/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(ifcupyis already importable in the venv,uv run --no-syncworks 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 toclass 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_saand validates against_SUPPORTED_SCHEDULES.Step 1: Create the branch
cd /home/konrad/Quip/xquad && git checkout -b feature/qui-854 origin/main- Step 2: Write the failing tests โ append inside
TestSolverCudaGPUMocked(aftertest_oom_guard):
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:
import numpy.typing as npt(b) Under _SUPPORTED_STRATEGIES = frozenset({"sa"}) (line 44) add:
_SUPPORTED_SCHEDULES = frozenset({"geometric", "linear"})(c) After _auto_beta_range (ends line 315) add:
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
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_envfake kernel (_make_sa_kernel, ~lines 495-556) - Test:
xqsa/tests/test_xqsa.py(append insideTestSolverCudaGPUMocked)
Interfaces:
Consumes:
_compute_beta_scheduleand_SUPPORTED_SCHEDULESfrom Task 1.Produces: new kernel ABI โ args
(h, J, samples, energies, randoms, betas, n, num_sweeps)wherebetasis a float64 device array of lengthnum_sweepsat index 5; new public paramsnum_sweeps_per_beta: int = 1andbeta_schedule_type: str = "linear"on__init__andsolvekwargs; 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:
/* 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:
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
int n,
int num_sweeps,
double beta_start,
double beta_endwith
const double* __restrict__ betas,
int n,
int num_sweepsplacing 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
double beta;
if (num_sweeps <= 1) {
beta = beta_start;
} else {
beta = beta_start
+ (beta_end - beta_start)
* ((double)sweep / (double)(num_sweeps - 1));
}with
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):
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:
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:
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:
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:
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:
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:
"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:
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):
# 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:
(
h,
j_matrix,
samples,
energies,
randoms,
beta_schedule,
np.int32(n),
np.int32(num_sweeps),
),- Step 5: Update the fake kernel in
mock_cupy_envin lockstep. In_make_sa_kernel(test_xqsa.py ~line 495), change the unpack line
h, J, samples, energies, randoms, n_val, ns_val, bs, be = argsto
h, J, samples, energies, randoms, betas, n_val, ns_val = argsdelete the lines beta_start = float(bs) and beta_end = float(be), and replace the in-loop block
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
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
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 insideTestSolverCudaGPUMocked)
Interfaces:
Consumes: kernel ABI from Task 2 โ the beta buffer is
args[5]of the fake-kernel call;_binary_kernelis afunctools.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):
/* 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
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): extendtest_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_paramsadd after thenum_sweepsassertion:
assert solver.num_sweeps_per_beta == 1
assert solver.beta_schedule_type == "linear"In test_custom_params, change the constructor call and add assertions:
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):
/* 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:
``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:
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:
## [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.
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
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 kleczkowskiMR 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_schedulesignature 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 withcd /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-pyfirst (writesxq-rs-workspace.pth; bareuv syncdoes not). Thenuv sync --extra cuda && make deps-py(re-sync installs cupy but clobbers the workspace install; the seconddeps-pyrepairs it). After that, ALWAYSuv run --no-sync ...โ a plainuv runre-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.pylines 1โ16), 100-char lines, โค100-line functions, Google-style docstrings on public APIs.make fmt-py lint-pymust 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=Nonedraws a randombase_seedfromnp.random.default_rng(None); parity thresholds KS ฮฑ=0.01, mean-shift โค2%, best-energy โค2% (2% mirrorsGLASS_TOLERANCEinxqsa/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]andcompare <candidate.json>(exit 0 = parity holds, 1 = failed). Task 2 runsrunon the unmodified solver; Task 6 runsrun+compareon the candidate.Consumes:
build_instance(n, seed)fromscripts/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:
/* 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.
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.jsonlStep 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)
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; doneExpected: 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(fixturemock_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)withbase_seedannp.uint64; Python RNG mirrors_splitmix64,_seed_for_replica,_xorshift64,_rand_unitwhose constants Task 4's CUDA prelude must match bit-for-bit.Step 1: Add Python mirrors of the device RNG above the
mock_cupy_envfixture
# 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
h, J, samples, energies, randoms, betas, n_val, ns_val = argswith
h, J, samples, energies, betas, n_val, ns_val, base_seed = argsInside the for rid in range(num_reads): loop, immediately after x = samples[rid], add:
rng_state = _seed_for_replica(int(base_seed), rid)and replace the acceptance draw
r = randoms[rid, sweep, i]with
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]toargs[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_mockedtest_default_schedule_bit_identical_mockedtest_single_beta_level_uses_beta_start_mockedtest_geometric_schedule_shape_mockedStep 5: Add seed-semantics tests to
TestSolverCudaGPUMocked
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
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:
SolverCudaGPUwith unchanged Python API (seed=still reproducible,seed=Nonestill random); norandomsbuffer, no OOM guard.Step 1: Add the RNG prelude and rewrite both kernel headers
In xqsa/cuda_gpu.py, above _SA_BINARY_KERNEL, add:
# 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:
_SA_BINARY_KERNEL = _RNG_PRELUDE + r"""
extern "C" __global__
void sa_binary(
...(same for _SA_SPIN_KERNEL).
In both kernel signatures, delete the line
const double* __restrict__ randoms,and add after int num_sweeps:
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
const double* rand_ptr = randoms + (long long)rid * num_sweeps * n;with
u64 rng = seed_for_replica(base_seed, rid);and replace
double r = rand_ptr[(long long)sweep * n + i];with
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_sachanges
Delete the OOM guard and buffer allocation (the whole block):
# 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:
# 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
(
h,
j_matrix,
samples,
energies,
randoms,
beta_schedule,
np.int32(n),
np.int32(num_sweeps),
),to
(
h,
j_matrix,
samples,
energies,
beta_schedule,
np.int32(n),
np.int32(num_sweeps),
base_seed,
),Step 4: Docstring/comment sweep in
cuda_gpu.pyClass 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 OOMValueErroris gone; the remainingValueErrorcauses (domain, parameter validation) still hold โ no text change needed unless it mentions memory. Check withrg -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.
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:
uv run --no-sync python - <<'EOF'
import cupy
from scripts_path_shim import * # noqa: F401,F403 (not needed; see below)
EOFUse this instead (single command):
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.mdStep 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
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
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 โ checkgit log -p -1 --follow CHANGELOG.mdfor the house style)Step 1: Changelog entry
Add under the unreleased/v0.3.1 section, matching surrounding entry style:
- `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.
git add CHANGELOG.md
git commit -m "docs: changelog for QUI-705 on-device CUDA RNG"- Step 3: Push and open the MR (stacked)
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_unitreturns 53-bit doubles), splitmix64 per-replica seeding (preludeseed_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:
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.
instructionscounts dispatches and is what aTracersees.stepscounts cost units, is whatset_step_limitbounds, 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 oneNOPdispatch, 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.
usizewidths are converted withu64::try_from(..).unwrap_or(u64::MAX), matchingcharge_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
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 modelsequality_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:
xqvmandxqvm_pymust 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::StepLimitExceededgains the position and the refused charge, so a fault is diagnosable the wayMemoryLimitExceededalready is.- QUI-1054 can then calibrate a flat
WeightPerStepagainstNOPplus 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 indocs/book/src/xqvm/limits-and-errors.md:36andexecution.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 orlet _ =every result (unused-resultsis blocking), document every public item. - After edits run
make fmt; before declaring a task done run the task's stated checks. xqvmisno_std-capable:metering.rsmust not usestd.
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 sono_stdbuilds 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 ofmetering.rs.scripts/check-metering-parity.py-- three-way guard: Rust constants, Python constants, and thespec/xqvm/METERING.mdtable 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--StepLimitExceededfields, and thepos()arm at:262.xqvm/src/lib.rs-- re-export the metering module.xqvm/src/tracer/*--StepState.stepfed frominstructions.xqvm/tests/integration.rs-- metering tests.xqffi/src/vm.rs:288-- addinstructions()next tosteps().xqvm_py/executor.py-- counter split and charge call sites.conformance/src/lib.rs--stepsonExpected/Outcome/PyOut.Makefile--metering-paritytarget wired intocheck-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:
// ---------------------------------------------------------------------------
// 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
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:
/// 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:
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):
/// 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):
/// 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:
self.charge_steps_base()?;
self.instructions += 1;Add both helpers next to charge (:670):
/// 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
cargo nextest run -p xqvm && cargo test --doc -p xqvmExpected: 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
make fmt && cargo clippy --workspace --all-targets --all-features -- -D warningsExpected: clean. conformance/src/lib.rs:377 matches E::StepLimitExceeded { .. } and is unaffected by the new fields.
- Step 8: Commit
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:
Vmfrom 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:
//! 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
cargo run --release --example metering_calibrationExpected: 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
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 asxqvm::metering):pub const BASE_STEPS: u64,COEFF_WRITE_STEPS: u64,MODEL_TERM_STEPS: u64,SAMPLE_COPY_STEPS: u64,ELEMENT_COPY_STEPS: u64pub fn equality_expansion_steps(n: u64) -> u64pub fn model_eval_steps(sample_len: u64, terms: u64) -> u64pub fn value_copy_steps(value: &RegVal) -> u64pub fn widen(n: usize) -> u64-- the shared saturatingusize-to-u64conversion
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:
/* Detailed source-code truncated for AI context efficiency. */- Step 2: Run the tests to verify they fail
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:
/* 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
cargo nextest run -p xqvm -E 'test(metering::)' && cargo clippy -p xqvm --all-targets --all-features -- -D warningsExpected: PASS, clean clippy.
- Step 6: Check the no_std build still works
cargo build -p xqvm --no-default-featuresExpected: success. metering.rs uses only core and crate::RegVal.
- Step 7: Commit
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:
ENERGYchargesBASE_STEPS + model_eval_steps(sample.len(), linear_len + quadratic_len).Step 1: Write the failing test
Append to xqvm/tests/integration.rs:
#[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
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:
// 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
cargo nextest run -p xqvm && cargo test --doc -p xqvmExpected: 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
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.rsat: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 samentheir byte charge uses.Step 1: Write the failing test
Append to xqvm/tests/integration.rs:
/* Detailed source-code truncated for AI context efficiency. */- Step 2: Run the tests to verify they fail
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):
/// 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
cargo nextest run -p xqvm && cargo test --doc -p xqvmExpected: PASS, including energy_charges_for_the_model_it_evaluates from Task 4, whose exact-difference assertion needs SETLINE charged.
- Step 5: Commit
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.rsat:1438,:1447,:1458(sample allocators),:919(RANGE/ITERint path),:951(ITERxqmx 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:
/* Detailed source-code truncated for AI context efficiency. */- Step 2: Run the tests to verify they fail
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 eachself.charge_variables(pos, size)?;addrustself.charge_steps(pos, widen(size).saturating_mul(SAMPLE_COPY_STEPS))?;Leave
exec_bqmx,exec_sqmxandexec_xqmxalone: an empty model allocates no coefficients, so the base cost is honest.exec_iterint path (:919): afterself.charge_variables(pos, range.len())?;addrustself.charge_steps(pos, widen(range.len()).saturating_mul(ELEMENT_COPY_STEPS))?;exec_iterxqmx path (:951): the handler already folds the slice to computecostin bytes. Add a second fold in the same shape, immediately afterself.charge(pos, cost)?;:rustlet 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_costfrom the same borrow that computescost, before thechargecall, 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:rustlet copy_cost = value_copy_steps(&value_to_clone); self.charge_steps(pos, copy_cost)?;where
value_to_cloneis the borrow the handler already resolves. Inexec_output(:1120) that means replacingrustlet 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
:864and:1082, adjusting the expression that names the value.RANGE(exec_range, whosecharge_variables(pos, range.len())is the call at:919's sibling site): chargewiden(count).saturating_mul(ELEMENT_COPY_STEPS)for the generated values, next to the existing byte charge.Step 4: Run the whole suite
cargo nextest run -p xqvm && cargo test --doc -p xqvm && cargo clippy --workspace --all-targets --all-features -- -D warningsExpected: 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
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-324run loop,:338-368charge helpers, and everyself._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.meteringwithBASE_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.instructionsproperty;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):
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.instructionsUse 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
uv run --no-sync pytest xqvm_py/tests/test_executor.py -k "charges_for_the_model or separate_counters" -vExpected: 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:
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:
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):
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 += unitsStore 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
uv run --no-sync pytest xqvm_py/tests -q && make lint-py && make fmt-check-pyExpected: 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
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),:344and: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 byxqvm_py/cli/run.py:159).Produces:
make metering-parity; an optionalstepsfield onexpected.jsonthat 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:
xqvm/src/metering.rs-- regex^pub const (\w+): u64 = (\d+);over the file.xqvm_py/metering.py-- import the module and read the same names.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
uv run --no-sync python scripts/check-metering-parity.pyExpected: 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):
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: OptiontoRawExpectedand toExpected::Success; a vector that omits it asserts nothing about cost, so the existing 100-odd vectors keep passing unchanged. - Add
steps: u64toOutcome::Success, filled fromvm.steps()inrun_rust_program(:344) and from a newsteps: Optionfield onPyOutinrun_python_file(:527). The Python CLI already emits it. - In
check(:556), compare steps only when the vector supplies them:
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
:25to 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:
{"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
make conformanceExpected: PASS for both conformance-rs and conformance-py, including the two new vectors.
- Step 7: Commit
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 sourcescripts/check-metering-parity.pyreads;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
NOPdispatch. Every instruction chargesBASE_STEPSbefore 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.pyparses:
| 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_stepsandvalue_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:
EQUALITYand theATLEASTfamily clone their index and coefficient vecs before charging, so up toO(n)element copies precede the charge for theO(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:
| 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 linkspec/xqvm/METERING.md.docs/book/src/xqvm/limits-and-errors.md:26: theStep countrow needs the same correction, plus the newStepLimitExceededmessage 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):
/// 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
make preflightExpected: 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
git add spec docs xqffi/src/vm.rs && git commit -s -m "docs(xqvm): specify step metering"Verification
Run before declaring the branch done:
make preflightThat 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:
cargo run --release --example metering_calibrationThe 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
WeightPerStepagainstNOPplus 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 |