From 293d56d12c938d778a48960adfd990521df7ea4c Mon Sep 17 00:00:00 2001 From: vsoch Date: Sun, 14 Jun 2026 14:51:58 -0700 Subject: [PATCH] ci: add docker bulids Signed-off-by: vsoch --- .github/workflows/docker.yaml | 49 ++++++ .gitignore | 20 +++ docker/braket-gateway/Dockerfile | 17 +++ docker/braket-gateway/gateway.py | 179 ++++++++++++++++++++++ docker/optimizer/Dockerfile | 22 +++ docker/optimizer/gateway.py | 179 ++++++++++++++++++++++ docker/optimizer/optimize.py | 217 +++++++++++++++++++++++++++ docker/problem-generator/Dockerfile | 14 ++ docker/problem-generator/generate.py | 100 ++++++++++++ docker/transpiler/Dockerfile | 12 ++ docker/transpiler/transpile.py | 138 +++++++++++++++++ 11 files changed, 947 insertions(+) create mode 100644 .github/workflows/docker.yaml create mode 100644 .gitignore create mode 100644 docker/braket-gateway/Dockerfile create mode 100644 docker/braket-gateway/gateway.py create mode 100644 docker/optimizer/Dockerfile create mode 100644 docker/optimizer/gateway.py create mode 100644 docker/optimizer/optimize.py create mode 100644 docker/problem-generator/Dockerfile create mode 100644 docker/problem-generator/generate.py create mode 100644 docker/transpiler/Dockerfile create mode 100644 docker/transpiler/transpile.py diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml new file mode 100644 index 0000000..2ce88c6 --- /dev/null +++ b/.github/workflows/docker.yaml @@ -0,0 +1,49 @@ +name: Build and push images + +on: + push: + branches: [main] + paths: + - "docker/**" + - ".github/workflows/docker.yaml" + pull_request: + paths: + - "docker/**" + +env: + REGISTRY: ghcr.io + ORG: converged-computing + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + strategy: + matrix: + component: + - problem-generator + - transpiler + - braket-gateway + - optimizer + + steps: + - uses: actions/checkout@v4 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push ${{ matrix.component }} + uses: docker/build-push-action@v5 + with: + context: docker/${{ matrix.component }} + push: ${{ github.event_name != 'pull_request' }} + tags: | + ${{ env.REGISTRY }}/${{ env.ORG }}/quantum-braket-${{ matrix.component }}:latest + ${{ env.REGISTRY }}/${{ env.ORG }}/quantum-braket-${{ matrix.component }}:${{ github.sha }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..46bf6f2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +__pycache__/ +*.pyc +*.pyo +.env +.venv/ +venv/ + +# experiment result outputs (committed templates only) +experiments/*/results/*.json +results.json +history.json +cost.json + +# editor +.vscode/ +.idea/ +*.swp + +# docker build cache +.docker/ diff --git a/docker/braket-gateway/Dockerfile b/docker/braket-gateway/Dockerfile new file mode 100644 index 0000000..7b94151 --- /dev/null +++ b/docker/braket-gateway/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.11-slim + +LABEL org.opencontainers.image.source="https://github.com/converged-computing/quantum-braket" +LABEL org.opencontainers.image.description="Braket gateway — submits QAOA circuits to AWS Braket SV1" + +WORKDIR /app + +RUN pip install --no-cache-dir amazon-braket-sdk==1.88.0 + +COPY gateway.py . + +ENV BRAKET_DEVICE=arn:aws:braket:::device/quantum-simulator/amazon/sv1 +ENV N_SHOTS=1000 +ENV WORKSPACE=/workspace +ENV ITERATION=0 + +CMD ["python", "gateway.py"] diff --git a/docker/braket-gateway/gateway.py b/docker/braket-gateway/gateway.py new file mode 100644 index 0000000..f486459 --- /dev/null +++ b/docker/braket-gateway/gateway.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +""" +braket-gateway: Submit QAOA circuits to AWS Braket and return cost values. + +This pod is the only one that touches AWS. It reads the current variational +parameters, rebuilds the circuit for those parameters, submits it to the +SV1 state vector simulator, and appends the measured cost to a results file. + +It is designed to be called repeatedly by the optimizer (once per iteration). +On each call it: + 1. Reads /workspace/params.json (written by transpiler or optimizer) + 2. Reads /workspace/problem.json + 3. Builds the circuit for the current (gamma, beta) + 4. Submits to Braket SV1 (synchronously, waits for result) + 5. Computes the QAOA cost from the measurement counts + 6. Appends {"iteration": i, "cost": c, "params": {...}} to /workspace/history.json + 7. Writes /workspace/cost.json {"cost": float} (read by optimizer) + +Environment variables: + BRAKET_DEVICE Braket device ARN (default: SV1) + N_SHOTS Number of measurement shots (default: 1000) + WORKSPACE Shared volume path (default: /workspace) + ITERATION Current optimizer iteration number (default: 0) +""" + +import json +import os +import sys +import time + +# SV1 device ARN — deterministic, no queue wait +SV1_ARN = "arn:aws:braket:::device/quantum-simulator/amazon/sv1" + + +def compute_maxcut_cost(counts, edges, n_qubits): + """ + Compute the expected max-cut value from measurement counts. + + For each bitstring, count the number of edges (u,v) where z_u != z_v + (i.e., the edge is cut). Return the weighted average over all shots. + + counts: dict mapping bitstring -> number of shots (Braket result format) + edges: list of [u, v, weight] + """ + total_shots = sum(counts.values()) + total_cost = 0.0 + + for bitstring, shots in counts.items(): + # Braket returns bitstrings as e.g. "0110" (leftmost = qubit 0) + if len(bitstring) != n_qubits: + # Some backends zero-pad; handle gracefully + bitstring = bitstring.zfill(n_qubits) + bits = [int(b) for b in bitstring] + cut_value = sum( + w for u, v, w in edges if bits[u] != bits[v] + ) + total_cost += shots * cut_value + + return total_cost / total_shots + + +def build_circuit_for_params(n_qubits, edges, gammas, betas): + """ + Build a Braket Circuit object for the current (gammas, betas) parameters. + Supports p >= 1 layers. + """ + from braket.circuits import Circuit + + circ = Circuit() + + # Initial superposition + for i in range(n_qubits): + circ.h(i) + + # QAOA layers + for layer in range(len(gammas)): + gamma = gammas[layer] + beta = betas[layer] + + # Cost unitary: CNOT - RZ(gamma) - CNOT for each edge + for u, v, _ in edges: + circ.cnot(u, v) + circ.rz(v, gamma) + circ.cnot(u, v) + + # Mixer unitary: RX(2*beta) on each qubit + for i in range(n_qubits): + circ.rx(i, 2 * beta) + + return circ + + +def main(): + workspace = os.environ.get("WORKSPACE", "/workspace") + device_arn = os.environ.get("BRAKET_DEVICE", SV1_ARN) + n_shots = int(os.environ.get("N_SHOTS", 1000)) + iteration = int(os.environ.get("ITERATION", 0)) + + # --- load problem --- + problem_path = os.path.join(workspace, "problem.json") + if not os.path.exists(problem_path): + print(f"[braket-gateway] ERROR: {problem_path} not found", file=sys.stderr) + sys.exit(1) + with open(problem_path) as f: + problem = json.load(f) + n_qubits = problem["n_qubits"] + edges = problem["edges"] + + # --- load current params --- + params_path = os.path.join(workspace, "params.json") + if not os.path.exists(params_path): + print(f"[braket-gateway] ERROR: {params_path} not found", file=sys.stderr) + sys.exit(1) + with open(params_path) as f: + params = json.load(f) + gammas = params["gammas"] + betas = params["betas"] + + print(f"[braket-gateway] iteration={iteration}, device={device_arn}, shots={n_shots}") + print(f"[braket-gateway] gammas={[round(g,4) for g in gammas]}, " + f"betas={[round(b,4) for b in betas]}") + + # --- build circuit --- + try: + from braket.aws import AwsDevice + circ = build_circuit_for_params(n_qubits, edges, gammas, betas) + except ImportError: + print("[braket-gateway] ERROR: amazon-braket-sdk not installed", file=sys.stderr) + sys.exit(1) + + # --- submit to Braket --- + t0 = time.time() + try: + device = AwsDevice(device_arn) + task = device.run(circ, shots=n_shots) + result = task.result() + except Exception as e: + print(f"[braket-gateway] ERROR submitting to Braket: {e}", file=sys.stderr) + sys.exit(1) + elapsed = time.time() - t0 + + # --- compute cost --- + counts = result.measurement_counts + cost = compute_maxcut_cost(counts, edges, n_qubits) + + print(f"[braket-gateway] Cost={cost:.6f} (elapsed {elapsed:.2f}s)") + + # --- write cost.json (read by optimizer) --- + cost_path = os.path.join(workspace, "cost.json") + with open(cost_path, "w") as f: + json.dump({"cost": cost, "iteration": iteration, "elapsed_s": elapsed}, f, indent=2) + + # --- append to history.json --- + history_path = os.path.join(workspace, "history.json") + history = [] + if os.path.exists(history_path): + with open(history_path) as f: + try: + history = json.load(f) + except json.JSONDecodeError: + history = [] + history.append({ + "iteration": iteration, + "cost": cost, + "gammas": gammas, + "betas": betas, + "elapsed_s": elapsed, + "n_shots": n_shots, + "device": device_arn, + }) + with open(history_path, "w") as f: + json.dump(history, f, indent=2) + + print(f"[braket-gateway] Wrote cost -> {cost_path}") + print(f"[braket-gateway] Appended -> {history_path} ({len(history)} entries)") + + +if __name__ == "__main__": + main() diff --git a/docker/optimizer/Dockerfile b/docker/optimizer/Dockerfile new file mode 100644 index 0000000..1c073db --- /dev/null +++ b/docker/optimizer/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.11-slim + +LABEL org.opencontainers.image.source="https://github.com/converged-computing/quantum-braket" +LABEL org.opencontainers.image.description="QAOA optimizer — COBYLA variational loop over Braket circuit evaluations" + +WORKDIR /app + +RUN pip install --no-cache-dir \ + scipy==1.13.0 \ + numpy==1.26.4 \ + amazon-braket-sdk==1.88.0 + +COPY optimize.py . +COPY gateway.py . + +ENV MAX_ITER=50 +ENV TOL=1e-4 +ENV GATEWAY_MODE=subprocess +ENV WORKSPACE=/workspace +ENV N_SHOTS=1000 + +CMD ["python", "optimize.py"] diff --git a/docker/optimizer/gateway.py b/docker/optimizer/gateway.py new file mode 100644 index 0000000..f486459 --- /dev/null +++ b/docker/optimizer/gateway.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +""" +braket-gateway: Submit QAOA circuits to AWS Braket and return cost values. + +This pod is the only one that touches AWS. It reads the current variational +parameters, rebuilds the circuit for those parameters, submits it to the +SV1 state vector simulator, and appends the measured cost to a results file. + +It is designed to be called repeatedly by the optimizer (once per iteration). +On each call it: + 1. Reads /workspace/params.json (written by transpiler or optimizer) + 2. Reads /workspace/problem.json + 3. Builds the circuit for the current (gamma, beta) + 4. Submits to Braket SV1 (synchronously, waits for result) + 5. Computes the QAOA cost from the measurement counts + 6. Appends {"iteration": i, "cost": c, "params": {...}} to /workspace/history.json + 7. Writes /workspace/cost.json {"cost": float} (read by optimizer) + +Environment variables: + BRAKET_DEVICE Braket device ARN (default: SV1) + N_SHOTS Number of measurement shots (default: 1000) + WORKSPACE Shared volume path (default: /workspace) + ITERATION Current optimizer iteration number (default: 0) +""" + +import json +import os +import sys +import time + +# SV1 device ARN — deterministic, no queue wait +SV1_ARN = "arn:aws:braket:::device/quantum-simulator/amazon/sv1" + + +def compute_maxcut_cost(counts, edges, n_qubits): + """ + Compute the expected max-cut value from measurement counts. + + For each bitstring, count the number of edges (u,v) where z_u != z_v + (i.e., the edge is cut). Return the weighted average over all shots. + + counts: dict mapping bitstring -> number of shots (Braket result format) + edges: list of [u, v, weight] + """ + total_shots = sum(counts.values()) + total_cost = 0.0 + + for bitstring, shots in counts.items(): + # Braket returns bitstrings as e.g. "0110" (leftmost = qubit 0) + if len(bitstring) != n_qubits: + # Some backends zero-pad; handle gracefully + bitstring = bitstring.zfill(n_qubits) + bits = [int(b) for b in bitstring] + cut_value = sum( + w for u, v, w in edges if bits[u] != bits[v] + ) + total_cost += shots * cut_value + + return total_cost / total_shots + + +def build_circuit_for_params(n_qubits, edges, gammas, betas): + """ + Build a Braket Circuit object for the current (gammas, betas) parameters. + Supports p >= 1 layers. + """ + from braket.circuits import Circuit + + circ = Circuit() + + # Initial superposition + for i in range(n_qubits): + circ.h(i) + + # QAOA layers + for layer in range(len(gammas)): + gamma = gammas[layer] + beta = betas[layer] + + # Cost unitary: CNOT - RZ(gamma) - CNOT for each edge + for u, v, _ in edges: + circ.cnot(u, v) + circ.rz(v, gamma) + circ.cnot(u, v) + + # Mixer unitary: RX(2*beta) on each qubit + for i in range(n_qubits): + circ.rx(i, 2 * beta) + + return circ + + +def main(): + workspace = os.environ.get("WORKSPACE", "/workspace") + device_arn = os.environ.get("BRAKET_DEVICE", SV1_ARN) + n_shots = int(os.environ.get("N_SHOTS", 1000)) + iteration = int(os.environ.get("ITERATION", 0)) + + # --- load problem --- + problem_path = os.path.join(workspace, "problem.json") + if not os.path.exists(problem_path): + print(f"[braket-gateway] ERROR: {problem_path} not found", file=sys.stderr) + sys.exit(1) + with open(problem_path) as f: + problem = json.load(f) + n_qubits = problem["n_qubits"] + edges = problem["edges"] + + # --- load current params --- + params_path = os.path.join(workspace, "params.json") + if not os.path.exists(params_path): + print(f"[braket-gateway] ERROR: {params_path} not found", file=sys.stderr) + sys.exit(1) + with open(params_path) as f: + params = json.load(f) + gammas = params["gammas"] + betas = params["betas"] + + print(f"[braket-gateway] iteration={iteration}, device={device_arn}, shots={n_shots}") + print(f"[braket-gateway] gammas={[round(g,4) for g in gammas]}, " + f"betas={[round(b,4) for b in betas]}") + + # --- build circuit --- + try: + from braket.aws import AwsDevice + circ = build_circuit_for_params(n_qubits, edges, gammas, betas) + except ImportError: + print("[braket-gateway] ERROR: amazon-braket-sdk not installed", file=sys.stderr) + sys.exit(1) + + # --- submit to Braket --- + t0 = time.time() + try: + device = AwsDevice(device_arn) + task = device.run(circ, shots=n_shots) + result = task.result() + except Exception as e: + print(f"[braket-gateway] ERROR submitting to Braket: {e}", file=sys.stderr) + sys.exit(1) + elapsed = time.time() - t0 + + # --- compute cost --- + counts = result.measurement_counts + cost = compute_maxcut_cost(counts, edges, n_qubits) + + print(f"[braket-gateway] Cost={cost:.6f} (elapsed {elapsed:.2f}s)") + + # --- write cost.json (read by optimizer) --- + cost_path = os.path.join(workspace, "cost.json") + with open(cost_path, "w") as f: + json.dump({"cost": cost, "iteration": iteration, "elapsed_s": elapsed}, f, indent=2) + + # --- append to history.json --- + history_path = os.path.join(workspace, "history.json") + history = [] + if os.path.exists(history_path): + with open(history_path) as f: + try: + history = json.load(f) + except json.JSONDecodeError: + history = [] + history.append({ + "iteration": iteration, + "cost": cost, + "gammas": gammas, + "betas": betas, + "elapsed_s": elapsed, + "n_shots": n_shots, + "device": device_arn, + }) + with open(history_path, "w") as f: + json.dump(history, f, indent=2) + + print(f"[braket-gateway] Wrote cost -> {cost_path}") + print(f"[braket-gateway] Appended -> {history_path} ({len(history)} entries)") + + +if __name__ == "__main__": + main() diff --git a/docker/optimizer/optimize.py b/docker/optimizer/optimize.py new file mode 100644 index 0000000..daa6e8b --- /dev/null +++ b/docker/optimizer/optimize.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +""" +optimizer: COBYLA optimizer loop for QAOA variational parameters. + +This pod orchestrates the full optimization loop. On each iteration it: + 1. Reads the current cost from /workspace/cost.json + 2. Updates (gamma, beta) using COBYLA + 3. Writes the new params to /workspace/params.json + 4. Signals the braket-gateway pod to run (in a Kubernetes workflow this + is done by spawning a new Job; in local/script mode the gateway is + called as a subprocess) + 5. Repeats until convergence or MAX_ITER is reached + 6. Writes final results to /workspace/results.json + +In Kubernetes the optimizer is the "driver" pod. It runs the COBYLA loop +internally and calls the Braket gateway via a subprocess exec OR waits for +a gateway Job to complete (configurable via GATEWAY_MODE env var). + +Environment variables: + MAX_ITER Maximum COBYLA iterations (default: 50) + TOL Convergence tolerance on cost improvement (default: 1e-4) + GATEWAY_MODE "subprocess" (local) or "job" (Kubernetes Job) (default: subprocess) + WORKSPACE Shared volume path (default: /workspace) + N_SHOTS Passed through to gateway (default: 1000) +""" + +import json +import os +import subprocess +import sys +import time + + +def read_cost(workspace): + cost_path = os.path.join(workspace, "cost.json") + with open(cost_path) as f: + return json.load(f)["cost"] + + +def write_params(workspace, gammas, betas, p): + params_path = os.path.join(workspace, "params.json") + with open(params_path, "w") as f: + json.dump({"gammas": list(gammas), "betas": list(betas), "p": p}, f, indent=2) + + +def call_gateway_subprocess(workspace, iteration, n_shots): + """Call the braket-gateway entrypoint directly as a subprocess.""" + env = os.environ.copy() + env["WORKSPACE"] = workspace + env["ITERATION"] = str(iteration) + env["N_SHOTS"] = str(n_shots) + result = subprocess.run( + [sys.executable, "/app/gateway.py"], + env=env, + capture_output=False, + ) + if result.returncode != 0: + raise RuntimeError(f"gateway exited with code {result.returncode}") + + +def cobyla_optimize(workspace, p, max_iter, tol, n_shots, gateway_mode): + """ + Run COBYLA optimization over (gammas, betas). + + SciPy's COBYLA minimizes, so we minimize -cost (maximizing cut value). + """ + from scipy.optimize import minimize + import numpy as np + + # Load initial params written by the transpiler + params_path = os.path.join(workspace, "params.json") + with open(params_path) as f: + init_params = json.load(f) + gammas0 = init_params["gammas"] + betas0 = init_params["betas"] + + x0 = np.array(gammas0 + betas0, dtype=float) + iteration_counter = [0] + cost_history = [] + + def objective(x): + it = iteration_counter[0] + gammas = list(x[:p]) + betas = list(x[p:]) + + write_params(workspace, gammas, betas, p) + + if gateway_mode == "subprocess": + call_gateway_subprocess(workspace, it, n_shots) + else: + raise NotImplementedError( + "gateway_mode='job' requires external Kubernetes Job orchestration. " + "Use gateway_mode='subprocess' for local/single-pod runs." + ) + + cost = read_cost(workspace) + cost_history.append({"iteration": it, "cost": cost, "gammas": gammas, "betas": betas}) + print(f"[optimizer] iter={it:3d} cost={cost:.6f} " + f"gamma0={gammas[0]:.4f} beta0={betas[0]:.4f}") + + iteration_counter[0] += 1 + return -cost # COBYLA minimizes; we maximize cut + + result = minimize( + objective, + x0, + method="COBYLA", + options={"maxiter": max_iter, "rhobeg": 0.5, "catol": tol}, + ) + + best_x = result.x + best_gammas = list(best_x[:p]) + best_betas = list(best_x[p:]) + best_cost = -result.fun + + return { + "best_cost": best_cost, + "best_gammas": best_gammas, + "best_betas": best_betas, + "n_iterations": iteration_counter[0], + "converged": result.success, + "scipy_message": result.message, + "cost_history": cost_history, + } + + +def compute_approximation_ratio(best_cost, problem): + """ + Compute the QAOA approximation ratio = best_cost / max_possible_cut. + For unit-weight graphs, max cut <= |E|. + (Exact max-cut is NP-hard in general; we use |E| as the upper bound.) + """ + n_edges = len(problem["edges"]) + return best_cost / n_edges if n_edges > 0 else 0.0 + + +def main(): + max_iter = int(os.environ.get("MAX_ITER", 50)) + tol = float(os.environ.get("TOL", 1e-4)) + gateway_mode = os.environ.get("GATEWAY_MODE", "subprocess") + workspace = os.environ.get("WORKSPACE", "/workspace") + n_shots = int(os.environ.get("N_SHOTS", 1000)) + + # Load problem to get p and n_qubits + problem_path = os.path.join(workspace, "problem.json") + if not os.path.exists(problem_path): + print(f"[optimizer] ERROR: {problem_path} not found", file=sys.stderr) + sys.exit(1) + with open(problem_path) as f: + problem = json.load(f) + + params_path = os.path.join(workspace, "params.json") + if not os.path.exists(params_path): + print(f"[optimizer] ERROR: {params_path} not found. " + "Has the transpiler pod completed?", file=sys.stderr) + sys.exit(1) + with open(params_path) as f: + p = json.load(f).get("p", 1) + + print(f"[optimizer] Starting COBYLA: max_iter={max_iter}, tol={tol}, " + f"p={p}, n_shots={n_shots}, mode={gateway_mode}") + + # Run the initial circuit evaluation (iteration 0) before the loop + # so that cost.json exists when COBYLA calls objective() the first time + write_params(workspace, json.load(open(params_path))["gammas"], + json.load(open(params_path))["betas"], p) + if gateway_mode == "subprocess": + call_gateway_subprocess(workspace, 0, n_shots) + + t0 = time.time() + opt = cobyla_optimize(workspace, p, max_iter, tol, n_shots, gateway_mode) + elapsed = time.time() - t0 + + approx_ratio = compute_approximation_ratio(opt["best_cost"], problem) + + results = { + "problem": { + "n_nodes": problem["n_nodes"], + "n_edges": len(problem["edges"]), + "n_qubits": problem["n_qubits"], + "k": problem["k"], + "seed": problem["seed"], + }, + "qaoa": { + "p": p, + "n_shots": n_shots, + "device": os.environ.get("BRAKET_DEVICE", + "arn:aws:braket:::device/quantum-simulator/amazon/sv1"), + }, + "optimization": { + "best_cost": opt["best_cost"], + "best_gammas": opt["best_gammas"], + "best_betas": opt["best_betas"], + "n_iterations": opt["n_iterations"], + "converged": opt["converged"], + "scipy_message": opt["scipy_message"], + "approximation_ratio": approx_ratio, + "total_elapsed_s": elapsed, + }, + "cost_history": opt["cost_history"], + } + + results_path = os.path.join(workspace, "results.json") + with open(results_path, "w") as f: + json.dump(results, f, indent=2) + + print(f"\n[optimizer] ===== Optimization complete =====") + print(f" best cost : {opt['best_cost']:.6f}") + print(f" approximation ratio: {approx_ratio:.4f}") + print(f" iterations : {opt['n_iterations']}") + print(f" converged : {opt['converged']}") + print(f" total elapsed : {elapsed:.2f}s") + print(f" results written to : {results_path}") + + +if __name__ == "__main__": + main() diff --git a/docker/problem-generator/Dockerfile b/docker/problem-generator/Dockerfile new file mode 100644 index 0000000..5cdbea9 --- /dev/null +++ b/docker/problem-generator/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.11-slim + +LABEL org.opencontainers.image.source="https://github.com/converged-computing/quantum-braket" +LABEL org.opencontainers.image.description="QAOA problem generator" + +WORKDIR /app +COPY generate.py . + +ENV N_NODES=10 +ENV K_REGULAR=3 +ENV SEED=42 +ENV WORKSPACE=/workspace + +CMD ["python", "generate.py"] diff --git a/docker/problem-generator/generate.py b/docker/problem-generator/generate.py new file mode 100644 index 0000000..f24ea6a --- /dev/null +++ b/docker/problem-generator/generate.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +""" +problem-generator: Generate a random k-regular graph max-cut instance. + +Writes to /workspace/problem.json: + { + "n_nodes": int, + "edges": [[u, v, weight], ...], + "seed": int, + "k": int, + "n_qubits": int # == n_nodes for max-cut QAOA + } + +Environment variables: + N_NODES Number of graph nodes (default: 10) + K_REGULAR Degree of regularity (default: 3) + SEED Random seed for reproducibility (default: 42) + WORKSPACE Output directory (default: /workspace) +""" + +import json +import os +import random +import sys + +def generate_k_regular_graph(n, k, seed): + """ + Generate a random k-regular graph on n nodes using a pairing model. + Returns a list of (u, v, weight) edges with unit weights. + Raises ValueError if a valid k-regular graph cannot be formed. + """ + if n * k % 2 != 0: + raise ValueError(f"n*k must be even for a k-regular graph (got n={n}, k={k})") + if k >= n: + raise ValueError(f"k must be less than n (got k={k}, n={n})") + + rng = random.Random(seed) + + # Configuration model: create k stubs per node, pair them randomly + for attempt in range(100): + stubs = [] + for node in range(n): + stubs.extend([node] * k) + rng.shuffle(stubs) + + edges = set() + valid = True + for i in range(0, len(stubs), 2): + u, v = stubs[i], stubs[i + 1] + if u == v or (min(u, v), max(u, v)) in edges: + valid = False + break + edges.add((min(u, v), max(u, v))) + + if valid: + return [(u, v, 1.0) for u, v in sorted(edges)] + + raise RuntimeError( + f"Could not generate a valid {k}-regular graph on {n} nodes after 100 attempts. " + "Try a different seed or smaller k." + ) + + +def main(): + n_nodes = int(os.environ.get("N_NODES", 10)) + k = int(os.environ.get("K_REGULAR", 3)) + seed = int(os.environ.get("SEED", 42)) + workspace = os.environ.get("WORKSPACE", "/workspace") + + print(f"[problem-generator] n_nodes={n_nodes}, k={k}, seed={seed}") + + try: + edges = generate_k_regular_graph(n_nodes, k, seed) + except (ValueError, RuntimeError) as e: + print(f"[problem-generator] ERROR: {e}", file=sys.stderr) + sys.exit(1) + + problem = { + "n_nodes": n_nodes, + "edges": edges, + "seed": seed, + "k": k, + "n_qubits": n_nodes, # one qubit per node for max-cut QAOA + } + + os.makedirs(workspace, exist_ok=True) + out_path = os.path.join(workspace, "problem.json") + with open(out_path, "w") as f: + json.dump(problem, f, indent=2) + + print(f"[problem-generator] Wrote {len(edges)} edges to {out_path}") + print(f"[problem-generator] Problem instance:") + print(f" nodes : {n_nodes}") + print(f" edges : {len(edges)}") + print(f" qubits: {problem['n_qubits']}") + print(f" seed : {seed}") + + +if __name__ == "__main__": + main() diff --git a/docker/transpiler/Dockerfile b/docker/transpiler/Dockerfile new file mode 100644 index 0000000..08b1a1c --- /dev/null +++ b/docker/transpiler/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.11-slim + +LABEL org.opencontainers.image.source="https://github.com/converged-computing/quantum-braket" +LABEL org.opencontainers.image.description="QAOA transpiler" + +WORKDIR /app +COPY transpile.py . + +ENV P_LAYERS=1 +ENV WORKSPACE=/workspace + +CMD ["python", "transpile.py"] diff --git a/docker/transpiler/transpile.py b/docker/transpiler/transpile.py new file mode 100644 index 0000000..6db2ec4 --- /dev/null +++ b/docker/transpiler/transpile.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +""" +transpiler: Build the QAOA ansatz circuit for a max-cut problem instance. + +Reads /workspace/problem.json +Writes /workspace/circuit.json (Braket IR / OpenQASM 3 serialized circuit) + /workspace/params.json (initial variational parameters) + +The QAOA circuit for max-cut has two layers per repetition (p): + - A cost unitary C(gamma): RZZ gates on each edge + - A mixer unitary B(beta): RX gates on each qubit + +We use p=1 by default (one QAOA layer), which is sufficient for small +benchmarking instances and keeps circuit depth low for SV1. + +Environment variables: + P_LAYERS Number of QAOA repetitions p (default: 1) + WORKSPACE Shared volume path (default: /workspace) +""" + +import json +import math +import os +import sys + + +def build_qaoa_openqasm(n_qubits, edges, gamma, beta): + """ + Return an OpenQASM 3 string for a single-layer (p=1) QAOA max-cut circuit. + + Cost unitary: exp(-i * gamma/2 * (1 - Z_u Z_v)) for each edge (u,v) + Implemented as CNOT - RZ(gamma) - CNOT + Mixer unitary: RX(2*beta) on each qubit + Measurement: all qubits + """ + lines = [ + "OPENQASM 3.0;", + 'include "stdgates.inc";', + "", + f"qubit[{n_qubits}] q;", + f"bit[{n_qubits}] c;", + "", + "// Initial superposition", + ] + for i in range(n_qubits): + lines.append(f"h q[{i}];") + + lines.append("") + lines.append("// Cost unitary (gamma layer)") + for u, v, _ in edges: + lines.append(f"cnot q[{u}], q[{v}];") + rz_angle = round(gamma, 10) + lines.append(f"rz({rz_angle}) q[{v}];") + lines.append(f"cnot q[{u}], q[{v}];") + + lines.append("") + lines.append("// Mixer unitary (beta layer)") + rx_angle = round(2 * beta, 10) + for i in range(n_qubits): + lines.append(f"rx({rx_angle}) q[{i}];") + + lines.append("") + lines.append("// Measurement") + for i in range(n_qubits): + lines.append(f"c[{i}] = measure q[{i}];") + + return "\n".join(lines) + + +def initial_params(p, seed=42): + """ + Return deterministic initial (gamma, beta) parameters for p QAOA layers. + Uses evenly spaced values in (0, pi) to avoid symmetry traps. + """ + import random + rng = random.Random(seed) + gammas = [rng.uniform(0.1, math.pi - 0.1) for _ in range(p)] + betas = [rng.uniform(0.1, math.pi / 2 - 0.1) for _ in range(p)] + return {"gammas": gammas, "betas": betas, "p": p} + + +def main(): + p = int(os.environ.get("P_LAYERS", 1)) + workspace = os.environ.get("WORKSPACE", "/workspace") + + problem_path = os.path.join(workspace, "problem.json") + if not os.path.exists(problem_path): + print(f"[transpiler] ERROR: {problem_path} not found. " + "Has the problem-generator pod completed?", file=sys.stderr) + sys.exit(1) + + with open(problem_path) as f: + problem = json.load(f) + + n_qubits = problem["n_qubits"] + edges = problem["edges"] + seed = problem.get("seed", 42) + + print(f"[transpiler] Building QAOA circuit: n_qubits={n_qubits}, edges={len(edges)}, p={p}") + + params = initial_params(p, seed=seed) + gamma0 = params["gammas"][0] + beta0 = params["betas"][0] + + # Build the OpenQASM 3 circuit for the initial parameters + qasm = build_qaoa_openqasm(n_qubits, edges, gamma0, beta0) + + circuit_doc = { + "format": "openqasm3", + "n_qubits": n_qubits, + "p": p, + "source": qasm, + "n_gates": len(edges) * 3 + n_qubits, # 3 gates per edge + RX per qubit + "depth_estimate": p * (len(edges) + 1), + } + + circuit_path = os.path.join(workspace, "circuit.json") + params_path = os.path.join(workspace, "params.json") + + with open(circuit_path, "w") as f: + json.dump(circuit_doc, f, indent=2) + + with open(params_path, "w") as f: + json.dump(params, f, indent=2) + + print(f"[transpiler] Wrote circuit -> {circuit_path}") + print(f"[transpiler] Wrote params -> {params_path}") + print(f"[transpiler] Circuit stats:") + print(f" n_qubits : {n_qubits}") + print(f" p layers : {p}") + print(f" est gates: {circuit_doc['n_gates']}") + print(f" est depth: {circuit_doc['depth_estimate']}") + print(f" gamma_0 : {gamma0:.4f}") + print(f" beta_0 : {beta0:.4f}") + + +if __name__ == "__main__": + main()