From d904da305d2c58f1e3cf74b3fd5bbf016fe51fa6 Mon Sep 17 00:00:00 2001 From: qottmann Date: Mon, 13 Jul 2026 18:15:59 +0200 Subject: [PATCH 1/6] Add frontend lowering for pbc.fabricate via pbc.ref.fabricate. Expose fabricate through Catalyst capture and QJIT lowering so qp.fabricate produces new qubits that compile to pbc.fabricate after value-semantics conversion. Co-authored-by: Cursor --- doc/releases/changelog-dev.md | 6 ++ frontend/catalyst/api_extensions/__init__.py | 2 + .../api_extensions/quantum_operators.py | 33 +++++++- frontend/catalyst/device/qjit_device.py | 1 + .../catalyst/from_plxpr/qfunc_interpreter.py | 36 ++++++-- .../from_plxpr/qref_jax_primitives.py | 43 +++++++++- frontend/catalyst/jax_primitives.py | 32 +++++++- frontend/test/lit/test_fabricate.py | 56 +++++++++++++ frontend/test/pytest/test_fabricate.py | 82 +++++++++++++++++++ mlir/include/PBC/IR/PBCRefOps.td | 22 +++++ mlir/lib/PBC/IR/PBCOps.cpp | 11 +++ .../Transforms/value_semantics_conversion.cpp | 14 ++++ 12 files changed, 326 insertions(+), 12 deletions(-) create mode 100644 frontend/test/lit/test_fabricate.py create mode 100644 frontend/test/pytest/test_fabricate.py diff --git a/doc/releases/changelog-dev.md b/doc/releases/changelog-dev.md index 63782f88aa..fdc2bdca53 100644 --- a/doc/releases/changelog-dev.md +++ b/doc/releases/changelog-dev.md @@ -394,6 +394,12 @@ `-stage` naming convention used when invoking them from the command line (e.g. `quantum-compilation-stage`). [#3002](https://github.com/PennyLaneAI/catalyst/pull/3002) +* A new PennyLane operation :func:`~.fabricate` has been added to expose the PBC + ``pbc.fabricate`` instruction from the frontend. The operation produces a new auxiliary + qubit in a logical factory state (``plus_i``, ``minus_i``, ``magic``, or ``magic_conj``) + and is lowered through the ``pbc.ref.fabricate`` reference-semantics op to + ``pbc.fabricate`` during compilation. +

Documentation 📝

* A broken link was removed in the [Compiler Core](https://docs.pennylane.ai/projects/catalyst/en/stable/modules/mlir.html) documentation page. The link referred to where precompiled decomposition rules were implemented, which has since been refactored. diff --git a/frontend/catalyst/api_extensions/__init__.py b/frontend/catalyst/api_extensions/__init__.py index 6c020597c8..f75f724099 100644 --- a/frontend/catalyst/api_extensions/__init__.py +++ b/frontend/catalyst/api_extensions/__init__.py @@ -39,6 +39,7 @@ ctrl, measure, pauli_measure, + fabricate, ) __all__ = ( @@ -58,6 +59,7 @@ "vmap", "measure", "pauli_measure", + "fabricate", "adjoint", "ctrl", ) diff --git a/frontend/catalyst/api_extensions/quantum_operators.py b/frontend/catalyst/api_extensions/quantum_operators.py index 959f939bba..332a979695 100644 --- a/frontend/catalyst/api_extensions/quantum_operators.py +++ b/frontend/catalyst/api_extensions/quantum_operators.py @@ -46,7 +46,7 @@ deduce_avals, new_inner_tracer, ) -from catalyst.jax_primitives import AbstractQreg, adjoint_p, measure_p, pauli_measure_p +from catalyst.jax_primitives import AbstractQreg, adjoint_p, fabricate_p, measure_p, pauli_measure_p from catalyst.jax_tracer import ( HybridOp, HybridOpRegion, @@ -243,6 +243,37 @@ def pauli_measure( return m +def fabricate(init_state: str) -> DynamicJaxprTracer: + r"""A :func:`qjit` compatible fabricate operation for PennyLane/Catalyst. + + .. important:: + + The :func:`qp.fabricate() ` function is **not** QJIT + compatible under the legacy tracing pathway; use :func:`catalyst.fabricate` instead. + + Args: + init_state (str): The logical state to fabricate. One of ``"plus_i"``, + ``"minus_i"``, ``"magic"``, or ``"magic_conj"``. + + Returns: + A JAX tracer for the fabricated qubit. + """ + EvaluationContext.check_is_tracing("catalyst.fabricate can only be used from within @qjit.") + EvaluationContext.check_is_quantum_tracing( + "catalyst.fabricate can only be used from within a qp.qnode." + ) + + valid_states = {"plus_i", "minus_i", "magic", "magic_conj"} + if init_state not in valid_states: + raise ValueError( + f'The init_state "{init_state}" is not allowed. ' + f"Allowed values are {sorted(valid_states)}." + ) + + qubit, = fabricate_p.bind(init_state=init_state) + return qubit + + def adjoint(f: Union[Callable, Operator], lazy=True) -> Union[Callable, Operator]: """A :func:`~.qjit` compatible adjoint transformer for PennyLane/Catalyst. diff --git a/frontend/catalyst/device/qjit_device.py b/frontend/catalyst/device/qjit_device.py index 9ef90a3028..065948f029 100644 --- a/frontend/catalyst/device/qjit_device.py +++ b/frontend/catalyst/device/qjit_device.py @@ -83,6 +83,7 @@ "MultiRZ", "PauliRot", "PauliMeasure", + "Fabricate", "PauliX", "PauliY", "PauliZ", diff --git a/frontend/catalyst/from_plxpr/qfunc_interpreter.py b/frontend/catalyst/from_plxpr/qfunc_interpreter.py index f644fe1337..284fdcb8d2 100644 --- a/frontend/catalyst/from_plxpr/qfunc_interpreter.py +++ b/frontend/catalyst/from_plxpr/qfunc_interpreter.py @@ -29,6 +29,7 @@ from pennylane.capture.primitives import ctrl_transform_prim as plxpr_ctrl_transform_prim from pennylane.capture.primitives import measure_prim as plxpr_measure_prim from pennylane.capture.primitives import operator_p +from pennylane.capture.primitives import fabricate_prim as plxpr_fabricate_prim from pennylane.capture.primitives import pauli_measure_prim as plxpr_pauli_measure_prim from pennylane.capture.primitives import quantum_subroutine_prim, transform_prim from pennylane.ftqc.primitives import measure_in_basis_prim as plxpr_measure_in_basis_prim @@ -40,6 +41,8 @@ qref_alloc_p, qref_compbasis_p, qref_dealloc_p, + qref_dealloc_qb_p, + qref_fabricate_p, qref_get_p, qref_gphase_p, qref_hermitian_p, @@ -427,17 +430,25 @@ def handle_allocate(self, *, num_wires, state=None, restored=False): def handle_deallocate(self, *wires): """Handle the conversion from plxpr to Catalyst jaxpr for the qp.deallocate primitive""" qregs = set() + standalone_qubits = [] for w in wires: - get_op = w.parent + parent_eqn = w.parent + if parent_eqn.primitive is qref_fabricate_p: + standalone_qubits.append(w) + elif parent_eqn.primitive is qref_get_p: + qreg = parent_eqn.in_tracers[0] + qregs.add(qreg) + else: + assert ( + parent_eqn.primitive is qref_get_p + ), "Manual deallocation is only supported for manually allocated or fabricated wires" + for qubit in standalone_qubits: + qref_dealloc_qb_p.bind(qubit) + if qregs: assert ( - get_op.primitive is qref_get_p - ), "Manual deallocation is only supported for manually allocated wires" - qreg = get_op.in_tracers[0] - qregs.add(qreg) - assert ( - len(qregs) == 1 - ), "Expected all wires to deallocate to come from the same allocation instruction" - qref_dealloc_p.bind(list(qregs)[0]) + len(qregs) == 1 + ), "Expected all wires to deallocate to come from the same allocation instruction" + qref_dealloc_p.bind(list(qregs)[0]) return [] @@ -543,6 +554,13 @@ def wrapper(*args): return () +@PLxPRToQuantumJaxprInterpreter.register_primitive(plxpr_fabricate_prim) +def handle_fabricate(self, *_, init_state=""): + """Handle the conversion from plxpr to Catalyst jaxpr for the fabricate primitive""" + qubit, = qref_fabricate_p.bind(init_state=init_state) + return (qubit,) + + @PLxPRToQuantumJaxprInterpreter.register_primitive(plxpr_pauli_measure_prim) def handle_pauli_measure(self, *wires_inval, pauli_word, **params): """Handle the conversion from plxpr to Catalyst jaxpr for the PauliMeasure primitive""" diff --git a/frontend/catalyst/from_plxpr/qref_jax_primitives.py b/frontend/catalyst/from_plxpr/qref_jax_primitives.py index 903942dfe3..103e8583f2 100644 --- a/frontend/catalyst/from_plxpr/qref_jax_primitives.py +++ b/frontend/catalyst/from_plxpr/qref_jax_primitives.py @@ -40,6 +40,7 @@ from catalyst.jax_extras.patches import mock_attributes from catalyst.jax_primitives import ( AbstractObs, + _logical_init_attr, _named_obs_attribute, extract_scalar, safe_cast_to_f64, @@ -61,13 +62,14 @@ ), ): from mlir_quantum.dialects.mbqc import RefMeasureInBasisOp - from mlir_quantum.dialects.pbc import RefPPMeasurementOp + from mlir_quantum.dialects.pbc import RefFabricateOp, RefPPMeasurementOp from mlir_quantum.dialects.qref import ( AdjointOp, AllocOp, ComputationalBasisOp, CustomOp, DeallocOp, + DeallocQubitOp, GetOp, GlobalPhaseOp, HermitianOp, @@ -147,6 +149,8 @@ class MeasurementPlane(Enum): qref_alloc_p = Primitive("qref_alloc") qref_dealloc_p = Primitive("qref_dealloc") qref_dealloc_p.multiple_results = True +qref_dealloc_qb_p = Primitive("qref_dealloc_qb") +qref_dealloc_qb_p.multiple_results = True qref_get_p = Primitive("qref_get") qref_set_state_p = Primitive("qref_state_prep") qref_set_state_p.multiple_results = True @@ -157,6 +161,8 @@ class MeasurementPlane(Enum): qref_gphase_p = Primitive("qref_gphase") qref_gphase_p.multiple_results = True qref_pauli_measure_p = Primitive("pref_pauli_measure") +qref_fabricate_p = Primitive("qref_fabricate") +qref_fabricate_p.multiple_results = True qref_pauli_rot_p = Primitive("qref_pauli_rot") qref_pauli_rot_p.multiple_results = True qref_unitary_p = Primitive("qref_unitary") @@ -215,6 +221,21 @@ def _qref_dealloc_lowering(jax_ctx: mlir.LoweringRuleContext, qreg): return () +# +# qref_dealloc_qb_p +# +@qref_dealloc_qb_p.def_abstract_eval +def _qref_dealloc_qb_abstract_eval(qubit): + return () + + +def _qref_dealloc_qb_lowering(jax_ctx: mlir.LoweringRuleContext, qubit): + ctx = jax_ctx.module_context.context + ctx.allow_unregistered_dialects = True + DeallocQubitOp(qubit=qubit) + return () + + # # qref_get_p # @@ -542,6 +563,24 @@ def _qref_pauli_measure_lowering( return (from_elements_op.results[0],) +# +# fabricate operation +# +@qref_fabricate_p.def_abstract_eval +def _qref_fabricate_abstract_eval(*_, init_state=""): + return (AbstractQubit(),) + + +def _qref_fabricate_lowering(jax_ctx: mlir.LoweringRuleContext, *_, init_state=""): + ctx = jax_ctx.module_context.context + ctx.allow_unregistered_dialects = True + + qubit_type = ir.OpaqueType.get("qref", "bit", ctx) + return RefFabricateOp( + qubits=[qubit_type], init_state=_logical_init_attr(ctx, init_state) + ).results + + # # qubit unitary operation # @@ -826,6 +865,7 @@ def _qref_hermitian_lowering(jax_ctx: mlir.LoweringRuleContext, matrix: ir.Value (qref_operator_p, _qref_operator_p_lowering), (qref_alloc_p, _qref_alloc_lowering), (qref_dealloc_p, _qref_dealloc_lowering), + (qref_dealloc_qb_p, _qref_dealloc_qb_lowering), (qref_get_p, _qref_get_lowering), (qref_set_state_p, _qref_set_state_lowering), (qref_set_basis_state_p, _qref_set_basis_state_lowering), @@ -833,6 +873,7 @@ def _qref_hermitian_lowering(jax_ctx: mlir.LoweringRuleContext, matrix: ir.Value (qref_gphase_p, _qref_gphase_lowering), (qref_pauli_rot_p, _qref_pauli_rot_lowering), (qref_pauli_measure_p, _qref_pauli_measure_lowering), + (qref_fabricate_p, _qref_fabricate_lowering), (qref_unitary_p, _qref_unitary_lowering), (qref_measure_p, _qref_measure_lowering), (qref_measure_in_basis_p, _qref_measure_in_basis_lowering), diff --git a/frontend/catalyst/jax_primitives.py b/frontend/catalyst/jax_primitives.py index 7d16000c84..5259f6bd67 100644 --- a/frontend/catalyst/jax_primitives.py +++ b/frontend/catalyst/jax_primitives.py @@ -87,7 +87,7 @@ VJPOp, ) from mlir_quantum.dialects.mitigation import ZneOp - from mlir_quantum.dialects.pbc import PPMeasurementOp + from mlir_quantum.dialects.pbc import FabricateOp, PPMeasurementOp from mlir_quantum.dialects.quantum import ( AdjointOp, AllocOp, @@ -298,6 +298,8 @@ class Folding(Enum): pauli_rot_p.multiple_results = True pauli_measure_p = Primitive("pauli_measure") pauli_measure_p.multiple_results = True +fabricate_p = Primitive("fabricate") +fabricate_p.multiple_results = True measure_p = Primitive("measure") measure_p.multiple_results = True compbasis_p = Primitive("compbasis") @@ -1660,6 +1662,29 @@ def _pauli_measure_lowering( return (from_elements_op.results[0],) + tuple(out_qubits) +# +# fabricate operation +# +@fabricate_p.def_abstract_eval +def _fabricate_abstract_eval(*_, init_state=""): + return (AbstractQbit(),) + + +@fabricate_p.def_impl +def _fabricate_def_impl(*args, **kwargs): # pragma: no cover + raise NotImplementedError() + + +def _fabricate_lowering(jax_ctx: mlir.LoweringRuleContext, *_, init_state=""): + ctx = jax_ctx.module_context.context + ctx.allow_unregistered_dialects = True + + qubit_type = ir.OpaqueType.get("quantum", "bit", ctx) + return FabricateOp( + out_qubits=[qubit_type], init_state=_logical_init_attr(ctx, init_state) + ).results + + # # measure # @@ -1760,6 +1785,10 @@ def _namedobs_abstract_eval(qubit, kind): return AbstractObs() +def _logical_init_attr(ctx, init_state: str): + return ir.Attribute.parse(f"#pbc", context=ctx) + + def _named_obs_attribute(ctx, kind: str): return ir.OpaqueAttr.get( "quantum", ("named_observable " + kind).encode("utf-8"), ir.NoneType.get(ctx), ctx @@ -3081,6 +3110,7 @@ def subroutine_lowering(*args, **kwargs): (unitary_p, _unitary_lowering), (pauli_rot_p, _pauli_rot_lowering), (pauli_measure_p, _pauli_measure_lowering), + (fabricate_p, _fabricate_lowering), (measure_p, _measure_lowering), (compbasis_p, _compbasis_lowering), (namedobs_p, _named_obs_lowering), diff --git a/frontend/test/lit/test_fabricate.py b/frontend/test/lit/test_fabricate.py new file mode 100644 index 0000000000..4ff7e25c40 --- /dev/null +++ b/frontend/test/lit/test_fabricate.py @@ -0,0 +1,56 @@ +# Copyright 2026 Xanadu Quantum Technologies Inc. + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# RUN: %PYTHON %s | FileCheck %s + +"""Test fabricate lowering to pbc.fabricate.""" + +import pennylane as qp +from pennylane.transforms import to_ppr + +from catalyst import qjit + + +def test_fabricate_lowering(): + """Test that qp.fabricate is lowered to pbc.fabricate.""" + qp.capture.enable() + dev = qp.device("null.qubit", wires=2) + + pipeline = [ + ( + "pipe", + [ + "canonicalize", + "verify-no-quantum-use-after-free", + "convert-to-value-semantics", + "canonicalize", + ], + ) + ] + + @qjit(pipelines=pipeline, target="mlir") + @qp.qnode(device=dev) + def circuit(): + magic = qp.fabricate("magic") + qp.pauli_measure("ZZ", wires=[0, magic]) + qp.deallocate(magic) + return qp.expval(qp.Z(0)) + + # CHECK: pbc.fabricate magic + # CHECK: pbc.ppm ["Z", "Z"] + print(circuit.mlir_opt) + qp.capture.disable() + + +test_fabricate_lowering() diff --git a/frontend/test/pytest/test_fabricate.py b/frontend/test/pytest/test_fabricate.py new file mode 100644 index 0000000000..8f29456f84 --- /dev/null +++ b/frontend/test/pytest/test_fabricate.py @@ -0,0 +1,82 @@ +# Copyright 2026 Xanadu Quantum Technologies Inc. + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for the fabricate operation.""" + +import pytest + +import pennylane as qp +from pennylane.ops.qubit.fabricate import Fabricate, fabricate, _VALID_INIT_STATES + +from catalyst import qjit + + +class TestFabricateOp: + """Tests for the Fabricate operator and function.""" + + @pytest.mark.parametrize("init_state", sorted(_VALID_INIT_STATES)) + def test_fabricate_valid_init_states(self, init_state): + """Test that valid init states are accepted.""" + op = Fabricate(init_state) + assert op.init_state == init_state + assert op.num_wires == 0 + + def test_fabricate_invalid_init_state(self): + """Test that invalid init states are rejected.""" + with pytest.raises(ValueError, match="not allowed"): + Fabricate("zero") + + @pytest.mark.parametrize("init_state", sorted(_VALID_INIT_STATES)) + def test_fabricate_function_capture(self, init_state): + """Test fabricate primitive binding under capture.""" + import jax + + qp.capture.enable() + + def circuit(): + fabricate(init_state) + + jaxpr = jax.make_jaxpr(circuit)().jaxpr + assert any(eqn.primitive.name == "fabricate" for eqn in jaxpr.eqns) + qp.capture.disable() + + def test_fabricate_mlir_lowering(self): + """Test that fabricate appears as pbc.fabricate in optimized MLIR.""" + qp.capture.enable() + dev = qp.device("null.qubit", wires=1) + + @qjit( + pipelines=[ + ( + "pipe", + [ + "canonicalize", + "verify-no-quantum-use-after-free", + "convert-to-value-semantics", + "canonicalize", + ], + ) + ], + target="mlir", + ) + @qp.qnode(device=dev) + def circuit(): + magic = fabricate("magic_conj") + qp.pauli_measure("Z", wires=[magic]) + qp.deallocate(magic) + return qp.expval(qp.Z(0)) + + mlir = circuit.mlir_opt + assert "pbc.fabricate" in mlir and "magic_conj" in mlir + qp.capture.disable() diff --git a/mlir/include/PBC/IR/PBCRefOps.td b/mlir/include/PBC/IR/PBCRefOps.td index aa9989dd61..61ba7636aa 100644 --- a/mlir/include/PBC/IR/PBCRefOps.td +++ b/mlir/include/PBC/IR/PBCRefOps.td @@ -37,3 +37,25 @@ def RefPPMeasurementOp : PBC_Op<"ref.ppm"> { let hasVerifier = 1; } + +def RefFabricateOp : PBC_Op<"ref.fabricate"> { + let summary = "Fabricate auxiliary qubits from qubit factories (reference semantics)."; + + let description = [{ + Identical to the pbc.fabricate operation, except in reference semantics. + }]; + + let arguments = (ins + LogicalInit:$init_state + ); + + let results = (outs + Variadic:$qubits + ); + + let assemblyFormat = [{ + $init_state attr-dict `:` type($qubits) + }]; + + let hasVerifier = 1; +} diff --git a/mlir/lib/PBC/IR/PBCOps.cpp b/mlir/lib/PBC/IR/PBCOps.cpp index 49aca12605..33b796bf20 100644 --- a/mlir/lib/PBC/IR/PBCOps.cpp +++ b/mlir/lib/PBC/IR/PBCOps.cpp @@ -23,6 +23,7 @@ #include "mlir/IR/Region.h" #include "QRef/IR/QRefDialect.h" +#include "QRef/IR/QRefTypes.h" #include "Quantum/IR/QuantumDialect.h" using namespace mlir; @@ -91,6 +92,16 @@ LogicalResult RefPPMeasurementOp::verify() return success(); } +LogicalResult RefFabricateOp::verify() +{ + auto initState = getInitState(); + if (initState == LogicalInitKind::zero || initState == LogicalInitKind::one || + initState == LogicalInitKind::plus || initState == LogicalInitKind::minus) { + return emitOpError("Logical state should not be fabricated, use `PrepareStateOp` instead."); + } + return mlir::success(); +} + LogicalResult SelectPPMeasurementOp::verify() { if (getInQubits().size() != getPauliProduct_0().size() || diff --git a/mlir/lib/QRef/Transforms/value_semantics_conversion.cpp b/mlir/lib/QRef/Transforms/value_semantics_conversion.cpp index 68cb97ea67..716bc99d47 100644 --- a/mlir/lib/QRef/Transforms/value_semantics_conversion.cpp +++ b/mlir/lib/QRef/Transforms/value_semantics_conversion.cpp @@ -1208,6 +1208,19 @@ void handlePPM(IRRewriter &builder, pbc::RefPPMeasurementOp rPPMOp, QubitValueTr builder.eraseOp(rPPMOp); } +void handleFabricate(IRRewriter &builder, pbc::RefFabricateOp rFabricateOp, + QubitValueTracker &tracker) +{ + OpBuilder::InsertionGuard guard(builder); + builder.setInsertionPoint(rFabricateOp); + Location loc = rFabricateOp.getLoc(); + + auto vFabricateOp = + pbc::FabricateOp::create(builder, loc, rFabricateOp.getInitState()); + tracker.setCurrentVQubit(rFabricateOp.getQubits().front(), + vFabricateOp.getOutQubits().front()); +} + void handleCall(IRRewriter &builder, func::CallOp callOp, QubitValueTracker &tracker) { OpBuilder::InsertionGuard guard(builder); @@ -1878,6 +1891,7 @@ void handleRegion(IRRewriter &builder, Region &r, QubitValueTracker &tracker) .Case( [&](auto o) { handleMeasureInBasis(builder, o, tracker); }) .Case([&](auto o) { handlePPM(builder, o, tracker); }) + .Case([&](auto o) { handleFabricate(builder, o, tracker); }) .Case([&](auto o) { handleAdjoint(builder, o, tracker); }) .Case([&](auto o) { handleIf(builder, o, tracker); }) .Case([&](auto o) { handleSwitch(builder, o, tracker); }) From 4b88ad354adea15dc49f4efbed05c4c1e6802e58 Mon Sep 17 00:00:00 2001 From: qottmann Date: Tue, 14 Jul 2026 11:09:36 +0200 Subject: [PATCH 2/6] Fix formatting for fabricate frontend changes. Apply black, isort, and clang-format so CI formatting checks pass. Co-authored-by: Cursor --- frontend/catalyst/api_extensions/quantum_operators.py | 2 +- frontend/catalyst/from_plxpr/qfunc_interpreter.py | 4 ++-- mlir/lib/QRef/Transforms/value_semantics_conversion.cpp | 6 ++---- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/frontend/catalyst/api_extensions/quantum_operators.py b/frontend/catalyst/api_extensions/quantum_operators.py index 332a979695..42c92a68e7 100644 --- a/frontend/catalyst/api_extensions/quantum_operators.py +++ b/frontend/catalyst/api_extensions/quantum_operators.py @@ -270,7 +270,7 @@ def fabricate(init_state: str) -> DynamicJaxprTracer: f"Allowed values are {sorted(valid_states)}." ) - qubit, = fabricate_p.bind(init_state=init_state) + (qubit,) = fabricate_p.bind(init_state=init_state) return qubit diff --git a/frontend/catalyst/from_plxpr/qfunc_interpreter.py b/frontend/catalyst/from_plxpr/qfunc_interpreter.py index 284fdcb8d2..303aaf4e15 100644 --- a/frontend/catalyst/from_plxpr/qfunc_interpreter.py +++ b/frontend/catalyst/from_plxpr/qfunc_interpreter.py @@ -27,9 +27,9 @@ from pennylane.capture import PlxprInterpreter, pause from pennylane.capture.primitives import cond_prim as pl_cond_prim from pennylane.capture.primitives import ctrl_transform_prim as plxpr_ctrl_transform_prim +from pennylane.capture.primitives import fabricate_prim as plxpr_fabricate_prim from pennylane.capture.primitives import measure_prim as plxpr_measure_prim from pennylane.capture.primitives import operator_p -from pennylane.capture.primitives import fabricate_prim as plxpr_fabricate_prim from pennylane.capture.primitives import pauli_measure_prim as plxpr_pauli_measure_prim from pennylane.capture.primitives import quantum_subroutine_prim, transform_prim from pennylane.ftqc.primitives import measure_in_basis_prim as plxpr_measure_in_basis_prim @@ -557,7 +557,7 @@ def wrapper(*args): @PLxPRToQuantumJaxprInterpreter.register_primitive(plxpr_fabricate_prim) def handle_fabricate(self, *_, init_state=""): """Handle the conversion from plxpr to Catalyst jaxpr for the fabricate primitive""" - qubit, = qref_fabricate_p.bind(init_state=init_state) + (qubit,) = qref_fabricate_p.bind(init_state=init_state) return (qubit,) diff --git a/mlir/lib/QRef/Transforms/value_semantics_conversion.cpp b/mlir/lib/QRef/Transforms/value_semantics_conversion.cpp index 716bc99d47..98f9d8d611 100644 --- a/mlir/lib/QRef/Transforms/value_semantics_conversion.cpp +++ b/mlir/lib/QRef/Transforms/value_semantics_conversion.cpp @@ -1215,10 +1215,8 @@ void handleFabricate(IRRewriter &builder, pbc::RefFabricateOp rFabricateOp, builder.setInsertionPoint(rFabricateOp); Location loc = rFabricateOp.getLoc(); - auto vFabricateOp = - pbc::FabricateOp::create(builder, loc, rFabricateOp.getInitState()); - tracker.setCurrentVQubit(rFabricateOp.getQubits().front(), - vFabricateOp.getOutQubits().front()); + auto vFabricateOp = pbc::FabricateOp::create(builder, loc, rFabricateOp.getInitState()); + tracker.setCurrentVQubit(rFabricateOp.getQubits().front(), vFabricateOp.getOutQubits().front()); } void handleCall(IRRewriter &builder, func::CallOp callOp, QubitValueTracker &tracker) From 67b9ec7390dd89ec9593c8441a6a0893a48b2554 Mon Sep 17 00:00:00 2001 From: qottmann Date: Tue, 14 Jul 2026 12:52:44 +0200 Subject: [PATCH 3/6] Update Fabricate test for wire-based operator API. Assert empty wires directly instead of num_wires == 0, matching PennyLane Fabricate after the produced wire is attached during plxpr conversion. Co-authored-by: Cursor --- frontend/test/pytest/test_fabricate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/test/pytest/test_fabricate.py b/frontend/test/pytest/test_fabricate.py index 8f29456f84..2dfb573d5d 100644 --- a/frontend/test/pytest/test_fabricate.py +++ b/frontend/test/pytest/test_fabricate.py @@ -30,7 +30,7 @@ def test_fabricate_valid_init_states(self, init_state): """Test that valid init states are accepted.""" op = Fabricate(init_state) assert op.init_state == init_state - assert op.num_wires == 0 + assert len(op.wires) == 0 def test_fabricate_invalid_init_state(self): """Test that invalid init states are rejected.""" From 92fc0a10c1de7d4c37b8f5f01ede09b93a9b143b Mon Sep 17 00:00:00 2001 From: qottmann Date: Tue, 14 Jul 2026 13:02:26 +0200 Subject: [PATCH 4/6] Use qjit(capture=True) and graph-decomposition fixtures in fabricate tests. Replace global capture toggles with capture=True on qjit and add an enable_graph_decomposition pytest fixture for fabricate lowering tests. Co-authored-by: Cursor --- frontend/test/lit/test_fabricate.py | 29 ++++++++++------------ frontend/test/pytest/conftest.py | 11 ++++++--- frontend/test/pytest/test_fabricate.py | 34 ++++++++++++-------------- 3 files changed, 36 insertions(+), 38 deletions(-) diff --git a/frontend/test/lit/test_fabricate.py b/frontend/test/lit/test_fabricate.py index 4ff7e25c40..8263a293b8 100644 --- a/frontend/test/lit/test_fabricate.py +++ b/frontend/test/lit/test_fabricate.py @@ -17,29 +17,27 @@ """Test fabricate lowering to pbc.fabricate.""" import pennylane as qp -from pennylane.transforms import to_ppr from catalyst import qjit +_PIPELINE = [ + ( + "pipe", + [ + "canonicalize", + "verify-no-quantum-use-after-free", + "convert-to-value-semantics", + "canonicalize", + ], + ) +] + def test_fabricate_lowering(): """Test that qp.fabricate is lowered to pbc.fabricate.""" - qp.capture.enable() dev = qp.device("null.qubit", wires=2) - pipeline = [ - ( - "pipe", - [ - "canonicalize", - "verify-no-quantum-use-after-free", - "convert-to-value-semantics", - "canonicalize", - ], - ) - ] - - @qjit(pipelines=pipeline, target="mlir") + @qjit(pipelines=_PIPELINE, target="mlir", capture=True) @qp.qnode(device=dev) def circuit(): magic = qp.fabricate("magic") @@ -50,7 +48,6 @@ def circuit(): # CHECK: pbc.fabricate magic # CHECK: pbc.ppm ["Z", "Z"] print(circuit.mlir_opt) - qp.capture.disable() test_fabricate_lowering() diff --git a/frontend/test/pytest/conftest.py b/frontend/test/pytest/conftest.py index 1e2e2901f2..70af9b13d7 100644 --- a/frontend/test/pytest/conftest.py +++ b/frontend/test/pytest/conftest.py @@ -58,14 +58,19 @@ def use_capture(): @pytest.fixture(scope="function") -def use_capture_dgraph(): +def enable_graph_decomposition(): + """Enable graph-decomposition around each test.""" + with qp.decomposition.toggle_graph_ctx(True): + yield + + +@pytest.fixture(scope="function") +def use_capture_dgraph(enable_graph_decomposition): """Enable capture and graph-decomposition before and disable them both after the test.""" qp.capture.enable() - qp.decomposition.enable_graph() try: yield finally: - qp.decomposition.disable_graph() qp.capture.disable() diff --git a/frontend/test/pytest/test_fabricate.py b/frontend/test/pytest/test_fabricate.py index 2dfb573d5d..d74e015c7f 100644 --- a/frontend/test/pytest/test_fabricate.py +++ b/frontend/test/pytest/test_fabricate.py @@ -21,6 +21,18 @@ from catalyst import qjit +_FABRICATE_PIPELINE = [ + ( + "pipe", + [ + "canonicalize", + "verify-no-quantum-use-after-free", + "convert-to-value-semantics", + "canonicalize", + ], + ) +] + class TestFabricateOp: """Tests for the Fabricate operator and function.""" @@ -38,38 +50,23 @@ def test_fabricate_invalid_init_state(self): Fabricate("zero") @pytest.mark.parametrize("init_state", sorted(_VALID_INIT_STATES)) + @pytest.mark.usefixtures("use_capture") def test_fabricate_function_capture(self, init_state): """Test fabricate primitive binding under capture.""" import jax - qp.capture.enable() - def circuit(): fabricate(init_state) jaxpr = jax.make_jaxpr(circuit)().jaxpr assert any(eqn.primitive.name == "fabricate" for eqn in jaxpr.eqns) - qp.capture.disable() + @pytest.mark.usefixtures("enable_graph_decomposition") def test_fabricate_mlir_lowering(self): """Test that fabricate appears as pbc.fabricate in optimized MLIR.""" - qp.capture.enable() dev = qp.device("null.qubit", wires=1) - @qjit( - pipelines=[ - ( - "pipe", - [ - "canonicalize", - "verify-no-quantum-use-after-free", - "convert-to-value-semantics", - "canonicalize", - ], - ) - ], - target="mlir", - ) + @qjit(pipelines=_FABRICATE_PIPELINE, target="mlir", capture=True) @qp.qnode(device=dev) def circuit(): magic = fabricate("magic_conj") @@ -79,4 +76,3 @@ def circuit(): mlir = circuit.mlir_opt assert "pbc.fabricate" in mlir and "magic_conj" in mlir - qp.capture.disable() From ee66a740638a9256edbef90a90ab063bd697cfe5 Mon Sep 17 00:00:00 2001 From: qottmann Date: Tue, 14 Jul 2026 13:08:34 +0200 Subject: [PATCH 5/6] Erase dangling RefFabricateOp after value-semantics conversion. Add a use_empty canonicalizer for pbc.ref.fabricate, assert ref ops are gone in fabricate tests, clarify deallocate error handling, and move the changelog entry to New features with the PR link. Co-authored-by: Cursor --- doc/releases/changelog-dev.md | 13 +++++++------ frontend/catalyst/from_plxpr/qfunc_interpreter.py | 6 +++--- frontend/test/lit/test_fabricate.py | 1 + frontend/test/pytest/test_fabricate.py | 1 + mlir/include/PBC/IR/PBCRefOps.td | 1 + mlir/lib/PBC/IR/PBCOps.cpp | 10 ++++++++++ 6 files changed, 23 insertions(+), 9 deletions(-) diff --git a/doc/releases/changelog-dev.md b/doc/releases/changelog-dev.md index fdc2bdca53..00ed9976dd 100644 --- a/doc/releases/changelog-dev.md +++ b/doc/releases/changelog-dev.md @@ -2,6 +2,13 @@

New features since last release

+* A new PennyLane operation :func:`~.fabricate` has been added to expose the PBC + ``pbc.fabricate`` instruction from the frontend. The operation produces a new auxiliary + qubit in a logical factory state (``plus_i``, ``minus_i``, ``magic``, or ``magic_conj``) + and is lowered through the ``pbc.ref.fabricate`` reference-semantics op to + ``pbc.fabricate`` during compilation. + [(#3019)](https://github.com/PennyLaneAI/catalyst/pull/3019) + * The `local-random` unitary folding option for :func:`~.mitigate_with_zne` is now implemented, reproducing Mitiq's ``fold_gates_at_random``: every gate is folded ``floor((scale_factor-1)/2)`` times, then a random subset is folded once more (without replacement) to reach ``scale_factor * n`` @@ -394,12 +401,6 @@ `-stage` naming convention used when invoking them from the command line (e.g. `quantum-compilation-stage`). [#3002](https://github.com/PennyLaneAI/catalyst/pull/3002) -* A new PennyLane operation :func:`~.fabricate` has been added to expose the PBC - ``pbc.fabricate`` instruction from the frontend. The operation produces a new auxiliary - qubit in a logical factory state (``plus_i``, ``minus_i``, ``magic``, or ``magic_conj``) - and is lowered through the ``pbc.ref.fabricate`` reference-semantics op to - ``pbc.fabricate`` during compilation. -

Documentation 📝

* A broken link was removed in the [Compiler Core](https://docs.pennylane.ai/projects/catalyst/en/stable/modules/mlir.html) documentation page. The link referred to where precompiled decomposition rules were implemented, which has since been refactored. diff --git a/frontend/catalyst/from_plxpr/qfunc_interpreter.py b/frontend/catalyst/from_plxpr/qfunc_interpreter.py index 303aaf4e15..6a1b0d1cac 100644 --- a/frontend/catalyst/from_plxpr/qfunc_interpreter.py +++ b/frontend/catalyst/from_plxpr/qfunc_interpreter.py @@ -439,9 +439,9 @@ def handle_deallocate(self, *wires): qreg = parent_eqn.in_tracers[0] qregs.add(qreg) else: - assert ( - parent_eqn.primitive is qref_get_p - ), "Manual deallocation is only supported for manually allocated or fabricated wires" + raise AssertionError( + "Manual deallocation is only supported for manually allocated or fabricated wires" + ) for qubit in standalone_qubits: qref_dealloc_qb_p.bind(qubit) if qregs: diff --git a/frontend/test/lit/test_fabricate.py b/frontend/test/lit/test_fabricate.py index 8263a293b8..a834f8675e 100644 --- a/frontend/test/lit/test_fabricate.py +++ b/frontend/test/lit/test_fabricate.py @@ -46,6 +46,7 @@ def circuit(): return qp.expval(qp.Z(0)) # CHECK: pbc.fabricate magic + # CHECK-NOT: pbc.ref.fabricate # CHECK: pbc.ppm ["Z", "Z"] print(circuit.mlir_opt) diff --git a/frontend/test/pytest/test_fabricate.py b/frontend/test/pytest/test_fabricate.py index d74e015c7f..457f26a5d7 100644 --- a/frontend/test/pytest/test_fabricate.py +++ b/frontend/test/pytest/test_fabricate.py @@ -76,3 +76,4 @@ def circuit(): mlir = circuit.mlir_opt assert "pbc.fabricate" in mlir and "magic_conj" in mlir + assert "pbc.ref.fabricate" not in mlir diff --git a/mlir/include/PBC/IR/PBCRefOps.td b/mlir/include/PBC/IR/PBCRefOps.td index 61ba7636aa..f80e1ee07f 100644 --- a/mlir/include/PBC/IR/PBCRefOps.td +++ b/mlir/include/PBC/IR/PBCRefOps.td @@ -57,5 +57,6 @@ def RefFabricateOp : PBC_Op<"ref.fabricate"> { $init_state attr-dict `:` type($qubits) }]; + let hasCanonicalizeMethod = 1; let hasVerifier = 1; } diff --git a/mlir/lib/PBC/IR/PBCOps.cpp b/mlir/lib/PBC/IR/PBCOps.cpp index 33b796bf20..f5746d08fb 100644 --- a/mlir/lib/PBC/IR/PBCOps.cpp +++ b/mlir/lib/PBC/IR/PBCOps.cpp @@ -102,6 +102,16 @@ LogicalResult RefFabricateOp::verify() return mlir::success(); } +LogicalResult RefFabricateOp::canonicalize(RefFabricateOp op, PatternRewriter &rewriter) +{ + if (op->use_empty()) { + rewriter.eraseOp(op); + return success(); + } + + return failure(); +} + LogicalResult SelectPPMeasurementOp::verify() { if (getInQubits().size() != getPauliProduct_0().size() || From b3004916d9f675c7efea46762c184da6d39d4f12 Mon Sep 17 00:00:00 2001 From: qottmann Date: Tue, 14 Jul 2026 13:29:00 +0200 Subject: [PATCH 6/6] Support fabricate and deallocate under capture=False legacy tracing. Handle standalone AbstractQbit tracers in QRegPromise and route qp.deallocate to catalyst.deallocate so PBC workflows compile with the old frontend. Co-authored-by: Cursor --- frontend/catalyst/api_extensions/__init__.py | 2 ++ .../api_extensions/quantum_operators.py | 32 +++++++++++++++++-- frontend/catalyst/jax_tracer.py | 28 +++++++++++++--- frontend/test/pytest/test_fabricate.py | 30 +++++++++++++++++ 4 files changed, 85 insertions(+), 7 deletions(-) diff --git a/frontend/catalyst/api_extensions/__init__.py b/frontend/catalyst/api_extensions/__init__.py index f75f724099..50e6d41622 100644 --- a/frontend/catalyst/api_extensions/__init__.py +++ b/frontend/catalyst/api_extensions/__init__.py @@ -37,6 +37,7 @@ MidCircuitPauliMeasure, adjoint, ctrl, + deallocate, measure, pauli_measure, fabricate, @@ -60,6 +61,7 @@ "measure", "pauli_measure", "fabricate", + "deallocate", "adjoint", "ctrl", ) diff --git a/frontend/catalyst/api_extensions/quantum_operators.py b/frontend/catalyst/api_extensions/quantum_operators.py index 42c92a68e7..bfc79cfb94 100644 --- a/frontend/catalyst/api_extensions/quantum_operators.py +++ b/frontend/catalyst/api_extensions/quantum_operators.py @@ -46,7 +46,14 @@ deduce_avals, new_inner_tracer, ) -from catalyst.jax_primitives import AbstractQreg, adjoint_p, fabricate_p, measure_p, pauli_measure_p +from catalyst.jax_primitives import ( + AbstractQreg, + adjoint_p, + fabricate_p, + measure_p, + pauli_measure_p, + qdealloc_qb_p, +) from catalyst.jax_tracer import ( HybridOp, HybridOpRegion, @@ -248,8 +255,9 @@ def fabricate(init_state: str) -> DynamicJaxprTracer: .. important:: - The :func:`qp.fabricate() ` function is **not** QJIT - compatible under the legacy tracing pathway; use :func:`catalyst.fabricate` instead. + Under the legacy tracing pathway, use :func:`catalyst.fabricate` or ensure + :func:`qp.fabricate() ` is called from within an active + :func:`~.qjit` compilation. Args: init_state (str): The logical state to fabricate. One of ``"plus_i"``, @@ -274,6 +282,24 @@ def fabricate(init_state: str) -> DynamicJaxprTracer: return qubit +def deallocate(*qubits) -> None: + r"""A :func:`qjit` compatible deallocate operation for standalone fabricated qubits. + + Deallocates one or more standalone value-semantics qubits, such as those returned + by :func:`fabricate`. + + Args: + *qubits: Standalone qubit tracers to deallocate. + """ + EvaluationContext.check_is_tracing("catalyst.deallocate can only be used from within @qjit.") + EvaluationContext.check_is_quantum_tracing( + "catalyst.deallocate can only be used from within a qp.qnode." + ) + + for qubit in qubits: + qdealloc_qb_p.bind(qubit) + + def adjoint(f: Union[Callable, Operator], lazy=True) -> Union[Callable, Operator]: """A :func:`~.qjit` compatible adjoint transformer for PennyLane/Catalyst. diff --git a/frontend/catalyst/jax_tracer.py b/frontend/catalyst/jax_tracer.py index 3eb26bae34..a619c66dc6 100644 --- a/frontend/catalyst/jax_tracer.py +++ b/frontend/catalyst/jax_tracer.py @@ -86,6 +86,7 @@ ) from catalyst.jax_extras.tracing import uses_transform from catalyst.jax_primitives import ( + AbstractQbit, AbstractQreg, compbasis_p, counts_p, @@ -398,6 +399,11 @@ def unify_convert_result_types(jaxprs, consts, nimplouts): return jaxpr_acc, type_acc[0], tracers_acc, consts_acc +def _is_standalone_qubit(wire) -> bool: + """Return True if ``wire`` is a standalone value-semantics qubit tracer.""" + return isinstance(wire, Tracer) and isinstance(get_aval(wire), AbstractQbit) + + class QRegPromise: """QReg adaptor tracing the qubit extractions and insertions. The adaptor works by postponing the insertions in order to re-use qubits later thus skipping the extractions.""" @@ -413,12 +419,21 @@ def extract(self, wires: List[Any], allow_reuse=False) -> List[DynamicJaxprTrace from cache""" # pylint: disable=consider-iterating-dictionary qrp = self - cached_tracers = {w for w in qrp.cache.keys() if isinstance(w, Tracer)} - requested_tracers = {w for w in wires if isinstance(w, Tracer)} + cached_tracers = {w for w in qrp.cache.keys() if _is_standalone_qubit(w)} + requested_tracers = {w for w in wires if _is_standalone_qubit(w)} if cached_tracers != requested_tracers: qrp.actualize() qubits = [] for w in wires: + if _is_standalone_qubit(w): + if w in qrp.cache and qrp.cache[w] is not None: + qubit = qrp.cache[w] + qubits.append(qubit) + if not allow_reuse: + qrp.cache[w] = None + else: + qubits.append(w) + continue if w in qrp.cache: qubit = qrp.cache[w] assert ( @@ -447,10 +462,15 @@ def actualize(self) -> DynamicJaxprTracer: """Prune the qubit cache by performing the postponed insertions.""" qrp = self qreg = qrp.base + standalone_cache = {} for w, qubit in qrp.cache.items(): - if qubit is not None: + if qubit is None: + continue + if _is_standalone_qubit(w): + standalone_cache[w] = qubit + else: qreg = qinsert_p.bind(qreg, w, qubit) - qrp.cache = {} + qrp.cache = standalone_cache qrp.base = qreg return qreg diff --git a/frontend/test/pytest/test_fabricate.py b/frontend/test/pytest/test_fabricate.py index 457f26a5d7..6ad3af737f 100644 --- a/frontend/test/pytest/test_fabricate.py +++ b/frontend/test/pytest/test_fabricate.py @@ -77,3 +77,33 @@ def circuit(): mlir = circuit.mlir_opt assert "pbc.fabricate" in mlir and "magic_conj" in mlir assert "pbc.ref.fabricate" not in mlir + + @pytest.mark.usefixtures("enable_graph_decomposition") + def test_fabricate_mlir_lowering_capture_false(self): + """Test fabricate lowering via the legacy tracing pathway.""" + dev = qp.device("null.qubit", wires=2) + pipe = [ + ( + "pipe", + [ + "canonicalize", + "verify-no-quantum-use-after-free", + "convert-to-value-semantics", + "canonicalize", + ], + ) + ] + + @qjit(pipelines=pipe, target="mlir", capture=False) + @qp.qnode(device=dev) + def circuit(): + magic = fabricate("magic") + qp.pauli_measure("ZZ", wires=[0, magic]) + qp.deallocate(magic) + return qp.expval(qp.Z(0)) + + mlir = circuit.mlir_opt + assert "pbc.fabricate" in mlir and "magic" in mlir + assert "pbc.ref.fabricate" not in mlir + assert "pbc.ppm" in mlir + assert "quantum.dealloc_qb" in mlir