quip-protocol

GitHub

experimental quip protocol network node

11,629 stars Python
RAW Rules

AGENTS.md

# 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).