Source code for qmlhc.backends.pennylane_backend

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
PennyLane Backend Adapter
-------------------------
Adapter for PennyLane-based quantum execution backends that conforms to the
``QuantumBackend`` contract.

This wrapper creates a PennyLane device, defines a lightweight variational
circuit (RY rotations + nearest-neighbor CNOT entanglement), and exposes
``run`` and ``project_future`` in the expected interface. The circuit returns
per-wire expectation values of Pauli-Z, yielding a vector ``S_t`` with
dimension equal to the number of qubits (and therefore to ``output_dim``).

Examples
--------
>>> import numpy as np
>>> from qmlhc.core.backend import BackendConfig
>>> from qmlhc.backends.pennylane_backend import PennyLaneBackend
>>> cfg = BackendConfig(output_dim=4, shots=None)
>>> be = PennyLaneBackend(cfg, num_qubits=4, device_name="default.qubit")
>>> be.encode(np.array([0.1, 0.2, 0.3, 0.4]))
>>> s_t = be.run()
>>> fut = be.project_future(s_t, branches=5)
>>> X = np.stack([np.zeros(4), np.ones(4)*0.1], axis=0)      # Example: batch execution
>>> be.run_batch(X).shape  # (B, D)
(2, 4)
>>> caps = be.capabilities()                          # Check device and backend capabilities (values depend on your device/config)
>>> caps["using_shots"], caps["supports_noise"]
(False, False)
"""

from __future__ import annotations

from typing import Any, Mapping, Optional

import numpy as np
import pennylane as qml

from ..core.backend import QuantumBackend, BackendConfig
from ..core.types import Array, Capabilities, GradientKind


[docs] class PennyLaneBackend(QuantumBackend): """ Wrap a PennyLane device and variational circuit into the ``QuantumBackend`` API. Parameters ---------- config : BackendConfig Backend configuration (e.g., ``output_dim``, ``shots``). num_qubits : int Number of qubits / wires. Must equal ``config.output_dim``. device_name : str, optional PennyLane device name, by default ``"default.qubit"``. shots : int or None, optional Number of device shots (``None`` for analytic mode). If ``None``, falls back to ``config.shots``. supports_noise : bool or None, optional Manual override for noise support in ``capabilities()``. If ``None`` (default), a heuristic is used based on the device; if ``True``/``False`` the reported capability is forced accordingly. Raises ------ ValueError If ``output_dim`` does not match ``num_qubits``. Notes ----- API usage in brief (details in the RST): - Call ``encode(x)`` with shape ``(D,)`` before ``run``. - ``D`` must match ``num_qubits`` and ``config.output_dim``. - Analytic mode is deterministic (``shots=None``); sampling mode (``shots>0``) introduces variance. - ``run_batch(X)`` expects shape ``(B, D)`` and returns ``(B, D)``. - ``supports_noise`` (init arg) lets you override noise reporting in ``capabilities()``. """ def __init__( self, config: BackendConfig, num_qubits: int, device_name: str = "default.mixed", # "default.mixed" enables realistic noise (decoherence, damping); use "default.qubit" for ideal or "lightning.qubit" for faster pure-state simulation. shots: Optional[int] = None, supports_noise: Optional[bool] = None, ) -> None: super().__init__(config) self._num_qubits = int(num_qubits) dev_shots = shots if shots is not None else self._cfg.shots self._dev = qml.device(device_name, wires=self._num_qubits) self._supports_noise_override = supports_noise if self.output_dim != self._num_qubits: raise ValueError( "output_dim must match number of qubits for PennyLaneBackend" ) # Define a compact circuit: # - RY rotations parameterized by the encoded input x # - Linear entanglement via CNOT gates # - Return Pauli-Z expectations per wire def circuit(x: Array) -> tuple[float, ...]: for i, val in enumerate(x): qml.RY(float(val), wires=i) for i in range(self._num_qubits - 1): qml.CNOT(wires=[i, i + 1]) # Physical noise model for i in range(self._num_qubits): qml.DepolarizingChannel(0.0012, wires=i) # gate error qml.PhaseDamping(0.0020, wires=i) # phase decoherence (T2) qml.AmplitudeDamping(0.0012, wires=i) # energy relaxation (T1) return tuple(qml.expval(qml.PauliZ(i)) for i in range(self._num_qubits)) qnode = qml.QNode(circuit, self._dev) self._shots_current = None if dev_shots is not None: qnode = qml.set_shots(qnode, dev_shots) self._shots_current = dev_shots self._circuit = qnode # ====================================================================== # Contract methods # ======================================================================
[docs] def run(self, params: Mapping[str, Any] | None = None) -> Array: """ Execute the PennyLane circuit on the last encoded input. Parameters ---------- params : dict or None, optional Unused in this minimal adapter; reserved for future extensions. Note ---- ``encode(x)`` must be called beforehand. The base class enforces this via ``_require_input()`` and raises if the input is missing. Returns ------- Array Validated state vector ``S_t`` of shape ``(D,)``. """ x = self._require_input() out = np.asarray(self._circuit(x), dtype=float).reshape(-1) return self._validate_state(out)
[docs] def run_batch(self, X: Array) -> Array: """ Execute a batch of inputs with shape ``(B, D)`` and return a matrix of shape ``(B, D)``. Requires ``D == num_qubits``. Validation of the batch result is delegated to the base class. Note ---- The batch must contain at least one row; empty batches are not supported. """ X = np.asarray(X, dtype=float) if X.ndim != 2 or X.shape[1] != self._num_qubits: raise ValueError(f"X must be (B, {self._num_qubits}), got {X.shape}.") outs = [np.asarray(self._circuit(x), dtype=float).reshape(-1) for x in X] return self._validate_branches(np.stack(outs, axis=0))
[docs] def project_future(self, s_t: np.ndarray, branches: int = 2) -> Array: """ Generate future projections around ``s_t`` using smooth additive deltas followed by ``tanh`` for numeric stability. Parameters ---------- s_t : np.ndarray Current state vector. branches : int, optional Number of future branches (K), by default 2. Returns ------- Array Future states matrix of shape ``(K, D)``. Note ---- This is a device-agnostic, low-cost projection utility. It does not change circuit parameters nor implement physical time evolution. """ s = self._validate_state(s_t) k = max(2, int(branches)) deltas = np.linspace(-0.12, 0.12, k, dtype=float) fut = np.stack([np.tanh(s + d) for d in deltas], axis=0) return self._validate_branches(fut)
[docs] def capabilities(self) -> Capabilities: """ Report merged capabilities (base + PennyLane device). Returns ------- Capabilities Capability dictionary including device version, qubit count, shot/noise support, batching, and gradient method. Notes ----- - ``max_qubits``: number of qubits configured for this instance. - ``supports_shots``: device family accepts finite shots (capability). - ``using_shots``: this instance currently samples (``shots`` is not None). - ``supports_noise``: noise support (override takes precedence). - ``supports_batch``: batch API is available via ``run_batch``. """ caps = super().capabilities() caps.update( { "backend_name": "PennyLaneDevice", "backend_version": qml.__version__, "max_qubits": self._num_qubits, "supports_shots": True, # PennyLane devices generally accept finite shots "using_shots": (getattr(self, "_shots_current", None) is not None), "supports_noise": ( self._supports_noise_override if self._supports_noise_override is not None else (hasattr(self._dev, "noise") or "default.mixed" in str(self._dev.name)) ), "supports_batch": True, "gradient": GradientKind.PARAMETER_SHIFT, } ) return caps