From 1c52075360f3729273b7e0997bcd6da61fa170ae Mon Sep 17 00:00:00 2001 From: Ali Asadi <10773383+maliasadi@users.noreply.github.com> Date: Thu, 20 Nov 2025 10:04:36 -0500 Subject: [PATCH 1/8] Remove RUNTIME_OPERATIONS from the frontend --- doc/releases/changelog-dev.md | 7 +++ frontend/catalyst/device/qjit_device.py | 58 +++++------------------ frontend/test/pytest/test_preprocess.py | 8 ++-- frontend/test/pytest/test_verification.py | 22 ++++----- 4 files changed, 35 insertions(+), 60 deletions(-) diff --git a/doc/releases/changelog-dev.md b/doc/releases/changelog-dev.md index a6857cce15..50b15141d1 100644 --- a/doc/releases/changelog-dev.md +++ b/doc/releases/changelog-dev.md @@ -7,6 +7,13 @@

Improvements 🛠

+* Remove the hardcoded list of runtime operations in the frontend. + This will allow arbitrary PL gates to be represented without hyperparameters in MLIR. + For gates that do not have a QIR representation, a runtime error will be raised at execution. + Users can still decompose these gates via `qml.transforms.decompose` + when both capture and graph-decomposition are enabled. + [(#2215)](https://github.com/PennyLaneAI/catalyst/pull/2215) + * Resource tracking now supports dynamic qubit allocation [(#2203)](https://github.com/PennyLaneAI/catalyst/pull/2203) diff --git a/frontend/catalyst/device/qjit_device.py b/frontend/catalyst/device/qjit_device.py index b4f92bf022..848a638d48 100644 --- a/frontend/catalyst/device/qjit_device.py +++ b/frontend/catalyst/device/qjit_device.py @@ -56,44 +56,6 @@ logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) -RUNTIME_OPERATIONS = [ - "CNOT", - "ControlledPhaseShift", - "CRot", - "CRX", - "CRY", - "CRZ", - "CSWAP", - "CY", - "CZ", - "Hadamard", - "Identity", - "IsingXX", - "IsingXY", - "IsingYY", - "IsingZZ", - "SingleExcitation", - "DoubleExcitation", - "ISWAP", - "MultiRZ", - "PauliX", - "PauliY", - "PauliZ", - "PCPhase", - "PhaseShift", - "PSWAP", - "QubitUnitary", - "Rot", - "RX", - "RY", - "RZ", - "S", - "SWAP", - "T", - "Toffoli", - "GlobalPhase", -] - RUNTIME_OBSERVABLES = [ "Identity", "PauliX", @@ -109,11 +71,9 @@ RUNTIME_MPS = ["ExpectationMP", "SampleMP", "VarianceMP", "CountsMP", "StateMP", "ProbabilityMP"] -# The runtime interface does not care about specific gate properties, so set them all to True. -RUNTIME_OPERATIONS = { - op: OperatorProperties(invertible=True, controllable=True, differentiable=True) - for op in RUNTIME_OPERATIONS -} +# A list of custom operations supported by the Catalyst compiler. +# This is useful especially for testing a device with custom operations. +CUSTOM_OPERATIONS = {} RUNTIME_OBSERVABLES = { obs: OperatorProperties(invertible=True, controllable=True, differentiable=True) @@ -199,6 +159,14 @@ def extract_backend_info(device: qml.devices.QubitDevice) -> BackendInfo: return BackendInfo(dname, device_name, device_lpath, device_kwargs) +def union_operations( + a: Dict[str, OperatorProperties], b: Dict[str, OperatorProperties] +) -> Dict[str, OperatorProperties]: + """Union of two sets of operator properties""" + return {**a, **b} + # return {k: a[k] & b[k] for k in (a.keys() & b.keys())} + + def intersect_operations( a: Dict[str, OperatorProperties], b: Dict[str, OperatorProperties] ) -> Dict[str, OperatorProperties]: @@ -223,8 +191,8 @@ def get_qjit_device_capabilities(target_capabilities: DeviceCapabilities) -> Dev qjit_capabilities = deepcopy(target_capabilities) # Intersection of gates and observables supported by the device and by Catalyst runtime. - qjit_capabilities.operations = intersect_operations( - target_capabilities.operations, RUNTIME_OPERATIONS + qjit_capabilities.operations = union_operations( + target_capabilities.operations, CUSTOM_OPERATIONS ) qjit_capabilities.observables = intersect_operations( target_capabilities.observables, RUNTIME_OBSERVABLES diff --git a/frontend/test/pytest/test_preprocess.py b/frontend/test/pytest/test_preprocess.py index a918ec09d4..916e3e7c97 100644 --- a/frontend/test/pytest/test_preprocess.py +++ b/frontend/test/pytest/test_preprocess.py @@ -118,14 +118,14 @@ def test_decompose_integration(self): @qml.qnode(dev) def circuit(theta: float): - qml.SingleExcitationPlus(theta, wires=[0, 1]) + qml.OrbitalRotation(theta, wires=[0, 1, 2, 3]) return qml.state() mlir = qjit(circuit, target="mlir").mlir + assert "SingleExcitation" in mlir assert "Hadamard" in mlir - assert "CNOT" in mlir - assert "RY" in mlir - assert "SingleExcitationPlus" not in mlir + assert "RX" in mlir + assert "OrbitalRotation" not in mlir def test_decompose_ops_to_unitary(self): """Test the decompose ops to unitary transform.""" diff --git a/frontend/test/pytest/test_verification.py b/frontend/test/pytest/test_verification.py index e50d5c10ce..b88cf26642 100644 --- a/frontend/test/pytest/test_verification.py +++ b/frontend/test/pytest/test_verification.py @@ -37,7 +37,7 @@ from catalyst.api_extensions import HybridAdjoint, HybridCtrl from catalyst.compiler import get_lib_path from catalyst.device import get_device_capabilities -from catalyst.device.qjit_device import RUNTIME_OPERATIONS, get_qjit_device_capabilities +from catalyst.device.qjit_device import CUSTOM_OPERATIONS, get_qjit_device_capabilities from catalyst.device.verification import validate_measurements # pylint: disable = unused-argument, unnecessary-lambda-assignment, unnecessary-lambda @@ -290,7 +290,7 @@ def test_non_controllable_gate_hybridctrl(self): # Note: The HybridCtrl operator is not currently supported with the QJIT device, but the # verification structure is in place, so we test the verification of its nested operators by # adding HybridCtrl to the list of native gates for the custom base device and by patching - # the list of RUNTIME_OPERATIONS for the QJIT device to include HybridCtrl for this test. + # the list of CUSTOM_OPERATIONS for the QJIT device to include HybridCtrl for this test. @qml.qnode( get_custom_device( @@ -302,12 +302,12 @@ def f(x: float): assert isinstance(op, HybridCtrl), f"op expected to be HybridCtrl but got {type(op)}" return qml.expval(qml.PauliX(0)) - runtime_ops_with_qctrl = deepcopy(RUNTIME_OPERATIONS) + runtime_ops_with_qctrl = deepcopy(CUSTOM_OPERATIONS) runtime_ops_with_qctrl["HybridCtrl"] = OperatorProperties( invertible=True, controllable=True, differentiable=True ) - with patch("catalyst.device.qjit_device.RUNTIME_OPERATIONS", runtime_ops_with_qctrl): + with patch("catalyst.device.qjit_device.CUSTOM_OPERATIONS", runtime_ops_with_qctrl): with pytest.raises(CompileError, match="PauliZ is not controllable"): qjit(f)(1.2) @@ -321,7 +321,7 @@ def test_hybridctrl_raises_error(self): """Test that a HybridCtrl operator is rejected by the verification.""" # TODO: If you are deleting this test because HybridCtrl support has been added, consider - # updating the tests that patch RUNTIME_OPERATIONS to inclue HybridCtrl accordingly + # updating the tests that patch CUSTOM_OPERATIONS to inclue HybridCtrl accordingly @qml.qnode(get_custom_device(non_controllable_gates={"PauliZ"}, wires=4)) def f(x: float): @@ -391,7 +391,7 @@ def test_hybrid_ctrl_containing_adjoint(self, adjoint_type, unsupported_gate_att # Note: The HybridCtrl operator is not currently supported with the QJIT device, but the # verification structure is in place, so we test the verification of its nested operators by # adding HybridCtrl to the list of native gates for the custom base device and by patching - # the list of RUNTIME_OPERATIONS for the QJIT device to include HybridCtrl for this test. + # the list of CUSTOM_OPERATIONS for the QJIT device to include HybridCtrl for this test. def _ops(x, wires): if adjoint_type == HybridAdjoint: @@ -410,12 +410,12 @@ def f(x: float): assert isinstance(base, adjoint_type), f"expected {adjoint_type} but got {type(op)}" return qml.expval(qml.PauliX(0)) - runtime_ops_with_qctrl = deepcopy(RUNTIME_OPERATIONS) + runtime_ops_with_qctrl = deepcopy(CUSTOM_OPERATIONS) runtime_ops_with_qctrl["HybridCtrl"] = OperatorProperties( invertible=True, controllable=True, differentiable=True ) - with patch("catalyst.device.qjit_device.RUNTIME_OPERATIONS", runtime_ops_with_qctrl): + with patch("catalyst.device.qjit_device.CUSTOM_OPERATIONS", runtime_ops_with_qctrl): with pytest.raises(CompileError, match=f"PauliZ is not {unsupported_gate_attribute}"): qjit(f)(1.2) @@ -434,7 +434,7 @@ def test_hybrid_adjoint_containing_hybrid_ctrl(self, ctrl_type, unsupported_gate # Note: The HybridCtrl operator is not currently supported with the QJIT device, but the # verification structure is in place, so we test the verification of its nested operators by # adding HybridCtrl to the list of native gates for the custom base device and by patching - # the list of RUNTIME_OPERATIONS for the QJIT device to include HybridCtrl for this test. + # the list of CUSTOM_OPERATIONS for the QJIT device to include HybridCtrl for this test. def _ops(x, wires): if ctrl_type == HybridCtrl: @@ -453,12 +453,12 @@ def f(x: float): assert isinstance(base, ctrl_type), f"expected {ctrl_type} but got {type(op)}" return qml.expval(qml.PauliX(0)) - runtime_ops_with_qctrl = deepcopy(RUNTIME_OPERATIONS) + runtime_ops_with_qctrl = deepcopy(CUSTOM_OPERATIONS) runtime_ops_with_qctrl["HybridCtrl"] = OperatorProperties( invertible=True, controllable=True, differentiable=True ) - with patch("catalyst.device.qjit_device.RUNTIME_OPERATIONS", runtime_ops_with_qctrl): + with patch("catalyst.device.qjit_device.CUSTOM_OPERATIONS", runtime_ops_with_qctrl): with pytest.raises(CompileError, match=f"PauliZ is not {unsupported_gate_attribute}"): qjit(f)(1.2) From 64cd133e0838ecb73b73db119524bc58a29d3a7c Mon Sep 17 00:00:00 2001 From: Ali Asadi <10773383+maliasadi@users.noreply.github.com> Date: Mon, 1 Dec 2025 14:00:29 -0500 Subject: [PATCH 2/8] Update --- doc/releases/changelog-dev.md | 4 -- frontend/catalyst/device/qjit_device.py | 56 +++++------------------ frontend/test/pytest/test_preprocess.py | 8 ++-- frontend/test/pytest/test_verification.py | 22 ++++----- 4 files changed, 26 insertions(+), 64 deletions(-) diff --git a/doc/releases/changelog-dev.md b/doc/releases/changelog-dev.md index 0c4f7ac800..701d504553 100644 --- a/doc/releases/changelog-dev.md +++ b/doc/releases/changelog-dev.md @@ -89,10 +89,6 @@ that include `qml.StatePrep` operations. [(#2230)](https://github.com/PennyLaneAI/catalyst/pull/2230) -* Resource tracking now tracks calls to `SetState` and `SetBasisState`, and can report results - that include `qml.StatePrep` operations. - [(#2230)](https://github.com/PennyLaneAI/catalyst/pull/2230) - * `qml.PCPhase` can be compiled and executed with capture enabled. [(#2226)](https://github.com/PennyLaneAI/catalyst/pull/2226) diff --git a/frontend/catalyst/device/qjit_device.py b/frontend/catalyst/device/qjit_device.py index b4f92bf022..2d9dc75d3b 100644 --- a/frontend/catalyst/device/qjit_device.py +++ b/frontend/catalyst/device/qjit_device.py @@ -56,44 +56,6 @@ logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) -RUNTIME_OPERATIONS = [ - "CNOT", - "ControlledPhaseShift", - "CRot", - "CRX", - "CRY", - "CRZ", - "CSWAP", - "CY", - "CZ", - "Hadamard", - "Identity", - "IsingXX", - "IsingXY", - "IsingYY", - "IsingZZ", - "SingleExcitation", - "DoubleExcitation", - "ISWAP", - "MultiRZ", - "PauliX", - "PauliY", - "PauliZ", - "PCPhase", - "PhaseShift", - "PSWAP", - "QubitUnitary", - "Rot", - "RX", - "RY", - "RZ", - "S", - "SWAP", - "T", - "Toffoli", - "GlobalPhase", -] - RUNTIME_OBSERVABLES = [ "Identity", "PauliX", @@ -109,11 +71,9 @@ RUNTIME_MPS = ["ExpectationMP", "SampleMP", "VarianceMP", "CountsMP", "StateMP", "ProbabilityMP"] -# The runtime interface does not care about specific gate properties, so set them all to True. -RUNTIME_OPERATIONS = { - op: OperatorProperties(invertible=True, controllable=True, differentiable=True) - for op in RUNTIME_OPERATIONS -} +# A list of custom operations supported by the Catalyst compiler. +# This is useful especially for testing a device with custom operations. +CUSTOM_OPERATIONS = {} RUNTIME_OBSERVABLES = { obs: OperatorProperties(invertible=True, controllable=True, differentiable=True) @@ -198,6 +158,12 @@ def extract_backend_info(device: qml.devices.QubitDevice) -> BackendInfo: return BackendInfo(dname, device_name, device_lpath, device_kwargs) +def union_operations( + a: Dict[str, OperatorProperties], b: Dict[str, OperatorProperties] +) -> Dict[str, OperatorProperties]: + """Union of two sets of operator properties""" + return {**a, **b} + # return {k: a[k] & b[k] for k in (a.keys() & b.keys())} def intersect_operations( a: Dict[str, OperatorProperties], b: Dict[str, OperatorProperties] @@ -223,8 +189,8 @@ def get_qjit_device_capabilities(target_capabilities: DeviceCapabilities) -> Dev qjit_capabilities = deepcopy(target_capabilities) # Intersection of gates and observables supported by the device and by Catalyst runtime. - qjit_capabilities.operations = intersect_operations( - target_capabilities.operations, RUNTIME_OPERATIONS + qjit_capabilities.operations = union_operations( + target_capabilities.operations, CUSTOM_OPERATIONS ) qjit_capabilities.observables = intersect_operations( target_capabilities.observables, RUNTIME_OBSERVABLES diff --git a/frontend/test/pytest/test_preprocess.py b/frontend/test/pytest/test_preprocess.py index a918ec09d4..916e3e7c97 100644 --- a/frontend/test/pytest/test_preprocess.py +++ b/frontend/test/pytest/test_preprocess.py @@ -118,14 +118,14 @@ def test_decompose_integration(self): @qml.qnode(dev) def circuit(theta: float): - qml.SingleExcitationPlus(theta, wires=[0, 1]) + qml.OrbitalRotation(theta, wires=[0, 1, 2, 3]) return qml.state() mlir = qjit(circuit, target="mlir").mlir + assert "SingleExcitation" in mlir assert "Hadamard" in mlir - assert "CNOT" in mlir - assert "RY" in mlir - assert "SingleExcitationPlus" not in mlir + assert "RX" in mlir + assert "OrbitalRotation" not in mlir def test_decompose_ops_to_unitary(self): """Test the decompose ops to unitary transform.""" diff --git a/frontend/test/pytest/test_verification.py b/frontend/test/pytest/test_verification.py index e50d5c10ce..b88cf26642 100644 --- a/frontend/test/pytest/test_verification.py +++ b/frontend/test/pytest/test_verification.py @@ -37,7 +37,7 @@ from catalyst.api_extensions import HybridAdjoint, HybridCtrl from catalyst.compiler import get_lib_path from catalyst.device import get_device_capabilities -from catalyst.device.qjit_device import RUNTIME_OPERATIONS, get_qjit_device_capabilities +from catalyst.device.qjit_device import CUSTOM_OPERATIONS, get_qjit_device_capabilities from catalyst.device.verification import validate_measurements # pylint: disable = unused-argument, unnecessary-lambda-assignment, unnecessary-lambda @@ -290,7 +290,7 @@ def test_non_controllable_gate_hybridctrl(self): # Note: The HybridCtrl operator is not currently supported with the QJIT device, but the # verification structure is in place, so we test the verification of its nested operators by # adding HybridCtrl to the list of native gates for the custom base device and by patching - # the list of RUNTIME_OPERATIONS for the QJIT device to include HybridCtrl for this test. + # the list of CUSTOM_OPERATIONS for the QJIT device to include HybridCtrl for this test. @qml.qnode( get_custom_device( @@ -302,12 +302,12 @@ def f(x: float): assert isinstance(op, HybridCtrl), f"op expected to be HybridCtrl but got {type(op)}" return qml.expval(qml.PauliX(0)) - runtime_ops_with_qctrl = deepcopy(RUNTIME_OPERATIONS) + runtime_ops_with_qctrl = deepcopy(CUSTOM_OPERATIONS) runtime_ops_with_qctrl["HybridCtrl"] = OperatorProperties( invertible=True, controllable=True, differentiable=True ) - with patch("catalyst.device.qjit_device.RUNTIME_OPERATIONS", runtime_ops_with_qctrl): + with patch("catalyst.device.qjit_device.CUSTOM_OPERATIONS", runtime_ops_with_qctrl): with pytest.raises(CompileError, match="PauliZ is not controllable"): qjit(f)(1.2) @@ -321,7 +321,7 @@ def test_hybridctrl_raises_error(self): """Test that a HybridCtrl operator is rejected by the verification.""" # TODO: If you are deleting this test because HybridCtrl support has been added, consider - # updating the tests that patch RUNTIME_OPERATIONS to inclue HybridCtrl accordingly + # updating the tests that patch CUSTOM_OPERATIONS to inclue HybridCtrl accordingly @qml.qnode(get_custom_device(non_controllable_gates={"PauliZ"}, wires=4)) def f(x: float): @@ -391,7 +391,7 @@ def test_hybrid_ctrl_containing_adjoint(self, adjoint_type, unsupported_gate_att # Note: The HybridCtrl operator is not currently supported with the QJIT device, but the # verification structure is in place, so we test the verification of its nested operators by # adding HybridCtrl to the list of native gates for the custom base device and by patching - # the list of RUNTIME_OPERATIONS for the QJIT device to include HybridCtrl for this test. + # the list of CUSTOM_OPERATIONS for the QJIT device to include HybridCtrl for this test. def _ops(x, wires): if adjoint_type == HybridAdjoint: @@ -410,12 +410,12 @@ def f(x: float): assert isinstance(base, adjoint_type), f"expected {adjoint_type} but got {type(op)}" return qml.expval(qml.PauliX(0)) - runtime_ops_with_qctrl = deepcopy(RUNTIME_OPERATIONS) + runtime_ops_with_qctrl = deepcopy(CUSTOM_OPERATIONS) runtime_ops_with_qctrl["HybridCtrl"] = OperatorProperties( invertible=True, controllable=True, differentiable=True ) - with patch("catalyst.device.qjit_device.RUNTIME_OPERATIONS", runtime_ops_with_qctrl): + with patch("catalyst.device.qjit_device.CUSTOM_OPERATIONS", runtime_ops_with_qctrl): with pytest.raises(CompileError, match=f"PauliZ is not {unsupported_gate_attribute}"): qjit(f)(1.2) @@ -434,7 +434,7 @@ def test_hybrid_adjoint_containing_hybrid_ctrl(self, ctrl_type, unsupported_gate # Note: The HybridCtrl operator is not currently supported with the QJIT device, but the # verification structure is in place, so we test the verification of its nested operators by # adding HybridCtrl to the list of native gates for the custom base device and by patching - # the list of RUNTIME_OPERATIONS for the QJIT device to include HybridCtrl for this test. + # the list of CUSTOM_OPERATIONS for the QJIT device to include HybridCtrl for this test. def _ops(x, wires): if ctrl_type == HybridCtrl: @@ -453,12 +453,12 @@ def f(x: float): assert isinstance(base, ctrl_type), f"expected {ctrl_type} but got {type(op)}" return qml.expval(qml.PauliX(0)) - runtime_ops_with_qctrl = deepcopy(RUNTIME_OPERATIONS) + runtime_ops_with_qctrl = deepcopy(CUSTOM_OPERATIONS) runtime_ops_with_qctrl["HybridCtrl"] = OperatorProperties( invertible=True, controllable=True, differentiable=True ) - with patch("catalyst.device.qjit_device.RUNTIME_OPERATIONS", runtime_ops_with_qctrl): + with patch("catalyst.device.qjit_device.CUSTOM_OPERATIONS", runtime_ops_with_qctrl): with pytest.raises(CompileError, match=f"PauliZ is not {unsupported_gate_attribute}"): qjit(f)(1.2) From 6f9a34fe0cc7409ea34bfab7e66b82acf5e119db Mon Sep 17 00:00:00 2001 From: Ali Asadi <10773383+maliasadi@users.noreply.github.com> Date: Mon, 1 Dec 2025 14:14:25 -0500 Subject: [PATCH 3/8] Update format --- frontend/catalyst/device/qjit_device.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/catalyst/device/qjit_device.py b/frontend/catalyst/device/qjit_device.py index 2d9dc75d3b..848a638d48 100644 --- a/frontend/catalyst/device/qjit_device.py +++ b/frontend/catalyst/device/qjit_device.py @@ -158,6 +158,7 @@ def extract_backend_info(device: qml.devices.QubitDevice) -> BackendInfo: return BackendInfo(dname, device_name, device_lpath, device_kwargs) + def union_operations( a: Dict[str, OperatorProperties], b: Dict[str, OperatorProperties] ) -> Dict[str, OperatorProperties]: @@ -165,6 +166,7 @@ def union_operations( return {**a, **b} # return {k: a[k] & b[k] for k in (a.keys() & b.keys())} + def intersect_operations( a: Dict[str, OperatorProperties], b: Dict[str, OperatorProperties] ) -> Dict[str, OperatorProperties]: From 73b48655707acf81c9d7aed21c8efb6626411a73 Mon Sep 17 00:00:00 2001 From: Ali Asadi <10773383+maliasadi@users.noreply.github.com> Date: Mon, 1 Dec 2025 18:14:25 -0500 Subject: [PATCH 4/8] Add is_lowering_compatible --- frontend/catalyst/device/decomposition.py | 3 ++- frontend/catalyst/device/op_support.py | 23 +++++++++++++++++ .../catalyst/from_plxpr/qfunc_interpreter.py | 25 +++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/frontend/catalyst/device/decomposition.py b/frontend/catalyst/device/decomposition.py index 22c66deac3..d72921ef31 100644 --- a/frontend/catalyst/device/decomposition.py +++ b/frontend/catalyst/device/decomposition.py @@ -42,6 +42,7 @@ is_controllable, is_differentiable, is_invertible, + is_lowering_compatible, is_supported, ) from catalyst.jax_tracer import HybridOpRegion, has_nested_tapes @@ -227,7 +228,7 @@ def catalyst_acceptance( if match and is_controllable(op.base, capabilities): return match - elif is_supported(op, capabilities): + elif is_supported(op, capabilities) and is_lowering_compatible(op): return op.name return None diff --git a/frontend/catalyst/device/op_support.py b/frontend/catalyst/device/op_support.py index f86f0cc2c9..64e033f8bb 100644 --- a/frontend/catalyst/device/op_support.py +++ b/frontend/catalyst/device/op_support.py @@ -41,6 +41,29 @@ def is_supported(op: Operator, capabilities: DeviceCapabilities) -> bool: return op.name in capabilities.operations +def is_lowering_compatible(op: Operator) -> bool: + """Check whether an operation is compatible with quantum instructions.""" + # Exceptions for operations that are not quantum instructions but are allowed + if isinstance(op, qml.Snapshot): + return True + + # Accepted hyperparameters for quantum instructions bind calls + _accepted_hyperparams = { + "num_wires", # CNOT, etc. + "n_wires", # Identity, etc. + "control_wires", # CNOT, etc. + "control_values", # CNOT, etc. + "work_wires", # CNOT, etc. + "work_wire_type", # CNOT, etc. + "base", # CNOT, etc. + } + + for hyperparam in op.hyperparameters: + if hyperparam not in _accepted_hyperparams: + return False + return True + + def _is_grad_recipe_same_as_catalyst(op): """Checks that the grad_recipe for the op matches the hard coded one in Catalyst.""" diff --git a/frontend/catalyst/from_plxpr/qfunc_interpreter.py b/frontend/catalyst/from_plxpr/qfunc_interpreter.py index 60b16cb325..89dbbdebfb 100644 --- a/frontend/catalyst/from_plxpr/qfunc_interpreter.py +++ b/frontend/catalyst/from_plxpr/qfunc_interpreter.py @@ -177,11 +177,25 @@ def interpret_operation(self, op, is_adjoint=False, control_values=(), control_w if any(not qreg.is_qubit_mode() and qreg.expired for qreg in in_qregs + in_ctrl_qregs): raise CompileError(f"Deallocated qubits cannot be used, but used in {op.name}.") + _spacial_bind = False if (fn := _special_op_bind_call.get(type(op))) is not None: bind_fn = partial(fn, hyperparameters=op.hyperparameters) + _spacial_bind = True else: bind_fn = qinst_p.bind + if not _spacial_bind: + # raise an error if there are unsupported hyperparameters + # for the generic qinst_p bind call + # This is to avoid silent bugs where hyperparameters + # are simply ignored + for key in op.hyperparameters.keys(): + if key not in _accepted_hyperparams: + raise CompileError( + f"Operation {op.name} has unsupported hyperparameter '{key}' " + "for generic quantum instruction binding." + ) + out_qubits = bind_fn( *[*in_qubits, *op.data, *in_ctrl_qubits, *control_values], op=op.name, @@ -780,3 +794,14 @@ def calling_convention(*args_plus_qreg): qml.PCPhase: _pcphase_bind_call, qml.PauliRot: _pauli_rot_bind_call, } + +# Accepted hyperparameters for quantum instructions bind calls +_accepted_hyperparams = { + "num_wires", # CNOT, etc. + "n_wires", # Identity, etc. + "control_wires", # CNOT, etc. + "control_values", # CNOT, etc. + "work_wires", # CNOT, etc. + "work_wire_type", # CNOT, etc. + "base", # CNOT, etc. +} From 791033fc94d84a3e3692ef03e59de8cc012225fc Mon Sep 17 00:00:00 2001 From: Ali Asadi <10773383+maliasadi@users.noreply.github.com> Date: Mon, 1 Dec 2025 23:20:40 -0500 Subject: [PATCH 5/8] Update --- frontend/catalyst/device/op_support.py | 4 +-- .../catalyst/from_plxpr/qfunc_interpreter.py | 32 ++++--------------- 2 files changed, 9 insertions(+), 27 deletions(-) diff --git a/frontend/catalyst/device/op_support.py b/frontend/catalyst/device/op_support.py index 64e033f8bb..201dfef035 100644 --- a/frontend/catalyst/device/op_support.py +++ b/frontend/catalyst/device/op_support.py @@ -42,9 +42,9 @@ def is_supported(op: Operator, capabilities: DeviceCapabilities) -> bool: def is_lowering_compatible(op: Operator) -> bool: - """Check whether an operation is compatible with quantum instructions.""" + """Check if an operation can be lowered to MLIR using JAX primitives.""" # Exceptions for operations that are not quantum instructions but are allowed - if isinstance(op, qml.Snapshot): + if isinstance(op, (qml.Snapshot, qml.PCPhase, qml.MultiRZ)): return True # Accepted hyperparameters for quantum instructions bind calls diff --git a/frontend/catalyst/from_plxpr/qfunc_interpreter.py b/frontend/catalyst/from_plxpr/qfunc_interpreter.py index 78b5c7e87c..dc6b2ca866 100644 --- a/frontend/catalyst/from_plxpr/qfunc_interpreter.py +++ b/frontend/catalyst/from_plxpr/qfunc_interpreter.py @@ -35,6 +35,7 @@ from pennylane.ftqc.primitives import measure_in_basis_prim as plxpr_measure_in_basis_prim from pennylane.measurements import CountsMP +from catalyst.device.op_support import is_lowering_compatible from catalyst.jax_extras import jaxpr_pad_consts from catalyst.jax_primitives import ( AbstractQbit, @@ -179,25 +180,17 @@ def interpret_operation(self, op, is_adjoint=False, control_values=(), control_w if any(not qreg.is_qubit_mode() and qreg.expired for qreg in in_qregs + in_ctrl_qregs): raise CompileError(f"Deallocated qubits cannot be used, but used in {op.name}.") - _spacial_bind = False if (fn := _special_op_bind_call.get(type(op))) is not None: bind_fn = partial(fn, hyperparameters=op.hyperparameters) - _spacial_bind = True else: + # FIXME: Remove this after integrating + if not is_lowering_compatible(op): + raise CompileError( + f"Operation {op.name} with hyperparameters {list(op.hyperparameters.keys())} " + "is not compatible with quantum instructions." + ) bind_fn = qinst_p.bind - if not _spacial_bind: - # raise an error if there are unsupported hyperparameters - # for the generic qinst_p bind call - # This is to avoid silent bugs where hyperparameters - # are simply ignored - for key in op.hyperparameters.keys(): - if key not in _accepted_hyperparams: - raise CompileError( - f"Operation {op.name} has unsupported hyperparameter '{key}' " - "for generic quantum instruction binding." - ) - out_qubits = bind_fn( *[*in_qubits, *op.data, *in_ctrl_qubits, *control_values], op=op.name, @@ -827,14 +820,3 @@ def calling_convention(*args_plus_qreg): qml.PCPhase: _pcphase_bind_call, qml.PauliRot: _pauli_rot_bind_call, } - -# Accepted hyperparameters for quantum instructions bind calls -_accepted_hyperparams = { - "num_wires", # CNOT, etc. - "n_wires", # Identity, etc. - "control_wires", # CNOT, etc. - "control_values", # CNOT, etc. - "work_wires", # CNOT, etc. - "work_wire_type", # CNOT, etc. - "base", # CNOT, etc. -} From 10bf1db88fa270b98a84ab6c16160d5c921b292e Mon Sep 17 00:00:00 2001 From: Ali Asadi <10773383+maliasadi@users.noreply.github.com> Date: Tue, 2 Dec 2025 11:03:00 -0500 Subject: [PATCH 6/8] Add pytest --- .../test/pytest/from_plxpr/test_from_plxpr.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/frontend/test/pytest/from_plxpr/test_from_plxpr.py b/frontend/test/pytest/from_plxpr/test_from_plxpr.py index 9090469f55..108108ae3a 100644 --- a/frontend/test/pytest/from_plxpr/test_from_plxpr.py +++ b/frontend/test/pytest/from_plxpr/test_from_plxpr.py @@ -179,6 +179,29 @@ def c(): with pytest.raises(NotImplementedError, match="not yet supported"): from_plxpr(jaxpr)() + def test_unsupported_op(self): + """Test that a CompileError is raised when an unsupported op is encountered.""" + + dev = qml.device("lightning.qubit", wires=5) + + @qml.qnode(dev) + def circuit(): + qml.QROM( + bitstrings=["010", "111", "110", "000"], + control_wires=[0, 1], + target_wires=[2, 3, 4], + work_wires=[5, 6, 7], + ) + return qml.state() + + jaxpr = jax.make_jaxpr(circuit)() + + with pytest.raises( + catalyst.utils.exceptions.CompileError, + match="Operation QROM with hyperparameters", + ): + from_plxpr(jaxpr)() + class TestCatalystCompareJaxpr: """Test comparing catalyst and pennylane jaxpr for a variety of situations.""" From 1aac68489729444efd6b0b4ce1e2cc2955a347b4 Mon Sep 17 00:00:00 2001 From: Ali Asadi <10773383+maliasadi@users.noreply.github.com> Date: Tue, 2 Dec 2025 15:34:07 -0500 Subject: [PATCH 7/8] Update --- doc/releases/changelog-dev.md | 15 ++++++---- frontend/catalyst/device/op_support.py | 21 +++++++------ frontend/catalyst/device/qjit_device.py | 1 - .../catalyst/from_plxpr/qfunc_interpreter.py | 4 ++- frontend/test/lit/test_from_plxpr.py | 30 +++++++++++++++++++ 5 files changed, 53 insertions(+), 18 deletions(-) diff --git a/doc/releases/changelog-dev.md b/doc/releases/changelog-dev.md index b9429d9daf..015dfcc683 100644 --- a/doc/releases/changelog-dev.md +++ b/doc/releases/changelog-dev.md @@ -70,11 +70,16 @@

Improvements 🛠

-* Remove the hardcoded list of runtime operations in the frontend. - This will allow arbitrary PL gates to be represented without hyperparameters in MLIR. - For gates that do not have a QIR representation, a runtime error will be raised at execution. - Users can still decompose these gates via `qml.transforms.decompose` - when both capture and graph-decomposition are enabled. +* The frontend no longer maintains a hardcoded list of runtime operations, + allowing arbitrary PennyLane gates with Quantum dialect-compatible + hyperparameters to be captured and represented in MLIR. + Users of the legacy compilation pipeline are unaffected, + as Catalyst continues to decompose unsupported gates + based on device capabilities before lowering to MLIR. + Gates that cannot be represented as MLIR operators will temporarily + raise a `CompileError` during program capture. + The long-term solution will integrate the new decomposition framework + with capture-enabled compilation. [(#2215)](https://github.com/PennyLaneAI/catalyst/pull/2215) * A new ``"changed"`` option has been added to the ``keep_intermediate`` parameter of diff --git a/frontend/catalyst/device/op_support.py b/frontend/catalyst/device/op_support.py index 201dfef035..0df047c9fd 100644 --- a/frontend/catalyst/device/op_support.py +++ b/frontend/catalyst/device/op_support.py @@ -44,24 +44,23 @@ def is_supported(op: Operator, capabilities: DeviceCapabilities) -> bool: def is_lowering_compatible(op: Operator) -> bool: """Check if an operation can be lowered to MLIR using JAX primitives.""" # Exceptions for operations that are not quantum instructions but are allowed + # via custom lowering rules. + # TODO: Revisit this as more explicit ops will be added to Catalyst Compiler. if isinstance(op, (qml.Snapshot, qml.PCPhase, qml.MultiRZ)): return True # Accepted hyperparameters for quantum instructions bind calls _accepted_hyperparams = { - "num_wires", # CNOT, etc. - "n_wires", # Identity, etc. - "control_wires", # CNOT, etc. - "control_values", # CNOT, etc. - "work_wires", # CNOT, etc. - "work_wire_type", # CNOT, etc. - "base", # CNOT, etc. + "base", + "n_wires", + "num_wires", + "control_wires", + "control_values", + "work_wires", + "work_wire_type", } - for hyperparam in op.hyperparameters: - if hyperparam not in _accepted_hyperparams: - return False - return True + return set(op.hyperparameters).issubset(_accepted_hyperparams) def _is_grad_recipe_same_as_catalyst(op): diff --git a/frontend/catalyst/device/qjit_device.py b/frontend/catalyst/device/qjit_device.py index 848a638d48..d6708b3851 100644 --- a/frontend/catalyst/device/qjit_device.py +++ b/frontend/catalyst/device/qjit_device.py @@ -164,7 +164,6 @@ def union_operations( ) -> Dict[str, OperatorProperties]: """Union of two sets of operator properties""" return {**a, **b} - # return {k: a[k] & b[k] for k in (a.keys() & b.keys())} def intersect_operations( diff --git a/frontend/catalyst/from_plxpr/qfunc_interpreter.py b/frontend/catalyst/from_plxpr/qfunc_interpreter.py index dc6b2ca866..10e605f1f8 100644 --- a/frontend/catalyst/from_plxpr/qfunc_interpreter.py +++ b/frontend/catalyst/from_plxpr/qfunc_interpreter.py @@ -183,7 +183,9 @@ def interpret_operation(self, op, is_adjoint=False, control_values=(), control_w if (fn := _special_op_bind_call.get(type(op))) is not None: bind_fn = partial(fn, hyperparameters=op.hyperparameters) else: - # FIXME: Remove this after integrating + # TODO: Remove this after enabling the graph-based decomposition by default + # With graph enabled, all unsupported templates and operations will be decomposed + # away resulting the same behaviour with capture disabled in Catalyst. if not is_lowering_compatible(op): raise CompileError( f"Operation {op.name} with hyperparameters {list(op.hyperparameters.keys())} " diff --git a/frontend/test/lit/test_from_plxpr.py b/frontend/test/lit/test_from_plxpr.py index 8cd9ff764c..922b9dbd78 100644 --- a/frontend/test/lit/test_from_plxpr.py +++ b/frontend/test/lit/test_from_plxpr.py @@ -457,3 +457,33 @@ def circuit2(): test_two_qnodes_with_different_passes_in_one_workflow() + + +def test_capture_custom_op(): + """Test capture of a custom op""" + + dev = qml.device("lightning.qubit", wires=2) + + class MuCustomOp(qml.operation.Operator): + """A custom operator for testing.""" + + def __init__(self, theta, wires): + """Initialize the custom operator.""" + super().__init__(theta, wires=wires) + + qml.capture.enable() + + @qml.qjit(target="mlir") + @qml.qnode(dev) + def circuit(): + # CHECK: [[QUBIT_1:%.+]] = quantum.extract %0[ 0] : !quantum.reg -> !quantum.bit + # CHECK-NEXT: [[QUBIT_2:%.+]] = quantum.extract %0[ 1] : !quantum.reg -> !quantum.bit + # CHECK-NEXT: {{%.+}} = quantum.custom "MuCustomOp"({{%.+}}) [[QUBIT_1]], [[QUBIT_2]] : !quantum.bit, !quantum.bit + MuCustomOp(0.5, wires=[0, 1]) + return qml.state() + + print(circuit.mlir) + qml.capture.disable() + + +test_capture_custom_op() From 3593b5bfb4cb4e62a1aa553e03262bc0a8155c6b Mon Sep 17 00:00:00 2001 From: Ali Asadi <10773383+maliasadi@users.noreply.github.com> Date: Tue, 2 Dec 2025 16:10:59 -0500 Subject: [PATCH 8/8] Disable too-many-lines pylint warnings in tests --- frontend/test/pytest/from_plxpr/test_from_plxpr.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/test/pytest/from_plxpr/test_from_plxpr.py b/frontend/test/pytest/from_plxpr/test_from_plxpr.py index a9cfedbac8..e969ee9aa4 100644 --- a/frontend/test/pytest/from_plxpr/test_from_plxpr.py +++ b/frontend/test/pytest/from_plxpr/test_from_plxpr.py @@ -36,6 +36,8 @@ pytestmark = pytest.mark.usefixtures("disable_capture") +# pylint: disable=too-many-lines + def catalyst_execute_jaxpr(jaxpr): """Create a function capable of executing the provided catalyst-variant jaxpr."""