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."""