experimental quip protocol network node
# AGENTS.md β QuIP Protocol Project Instructions
Cross-tool instructions for AI coding assistants (Claude Code, Codex, Cursor, Gemini CLI).
For the **runtime architecture**, read [`ARCHITECTURE.md`](./ARCHITECTURE.md).
This file is the developer-facing how-to: commands, dependencies, code
style. Anything about how the system *runs* belongs in
`ARCHITECTURE.md`.
## Environment
The `.quip` virtualenv is already active in development shells β
don't prefix shell commands with `source .quip/bin/activate`.
```bash
# Fresh install (only if .quip doesn't exist yet)
python3 -m venv .quip
source .quip/bin/activate
pip install -U pip setuptools wheel
pip install -e . # core + CPU
pip install -e .[cuda] # CUDA backend
pip install -e .[metal] # Apple Silicon backend
pip install -e .[dev] # pytest + pytest-asyncio
```
`.env` holds `DWAVE_API_KEY` and other credentials. **Never read or
display its contents.**
## Running the miner
The CLI is `quip-miner` (defined in `quip_cli.py`, entry point in
`pyproject.toml`). It attaches to a substrate validator over WS or
HTTP; there is no longer any in-process P2P node to run.
```bash
# Generate a hybrid sr25519 + ML-DSA-44 keystore
quip-miner keygen --out ~/.quip-miner/signing.json
# Bootstrap (one-shot reachability + funding check against a validator)
quip-miner bootstrap --validator ws://127.0.0.1:9944 \
--signer-key ~/.quip-miner/signing.json
# CPU PoW miner
quip-miner cpu --validator ws://127.0.0.1:9944 \
--signer-key ~/.quip-miner/signing.json
# CUDA / Metal / D-Wave
quip-miner gpu --validator ws://127.0.0.1:9944 --gpu-backend local --signer-key ...
quip-miner gpu --validator ws://127.0.0.1:9944 --gpu-backend metal --signer-key ...
quip-miner qpu --validator ws://127.0.0.1:9944 --daily-budget 30s --signer-key ...
# Multiple CPU workers (PoW + mempool jobs share the same workers)
quip-miner cpu --validator ws://... --num-cpus 4 --signer-key ...
# TOML config (see docker/quip-miner.cpu.toml, docker/quip-miner.cuda.toml)
quip-miner cpu --config ./docker/quip-miner.cpu.toml
# Production: run everything the config declares, supervised
quip-miner --config ./docker/quip-miner.cpu.toml
# Narrow a multi-backend config to one miner type (CLI-only; the
# supervisor echoes which configured types were dropped)
quip-miner --config config.toml --mode gpu
```
Miner-type selection is CLI-only: the supervisor's `--mode cpu|gpu|qpu`
keeps one configured type (warning about the dropped ones), and a
direct `quip-miner cpu|gpu|qpu` run does the same narrowing with the
same warning. There is no config key for it β a legacy `[miner] mode`
key still loads but is ignored.
**Mempool participation is config-only and per-miner** β `mempool` is
an unquoted TOML bool set INSIDE each backend section
(`[cpu] mempool = false`, `[gpu]`/`[metal]`/`[modal]`, or a qpu vendor
section like `[dwave] mempool = true`); defaults: cpu/gpu on, qpu off β
paid QPU samples are opt-in. A `mempool` key in `[miner]` is rejected
at load time; `[miner] mempool_min_reward` (0 = accept all) stays
global. There is no CLI flag for it and no mempool-only mode (`--mode`
selects miner types, not the work source): every worker mines PoW
continuously, mempool jobs preempt PoW on the same workers, and PoW
resumes afterward. Solver
registration is automatic at miner startup (query-first, never
auto-deregisters β switching solver type requires an explicit
`quip-miner deregister-solver` and restart). A mempool-fatal submit
receipt parks the mempool side for the run while PoW mining continues.
On a multi-backend config the mempool owner is derived from the
per-section keys: an explicit `mempool = true` outranks default-on
groups, then the first default-on group in canonical cpu,gpu,qpu order
owns; every other child resolves mempool off from the same TOML (one
substrate account can only register one solver type on chain). Set
`mempool = false` in a section to move ownership to the next group.
Nothing is transported out-of-band, so supervised, direct-subcommand,
and `--mode`-narrowed runs all agree; the supervisor echoes the
election so operators see why a child is pow-only.
Live integration uses the docker-compose validator under `docker/`
(`docker compose up quip-validator`); the validator listens on
`ws://127.0.0.1:9944` by default.
**Metal interactive cap (Apple Silicon):** the `[metal]` section runs an
adaptive governor when `yielding` is on (default). It senses HID-idle /
thermal / battery / displays and caps **GPU occupancy** β the jank lever is
concurrent threads per command buffer (`problems Γ reads`), not core count or
duty cycle. While you're present it splits reads so each command buffer stays
under `active_util` % of the GPU's max thread capacity
(`maxTotalThreadsPerThreadgroup Γ cores`; default 85); idle/headless runs
uncapped (full speed); thermal-serious halves it; battery / critical thermal
pause. Total reads and sweeps are always preserved. (On an M4 Max steady-state
mining is smooth even at full saturation, so the cap is mainly insurance for
weaker GPUs / sustained thermal load.) This path
is **independent of the CUDA util monitor** β it lives in
`GPU/metal_scheduler.py` + `GPU/macos_sensors.py` and shares no utilization
machinery with `GPU/util_monitor.py`. See `docs/metal-gpu-governor.md`.
## Testing
```bash
# All tests
python -m pytest tests/ -v
# Single file / single test
python -m pytest tests/test_pool_client.py -v
python -m pytest tests/test_pool_client.py::test_get_head_forwards_empty_args -v
```
There are no `smoke_node_*.py` scripts in `tests/` anymore β the old
in-process P2P node smoke tests are gone. Live integration is via
the docker-compose validator described above.
## Benchmarking and tools
```bash
# CPU baseline
python tools/cpu_baseline.py --quick
python tools/cpu_baseline.py --quick \
--topology dwave_topologies/topologies/advantage2_system1.json.gz
# Topology analysis
python tools/analyze_topology_sizes.py --configs "8,2" --samples 10
python tools/validate_mined_topology.py --all
# Precompute embedding for QPU (slow)
python tools/analyze_topology_sizes.py --configs "9,2" \
--precompute-embedding --embedding-timeout 1w
# GPU benchmarks (Modal Labs)
modal run benchmarks/gpu_benchmark_modal.py
# Download + re-validate every on-chain win (walks the proof chain)
python tools/download_and_validate_wins.py \
--url wss://qpu-1.nodes.quip.network/rpc --out quip_wins
```
**Never run QPU benchmarks in the background.** Provide the command;
let the operator execute it.
### Downloading winning-solution BQMs
`submit_proof` stores a compact seed (nonce + topology hash), not the full
Binary Quadratic Model. Add `--dump-bqm` to also reconstruct each win's Ising
model and write it to `<out>.bqms.jsonl`:
```bash
python tools/download_and_validate_wins.py \
--url wss://qpu-1.nodes.quip.network/rpc \
--max 50 --dump-bqm --out quip_wins
```
Each line is one model: `block_number`, `nonce`, `topology_hash`, plus the
reconstructed Ising model as flat lists β `h: [[node_id, bias], ...]` and
`j: [[u, v, coupling], ...]`. Reload into the dicts the energy functions
expect with:
```python
h = {n: b for n, b in rec["h"]}
J = {(u, v): c for u, v, c in rec["j"]}
```
The model is re-derived from the nonce + topology snapshot via
`generate_ising_model_from_nonce` β the same function `_finalize_sample` and
the validator use, so a green validation run proves the dumped BQMs are
correct.
### Modal Labs (cloud GPU)
```bash
pip install modal
modal token new # opens a browser for authentication
```
## Dependencies
From `pyproject.toml`:
- **Core**: `dwave-ocean-sdk`, `numpy`, `aiohttp`, `click`, `blake3`,
`substrate-interface`, `scalecodec`, `dilithium-py` (ML-DSA-44),
`python-dotenv`, `tomli` (on 3.10).
- **CUDA** (optional): `cupy-cuda12x`, `nvidia-ml-py`.
- **Metal** (optional): `pyobjc-framework-Metal`,
`pyobjc-framework-MetalPerformanceShaders`.
- **fast** (optional): `pyzmq`, `uvloop`.
- **dev**: `pytest`, `pytest-asyncio`.
Python 3.10+ required.
**Removed in v0.2**: `aioquic`, `hashsigs` (legacy SPHINCS+),
`cryptography` (was for self-signed TLS). The QUIC P2P stack went
with them.
## Topology management (`dwave_topologies/`)
- `topologies/*.json.gz` β hardware topology files (Advantage2, Chimera, Pegasus, Zephyr).
- `embeddings/*.json.gz` β precomputed embeddings for QPU hardware mapping.
- `embedded_topology.py`, `embedding_loader.py`, `smart_embedding.py` β loaders and embedding utilities.
- **Default**: Zephyr Z(9,2) β 1,368 nodes, 7,692 edges.
- For full Advantage2 hardware: `advantage2_system1.json.gz` (~4,579 qubits).
**QPU solver revision updates** (when D-Wave recalibrates):
1. Replace the topology file in `topologies/`.
2. Verify existing `embeddings/` still match the new graph; copy over compatible ones.
3. Delete stale topology files and incompatible embeddings.
## Critical parameters
**Genesis block defaults** (`genesis_block.json`):
```python
difficulty_energy = -2500.0
min_diversity = 0.2
min_solutions = 5
h_values = [-1.0, 0.0, 1.0]
```
**Production Z(9,2) targets:**
```python
difficulty_energy = -4100.0
min_diversity = 0.15
min_solutions = 5
```
**Energy ranges by topology (GSE):**
| Topology | Range | Size |
|---|---|---|
| Z(8,2) | -2869 to -2677 | 192 |
| Z(9,2) | -4100 to -3870 | 230 (default) |
| Z(10,2) | -5470 to -5200 | 270 |
| Z(11,4) | -15170 to -14158 | 1012 |
| Advantage2 (full) | similar to Z(11,4) | ~4579 qubits |
**QPU h/J ranges:** See [`docs/dwave-solver-ranges.md`](docs/dwave-solver-ranges.md)
for per-solver `h_range`, `j_range`, `extended_j_range`, and
`per_qubit_coupling_range`. Regenerate with
`python tools/dump_solver_ranges.py`.
**Per-miner adaptive parameters:**
| Backend | num_sweeps | num_reads |
|---|---|---|
| CPU/SA | 64β4096 | 64β512 |
| GPU/CUDA | 256β2048 | (adaptive) |
| GPU/Metal | 64β512 | (adaptive) |
| Modal | 128β4096 | (adaptive) |
| QPU | annealing 5β20Β΅s | 32β64 |
## Identifiers: solution number vs `dispatch_id`
Two distinct identifiers exist; **never conflate them**, and never persist
on `dispatch_id`.
- **Solution number** β the chain-global *ordinal* of the QPoW solution
being mined: `count(QuantumPow.WinningSolutions) + 1`. The chain has no
stored solution counter β solutions are keyed in `WinningSolutions` by
the block number they won at (`submitted_at == key`), so the ordinal is
derived by counting that map's keys (cheap: one paged `state_getKeysPaged`
over the storage prefix, keys only). It is **determinable at round
start** β a round mines toward a specific upcoming solution number; the
round's `last_proof_block_hash` (= `block_hash(LastProofBlock)`) stays
constant until a proof wins and advances it. Compute the count **once per
round and cache it** (recount only when `last_proof_block_hash` changes);
it is global, monotonic, and never repeats.
- **Not the same as a block number.** `LastProofBlock` (e.g. 52507) is
the block number of the most recent winning proof β the round anchor,
not the solution ordinal (e.g. 196). Don't conflate them.
- **This is the on-disk key for the mining-attempts archive**
(`{base}/{solution_number}/β¦`). Because it tracks the logical
solution, a controller/worker restart mid-round correctly *resumes*
writing into the same solution dir β that is not stale accretion, it
is the same solution. Different solution number β different dir.
- **`dispatch_id`** β an **internal-only** schedulerβworker coordination
handle: `_dispatch_contexts[(handle_id, dispatch_id)]`, cancel-ack
(`_await_done_sentinel`), and the preview channel (see `ARCHITECTURE.md`
Β§3.3). It is a process-local monotonic counter that **resets to 0 on
restart**, so it collides across runs and MUST NOT be used as a durable
or on-disk identity. Keep it in memory for pairing responses to
contexts; never name persisted artifacts after it.
The old `SubmissionLogger` `next_solution_id` local counter was removed;
the on-disk archive is keyed by the chain-derived solution number.
## Code style
- **Imports** at top of file, in stdlib β third-party β local order. Absolute imports only. No inline imports inside functions/methods. Exception: optional-dependency `try/except` at module level.
- **Concurrency**: NEVER use threads. Multiprocessing only. Async via `asyncio` for network operations. Mining runs in worker processes (`shared/miner_worker.py:MinerHandle`).
- **Type hints** on public APIs. Google-style docstrings on non-trivial public functions.
- **Logging** via `logging.getLogger(__name__)` at module level. The custom formatter and component classification live in `shared/logging_config.py`.
- **No `Co-Authored-By: <assistant> ...` lines in commits.**
See [`ARCHITECTURE.md`](./ARCHITECTURE.md) Β§9 for the cleanup-candidates
list (vestigial code flagged for removal).
## Deployment
- **Docker** (`docker/`): `Dockerfile.cpu`, `Dockerfile.cuda`, `docker-compose.yml`, `entrypoint.sh`, plus the example TOML configs (`quip-miner.cpu.toml`, `quip-miner.cuda.toml`).
- **Cloud**: `akash/` and `aws/` contain deployment configs.
- **GitLab CI** (`.gitlab-ci.yml`): builds CPU + CUDA Docker images on main/tags.
## Concurrency: processes, not threads
Use `multiprocessing` for concurrency. Do **not** introduce `threading.Thread`
in our own code. Threads share the GIL; a CPU-bound thread starves its
siblings. This stalled the QPU stream pump in v0.2 β the per-attempt consumer
held the GIL ~1.2 s and the pump thread could not drive the sampler, so the
QPU pipeline drained (throughput 0.6 vs ~8 subs/sec).
Rules:
- New background work β a `multiprocessing` process (use `spawn`).
- Share scalars via `multiprocessing.Value`; share large numpy buffers via
`multiprocessing.shared_memory.SharedMemory` (zero-copy), never by pickling
per item on a hot path.
- `threading.Lock`/`RLock` for intra-process state is fine (a lock is not a
thread). Cross-process correctness uses `os.replace`/PIPE_BUF atomicity or
`mp` primitives, not locks.
- Exceptions we don't control: third-party internal threads (D-Wave SDK,
asyncio executors, stdlib `QueueListener` if ever reused). Document any such
exception inline with the reason.
## Debugging a hung process (get a traceback)
`SIGINT` (Ctrl-C) only helps when the **main thread is running Python
bytecode** β it raises `KeyboardInterrupt` at the next bytecode boundary. A
process wedged in a C-level call (a lock/`join`/`wait`, an `mp.Queue`
feeder-thread join at interpreter shutdown, a blocking syscall) won't unwind,
so `SIGINT` just kills it with **no traceback**. Don't reach for it on a hang.
Use **`faulthandler`** + **`SIGABRT`** β the repo convention (CI already runs
`timeout --signal=ABRT β¦ python -X faulthandler -m pytest`):
```bash
# 1. Start the process with faulthandler enabled (installs handlers for the
# fatal signals SIGSEGV/SIGFPE/SIGABRT/SIGBUS/SIGILL).
PYTHONFAULTHANDLER=1 python tools/whatever.py β¦ # or: python -X faulthandler β¦
# 2. When it hangs, dump every thread's Python (and C) stack to stderr:
kill -ABRT <pid> # SIGABRT -> faulthandler dumps, then the process aborts (exit 134)
```
This prints the exact frame each thread is stuck in (e.g. `threading.py:wait`
β an unjoined queue feeder thread). Notes:
- **Pre-arm it.** Faulthandler must be enabled *before* the hang. For tools we
expect to run interactively against the QPU/long pipelines, prefer enabling
it (env var or `faulthandler.enable()` at startup) so a hang is debuggable.
- **Dump without killing:** `faulthandler.register(signal.SIGUSR1)` in code,
then `kill -USR1 <pid>` dumps and **continues** (repeatable). `SIGABRT` is
fatal; `SIGUSR1` (registered) is not.
- **No instrumentation available?** `py-spy dump --pid <pid>` attaches to any
running CPython and prints all-thread tracebacks without a signal or restart.
- **Inspect the tree first:** `ps -o pid,ppid,stat,command -p <pid>` and
`pgrep -P <pid>` reveal stuck children / orphans (`PPID 1` = reparented after
a parent crash). An interpreter that won't exit is usually blocked joining a
non-daemon child or an `mp.Queue` feeder thread (call `cancel_join_thread()`
on queues whose buffered data is worthless at teardown).