Repository: qiskit-community/qiskit-braket-provider

Issue:
Extend `to_braket` to respect gate angle restrictions of native gates
<!-- ⚠️ If you do not respect this template, your issue will be closed -->
<!-- ⚠️ Make sure to browse the opened and closed issues to confirm this idea does not exist. -->


Follow issue https://github.com/qiskit-community/qiskit-braket-provider/issues/138, the `to_braket` function converts the input QIskit circuit into a Braket circuit that contains the backend's native gates. However, such conversion does not range of angle is also not considered by the `to_braket` function.  The output circuit of  `to_braket` may currently contain angle not supported by the backend.

Specifically this issue should focus on 
* Rigetti (fixed angle list) and 
* IonQ (MS arbitrary angle range, see angle 3)

### What is the expected behavior?
When apply the `to_braket` function to a QIskit circuit, shown as the code snippet below,
```
backend = AWSBraketProvider().get_backend("...")
circuit = QuantumCircuit(2)
circuit.x(0)
circuit.y(1)
circuit.rzz(1, 0, 1)
braket_circuit = to_braket(circuit, backend, native=True)
```
the output `braket_circuit` is a circuit in Braket Circuit class. The output Braket circuit should be equivalent to the input Qiskit circuit, but it only contains native gates of the device with the correct range of the angles. The two-qubit native gates apply only on the physical edge of the device.

### Acceptance criteria
- The output Braket circuit only use angles supported by the backend. Currently, the only backends that has such restriction in angles are Rigetti backends. As a native gate in Rigetti backends, the Rx can only accept +pi, -pi, +pi/2 and -pi/2.
- There is a extensible way to specify angle restriction for a backend. For example, a new restriction can be added to a backend without complex change in the code.


Note: A similar issue participated in UnitaryHack2024: https://github.com/qiskit-community/qiskit-braket-provider/issues/176

Hints from issue comments:
@lavanya-m-k: @peterkomar-aws Could you please assign this issue to me. Here is the PR https://github.com/qiskit-community/qiskit-braket-provider/pull/204. Thanks!!

Solution patch:
diff --git a/qiskit_braket_provider/providers/adapter.py b/qiskit_braket_provider/providers/adapter.py
index 10b55a1..cea4413 100644
--- a/qiskit_braket_provider/providers/adapter.py
+++ b/qiskit_braket_provider/providers/adapter.py
@@ -242,6 +242,31 @@ def native_gate_set(properties: DeviceCapabilities) -> set[str]:
     }
 
 
+def native_angle_restrictions(
+    properties: DeviceCapabilities,
+) -> dict[str, dict[int, Union[set[float], tuple[float, float]]]]:
+    """Returns angle restrictions for gates natively supported by a Braket device.
+
+    The returned mapping specifies, for each gate name, constraints on the
+    gate parameters indexed by their position. The constraint can either be a
+    set of allowed angles or a tuple representing an inclusive ``(min, max)``
+    range. Angle units are in radians.
+
+    Args:
+        properties (DeviceCapabilities): The device properties of the Braket device.
+
+    Returns:
+        dict[str, dict[int, Union[set[float], tuple[float, float]]]]: Mapping
+            of gate names to parameter index restrictions.
+    """
+
+    if isinstance(properties, (RigettiDeviceCapabilities, RigettiDeviceCapabilitiesV2)):
+        return {"rx": {0: {pi, -pi, pi / 2, -pi / 2}}}
+    if isinstance(properties, IonqDeviceCapabilities):
+        return {"ms": {2: (0.0, 0.25)}}
+    return {}
+
+
 def gateset_from_properties(properties: OpenQASMDeviceActionProperties) -> set[str]:
     """Returns the gateset supported by a Braket device with the given properties
 
@@ -464,6 +489,9 @@ def to_braket(
     basis_gates: Optional[Iterable[str]] = None,
     verbatim: bool = False,
     connectivity: Optional[list[list[int]]] = None,
+    angle_restrictions: Optional[
+        dict[str, dict[int, Union[set[float], tuple[float, float]]]]
+    ] = None,
 ) -> Circuit:
     """Return a Braket quantum circuit from a Qiskit quantum circuit.
 
@@ -476,6 +504,8 @@ def to_braket(
             words without transpiling it. Default: False.
         connectivity (Optional[list[list[int]]): If provided, will transpile to a circuit
             with this connectivity. Default: `None`.
+        angle_restrictions (Optional[dict]): Mapping of gate names to parameter
+            angle constraints used to validate numeric parameters. Default: ``None``.
 
     Returns:
         Circuit: Braket circuit
@@ -541,6 +571,7 @@ def to_braket(
                     f"Cannot apply operation {gate_name} to measured qubits {intersection}"
                 )
             params = _create_free_parameters(operation)
+            _validate_angle_restrictions(gate_name, params, angle_restrictions)
             if gate_name in _QISKIT_CONTROLLED_GATE_NAMES_TO_BRAKET_GATES:
                 for gate in _QISKIT_CONTROLLED_GATE_NAMES_TO_BRAKET_GATES[gate_name](
                     *params
@@ -594,6 +625,53 @@ def _create_free_parameters(operation):
     return params
 
 
+def _validate_angle_restrictions(
+    gate_name: str,
+    params: Iterable,
+    angle_restrictions: Optional[
+        dict[str, dict[int, Union[set[float], tuple[float, float]]]]
+    ],
+) -> None:
+    """Validate gate parameter angles against a restriction map.
+
+    Parameters that are ``FreeParameter`` or ``ParameterExpression`` instances
+    are ignored. Numeric angles are validated against the entry in
+    ``angle_restrictions`` for the ``gate_name``. Each restriction can be a set
+    of discrete allowed values or a ``(min, max)`` tuple describing an inclusive
+    range.
+    """
+    if not angle_restrictions or gate_name not in angle_restrictions:
+        return
+    restrictions = angle_restrictions[gate_name]
+    params = list(params)
+    for index, restriction in restrictions.items():
+        if index >= len(params):
+            continue
+        param = params[index]
+        if isinstance(
+            param,
+            (
+                FreeParameter,
+                FreeParameterExpression,
+                ParameterExpression,
+            ),
+        ):
+            continue
+        angle = float(param)
+        if isinstance(restriction, set):
+            if not any(abs(angle - allowed) <= _EPS for allowed in restriction):
+                raise ValueError(
+                    f"Angle {angle} for {gate_name} parameter {index} is not supported"
+                )
+        else:
+            min_angle, max_angle = restriction
+            if angle < min_angle - _EPS or angle > max_angle + _EPS:
+                raise ValueError(
+                    f"Angle {angle} for {gate_name} parameter {index} "
+                    f"not in range [{min_angle}, {max_angle}]"
+                )
+
+
 def _rename_param_vector_element(parameter):
     return str(parameter).replace("[", "_").replace("]", "")
 
diff --git a/qiskit_braket_provider/providers/braket_backend.py b/qiskit_braket_provider/providers/braket_backend.py
index 1301b2f..7ec3be2 100644
--- a/qiskit_braket_provider/providers/braket_backend.py
+++ b/qiskit_braket_provider/providers/braket_backend.py
@@ -23,6 +23,7 @@ from .adapter import (
     aws_device_to_target,
     gateset_from_properties,
     local_simulator_to_target,
+    native_angle_restrictions,
     native_gate_connectivity,
     native_gate_set,
     to_braket,
@@ -340,10 +341,17 @@ class BraketAwsBackend(BraketBackend):
         connectivity = (
             native_gate_connectivity(self._device.properties) if native else None
         )
+        angle_restrictions = (
+            native_angle_restrictions(self._device.properties) if native else None
+        )
 
         braket_circuits = [
             to_braket(
-                circ, basis_gates=gateset, verbatim=verbatim, connectivity=connectivity
+                circ,
+                basis_gates=gateset,
+                verbatim=verbatim,
+                connectivity=connectivity,
+                angle_restrictions=angle_restrictions,
             )
             for circ in circuits
         ]


Test patch:
diff --git a/tests/providers/test_adapter.py b/tests/providers/test_adapter.py
index 66e4ad4..199221f 100644
--- a/tests/providers/test_adapter.py
+++ b/tests/providers/test_adapter.py
@@ -7,6 +7,8 @@ import numpy as np
 import pytest
 from braket.circuits import Circuit, FreeParameter, Gate, Instruction
 from braket.circuits.angled_gate import AngledGate, DoubleAngledGate, TripleAngledGate
+from braket.device_schema.ionq import IonqDeviceCapabilities
+from braket.device_schema.simulators import GateModelSimulatorDeviceCapabilities
 from qiskit import ClassicalRegister, QuantumCircuit, QuantumRegister
 from qiskit.circuit import Parameter, ParameterVector
 from qiskit.circuit.library import GlobalPhaseGate, PauliEvolutionGate
@@ -18,8 +20,10 @@ from qiskit_braket_provider.providers.adapter import (
     _GATE_NAME_TO_BRAKET_GATE,
     _GATE_NAME_TO_QISKIT_GATE,
     _get_controlled_gateset,
+    _validate_angle_restrictions,
     convert_qiskit_to_braket_circuit,
     convert_qiskit_to_braket_circuits,
+    native_angle_restrictions,
     to_braket,
     to_qiskit,
 )
@@ -600,6 +604,133 @@ class TestAdapter(TestCase):
             )
         )
 
+    def test_angle_restrictions_rigetti(self):
+        """Tests that angle restrictions for native gates are enforced."""
+        circuit = QuantumCircuit(1)
+        circuit.rx(np.pi / 4, 0)
+
+        restrictions = {"rx": {0: {np.pi, -np.pi, np.pi / 2, -np.pi / 2}}}
+        with pytest.raises(ValueError):
+            to_braket(circuit, basis_gates={"rx"}, angle_restrictions=restrictions)
+
+    def test_angle_restrictions_rigetti_valid(self):
+        """Tests that allowed Rigetti angles pass validation."""
+        circuit = QuantumCircuit(1)
+        circuit.rx(np.pi / 2, 0)
+
+        restrictions = {"rx": {0: {np.pi, -np.pi, np.pi / 2, -np.pi / 2}}}
+        braket_circuit = to_braket(
+            circuit, basis_gates={"rx"}, angle_restrictions=restrictions
+        )
+        assert len(braket_circuit.instructions) == 1
+
+    def test_validate_angle_restrictions_extra_index(self):
+        """Restrictions on unused parameter indices should be ignored."""
+        _validate_angle_restrictions("rx", [0.0], {"rx": {1: {0.0}}})
+
+    def test_native_angle_restrictions_default(self):
+        """Unknown device types should return an empty restriction map."""
+        properties = GateModelSimulatorDeviceCapabilities.parse_obj(
+            {
+                "braketSchemaHeader": {
+                    "name": "braket.device_schema.simulators.gate_model_simulator_device_capabilities",
+                    "version": "1",
+                },
+                "service": {"executionWindows": [], "shotsRange": [1, 10]},
+                "action": {
+                    "braket.ir.jaqcd.program": {
+                        "actionType": "braket.ir.jaqcd.program",
+                        "version": ["1"],
+                        "supportedOperations": ["x"],
+                    }
+                },
+                "paradigm": {"qubitCount": 2},
+                "deviceParameters": {},
+            }
+        )
+        assert not native_angle_restrictions(properties)
+
+    def test_angle_restrictions_ionq(self):
+        """Tests IonQ MS gate angle range enforcement."""
+        circuit = QuantumCircuit(2)
+        circuit.append(ionq_gates.MSGate(0, 0, 3), [0, 1])
+
+        restrictions = {"ms": {2: (0.0, 0.25)}}
+        with pytest.raises(ValueError):
+            to_braket(circuit, basis_gates={"ms"}, angle_restrictions=restrictions)
+
+    def test_angle_restrictions_ionq_valid(self):
+        """Tests that allowed IonQ MS angles pass validation."""
+        circuit = QuantumCircuit(2)
+        circuit.append(ionq_gates.MSGate(0, 0, 0.25), [0, 1])
+
+        restrictions = {"ms": {2: (0.0, 0.25)}}
+        braket_circuit = to_braket(
+            circuit, basis_gates={"ms"}, angle_restrictions=restrictions
+        )
+        assert len(braket_circuit.instructions) == 1
+
+    def test_native_angle_restrictions_ionq(self):
+        """Tests that IonQ capabilities return the correct angle restriction map."""
+        properties = IonqDeviceCapabilities.parse_obj(
+            {
+                "braketSchemaHeader": {
+                    "name": "braket.device_schema.ionq.ionq_device_capabilities",
+                    "version": "1",
+                },
+                "service": {
+                    "braketSchemaHeader": {
+                        "name": "braket.device_schema.device_service_properties",
+                        "version": "1",
+                    },
+                    "executionWindows": [],
+                    "shotsRange": [1, 10],
+                    "deviceCost": {"price": 0.25, "unit": "minute"},
+                    "deviceDocumentation": {
+                        "imageUrl": "",
+                        "summary": "",
+                        "externalDocumentationUrl": "",
+                    },
+                    "deviceLocation": "us-east-1",
+                    "updatedAt": "2020-06-16T00:00:00",
+                },
+                "action": {
+                    "braket.ir.jaqcd.program": {
+                        "actionType": "braket.ir.jaqcd.program",
+                        "version": ["1"],
+                        "supportedOperations": ["x"],
+                    }
+                },
+                "paradigm": {
+                    "braketSchemaHeader": {
+                        "name": "braket.device_schema.gate_model_qpu_paradigm_properties",
+                        "version": "1",
+                    },
+                    "qubitCount": 2,
+                    "nativeGateSet": [],
+                    "connectivity": {
+                        "fullyConnected": False,
+                        "connectivityGraph": {"0": ["1"], "1": ["0"]},
+                    },
+                },
+                "deviceParameters": {},
+            }
+        )
+
+        assert native_angle_restrictions(properties) == {"ms": {2: (0.0, 0.25)}}
+
+    def test_angle_restrictions_skip_parameter_expression(self):
+        """Parameters that are expressions should bypass angle validation."""
+        theta = Parameter("theta")
+        circuit = QuantumCircuit(1)
+        circuit.rx(theta, 0)
+
+        restrictions = {"rx": {0: {0.0}}}
+        braket_circuit = to_braket(
+            circuit, basis_gates={"rx"}, angle_restrictions=restrictions
+        )
+        assert len(braket_circuit.instructions) == 1
+
 
 class TestFromBraket(TestCase):
     """Test Braket circuit conversion."""