diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..5f9a70a --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,37 @@ +name: Tests + +on: + push: + branches: [ main, feat/* ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync --dev + + - name: Run tests with coverage + run: uv run pytest --verbose --cov=bpdecoderplus --cov-report=xml --cov-report=term + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + file: ./coverage.xml + flags: unittests + name: codecov-umbrella + fail_ci_if_error: false diff --git a/.gitignore b/.gitignore index d33eb84..e0f3da4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,34 @@ +# macOS .DS_Store + +# Jupyter .ipynb_checkpoints/ + +# Julia Manifest.toml + +# IDE .vscode/ +.idea/ + +# Python +.venv/ +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +dist/ +build/ +.eggs/ +*.egg +.pytest_cache/ +.coverage +coverage.xml +htmlcov/ +.uv/ +uv.lock + +# LaTeX *.aux *.fls *.log diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fa7130c --- /dev/null +++ b/Makefile @@ -0,0 +1,37 @@ +.PHONY: help install setup test test-cov generate-dataset clean + +help: + @echo "Available targets:" + @echo " install - Install uv package manager" + @echo " setup - Set up development environment with uv" + @echo " generate-dataset - Generate noisy circuit dataset" + @echo " test - Run tests" + @echo " test-cov - Run tests with coverage report" + @echo " clean - Remove generated files and caches" + +install: + @command -v uv >/dev/null 2>&1 || { \ + echo "Installing uv..."; \ + curl -LsSf https://astral.sh/uv/install.sh | sh; \ + } + +setup: install + uv sync --dev + +generate-dataset: + uv run generate-noisy-circuits --distance 3 --p 0.01 --rounds 3 5 7 --task z --output datasets/noisy_circuits + +test: + uv run pytest + +test-cov: + uv run pytest --cov=bpdecoderplus --cov-report=html --cov-report=term + +clean: + rm -rf .pytest_cache + rm -rf __pycache__ + rm -rf htmlcov + rm -rf .coverage + rm -rf coverage.xml + find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true + find . -type f -name "*.pyc" -delete diff --git a/README.md b/README.md index dd6eb90..369a681 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # BPDecoderPlus: Quantum Error Correction with Belief Propagation +[![Tests](https://github.com/GiggleLiu/BPDecoderPlus/actions/workflows/test.yml/badge.svg)](https://github.com/GiggleLiu/BPDecoderPlus/actions/workflows/test.yml) +[![codecov](https://codecov.io/gh/GiggleLiu/BPDecoderPlus/branch/main/graph/badge.svg)](https://codecov.io/gh/GiggleLiu/BPDecoderPlus) + A winter school project on circuit-level decoding of surface codes using belief propagation and integer programming decoders, with extensions for atom loss in neutral atom quantum computers. ## Project Goals diff --git a/datasets/noisy_circuits/README.md b/datasets/noisy_circuits/README.md new file mode 100644 index 0000000..9bd1385 --- /dev/null +++ b/datasets/noisy_circuits/README.md @@ -0,0 +1,234 @@ +# Noisy Circuit Dataset (Surface Code, d=3) + +Circuit-level surface-code memory experiments generated with Stim for **Belief Propagation (BP) decoding** demonstrations. + +## Overview + +| Parameter | Value | +|-----------|-------| +| Code | Rotated surface code | +| Distance | d = 3 | +| Noise model | i.i.d. depolarizing | +| Error rate | p = 0.01 | +| Task | Z-memory experiment | +| Rounds | 3, 5, 7 | + +### Noise Application Points +- Clifford gates (`after_clifford_depolarization`) +- Data qubits between rounds (`before_round_data_depolarization`) +- Resets (`after_reset_flip_probability`) +- Measurements (`before_measure_flip_probability`) + +## Files + +| File | Description | +|------|-------------| +| `sc_d3_r3_p0010_z.stim` | 3 rounds, p=0.01, Z-memory | +| `sc_d3_r5_p0010_z.stim` | 5 rounds, p=0.01, Z-memory | +| `sc_d3_r7_p0010_z.stim` | 7 rounds, p=0.01, Z-memory | +| `sc_d3_layout.png` | Qubit layout visualization | +| `parity_check_matrix.png` | BP parity check matrix H | +| `syndrome_stats.png` | Detection event statistics | +| `single_syndrome.png` | Example syndrome pattern | + +## Qubit Layout + +The surface code layout showing qubit positions (data + ancilla): + +![Qubit Layout](sc_d3_layout.png) + +## Using This Dataset for BP Decoding + +### Step 1: Load Circuit and Extract Detector Error Model (DEM) + +The Detector Error Model is the key input for BP decoding. It describes which errors trigger which detectors. + +```python +import stim +import numpy as np + +# Load circuit +circuit = stim.Circuit.from_file("datasets/noisy_circuits/sc_d3_r3_p0010_z.stim") + +# Extract DEM - this is what BP needs +dem = circuit.detector_error_model(decompose_errors=True) +print(f"Detectors: {dem.num_detectors}") # 24 +print(f"Error mechanisms: {dem.num_errors}") # 286 +print(f"Observables: {dem.num_observables}") # 1 +``` + +### Step 2: Build Parity Check Matrix H + +BP operates on the parity check matrix where `H[i,j] = 1` means error `j` triggers detector `i`. + +```python +def build_parity_check_matrix(dem): + """Convert DEM to parity check matrix H and prior probabilities.""" + errors = [] + for inst in dem.flattened(): + if inst.type == 'error': + prob = inst.args_copy()[0] + dets = [t.val for t in inst.targets_copy() if t.is_relative_detector_id()] + obs = [t.val for t in inst.targets_copy() if t.is_logical_observable_id()] + errors.append({'prob': prob, 'detectors': dets, 'observables': obs}) + + n_detectors = dem.num_detectors + n_errors = len(errors) + + # Parity check matrix + H = np.zeros((n_detectors, n_errors), dtype=np.uint8) + # Prior error probabilities (for BP initialization) + priors = np.zeros(n_errors) + # Which errors flip the logical observable + obs_flip = np.zeros(n_errors, dtype=np.uint8) + + for j, e in enumerate(errors): + priors[j] = e['prob'] + for d in e['detectors']: + H[d, j] = 1 + if e['observables']: + obs_flip[j] = 1 + + return H, priors, obs_flip + +H, priors, obs_flip = build_parity_check_matrix(dem) +print(f"H shape: {H.shape}") # (24, 286) +``` + +The parity check matrix structure: + +![Parity Check Matrix](parity_check_matrix.png) + +### Step 3: Sample Syndromes (Detection Events) + +```python +# Compile sampler +sampler = circuit.compile_detector_sampler() + +# Sample detection events + observable flip +n_shots = 1000 +samples = sampler.sample(n_shots, append_observables=True) + +# Split into syndrome and observable +syndromes = samples[:, :-1] # shape: (n_shots, n_detectors) +actual_obs_flips = samples[:, -1] # shape: (n_shots,) + +print(f"Syndrome shape: {syndromes.shape}") +print(f"Example syndrome: {syndromes[0]}") +``` + +### Step 4: BP Decoding (Pseudocode) + +```python +def bp_decode(H, syndrome, priors, max_iter=50, damping=0.5): + """ + Belief Propagation decoder (min-sum variant). + + Args: + H: Parity check matrix (n_detectors, n_errors) + syndrome: Detection events (n_detectors,) + priors: Prior error probabilities (n_errors,) + max_iter: Maximum BP iterations + damping: Message damping factor + + Returns: + estimated_errors: Most likely error pattern (n_errors,) + soft_output: Log-likelihood ratios (n_errors,) + """ + n_checks, n_vars = H.shape + + # Initialize LLRs from priors: LLR = log((1-p)/p) + llr_prior = np.log((1 - priors) / priors) + + # Messages: check-to-variable and variable-to-check + # ... BP message passing iterations ... + + # Hard decision + estimated_errors = (soft_output < 0).astype(int) + + return estimated_errors, soft_output + +# Decode each syndrome +for i in range(n_shots): + syndrome = syndromes[i] + estimated_errors, _ = bp_decode(H, syndrome, priors) + + # Predict observable flip + predicted_obs_flip = np.dot(estimated_errors, obs_flip) % 2 + + # Check if decoding succeeded + success = (predicted_obs_flip == actual_obs_flips[i]) +``` + +### Step 5: Evaluate Decoder Performance + +After decoding, compare predicted vs actual observable flips to measure logical error rate. + +```python +def evaluate_decoder(decoder_fn, circuit, n_shots=10000): + """Evaluate decoder logical error rate.""" + dem = circuit.detector_error_model(decompose_errors=True) + H, priors, obs_flip = build_parity_check_matrix(dem) + + sampler = circuit.compile_detector_sampler() + samples = sampler.sample(n_shots, append_observables=True) + syndromes = samples[:, :-1] + actual_obs = samples[:, -1] + + errors = 0 + for i in range(n_shots): + est_errors, _ = decoder_fn(H, syndromes[i], priors) + pred_obs = np.dot(est_errors, obs_flip) % 2 + if pred_obs != actual_obs[i]: + errors += 1 + + return errors / n_shots + +# logical_error_rate = evaluate_decoder(bp_decode, circuit) +``` + +## Syndrome Statistics + +Detection event frequencies across 1000 shots (left) and baseline observable flip rate without decoding (right): + +![Syndrome Statistics](syndrome_stats.png) + +## Example Syndrome + +A single syndrome sample showing which detectors fired (red = triggered): + +![Single Syndrome](single_syndrome.png) + +## Regenerating the Dataset + +```bash +# Install the package with uv +uv sync + +# Generate circuits using the CLI +uv run generate-noisy-circuits \ + --distance 3 \ + --p 0.01 \ + --rounds 3 5 7 \ + --task z \ + --output datasets/noisy_circuits +``` + +## Extending the Dataset + +```bash +# Different error rates +uv run generate-noisy-circuits --p 0.005 --rounds 3 5 7 + +# Different distances +uv run generate-noisy-circuits --distance 5 --rounds 5 7 9 + +# X-memory experiment +uv run generate-noisy-circuits --task x --rounds 3 5 7 +``` + +## References + +- [Stim Documentation](https://github.com/quantumlib/Stim) +- [BP+OSD Decoder Paper](https://arxiv.org/abs/2005.07016) +- [Surface Code Decoding Review](https://quantum-journal.org/papers/q-2024-10-10-1498/) diff --git a/datasets/noisy_circuits/parity_check_matrix.png b/datasets/noisy_circuits/parity_check_matrix.png new file mode 100644 index 0000000..4d6e5f6 Binary files /dev/null and b/datasets/noisy_circuits/parity_check_matrix.png differ diff --git a/datasets/noisy_circuits/sc_d3_layout.png b/datasets/noisy_circuits/sc_d3_layout.png new file mode 100644 index 0000000..0c39f39 Binary files /dev/null and b/datasets/noisy_circuits/sc_d3_layout.png differ diff --git a/datasets/noisy_circuits/sc_d3_r3_p0010_z.stim b/datasets/noisy_circuits/sc_d3_r3_p0010_z.stim new file mode 100644 index 0000000..187876d --- /dev/null +++ b/datasets/noisy_circuits/sc_d3_r3_p0010_z.stim @@ -0,0 +1,89 @@ +QUBIT_COORDS(1, 1) 1 +QUBIT_COORDS(2, 0) 2 +QUBIT_COORDS(3, 1) 3 +QUBIT_COORDS(5, 1) 5 +QUBIT_COORDS(1, 3) 8 +QUBIT_COORDS(2, 2) 9 +QUBIT_COORDS(3, 3) 10 +QUBIT_COORDS(4, 2) 11 +QUBIT_COORDS(5, 3) 12 +QUBIT_COORDS(6, 2) 13 +QUBIT_COORDS(0, 4) 14 +QUBIT_COORDS(1, 5) 15 +QUBIT_COORDS(2, 4) 16 +QUBIT_COORDS(3, 5) 17 +QUBIT_COORDS(4, 4) 18 +QUBIT_COORDS(5, 5) 19 +QUBIT_COORDS(4, 6) 25 +R 1 3 5 8 10 12 15 17 19 +X_ERROR(0.01) 1 3 5 8 10 12 15 17 19 +R 2 9 11 13 14 16 18 25 +X_ERROR(0.01) 2 9 11 13 14 16 18 25 +TICK +DEPOLARIZE1(0.01) 1 3 5 8 10 12 15 17 19 +H 2 11 16 25 +DEPOLARIZE1(0.01) 2 11 16 25 +TICK +CX 2 3 16 17 11 12 15 14 10 9 19 18 +DEPOLARIZE2(0.01) 2 3 16 17 11 12 15 14 10 9 19 18 +TICK +CX 2 1 16 15 11 10 8 14 3 9 12 18 +DEPOLARIZE2(0.01) 2 1 16 15 11 10 8 14 3 9 12 18 +TICK +CX 16 10 11 5 25 19 8 9 17 18 12 13 +DEPOLARIZE2(0.01) 16 10 11 5 25 19 8 9 17 18 12 13 +TICK +CX 16 8 11 3 25 17 1 9 10 18 5 13 +DEPOLARIZE2(0.01) 16 8 11 3 25 17 1 9 10 18 5 13 +TICK +H 2 11 16 25 +DEPOLARIZE1(0.01) 2 11 16 25 +TICK +X_ERROR(0.01) 2 9 11 13 14 16 18 25 +MR 2 9 11 13 14 16 18 25 +X_ERROR(0.01) 2 9 11 13 14 16 18 25 +DETECTOR(0, 4, 0) rec[-4] +DETECTOR(2, 2, 0) rec[-7] +DETECTOR(4, 4, 0) rec[-2] +DETECTOR(6, 2, 0) rec[-5] +REPEAT 2 { + TICK + DEPOLARIZE1(0.01) 1 3 5 8 10 12 15 17 19 + H 2 11 16 25 + DEPOLARIZE1(0.01) 2 11 16 25 + TICK + CX 2 3 16 17 11 12 15 14 10 9 19 18 + DEPOLARIZE2(0.01) 2 3 16 17 11 12 15 14 10 9 19 18 + TICK + CX 2 1 16 15 11 10 8 14 3 9 12 18 + DEPOLARIZE2(0.01) 2 1 16 15 11 10 8 14 3 9 12 18 + TICK + CX 16 10 11 5 25 19 8 9 17 18 12 13 + DEPOLARIZE2(0.01) 16 10 11 5 25 19 8 9 17 18 12 13 + TICK + CX 16 8 11 3 25 17 1 9 10 18 5 13 + DEPOLARIZE2(0.01) 16 8 11 3 25 17 1 9 10 18 5 13 + TICK + H 2 11 16 25 + DEPOLARIZE1(0.01) 2 11 16 25 + TICK + X_ERROR(0.01) 2 9 11 13 14 16 18 25 + MR 2 9 11 13 14 16 18 25 + X_ERROR(0.01) 2 9 11 13 14 16 18 25 + SHIFT_COORDS(0, 0, 1) + DETECTOR(2, 0, 0) rec[-8] rec[-16] + DETECTOR(2, 2, 0) rec[-7] rec[-15] + DETECTOR(4, 2, 0) rec[-6] rec[-14] + DETECTOR(6, 2, 0) rec[-5] rec[-13] + DETECTOR(0, 4, 0) rec[-4] rec[-12] + DETECTOR(2, 4, 0) rec[-3] rec[-11] + DETECTOR(4, 4, 0) rec[-2] rec[-10] + DETECTOR(4, 6, 0) rec[-1] rec[-9] +} +X_ERROR(0.01) 1 3 5 8 10 12 15 17 19 +M 1 3 5 8 10 12 15 17 19 +DETECTOR(0, 4, 1) rec[-3] rec[-6] rec[-13] +DETECTOR(2, 2, 1) rec[-5] rec[-6] rec[-8] rec[-9] rec[-16] +DETECTOR(4, 4, 1) rec[-1] rec[-2] rec[-4] rec[-5] rec[-11] +DETECTOR(6, 2, 1) rec[-4] rec[-7] rec[-14] +OBSERVABLE_INCLUDE(0) rec[-7] rec[-8] rec[-9] \ No newline at end of file diff --git a/datasets/noisy_circuits/sc_d3_r5_p0010_z.stim b/datasets/noisy_circuits/sc_d3_r5_p0010_z.stim new file mode 100644 index 0000000..79afc47 --- /dev/null +++ b/datasets/noisy_circuits/sc_d3_r5_p0010_z.stim @@ -0,0 +1,89 @@ +QUBIT_COORDS(1, 1) 1 +QUBIT_COORDS(2, 0) 2 +QUBIT_COORDS(3, 1) 3 +QUBIT_COORDS(5, 1) 5 +QUBIT_COORDS(1, 3) 8 +QUBIT_COORDS(2, 2) 9 +QUBIT_COORDS(3, 3) 10 +QUBIT_COORDS(4, 2) 11 +QUBIT_COORDS(5, 3) 12 +QUBIT_COORDS(6, 2) 13 +QUBIT_COORDS(0, 4) 14 +QUBIT_COORDS(1, 5) 15 +QUBIT_COORDS(2, 4) 16 +QUBIT_COORDS(3, 5) 17 +QUBIT_COORDS(4, 4) 18 +QUBIT_COORDS(5, 5) 19 +QUBIT_COORDS(4, 6) 25 +R 1 3 5 8 10 12 15 17 19 +X_ERROR(0.01) 1 3 5 8 10 12 15 17 19 +R 2 9 11 13 14 16 18 25 +X_ERROR(0.01) 2 9 11 13 14 16 18 25 +TICK +DEPOLARIZE1(0.01) 1 3 5 8 10 12 15 17 19 +H 2 11 16 25 +DEPOLARIZE1(0.01) 2 11 16 25 +TICK +CX 2 3 16 17 11 12 15 14 10 9 19 18 +DEPOLARIZE2(0.01) 2 3 16 17 11 12 15 14 10 9 19 18 +TICK +CX 2 1 16 15 11 10 8 14 3 9 12 18 +DEPOLARIZE2(0.01) 2 1 16 15 11 10 8 14 3 9 12 18 +TICK +CX 16 10 11 5 25 19 8 9 17 18 12 13 +DEPOLARIZE2(0.01) 16 10 11 5 25 19 8 9 17 18 12 13 +TICK +CX 16 8 11 3 25 17 1 9 10 18 5 13 +DEPOLARIZE2(0.01) 16 8 11 3 25 17 1 9 10 18 5 13 +TICK +H 2 11 16 25 +DEPOLARIZE1(0.01) 2 11 16 25 +TICK +X_ERROR(0.01) 2 9 11 13 14 16 18 25 +MR 2 9 11 13 14 16 18 25 +X_ERROR(0.01) 2 9 11 13 14 16 18 25 +DETECTOR(0, 4, 0) rec[-4] +DETECTOR(2, 2, 0) rec[-7] +DETECTOR(4, 4, 0) rec[-2] +DETECTOR(6, 2, 0) rec[-5] +REPEAT 4 { + TICK + DEPOLARIZE1(0.01) 1 3 5 8 10 12 15 17 19 + H 2 11 16 25 + DEPOLARIZE1(0.01) 2 11 16 25 + TICK + CX 2 3 16 17 11 12 15 14 10 9 19 18 + DEPOLARIZE2(0.01) 2 3 16 17 11 12 15 14 10 9 19 18 + TICK + CX 2 1 16 15 11 10 8 14 3 9 12 18 + DEPOLARIZE2(0.01) 2 1 16 15 11 10 8 14 3 9 12 18 + TICK + CX 16 10 11 5 25 19 8 9 17 18 12 13 + DEPOLARIZE2(0.01) 16 10 11 5 25 19 8 9 17 18 12 13 + TICK + CX 16 8 11 3 25 17 1 9 10 18 5 13 + DEPOLARIZE2(0.01) 16 8 11 3 25 17 1 9 10 18 5 13 + TICK + H 2 11 16 25 + DEPOLARIZE1(0.01) 2 11 16 25 + TICK + X_ERROR(0.01) 2 9 11 13 14 16 18 25 + MR 2 9 11 13 14 16 18 25 + X_ERROR(0.01) 2 9 11 13 14 16 18 25 + SHIFT_COORDS(0, 0, 1) + DETECTOR(2, 0, 0) rec[-8] rec[-16] + DETECTOR(2, 2, 0) rec[-7] rec[-15] + DETECTOR(4, 2, 0) rec[-6] rec[-14] + DETECTOR(6, 2, 0) rec[-5] rec[-13] + DETECTOR(0, 4, 0) rec[-4] rec[-12] + DETECTOR(2, 4, 0) rec[-3] rec[-11] + DETECTOR(4, 4, 0) rec[-2] rec[-10] + DETECTOR(4, 6, 0) rec[-1] rec[-9] +} +X_ERROR(0.01) 1 3 5 8 10 12 15 17 19 +M 1 3 5 8 10 12 15 17 19 +DETECTOR(0, 4, 1) rec[-3] rec[-6] rec[-13] +DETECTOR(2, 2, 1) rec[-5] rec[-6] rec[-8] rec[-9] rec[-16] +DETECTOR(4, 4, 1) rec[-1] rec[-2] rec[-4] rec[-5] rec[-11] +DETECTOR(6, 2, 1) rec[-4] rec[-7] rec[-14] +OBSERVABLE_INCLUDE(0) rec[-7] rec[-8] rec[-9] \ No newline at end of file diff --git a/datasets/noisy_circuits/sc_d3_r7_p0010_z.stim b/datasets/noisy_circuits/sc_d3_r7_p0010_z.stim new file mode 100644 index 0000000..c79fed7 --- /dev/null +++ b/datasets/noisy_circuits/sc_d3_r7_p0010_z.stim @@ -0,0 +1,89 @@ +QUBIT_COORDS(1, 1) 1 +QUBIT_COORDS(2, 0) 2 +QUBIT_COORDS(3, 1) 3 +QUBIT_COORDS(5, 1) 5 +QUBIT_COORDS(1, 3) 8 +QUBIT_COORDS(2, 2) 9 +QUBIT_COORDS(3, 3) 10 +QUBIT_COORDS(4, 2) 11 +QUBIT_COORDS(5, 3) 12 +QUBIT_COORDS(6, 2) 13 +QUBIT_COORDS(0, 4) 14 +QUBIT_COORDS(1, 5) 15 +QUBIT_COORDS(2, 4) 16 +QUBIT_COORDS(3, 5) 17 +QUBIT_COORDS(4, 4) 18 +QUBIT_COORDS(5, 5) 19 +QUBIT_COORDS(4, 6) 25 +R 1 3 5 8 10 12 15 17 19 +X_ERROR(0.01) 1 3 5 8 10 12 15 17 19 +R 2 9 11 13 14 16 18 25 +X_ERROR(0.01) 2 9 11 13 14 16 18 25 +TICK +DEPOLARIZE1(0.01) 1 3 5 8 10 12 15 17 19 +H 2 11 16 25 +DEPOLARIZE1(0.01) 2 11 16 25 +TICK +CX 2 3 16 17 11 12 15 14 10 9 19 18 +DEPOLARIZE2(0.01) 2 3 16 17 11 12 15 14 10 9 19 18 +TICK +CX 2 1 16 15 11 10 8 14 3 9 12 18 +DEPOLARIZE2(0.01) 2 1 16 15 11 10 8 14 3 9 12 18 +TICK +CX 16 10 11 5 25 19 8 9 17 18 12 13 +DEPOLARIZE2(0.01) 16 10 11 5 25 19 8 9 17 18 12 13 +TICK +CX 16 8 11 3 25 17 1 9 10 18 5 13 +DEPOLARIZE2(0.01) 16 8 11 3 25 17 1 9 10 18 5 13 +TICK +H 2 11 16 25 +DEPOLARIZE1(0.01) 2 11 16 25 +TICK +X_ERROR(0.01) 2 9 11 13 14 16 18 25 +MR 2 9 11 13 14 16 18 25 +X_ERROR(0.01) 2 9 11 13 14 16 18 25 +DETECTOR(0, 4, 0) rec[-4] +DETECTOR(2, 2, 0) rec[-7] +DETECTOR(4, 4, 0) rec[-2] +DETECTOR(6, 2, 0) rec[-5] +REPEAT 6 { + TICK + DEPOLARIZE1(0.01) 1 3 5 8 10 12 15 17 19 + H 2 11 16 25 + DEPOLARIZE1(0.01) 2 11 16 25 + TICK + CX 2 3 16 17 11 12 15 14 10 9 19 18 + DEPOLARIZE2(0.01) 2 3 16 17 11 12 15 14 10 9 19 18 + TICK + CX 2 1 16 15 11 10 8 14 3 9 12 18 + DEPOLARIZE2(0.01) 2 1 16 15 11 10 8 14 3 9 12 18 + TICK + CX 16 10 11 5 25 19 8 9 17 18 12 13 + DEPOLARIZE2(0.01) 16 10 11 5 25 19 8 9 17 18 12 13 + TICK + CX 16 8 11 3 25 17 1 9 10 18 5 13 + DEPOLARIZE2(0.01) 16 8 11 3 25 17 1 9 10 18 5 13 + TICK + H 2 11 16 25 + DEPOLARIZE1(0.01) 2 11 16 25 + TICK + X_ERROR(0.01) 2 9 11 13 14 16 18 25 + MR 2 9 11 13 14 16 18 25 + X_ERROR(0.01) 2 9 11 13 14 16 18 25 + SHIFT_COORDS(0, 0, 1) + DETECTOR(2, 0, 0) rec[-8] rec[-16] + DETECTOR(2, 2, 0) rec[-7] rec[-15] + DETECTOR(4, 2, 0) rec[-6] rec[-14] + DETECTOR(6, 2, 0) rec[-5] rec[-13] + DETECTOR(0, 4, 0) rec[-4] rec[-12] + DETECTOR(2, 4, 0) rec[-3] rec[-11] + DETECTOR(4, 4, 0) rec[-2] rec[-10] + DETECTOR(4, 6, 0) rec[-1] rec[-9] +} +X_ERROR(0.01) 1 3 5 8 10 12 15 17 19 +M 1 3 5 8 10 12 15 17 19 +DETECTOR(0, 4, 1) rec[-3] rec[-6] rec[-13] +DETECTOR(2, 2, 1) rec[-5] rec[-6] rec[-8] rec[-9] rec[-16] +DETECTOR(4, 4, 1) rec[-1] rec[-2] rec[-4] rec[-5] rec[-11] +DETECTOR(6, 2, 1) rec[-4] rec[-7] rec[-14] +OBSERVABLE_INCLUDE(0) rec[-7] rec[-8] rec[-9] \ No newline at end of file diff --git a/datasets/noisy_circuits/single_syndrome.png b/datasets/noisy_circuits/single_syndrome.png new file mode 100644 index 0000000..fbc5289 Binary files /dev/null and b/datasets/noisy_circuits/single_syndrome.png differ diff --git a/datasets/noisy_circuits/syndrome_stats.png b/datasets/noisy_circuits/syndrome_stats.png new file mode 100644 index 0000000..40f9ef4 Binary files /dev/null and b/datasets/noisy_circuits/syndrome_stats.png differ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..59ff2bd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,53 @@ +[project] +name = "bpdecoderplus" +version = "0.1.0" +description = "Noisy circuit generation for BP decoding of surface codes" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MIT" } +authors = [ + { name = "BPDecoderPlus Contributors" } +] +keywords = ["quantum", "error-correction", "surface-code", "belief-propagation", "stim"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Scientific/Engineering :: Physics", +] + +dependencies = [ + "stim>=1.12.0", + "numpy>=1.24.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "pytest-cov>=4.0.0", +] + +[project.scripts] +bpdecoderplus = "bpdecoderplus.cli:main" +generate-noisy-circuits = "bpdecoderplus.cli:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/bpdecoderplus"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] + +[tool.uv] +dev-dependencies = [ + "pytest>=7.0.0", + "pytest-cov>=4.0.0", +] diff --git a/src/bpdecoderplus/__init__.py b/src/bpdecoderplus/__init__.py new file mode 100644 index 0000000..84bd41c --- /dev/null +++ b/src/bpdecoderplus/__init__.py @@ -0,0 +1,23 @@ +""" +BPDecoderPlus: Noisy circuit generation for BP decoding of surface codes. + +This package provides tools for generating noisy surface-code circuits +in Stim format for belief propagation (BP) decoding demonstrations. +""" + +from bpdecoderplus.circuit import ( + generate_circuit, + parse_rounds, + prob_tag, + run_smoke_test, + write_circuit, +) + +__version__ = "0.1.0" +__all__ = [ + "generate_circuit", + "parse_rounds", + "prob_tag", + "run_smoke_test", + "write_circuit", +] diff --git a/src/bpdecoderplus/circuit.py b/src/bpdecoderplus/circuit.py new file mode 100644 index 0000000..7db15c3 --- /dev/null +++ b/src/bpdecoderplus/circuit.py @@ -0,0 +1,137 @@ +""" +Circuit generation module for noisy surface-code circuits. + +This module provides functions to generate rotated surface-code memory +experiments with circuit-level depolarizing noise using Stim. +""" + +from __future__ import annotations + +import pathlib +from typing import Iterable + +import stim + + +def parse_rounds(values: Iterable[int]) -> list[int]: + """ + Parse and validate round counts. + + Args: + values: Iterable of round counts. + + Returns: + Sorted list of unique positive integers. + + Raises: + ValueError: If no positive integers are provided. + """ + unique = sorted({int(v) for v in values if int(v) > 0}) + if not unique: + raise ValueError("At least one positive integer round count is required.") + return unique + + +def prob_tag(p: float) -> str: + """ + Convert probability to filename-safe tag. + + Args: + p: Probability value (e.g., 0.01). + + Returns: + String tag (e.g., "p0010" for p=0.01). + """ + # Format as 3 decimal places without decimal point + # e.g., 0.01 -> "p0010", 0.001 -> "p0001" + return f"p{p:.3f}".replace(".", "") + + +def generate_circuit( + distance: int, + rounds: int, + p: float, + task: str = "z", +) -> stim.Circuit: + """ + Build a rotated memory surface-code circuit with depolarizing noise. + + Args: + distance: Surface code distance. + rounds: Number of measurement rounds. + p: Depolarizing error rate (must be in (0, 1)). + task: Memory experiment orientation, either "z" or "x". + + Returns: + Stim circuit with noise applied to: + - Clifford gates (after_clifford_depolarization) + - Data qubits between rounds (before_round_data_depolarization) + - Measurements (before_measure_flip_probability) + - Resets (after_reset_flip_probability) + + Raises: + ValueError: If task is not 'x' or 'z'. + ValueError: If p is not in (0, 1). + """ + if task not in {"x", "z"}: + raise ValueError("task must be 'x' or 'z'") + if not 0.0 < p < 1.0: + raise ValueError("p must be in (0, 1)") + + name = f"surface_code:rotated_memory_{task}" + return stim.Circuit.generated( + name, + distance=distance, + rounds=rounds, + after_clifford_depolarization=p, + before_round_data_depolarization=p, + before_measure_flip_probability=p, + after_reset_flip_probability=p, + ) + + +def run_smoke_test(circuit: stim.Circuit, shots: int = 4) -> None: + """ + Quick structural check: compile detector sampler and draw samples. + + Args: + circuit: Stim circuit to test. + shots: Number of samples to draw. + + Raises: + Exception: If the circuit is invalid or cannot be sampled. + """ + sampler = circuit.compile_detector_sampler() + sampler.sample(shots) + + +def write_circuit(circuit: stim.Circuit, path: pathlib.Path) -> None: + """ + Write a Stim circuit to a file. + + Args: + circuit: Stim circuit to write. + path: Output file path. + """ + path.write_text(str(circuit)) + + +def generate_filename( + distance: int, + rounds: int, + p: float, + task: str, +) -> str: + """ + Generate a standardized filename for a circuit. + + Args: + distance: Surface code distance. + rounds: Number of measurement rounds. + p: Error probability. + task: Memory experiment orientation. + + Returns: + Filename string (e.g., "sc_d3_r5_p0010_z.stim"). + """ + return f"sc_d{distance}_r{rounds}_{prob_tag(p)}_{task}.stim" diff --git a/src/bpdecoderplus/cli.py b/src/bpdecoderplus/cli.py new file mode 100644 index 0000000..180240e --- /dev/null +++ b/src/bpdecoderplus/cli.py @@ -0,0 +1,118 @@ +""" +Command-line interface for generating noisy surface-code circuits. +""" + +from __future__ import annotations + +import argparse +import pathlib +import sys + +from bpdecoderplus.circuit import ( + generate_circuit, + generate_filename, + parse_rounds, + run_smoke_test, + write_circuit, +) + + +def create_parser() -> argparse.ArgumentParser: + """Create and configure the argument parser.""" + parser = argparse.ArgumentParser( + description="Generate noisy surface-code Stim circuits (rotated memory).", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser.add_argument( + "-o", + "--output", + type=pathlib.Path, + default=pathlib.Path("datasets/noisy_circuits"), + help="Output directory for .stim circuits", + ) + parser.add_argument( + "-d", + "--distance", + type=int, + default=3, + help="Surface-code distance", + ) + parser.add_argument( + "-r", + "--rounds", + nargs="+", + type=int, + default=[3, 5, 7], + help="List of measurement rounds to generate", + ) + parser.add_argument( + "-p", + "--p", + type=float, + default=0.01, + help="Depolarizing error rate", + ) + parser.add_argument( + "--task", + choices=["x", "z"], + default="z", + help="Memory experiment orientation", + ) + parser.add_argument( + "--no-smoke-test", + action="store_true", + help="Skip compiling and sampling for quick validation", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + """ + Main entry point for the CLI. + + Args: + argv: Command-line arguments (defaults to sys.argv[1:]). + + Returns: + Exit code (0 for success, non-zero for failure). + """ + parser = create_parser() + args = parser.parse_args(argv) + + # Validate error rate + if not 0.0 < args.p < 1.0: + print(f"Error: p must be in (0, 1), got {args.p}", file=sys.stderr) + return 1 + + # Parse and validate rounds + try: + rounds_list = parse_rounds(args.rounds) + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + + # Create output directory + args.output.mkdir(parents=True, exist_ok=True) + + # Generate circuits + for r in rounds_list: + circuit = generate_circuit(args.distance, r, args.p, args.task) + + if not args.no_smoke_test: + try: + run_smoke_test(circuit) + except Exception as e: + print(f"Error: Smoke test failed for r={r}: {e}", file=sys.stderr) + return 1 + + filename = generate_filename(args.distance, r, args.p, args.task) + output_path = args.output / filename + write_circuit(circuit, output_path) + print(f"Wrote {output_path}") + + print("Done.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..2e36e72 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Tests for bpdecoderplus package diff --git a/tests/test_circuit.py b/tests/test_circuit.py new file mode 100644 index 0000000..e1dfce7 --- /dev/null +++ b/tests/test_circuit.py @@ -0,0 +1,175 @@ +"""Tests for circuit generation module.""" + +from __future__ import annotations + +import pathlib +import tempfile + +import pytest +import stim + +from bpdecoderplus.circuit import ( + generate_circuit, + generate_filename, + parse_rounds, + prob_tag, + run_smoke_test, + write_circuit, +) + + +class TestParseRounds: + """Tests for parse_rounds function.""" + + def test_basic_list(self): + """Test parsing a basic list of rounds.""" + result = parse_rounds([3, 5, 7]) + assert result == [3, 5, 7] + + def test_unsorted_input(self): + """Test that output is sorted.""" + result = parse_rounds([7, 3, 5]) + assert result == [3, 5, 7] + + def test_duplicates_removed(self): + """Test that duplicates are removed.""" + result = parse_rounds([3, 5, 3, 5, 7]) + assert result == [3, 5, 7] + + def test_negative_values_filtered(self): + """Test that non-positive values are filtered out.""" + result = parse_rounds([3, -1, 5, 0, 7]) + assert result == [3, 5, 7] + + def test_empty_after_filter_raises(self): + """Test that ValueError is raised when no valid rounds remain.""" + with pytest.raises(ValueError, match="At least one positive"): + parse_rounds([-1, 0]) + + def test_empty_input_raises(self): + """Test that ValueError is raised for empty input.""" + with pytest.raises(ValueError, match="At least one positive"): + parse_rounds([]) + + +class TestProbTag: + """Tests for prob_tag function.""" + + def test_p001(self): + """Test conversion of p=0.01.""" + assert prob_tag(0.01) == "p0010" + + def test_p0001(self): + """Test conversion of p=0.001.""" + assert prob_tag(0.001) == "p0001" + + def test_p005(self): + """Test conversion of p=0.05.""" + assert prob_tag(0.05) == "p0050" + + def test_p01(self): + """Test conversion of p=0.1.""" + assert prob_tag(0.1) == "p0100" + + def test_p0005(self): + """Test conversion of p=0.005.""" + assert prob_tag(0.005) == "p0005" + + +class TestGenerateCircuit: + """Tests for generate_circuit function.""" + + def test_basic_generation(self): + """Test basic circuit generation.""" + circuit = generate_circuit(distance=3, rounds=3, p=0.01, task="z") + assert isinstance(circuit, stim.Circuit) + + def test_circuit_has_detectors(self): + """Test that circuit contains detectors.""" + circuit = generate_circuit(distance=3, rounds=3, p=0.01, task="z") + dem = circuit.detector_error_model() + assert dem.num_detectors > 0 + + def test_circuit_has_observable(self): + """Test that circuit has a logical observable.""" + circuit = generate_circuit(distance=3, rounds=3, p=0.01, task="z") + dem = circuit.detector_error_model() + assert dem.num_observables == 1 + + def test_task_x(self): + """Test X-memory task generation.""" + circuit = generate_circuit(distance=3, rounds=3, p=0.01, task="x") + assert isinstance(circuit, stim.Circuit) + + def test_invalid_task_raises(self): + """Test that invalid task raises ValueError.""" + with pytest.raises(ValueError, match="task must be"): + generate_circuit(distance=3, rounds=3, p=0.01, task="y") + + def test_invalid_p_raises(self): + """Test that invalid p raises ValueError.""" + with pytest.raises(ValueError, match="p must be in"): + generate_circuit(distance=3, rounds=3, p=0.0, task="z") + with pytest.raises(ValueError, match="p must be in"): + generate_circuit(distance=3, rounds=3, p=1.0, task="z") + with pytest.raises(ValueError, match="p must be in"): + generate_circuit(distance=3, rounds=3, p=-0.1, task="z") + + def test_different_distances(self): + """Test circuit generation with different distances.""" + for d in [3, 5, 7]: + circuit = generate_circuit(distance=d, rounds=3, p=0.01, task="z") + assert isinstance(circuit, stim.Circuit) + + def test_different_rounds(self): + """Test circuit generation with different rounds.""" + for r in [1, 3, 5, 10]: + circuit = generate_circuit(distance=3, rounds=r, p=0.01, task="z") + assert isinstance(circuit, stim.Circuit) + + +class TestRunSmokeTest: + """Tests for run_smoke_test function.""" + + def test_valid_circuit_passes(self): + """Test that valid circuit passes smoke test.""" + circuit = generate_circuit(distance=3, rounds=3, p=0.01, task="z") + # Should not raise + run_smoke_test(circuit, shots=2) + + def test_custom_shots(self): + """Test smoke test with custom shot count.""" + circuit = generate_circuit(distance=3, rounds=3, p=0.01, task="z") + # Should not raise + run_smoke_test(circuit, shots=10) + + +class TestWriteCircuit: + """Tests for write_circuit function.""" + + def test_write_and_read(self): + """Test writing circuit and reading it back.""" + circuit = generate_circuit(distance=3, rounds=3, p=0.01, task="z") + + with tempfile.TemporaryDirectory() as tmpdir: + path = pathlib.Path(tmpdir) / "test.stim" + write_circuit(circuit, path) + + # Read back and verify + assert path.exists() + loaded = stim.Circuit.from_file(str(path)) + assert str(circuit) == str(loaded) + + +class TestGenerateFilename: + """Tests for generate_filename function.""" + + def test_basic_filename(self): + """Test basic filename generation.""" + filename = generate_filename(distance=3, rounds=5, p=0.01, task="z") + assert filename == "sc_d3_r5_p0010_z.stim" + + def test_x_task(self): + """Test filename with x task.""" + filename = generate_filename(distance=5, rounds=7, p=0.001, task="x") + assert filename == "sc_d5_r7_p0001_x.stim" diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..fa13a45 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,115 @@ +"""Tests for CLI module.""" + +from __future__ import annotations + +import pathlib +import tempfile + +import pytest + +from bpdecoderplus.cli import create_parser, main + + +class TestCreateParser: + """Tests for create_parser function.""" + + def test_parser_defaults(self): + """Test parser has correct defaults.""" + parser = create_parser() + args = parser.parse_args([]) + + assert args.output == pathlib.Path("datasets/noisy_circuits") + assert args.distance == 3 + assert args.rounds == [3, 5, 7] + assert args.p == 0.01 + assert args.task == "z" + assert args.no_smoke_test is False + + def test_custom_arguments(self): + """Test parser with custom arguments.""" + parser = create_parser() + args = parser.parse_args([ + "-d", "5", + "-r", "1", "3", + "-p", "0.02", + "--task", "x", + "--no-smoke-test", + ]) + + assert args.distance == 5 + assert args.rounds == [1, 3] + assert args.p == 0.02 + assert args.task == "x" + assert args.no_smoke_test is True + + +class TestMain: + """Tests for main function.""" + + def test_basic_generation(self): + """Test basic circuit generation via CLI.""" + with tempfile.TemporaryDirectory() as tmpdir: + result = main([ + "-o", tmpdir, + "-d", "3", + "-r", "3", + "-p", "0.01", + "--no-smoke-test", + ]) + assert result == 0 + + # Check output file exists + output_file = pathlib.Path(tmpdir) / "sc_d3_r3_p0010_z.stim" + assert output_file.exists() + + def test_multiple_rounds(self): + """Test generation of multiple rounds.""" + with tempfile.TemporaryDirectory() as tmpdir: + result = main([ + "-o", tmpdir, + "-r", "3", "5", "7", + "--no-smoke-test", + ]) + assert result == 0 + + # Check all output files exist + for r in [3, 5, 7]: + output_file = pathlib.Path(tmpdir) / f"sc_d3_r{r}_p0010_z.stim" + assert output_file.exists() + + def test_invalid_p_returns_error(self): + """Test that invalid p returns error code.""" + with tempfile.TemporaryDirectory() as tmpdir: + result = main(["-o", tmpdir, "-p", "1.5"]) + assert result == 1 + + def test_invalid_rounds_returns_error(self): + """Test that invalid rounds returns error code.""" + with tempfile.TemporaryDirectory() as tmpdir: + result = main(["-o", tmpdir, "-r", "0", "-1"]) + assert result == 1 + + def test_x_task(self): + """Test X-memory task generation.""" + with tempfile.TemporaryDirectory() as tmpdir: + result = main([ + "-o", tmpdir, + "-r", "3", + "--task", "x", + "--no-smoke-test", + ]) + assert result == 0 + + output_file = pathlib.Path(tmpdir) / "sc_d3_r3_p0010_x.stim" + assert output_file.exists() + + def test_with_smoke_test(self): + """Test generation with smoke test enabled.""" + with tempfile.TemporaryDirectory() as tmpdir: + result = main([ + "-o", tmpdir, + "-d", "3", + "-r", "3", + "-p", "0.01", + ]) + assert result == 0