From 990c0c4150f40fe08c81e02d378d81e756442496 Mon Sep 17 00:00:00 2001 From: shamangeorge Date: Thu, 16 Apr 2026 09:55:47 -0400 Subject: [PATCH 1/4] feat(gpu-backend): implement CuPy-based GPU gate simulation for cuquantum backend The previous implementation called qutip_cuquantum.set_as_default() before delegating to dynamic_verify, but this only activates GPU for ODE-based solvers (SESolver/MESolver with CuVern7). The gate-based matrix multiplication in dynamic_verify always ran on CPU regardless. Fix: add dynamic_verify_gpu() which builds the state vector as a cupy ndarray and applies gate matrices via cupy matmuls on the GPU. The final state is transferred back to CPU only for the entanglement analysis (ptrace, entropy_vn). CuQuantumBackend.verify() now delegates to dynamic_verify_gpu() directly. Also fixes a silent qutip 5.x compatibility breakage that was keeping QUTIP_AVAILABLE = False for all users: - partial_trace removed from qutip; replaced with Qobj.ptrace() which requires proper multi-qubit dims (basis([2]*n, [0]*n) not basis(2**n, 0)) - qutip.qip.operations moved to the separate qutip_qip package (pip install qutip-qip); gate_expand_1toN renamed to expand_operator with new signature - gate constructors (cnot, cz, swap, rx/ry/rz) no longer accept N/target for expansion; use expand_operator(gate(), dims=[2]*n, targets=...) instead - qutip 5.x inner products return complex scalar, not 1x1 Qobj Entanglement check heuristic: changed from "all adjacent pairs must be entangled" to "at least one pair must be entangled", fixing false negatives for machines where only a subset of qubits form a Bell pair (e.g. quantum teleportation). Added early skip when no superposition-creating gates exist in the path (CNOT on |0...0> produces no entanglement). Packages added: qutip-qip, matplotlib --- q_orca/backends/cuquantum_backend.py | 5 +- q_orca/verifier/dynamic.py | 194 ++++++++++++++++++++++----- tests/test_backends.py | 79 ++++++++++- tests/test_vqe_rotation.py | 3 +- 4 files changed, 245 insertions(+), 36 deletions(-) diff --git a/q_orca/backends/cuquantum_backend.py b/q_orca/backends/cuquantum_backend.py index d6e83ad..0692403 100644 --- a/q_orca/backends/cuquantum_backend.py +++ b/q_orca/backends/cuquantum_backend.py @@ -6,6 +6,7 @@ from q_orca.ast import QMachineDef from q_orca.backends.base import BackendAdapter, BackendResult, BackendUnavailableError +from q_orca.verifier.dynamic import dynamic_verify_gpu from q_orca.verifier.types import QVerificationResult # Detect availability at module load time @@ -48,9 +49,7 @@ def verify( "qutip-cuquantum is not installed. " "Install with: pip install qutip-cuquantum" ) - # When actually available, delegate to QuTiP with cuQuantum provider - from q_orca.verifier.dynamic import dynamic_verify - result = dynamic_verify(machine) + result = dynamic_verify_gpu(machine) backend_result = BackendResult( name=self.name, version=self.version, diff --git a/q_orca/verifier/dynamic.py b/q_orca/verifier/dynamic.py index 52c2929..ed7f992 100644 --- a/q_orca/verifier/dynamic.py +++ b/q_orca/verifier/dynamic.py @@ -18,12 +18,13 @@ from q_orca.verifier.types import QVerificationError, QVerificationResult # QuTiP imports with graceful fallback +# qutip 5.x split gate operations into the separate qutip_qip package. QUTIP_AVAILABLE = False try: - from qutip import basis, ket2dm, partial_trace, entropy_vn, qeye, Qobj - from qutip.qip.operations import ( + from qutip import basis, ket2dm, entropy_vn, qeye, Qobj + from qutip_qip.operations import ( hadamard_transform, cnot, x_gate, y_gate, z_gate, - rx, ry, rz, gate_expand_1toN, cz, swap, + rx, ry, rz, expand_operator, cz_gate, swap, ) QUTIP_AVAILABLE = True except ImportError: @@ -164,7 +165,7 @@ def _parse_single_gate_to_dict(effect_str: str) -> Optional[Dict[str, Any]]: def _expand_1qubit_gate(op: Qobj, n_qubits: int, target: int) -> Qobj: """Expand a single-qubit gate to act on the full register.""" - return gate_expand_1toN(op, n_qubits, target) + return expand_operator(op, dims=[2] * n_qubits, targets=target) def _get_qutip_operator(gate: Dict[str, Any], n_qubits: int) -> Qobj: @@ -175,7 +176,7 @@ def _get_qutip_operator(gate: Dict[str, Any], n_qubits: int) -> Qobj: params = gate.get("params", {}) if not targets: - return qeye(2 ** n_qubits) + return qeye([2] * n_qubits) if name == "H": op = hadamard_transform() @@ -196,35 +197,32 @@ def _get_qutip_operator(gate: Dict[str, Any], n_qubits: int) -> Qobj: elif name in ("CNOT", "CX"): ctrl = controls[0] if controls else targets[0] tgt = targets[1] if len(targets) > 1 else targets[0] - return cnot(n_qubits, ctrl, tgt) + return expand_operator(cnot(), dims=[2] * n_qubits, targets=[ctrl, tgt]) elif name == "CZ": ctrl = controls[0] if controls else targets[0] tgt = targets[0] - return cz(n_qubits, ctrl, tgt) + return expand_operator(cz_gate(), dims=[2] * n_qubits, targets=[ctrl, tgt]) elif name == "SWAP": tgt1 = targets[0] tgt2 = targets[1] if len(targets) > 1 else targets[0] - return swap(n_qubits, tgt1, tgt2) + return expand_operator(swap(), dims=[2] * n_qubits, targets=[tgt1, tgt2]) elif name == "RX": theta = params.get("theta", 0.0) - op = rx(theta) - return _expand_1qubit_gate(op, n_qubits, targets[0]) + return expand_operator(rx(theta), dims=[2] * n_qubits, targets=targets[0]) elif name == "RY": theta = params.get("theta", 0.0) - op = ry(theta) - return _expand_1qubit_gate(op, n_qubits, targets[0]) + return expand_operator(ry(theta), dims=[2] * n_qubits, targets=targets[0]) elif name == "RZ": theta = params.get("theta", 0.0) - op = rz(theta) - return _expand_1qubit_gate(op, n_qubits, targets[0]) + return expand_operator(rz(theta), dims=[2] * n_qubits, targets=targets[0]) else: - return qeye(2 ** n_qubits) + return qeye([2] * n_qubits) def _evolve_path(initial_psi: Qobj, path_gates: List[Dict[str, Any]], n_qubits: int) -> Qobj: @@ -239,8 +237,8 @@ def _evolve_path(initial_psi: Qobj, path_gates: List[Dict[str, Any]], n_qubits: def _entanglement_entropy(subsystem: List[int], psi: Qobj, n_qubits: int) -> float: """Von Neumann entropy of the reduced density matrix on `subsystem`.""" rho = ket2dm(psi) - traced_out = [i for i in range(n_qubits) if i not in subsystem] - rho_reduced = partial_trace(rho, traced_out) + # ptrace(sel) keeps the listed subsystems; requires multi-qubit dims on rho + rho_reduced = rho.ptrace(subsystem) return float(entropy_vn(rho_reduced, base=2)) @@ -248,12 +246,11 @@ def _schmidt_rank_across_bipartition( psi: Qobj, partition_a: List[int], partition_b: List[int], n_qubits: int ) -> int: """True Schmidt rank for a pure state across two groups A and B.""" - keep = set(partition_a) | set(partition_b) - traced = [i for i in range(n_qubits) if i not in keep] - rho_ab = partial_trace(ket2dm(psi), traced) if traced else ket2dm(psi) + keep = list(set(partition_a) | set(partition_b)) + rho_ab = ket2dm(psi).ptrace(keep) if len(keep) < n_qubits else ket2dm(psi) - # Compute Schmidt rank via eigenvalues of reduced density matrix - rho_a = partial_trace(rho_ab, partition_b) + # Compute Schmidt rank via eigenvalues of reduced density matrix A + rho_a = rho_ab.ptrace([keep.index(q) for q in partition_a]) import numpy as np evals = np.abs(rho_a.eigenenergies()) rank = int(np.sum(evals > 1e-10)) @@ -276,14 +273,20 @@ def _check_dynamic_entanglement( if n_qubits < 2: return {"skipped": True, "reason": "Need at least 2 qubits", "passed": True} - initial_psi = basis(2 ** n_qubits, 0) + # Skip if no superposition-creating gates exist in the path — starting from + # |0...0>, CNOT/CZ/SWAP alone can never produce entanglement. + superposition_gates = {"H", "RX", "RY", "RZ"} + if not any(g.get("name", "").upper() in superposition_gates for g in path_gates): + return {"skipped": True, "reason": "No superposition gates in path", "passed": True} + + initial_psi = basis([2] * n_qubits, [0] * n_qubits) final_psi = _evolve_path(initial_psi, path_gates, n_qubits) report: Dict[str, Any] = { "state": state_label, "entropy_checks": {}, "schmidt_ranks": {}, - "passed": True, + "passed": False, # must find at least one entangled pair "details": {}, } @@ -297,13 +300,14 @@ def _check_dynamic_entanglement( rank = _schmidt_rank_across_bipartition(final_psi, [q1], [q2], n_qubits) report["schmidt_ranks"][f"q{q1}-q{q2}"] = rank - if entropy_q1 < tolerance: - report["passed"] = False - report["details"][f"q{q1}"] = "no entanglement detected (entropy ≈ 0)" - - if rank <= 1: - report["passed"] = False - report["details"][f"q{q1}-q{q2}"] = f"Schmidt rank {rank} ≤ 1" + if entropy_q1 >= tolerance and rank > 1: + # At least one entangled pair found — that is sufficient + report["passed"] = True + else: + if entropy_q1 < tolerance: + report["details"][f"q{q1}"] = "no entanglement detected (entropy ≈ 0)" + if rank <= 1: + report["details"][f"q{q1}-q{q2}"] = f"Schmidt rank {rank} ≤ 1" return report @@ -314,6 +318,134 @@ def _check_dynamic_entanglement( entanglement_entropy = _entanglement_entropy schmidt_rank_across_bipartition = _schmidt_rank_across_bipartition +# --------------------------------------------------------------------------- +# GPU-accelerated path via CuPy +# --------------------------------------------------------------------------- + +CUPY_AVAILABLE = False +try: + import cupy as _cp + CUPY_AVAILABLE = True +except ImportError: + pass + + +def _evolve_path_gpu( + initial_psi: "Any", # cupy ndarray (2**n, 1) + path_gates: List[Dict[str, Any]], + n_qubits: int, +) -> "Any": # cupy ndarray + """Apply gate sequence to a cupy state vector using GPU matrix multiplication.""" + import cupy as cp + psi = initial_psi + for gate in path_gates: + U_np = _get_qutip_operator(gate, n_qubits).full() + U_gpu = cp.asarray(U_np) + psi = U_gpu @ psi + return psi + + +def dynamic_verify_gpu(machine: QMachineDef) -> QVerificationResult: + """GPU-accelerated verification using CuPy for gate matrix operations. + + Runs the same checks as dynamic_verify but offloads state-vector evolution + to GPU via CuPy matrix multiplications. Falls back to the CPU path if CuPy + or QuTiP is unavailable. + """ + if not CUPY_AVAILABLE or not QUTIP_AVAILABLE: + return dynamic_verify(machine) + + import cupy as cp + + errors: list[QVerificationError] = [] + + qubit_count = _infer_qubit_count(machine) + err_msg, gate_sequence = _build_gate_sequence(machine, qubit_count) + if err_msg: + return QVerificationResult(valid=True, errors=[]) + + all_gates = [g for gates in gate_sequence for g in gates] + + # Build initial |0...0> state on GPU + dim = 2 ** qubit_count + psi_gpu = cp.zeros((dim, 1), dtype=cp.complex128) + psi_gpu[0, 0] = 1.0 + + # Evolve on GPU + psi_gpu = _evolve_path_gpu(psi_gpu, all_gates, qubit_count) + + # Bring final state back to CPU as a QuTiP ket for analysis + psi_np = cp.asnumpy(psi_gpu) + final_psi = Qobj(psi_np, dims=[[2] * qubit_count, [1] * qubit_count]) + + # Entanglement checks (identical logic to dynamic_verify, CPU-side) + entangled_kinds = {"bell", "ghz", "epr", "entangl"} + entangled_states = [ + s for s in machine.states + if any(k in (s.state_expression or "").lower() or k in (s.name or "").lower() + for k in entangled_kinds) + ] + + invariant_pairs = [ + (inv.qubits[0], inv.qubits[1]) + for inv in getattr(machine, "invariants", []) + if inv.kind in ("entanglement", "schmidt_rank") and len(inv.qubits) >= 2 + ] + + for state in entangled_states: + expected_pairs = invariant_pairs or [(i, i + 1) for i in range(qubit_count - 1)] + report: Dict[str, Any] = {"passed": True, "details": {}} + + for q1, q2 in expected_pairs: + entropy_q1 = _entanglement_entropy([q1], final_psi, qubit_count) + rank = _schmidt_rank_across_bipartition(final_psi, [q1], [q2], qubit_count) + + if entropy_q1 < 1e-8: + report["passed"] = False + report["details"][f"q{q1}"] = "no entanglement detected (entropy ≈ 0)" + if rank <= 1: + report["passed"] = False + report["details"][f"q{q1}-q{q2}"] = f"Schmidt rank {rank} ≤ 1" + + if not report["passed"]: + details_str = "; ".join(f"{k}: {v}" for k, v in report["details"].items()) + errors.append(QVerificationError( + code="DYNAMIC_NO_ENTANGLEMENT", + message=f"State '{state.name}' should be entangled but verification failed: {details_str}", + severity="error", + location={"state": state.name}, + suggestion="Ensure the circuit creates an entangled state with CNOT or CZ gates", + )) + + # Collapse completeness check (identical to dynamic_verify) + measure_events = {e.name for e in machine.events if "measure" in e.name.lower()} + measure_transitions = [ + t for t in machine.transitions + if any(m in t.event.lower() for m in measure_events) + ] + if measure_transitions: + prob_sum = 0.0 + has_probs = False + for t in measure_transitions: + if t.guard: + guard_def = next((g for g in machine.guards if g.name == t.guard.name), None) + if guard_def and guard_def.expression.kind == "probability": + prob_sum += guard_def.expression.outcome.probability + has_probs = True + if has_probs and abs(prob_sum - 1.0) > 0.01: + errors.append(QVerificationError( + code="DYNAMIC_INCOMPLETE_COLLAPSE", + message=f"Measurement branches have probabilities summing to {prob_sum:.4f}, expected 1.0", + severity="error", + location=None, + suggestion="Ensure all collapse outcomes are covered with probabilities summing to 1", + )) + + return QVerificationResult( + valid=not any(e.severity == "error" for e in errors), + errors=errors, + ) + def dynamic_verify(machine: QMachineDef) -> QVerificationResult: """Run QuTiP-based dynamic quantum verification. diff --git a/tests/test_backends.py b/tests/test_backends.py index d65ebce..9d73baf 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -4,7 +4,7 @@ import subprocess import sys -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, call, patch import pytest @@ -185,6 +185,83 @@ def test_backend_result_metadata_fields(self): # Task 9.2 — compile_to_cudaq tests # --------------------------------------------------------------------------- +class TestCuQuantumGPUActivation: + """Tests that the cuquantum backend uses CuPy for GPU-accelerated gate simulation.""" + + def test_evolve_path_gpu_returns_cupy_array(self): + """_evolve_path_gpu must return a cupy ndarray on the GPU device.""" + try: + import cupy as cp + except ImportError: + pytest.skip("cupy not installed") + from q_orca.verifier.dynamic import _evolve_path_gpu, CUPY_AVAILABLE, _infer_qubit_count, _build_gate_sequence + if not CUPY_AVAILABLE: + pytest.skip("cupy not available in dynamic module") + + machine = _parse_bell() + n = _infer_qubit_count(machine) + _, gate_seq = _build_gate_sequence(machine, n) + all_gates = [g for gates in gate_seq for g in gates] + + psi_gpu = cp.zeros((2 ** n, 1), dtype=cp.complex128) + psi_gpu[0, 0] = 1.0 + result = _evolve_path_gpu(psi_gpu, all_gates, n) + + assert isinstance(result, cp.ndarray), "result must be a cupy array (GPU)" + assert result.device.id >= 0 + + def test_gpu_result_matches_cpu(self): + """GPU and CPU verification must agree on validity and error codes.""" + try: + import cupy # noqa: F401 + except ImportError: + pytest.skip("cupy not installed") + from q_orca.verifier.dynamic import dynamic_verify, dynamic_verify_gpu, CUPY_AVAILABLE + if not CUPY_AVAILABLE: + pytest.skip("cupy not available in dynamic module") + + machine = _parse_bell() + cpu_result = dynamic_verify(machine) + gpu_result = dynamic_verify_gpu(machine) + + assert gpu_result.valid == cpu_result.valid + assert {e.code for e in gpu_result.errors} == {e.code for e in cpu_result.errors} + + def test_gpu_memory_allocated_during_verify(self): + """cupy memory pool total must grow after the first GPU verification call.""" + try: + import cupy as cp + except ImportError: + pytest.skip("cupy not installed") + from q_orca.verifier.dynamic import dynamic_verify_gpu, CUPY_AVAILABLE + if not CUPY_AVAILABLE: + pytest.skip("cupy not available in dynamic module") + + machine = _parse_bell() + pool = cp.get_default_memory_pool() + pool.free_all_blocks() + before = pool.total_bytes() + + dynamic_verify_gpu(machine) + cp.cuda.Stream.null.synchronize() + + assert pool.total_bytes() > before, "no GPU memory was allocated during verification" + + def test_cuquantum_backend_calls_gpu_verify(self): + """CuQuantumBackend.verify() must delegate to dynamic_verify_gpu.""" + from q_orca.backends.cuquantum_backend import CuQuantumBackend, AVAILABLE + if not AVAILABLE: + pytest.skip("qutip_cuquantum not installed") + + machine = _parse_bell() + backend = CuQuantumBackend() + + with patch("q_orca.backends.cuquantum_backend.dynamic_verify_gpu") as mock_gpu: + mock_gpu.return_value = QVerificationResult(valid=True, errors=[]) + backend.verify(machine) + mock_gpu.assert_called_once_with(machine) + + class TestCompileToCudaQ: """Tests for the CUDA-Q compiler target.""" diff --git a/tests/test_vqe_rotation.py b/tests/test_vqe_rotation.py index 1026fd1..f80cb82 100644 --- a/tests/test_vqe_rotation.py +++ b/tests/test_vqe_rotation.py @@ -74,5 +74,6 @@ def test_simulate_state_vector(self): from qutip import Qobj psi_formula = Qobj([[c0], [c1]]) - fidelity = abs((psi_formula.dag() * psi_expected)[0, 0]) ** 2 + inner = psi_formula.dag() * psi_expected + fidelity = abs(inner[0, 0] if hasattr(inner, "__getitem__") else inner) ** 2 assert fidelity > 0.999999, f"State fidelity too low: {fidelity}" From bcaa236e6fd24e7925f0d247f1417318c6613c40 Mon Sep 17 00:00:00 2001 From: shamangeorge Date: Thu, 16 Apr 2026 10:08:11 -0400 Subject: [PATCH 2/4] feat(demo): wire cuquantum GPU backend into quantum-evolve GA demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously _verify_source used skip_dynamic=True, bypassing all quantum circuit simulation. Each individual is now verified with the full dynamic pipeline via the cuquantum backend (CuPy GPU matmuls), giving a real cost signal per evaluation rather than structural checks only. Changes: - New BACKEND module-level flag (default: "cuquantum"); set to "qutip" for CPU-only or "none" to restore the old skip_dynamic behaviour - New --backend CLI flag wired through to _verify_source - GPU memory delta (+N bytes) printed after each verification call so you can confirm GPU is actually being used - Design goal updated from 3-qubit bit-flip correction to 4-qubit Grover search (target |1010>, 2 iterations) — more qubits means more GPU work per verification and a harder fitness landscape for the GA --- demos/quantum_evolve/demo.py | 73 ++++++++++++++++++++++++++++-------- 1 file changed, 57 insertions(+), 16 deletions(-) diff --git a/demos/quantum_evolve/demo.py b/demos/quantum_evolve/demo.py index 27589a6..929bd6b 100644 --- a/demos/quantum_evolve/demo.py +++ b/demos/quantum_evolve/demo.py @@ -148,25 +148,29 @@ def _panel(title: str, content: str, color: str = C.BCYAN, width: int = 62): FITNESS_TARGET = 99.0 MUTATION_RATE = 0.5 ELITISM = 1 +BACKEND = "cuquantum" # set to "qutip" to disable GPU, or "none" to skip dynamic DEFAULT_DESIGN_GOAL = textwrap.dedent("""\ - Design a quantum state machine that implements the 3-qubit bit-flip - error correction code. + Design a quantum state machine that implements Grover's search algorithm + on 4 qubits, searching for the target state |1010>. Requirements: - 1. Three qubits in context: one logical data qubit (q0) and two ancillas (q1, q2) - 2. Encoding phase: spread the logical qubit across all three using CNOT gates - so |0> -> |000> and |1> -> |111> - 3. Error phase: model a single-qubit X (bit-flip) error on one of the three qubits - 4. Syndrome measurement: measure the two parity checks (q0⊕q1 and q1⊕q2) - using ancilla-based CNOT + measurement — each collapses to a 0 or 1 syndrome bit - 5. Correction phase: based on the 2-bit syndrome (4 branches: no error, error on - q0, q1, or q2), apply the corrective X gate to the identified qubit - 6. Include all four syndrome collapse branches with probability guards - 7. Mark the final corrected states as [final] - - The machine should have descriptive state names for each phase, proper context - fields, and verification rules (unitarity, no-cloning at minimum).""") + 1. Four qubits in context: q0, q1, q2, q3 + 2. Initialization phase: apply Hadamard to all 4 qubits to create uniform superposition + 3. Oracle phase: mark the target |1010> by flipping its phase (apply a + multi-controlled Z (or equivalent CNOT+H decomposition) that introduces + a -1 phase on |1010> and leaves all other amplitudes unchanged) + 4. Diffusion phase: apply the Grover diffusion operator + (H^4 · (2|0><0| - I) · H^4) to amplify the target amplitude + 5. Run 2 full Grover iterations (oracle + diffusion each time) + 6. Measurement phase: measure all 4 qubits; the outcome |1010> should have + high probability (~97%) + 7. Include at least 2 collapse branches in the measurement with probability guards + 8. Mark the post-measurement states as [final] + + State names must be valid identifiers (letters, digits, underscores only — + no Dirac ket notation in state names). + Include verification rules: unitarity, entanglement, no_cloning.""") DESIGN_GOAL = DEFAULT_DESIGN_GOAL # May be overridden by --goal / --goal-file @@ -276,7 +280,36 @@ def _verify_source(source: str) -> tuple[bool, list[str]]: if not parsed.file.machines: return False, ["No machine definition found"] machine = parsed.file.machines[0] - result = verify(machine, VerifyOptions(skip_completeness=True, skip_dynamic=True)) + + skip_dynamic = BACKEND == "none" + opts = VerifyOptions( + skip_completeness=True, + skip_dynamic=skip_dynamic, + backend=BACKEND if not skip_dynamic else "qutip", + ) + + gpu_before = 0 + if BACKEND == "cuquantum" and not skip_dynamic: + try: + import cupy as cp + pool = cp.get_default_memory_pool() + gpu_before = pool.total_bytes() + except ImportError: + pass + + result = verify(machine, opts) + + if BACKEND == "cuquantum" and not skip_dynamic: + try: + import cupy as cp + cp.cuda.Stream.null.synchronize() + gpu_after = cp.get_default_memory_pool().total_bytes() + delta = gpu_after - gpu_before + if delta > 0: + print(f" {C.DIM}↳ GPU Δmem: +{delta:,} bytes{C.RESET}", flush=True) + except ImportError: + pass + errors = [f"{e.code}: {e.message}" for e in result.errors if e.severity == "error"] return result.valid, errors except Exception as e: @@ -820,6 +853,7 @@ async def main(): _get_provider() config = load_config() _info(f"LLM provider: {C.WHITE}{config.provider} / {config.model}{C.RESET}") + _info(f"Verify backend: {C.WHITE}{BACKEND}{C.RESET}") _info(f"Population: {C.WHITE}{POPULATION_SIZE}{C.RESET} " f"Max generations: {C.WHITE}{MAX_GENERATIONS}{C.RESET}") _info(f"Fitness target: {C.WHITE}{FITNESS_TARGET}{C.RESET}") @@ -948,6 +982,11 @@ def _parse_args(): "--fitness-target", type=float, default=None, help=f"Fitness target to converge (default: {FITNESS_TARGET})", ) + parser.add_argument( + "--backend", type=str, default=None, + help="Verification backend: cuquantum (GPU), qutip (CPU), none (skip dynamic). " + f"Default: {BACKEND}", + ) return parser.parse_args() @@ -965,5 +1004,7 @@ def _parse_args(): MAX_GENERATIONS = args.generations if args.fitness_target is not None: FITNESS_TARGET = args.fitness_target + if args.backend is not None: + BACKEND = args.backend asyncio.run(main()) From 307384cd45eb6434baa721457dc6b54a36cb819f Mon Sep 17 00:00:00 2001 From: shamangeorge Date: Thu, 16 Apr 2026 13:17:17 -0400 Subject: [PATCH 3/4] refactor(demo): split grover goal into dedicated demo, revert bit-flip default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quantum_evolve demo default goal was changed to Grover search in the GPU fix PR, but bit-flip error correction is the more representative default — it exercises mid-circuit measurements, which Grover search does not. Revert quantum_evolve/demo.py default to 3-qubit bit-flip error correction. Add demos/grover_evolve/demo.py as a thin shim (~50 lines) that overrides the goal to 4-qubit Grover search with cuquantum backend and delegates all GA machinery to the parent demo. Each demo now has a single clear purpose. --- demos/grover_evolve/demo.py | 57 ++++++++++++++++++++++++++++++++++++ demos/quantum_evolve/demo.py | 33 ++++++++++----------- 2 files changed, 72 insertions(+), 18 deletions(-) create mode 100644 demos/grover_evolve/demo.py diff --git a/demos/grover_evolve/demo.py b/demos/grover_evolve/demo.py new file mode 100644 index 0000000..c0acb49 --- /dev/null +++ b/demos/grover_evolve/demo.py @@ -0,0 +1,57 @@ +"""Grover Search variant of the quantum-evolve GA demo. + +Evolves a 4-qubit Grover's search state machine (target |1010>, 2 iterations) +using the cuquantum GPU backend for verification. + +All GA machinery lives in demos/quantum_evolve/demo.py — this module just +overrides the design goal and backend, then delegates to main() there. + +Usage: + python demos/grover_evolve/demo.py + python demos/grover_evolve/demo.py --backend qutip --population 5 --generations 4 +""" + +from __future__ import annotations + +import sys +import textwrap +from pathlib import Path + +# Make the sibling quantum_evolve package importable +sys.path.insert(0, str(Path(__file__).parent.parent)) + +import quantum_evolve.demo as _demo # noqa: E402 + +# ── Override goal and backend before main() reads the globals ───────────────── + +_GROVER_GOAL = textwrap.dedent("""\ + Design a quantum state machine that implements Grover's search algorithm + on 4 qubits, searching for the target state |1010>. + + Requirements: + 1. Four qubits in context: q0, q1, q2, q3 + 2. Initialization phase: apply Hadamard to all 4 qubits to create uniform superposition + 3. Oracle phase: mark the target |1010> by flipping its phase (apply a + multi-controlled Z (or equivalent CNOT+H decomposition) that introduces + a -1 phase on |1010> and leaves all other amplitudes unchanged) + 4. Diffusion phase: apply the Grover diffusion operator + (H^4 · (2|0><0| - I) · H^4) to amplify the target amplitude + 5. Run 2 full Grover iterations (oracle + diffusion each time) + 6. Measurement phase: measure all 4 qubits; the outcome |1010> should have + high probability (~97%) + 7. Include at least 2 collapse branches in the measurement with probability guards + 8. Mark the post-measurement states as [final] + + State names must be valid identifiers (letters, digits, underscores only — + no Dirac ket notation in state names). + Include verification rules: unitarity, entanglement, no_cloning.""") + +_demo.DEFAULT_DESIGN_GOAL = _GROVER_GOAL +_demo.DESIGN_GOAL = _GROVER_GOAL +_demo.BACKEND = "cuquantum" + +# ── Entry point ─────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + # _parse_args() reads sys.argv so --population, --generations etc. still work + _demo.asyncio.run(_demo.main()) diff --git a/demos/quantum_evolve/demo.py b/demos/quantum_evolve/demo.py index 929bd6b..6eba07b 100644 --- a/demos/quantum_evolve/demo.py +++ b/demos/quantum_evolve/demo.py @@ -151,26 +151,23 @@ def _panel(title: str, content: str, color: str = C.BCYAN, width: int = 62): BACKEND = "cuquantum" # set to "qutip" to disable GPU, or "none" to skip dynamic DEFAULT_DESIGN_GOAL = textwrap.dedent("""\ - Design a quantum state machine that implements Grover's search algorithm - on 4 qubits, searching for the target state |1010>. + Design a quantum state machine that implements the 3-qubit bit-flip + error correction code. Requirements: - 1. Four qubits in context: q0, q1, q2, q3 - 2. Initialization phase: apply Hadamard to all 4 qubits to create uniform superposition - 3. Oracle phase: mark the target |1010> by flipping its phase (apply a - multi-controlled Z (or equivalent CNOT+H decomposition) that introduces - a -1 phase on |1010> and leaves all other amplitudes unchanged) - 4. Diffusion phase: apply the Grover diffusion operator - (H^4 · (2|0><0| - I) · H^4) to amplify the target amplitude - 5. Run 2 full Grover iterations (oracle + diffusion each time) - 6. Measurement phase: measure all 4 qubits; the outcome |1010> should have - high probability (~97%) - 7. Include at least 2 collapse branches in the measurement with probability guards - 8. Mark the post-measurement states as [final] - - State names must be valid identifiers (letters, digits, underscores only — - no Dirac ket notation in state names). - Include verification rules: unitarity, entanglement, no_cloning.""") + 1. Three qubits in context: one logical data qubit (q0) and two ancillas (q1, q2) + 2. Encoding phase: spread the logical qubit across all three using CNOT gates + so |0> -> |000> and |1> -> |111> + 3. Error phase: model a single-qubit X (bit-flip) error on one of the three qubits + 4. Syndrome measurement: measure the two parity checks (q0⊕q1 and q1⊕q2) + using ancilla-based CNOT + measurement — each collapses to a 0 or 1 syndrome bit + 5. Correction phase: based on the 2-bit syndrome (4 branches: no error, error on + q0, q1, or q2), apply the corrective X gate to the identified qubit + 6. Include all four syndrome collapse branches with probability guards + 7. Mark the final corrected states as [final] + + The machine should have descriptive state names for each phase, proper context + fields, and verification rules (unitarity, no-cloning at minimum).""") DESIGN_GOAL = DEFAULT_DESIGN_GOAL # May be overridden by --goal / --goal-file From ac46f60346240de7ba35c4796aa51765bb91cfd9 Mon Sep 17 00:00:00 2001 From: shamangeorge Date: Thu, 16 Apr 2026 13:24:27 -0400 Subject: [PATCH 4/4] fix(review): address jascal/q-orca-lang#8 required changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Add qutip-qip to quantum/all extras in pyproject.toml Without it, a fresh install silently lands on QUTIP_AVAILABLE=False because qutip 5.x split gate ops into a separate package. 2. Fix GPU entanglement logic to match CPU semantics dynamic_verify_gpu started report["passed"]=True and used fail-on-any; _check_dynamic_entanglement uses passed=False / pass-on-any. The two paths would disagree on machines where only some adjacent pairs are entangled. Also adds the superposition gate guard (H/RX/RY/RZ) that the CPU path already had. 3. Implement _check_unitary_gates and wire into both paths The dynamic_verify docstring claimed "circuit unitary verification" but the body never did it. Adds _check_unitary_gates (||U†U - I|| check, emits DYNAMIC_NON_UNITARY_GATE), called on CPU in both dynamic_verify and dynamic_verify_gpu before the GPU evolution step. --- pyproject.toml | 4 +- q_orca/verifier/dynamic.py | 76 +++++++++++++++++++++++++++++++++----- 2 files changed, 69 insertions(+), 11 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3ea3055..36f4768 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,9 +30,9 @@ Source = "https://github.com/jascal/q-orca-lang" [project.optional-dependencies] dev = ["pytest", "ruff"] -quantum = ["qiskit", "qutip"] +quantum = ["qiskit", "qutip", "qutip-qip"] mcp = ["pyyaml"] -all = ["qiskit", "qutip", "pyyaml"] +all = ["qiskit", "qutip", "qutip-qip", "pyyaml"] [project.scripts] q-orca = "q_orca.cli:main" diff --git a/q_orca/verifier/dynamic.py b/q_orca/verifier/dynamic.py index ed7f992..cb96133 100644 --- a/q_orca/verifier/dynamic.py +++ b/q_orca/verifier/dynamic.py @@ -312,8 +312,46 @@ def _check_dynamic_entanglement( return report +def _check_unitary_gates( + gate_sequence: List[List[Dict[str, Any]]], n_qubits: int +) -> List[QVerificationError]: + """Verify every distinct gate in the sequence satisfies U†U ≈ I.""" + import numpy as np + + errors: List[QVerificationError] = [] + identity = qeye([2] * n_qubits) + seen: set = set() + + for step_gates in gate_sequence: + for gate in step_gates: + name = gate.get("name", "UNKNOWN") + key = ( + name, + tuple(gate.get("targets", [])), + tuple(gate.get("controls", [])), + tuple(sorted(gate.get("params", {}).items())), + ) + if key in seen: + continue + seen.add(key) + + U = _get_qutip_operator(gate, n_qubits) + diff = float((U.dag() * U - identity).norm()) + if diff > 1e-10: + errors.append(QVerificationError( + code="DYNAMIC_NON_UNITARY_GATE", + message=f"Gate '{name}' is not unitary: ‖U†U − I‖ = {diff:.2e}", + severity="error", + location={"gate": name}, + suggestion="All quantum gates must be unitary; check gate parameters", + )) + + return errors + + # Public API — callable directly from tests or external code check_dynamic_entanglement = _check_dynamic_entanglement +check_unitary_gates = _check_unitary_gates evolve_path = _evolve_path entanglement_entropy = _entanglement_entropy schmidt_rank_across_bipartition = _schmidt_rank_across_bipartition @@ -366,19 +404,26 @@ def dynamic_verify_gpu(machine: QMachineDef) -> QVerificationResult: all_gates = [g for gates in gate_sequence for g in gates] + # Unitarity check runs on CPU — gate matrices are identical on both paths. + errors.extend(_check_unitary_gates(gate_sequence, qubit_count)) + # Build initial |0...0> state on GPU dim = 2 ** qubit_count psi_gpu = cp.zeros((dim, 1), dtype=cp.complex128) psi_gpu[0, 0] = 1.0 - # Evolve on GPU + # Evolve on GPU. + # NOTE: each gate matrix is transferred CPU→GPU individually here. + # For long circuits this is the dominant latency; caching gate matrices on + # GPU across calls is future work. psi_gpu = _evolve_path_gpu(psi_gpu, all_gates, qubit_count) # Bring final state back to CPU as a QuTiP ket for analysis psi_np = cp.asnumpy(psi_gpu) final_psi = Qobj(psi_np, dims=[[2] * qubit_count, [1] * qubit_count]) - # Entanglement checks (identical logic to dynamic_verify, CPU-side) + # Entanglement checks — semantics match _check_dynamic_entanglement on CPU: + # pass if ANY expected pair is entangled (not ALL pairs must be). entangled_kinds = {"bell", "ghz", "epr", "entangl"} entangled_states = [ s for s in machine.states @@ -392,20 +437,30 @@ def dynamic_verify_gpu(machine: QMachineDef) -> QVerificationResult: if inv.kind in ("entanglement", "schmidt_rank") and len(inv.qubits) >= 2 ] + # Skip entanglement check entirely when no superposition gates are present — + # CNOT/CZ/SWAP on |0...0> cannot produce entanglement. + superposition_gates = {"H", "RX", "RY", "RZ"} + has_superposition = any(g.get("name", "").upper() in superposition_gates for g in all_gates) + for state in entangled_states: + if not has_superposition: + continue + expected_pairs = invariant_pairs or [(i, i + 1) for i in range(qubit_count - 1)] - report: Dict[str, Any] = {"passed": True, "details": {}} + report: Dict[str, Any] = {"passed": False, "details": {}} for q1, q2 in expected_pairs: entropy_q1 = _entanglement_entropy([q1], final_psi, qubit_count) rank = _schmidt_rank_across_bipartition(final_psi, [q1], [q2], qubit_count) - if entropy_q1 < 1e-8: - report["passed"] = False - report["details"][f"q{q1}"] = "no entanglement detected (entropy ≈ 0)" - if rank <= 1: - report["passed"] = False - report["details"][f"q{q1}-q{q2}"] = f"Schmidt rank {rank} ≤ 1" + if entropy_q1 >= 1e-8 and rank > 1: + # At least one entangled pair found — sufficient to pass + report["passed"] = True + else: + if entropy_q1 < 1e-8: + report["details"][f"q{q1}"] = "no entanglement detected (entropy ≈ 0)" + if rank <= 1: + report["details"][f"q{q1}-q{q2}"] = f"Schmidt rank {rank} ≤ 1" if not report["passed"]: details_str = "; ".join(f"{k}: {v}" for k, v in report["details"].items()) @@ -472,6 +527,9 @@ def dynamic_verify(machine: QMachineDef) -> QVerificationResult: # Flatten gate_sequence for entanglement checks all_gates = [g for gates in gate_sequence for g in gates] + # Unitarity check — verify every gate satisfies U†U ≈ I + errors.extend(_check_unitary_gates(gate_sequence, qubit_count)) + # Check entanglement for states that are explicitly declared as entangled entangled_kinds = {"bell", "ghz", "epr", "entangl"} entangled_states = [