Skip to content

Expose Rust Custom Gates to Python - #15723

Open
Raynel Sanchez (raynelfss) wants to merge 8 commits into
Qiskit:mainfrom
raynelfss:python-custom
Open

Raynel Sanchez (raynelfss) wants to merge 8 commits into
Qiskit:mainfrom
raynelfss:python-custom

Conversation

@raynelfss

@raynelfss Raynel Sanchez (raynelfss) commented Feb 23, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Blocked by and built on top of #15684

  • The following commits expose CustomOperation structs to Python via a wrapper of type PyCustomOp.
  • Introduce CustomGate and CustomInstruction Python side structs to represent custom Rust operations with the correct class hierarchy.

Details and comments

@raynelfss Raynel Sanchez (raynelfss) added on hold Can not fix yet Changelog: Added Add an "Added" entry in the GitHub Release changelog. Rust This PR or issue is related to Rust code in the repository mod: circuit Related to the core of the `QuantumCircuit` class or the circuit library labels Feb 23, 2026
@raynelfss
Raynel Sanchez (raynelfss) force-pushed the python-custom branch 3 times, most recently from 61dad1e to b35d1c7 Compare February 25, 2026 19:01
@raynelfss
Raynel Sanchez (raynelfss) force-pushed the python-custom branch 2 times, most recently from 2ad0b09 to 7815ae9 Compare March 5, 2026 17:42
@raynelfss
Raynel Sanchez (raynelfss) marked this pull request as ready for review March 5, 2026 17:57
@raynelfss
Raynel Sanchez (raynelfss) requested a review from a team as a code owner March 5, 2026 17:57
@qiskit-bot

Copy link
Copy Markdown
Collaborator

One or more of the following people are relevant to this code:

  • @Qiskit/terra-core
  • @mtreinish

@raynelfss
Raynel Sanchez (raynelfss) force-pushed the python-custom branch 2 times, most recently from 10e91ac to affdb50 Compare March 10, 2026 14:36
@raynelfss Raynel Sanchez (raynelfss) added this to the 2.5.0 milestone Mar 18, 2026
Raynel Sanchez (raynelfss) added a commit to raynelfss/qiskit that referenced this pull request May 13, 2026
- Add errors any time any python specific methods are invoked on any `CustomOperation` instances (to be resolved by Qiskit#15723).
@coveralls

Coveralls (coveralls) commented May 27, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 27134596691

Warning

Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes.
Quick fix: rebase this PR. Learn more →

Coverage decreased (-0.04%) to 87.407%

Details

  • Coverage decreased (-0.04%) from the base build.
  • Patch coverage: 79 uncovered changes across 4 files (150 of 229 lines covered, 65.5%).
  • 450 coverage regressions across 16 files.

Uncovered Changes

File Changed Covered %
crates/circuit/src/operations.rs 193 123 63.73%
qiskit/circuit/gate.py 9 5 55.56%
qiskit/circuit/instruction.py 7 4 57.14%
crates/transpiler/src/passes/high_level_synthesis.rs 2 0 0.0%
Total (7 files) 229 150 65.5%

Coverage Regressions

450 previously-covered lines in 16 files lost coverage.

Top 10 Files by Coverage Loss Lines Losing Coverage Coverage
crates/transpiler/src/target/mod.rs 160 81.96%
crates/transpiler/src/passes/optimize_1q_gates_decomposition.rs 71 76.86%
crates/providers/src/data_tree.rs 64 89.47%
crates/providers/src/tensor.rs 39 94.8%
crates/providers/src/program_node.rs 25 10.71%
crates/transpiler/src/transpiler.rs 23 94.47%
crates/accelerate/src/twirling.rs 13 94.17%
crates/transpiler/src/passes/commutation_cancellation.rs 13 92.86%
crates/providers/src/store.rs 9 88.31%
qiskit/transpiler/preset_passmanagers/generate_preset_pass_manager.py 8 95.14%

Coverage Stats

Coverage Status
Relevant Lines: 125202
Covered Lines: 109435
Line Coverage: 87.41%
Coverage Strength: 956488.76 hits per line

💛 - Coveralls

@raynelfss
Raynel Sanchez (raynelfss) force-pushed the python-custom branch 3 times, most recently from 48112b4 to 9bc20fa Compare May 30, 2026 17:07

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ownership and testing stories here are my biggest questions. I think we really should get some set up where we can drive/test things from Python space: that'd give us a much clearer view on how well this hangs together.

pub struct PyCustomOperation {
pub inner: Box<dyn CustomOperation>,
pub parameters: Option<SmallVec<[Param; 3]>>,
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few questions here:

  • how convinced are we of this ownership model of cloning the Box<dyn CustomOperation> in order to expose it to Python space? At the very least, it feels like we might want the clone to be lazy in some form or another.
  • do we need to derive from_py_object? This is a potentially heavy class, and we probably shouldn't be cloning it in Rust without that being very explicit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Hmm... For this I'm not sure how we should proceed. I could see a how we could use Arc for this but it would make operations immutable from Python, which to be honest I'm not sure if it should be the way to go. Parameters should be mutable at least but I don't see how we'd change the operation at a base level.
  • I'll remove it. Honestly I don't see how we'd explicitly need this during extraction as we'd just clone the parameters and the Box<dyn CustomOperation> (which I'm starting to think maybe we need to make it into something different - maybe use Arc<Box<T>> instead?)

Comment thread crates/circuit/src/operations.rs Resolved
Comment thread crates/circuit/src/operations.rs Resolved
Comment on lines +2184 to +2199
/// Creates a Python view from a [`CustomOperation`] instance.
///
/// # Arguments:
///
/// * `op` - The operation instance within a box.
/// * `parameters` - The parameters for this operation, if needed.
///
/// # Returns:
///
/// The python view of the [`CustomOperation`] dyn-object.
pub fn new(op: Box<dyn CustomOperation>, parameters: Option<SmallVec<[Param; 3]>>) -> Self {
Self {
inner: op,
parameters,
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the inner fields are pub, this method isn't really needed - the struct-constructor form PyCustomOperation { inner: ..., parameters: ... } is clearer at the callsite than a list of unnamed options.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a couple lazy private fields which make this method required now.

Comment on lines +2201 to +2218
/// Creates a more complete Python view of the operation with the associated types.
///
/// This version of the function will use clone the original instance. If you posess
/// ownership of the object and want to consume it, use [`PyCustomOperation::into_py_op.`]
pub fn create_py_op<'py>(&'py self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
let cloned = self.clone();

Self::into_py_op(cloned, py)
}

/// Creates a more complete Python view of the operation with the associated types.
pub fn into_py_op<'py>(self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
let py_type = match self.inner.is_unitary() {
true => CUSTOM_GATE.get_bound(py),
false => CUSTOM_INSTRUCTION.get_bound(py),
};
py_type.call1((self,))
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

create_py_op is an unusual choice here - I'm not sure why hiding a clone is particularly useful. ob.clone().into_py_op(py) is a common and fine pattern.

Comment on lines +2907 to +2968
// Try Python:
Python::attach(|py| -> PyResult<()> {
for (idx, inst) in circuit.data().iter().enumerate() {
let unpacked_operation = circuit.unpack_py_op(py, inst)?.into_bound(py);

// Compare repr
assert_eq!(
format!(
"Instruction(name='{}', num_qubits={}, num_clbits={}, params={:?})",
gates[idx].name(),
gates[idx].num_qubits(),
gates[idx].num_clbits(),
inst.params_view()
.iter()
.filter_map(|params| params.try_float())
.collect::<Vec<_>>(),
),
unpacked_operation.repr()?.to_string()
);

// Compare python matrix
let orig_matrix = gates[idx]
.matrix(inst.params_view())
.expect("The provided parameters should generate the matrix");
let retrieved_array = unpacked_operation
.call_method0("to_matrix")?
.cast_into::<PyArray2<Complex64>>()?;
let retrieved_matrix = retrieved_array.to_owned_array();
assert!(approx::abs_diff_eq!(
orig_matrix,
retrieved_matrix,
epsilon = 1e-12
));

// Compare params
assert!(
unpacked_operation
.getattr("params")?
.eq(inst.params_view().into_pyobject(py)?)?
);

// Try roundtrip
let roundtrip = unpacked_operation.extract::<OperationFromPython<CircuitData>>()?;
let OperationRef::CustomOperation(roundtrip_native) = roundtrip.operation.view()
else {
panic!(
"Retreived operation is not an instance of CustomOperation: '{:?}'",
roundtrip.operation.view()
);
};

assert_eq!(gates[idx], roundtrip_native);
assert_eq!(gates[idx].is_unitary(), roundtrip_native.is_unitary());
assert_eq!(
gates[idx].label(),
roundtrip.label.as_deref().map(|x| x.as_str())
);
}
Ok(())
})
.expect("Something went wrong on the Python side.");
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not a great idea to drive this from Rust space - it implicitly accesses two different compilations of the Rust circuit crate (one direct dependency, one via implicit imports of Python-space Qiskit). It'd quite possibly be better to set up some dummy test-only objects into _accelerate and then drive the tests of them from Python space.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm... This might be an issue for testing, unless we create a dummy function that can only be tested from Python. Sort of how we did for #13995.

Comment thread qiskit/circuit/gate.py Resolved
pull Bot pushed a commit to Intuitive-Flow/qiskit that referenced this pull request Jun 3, 2026
* Initial: Add Custom Gates/Instructions in Rust
- Add `CustomOperation` trait that allows users to implement methods available in both `Instruction`s and `Gate`s.
- Add the packed representation of `CustomOperation` within a `PackedOperation` called `NativeOperation`.
- Use example within `BasisTranslator`'s `compose transforms function where a dummy gate called `CustomDummy` is added to each `DAGCircuit` instance natively.
- Adapt codebase to new additions.

* Fix: Add case for `CustomInstruction` in QPY
- Add missing case for `CustomInstruction` views in QPY.
- Add docstring to `CustomOperation` trait.
- Add miri flag to `try_add_to_circuit`.

* Fix: Add missing methods in `PackedInstruction`.
- Fix formatting and clean up tests.

* Add: `downcast_ref` for `NativeOperation`.
- Add missing docstrings.

* Remove: Gate and Instruction views.
- The following commit removes different views for `Instruction` vs `Gate` in favor of a consolidated view. Users should be able to use `CustomOperation::kind()` to see the type of operation enclosed.

* Add: Method to push custom operation to a Circuit
- Add `CircuitData::push_custom_operation` method to directly push `CustomOperation` instances into a `CircuitData` instance.
- Add `PackedInstruction::from_custom_operation` method to create a full packed instruction directly from a `CustomOperation` implementor.

* Fix: Implement `Deref` and `DerefMut` for `CustomOp`.

* Fix: `Native` -> `Custom`

* Refactor: Hide `CustomOp` to be internal to the `Circuit` crate
The `CustomOp` struct acts as a container to the `Box<dyn CustomOperation>` and its presence outisde of this crate seems to be a little redundant. The following commit reduces the visibility of this struct to only the `circuit` crate, which makes it easier to process a custom operation without the added layer of wrapping it within this struct.

* Docs: Fix `CustomOperation`s docstring

* Revert: Changes to caused by clippy.

* Fix: Address minor review comments
- Rename `CustomOp` to `BoxedCustomOperation`.
- Remove `from_custom_operation` function.
- Rename `from_boxed_custom_operation` to `from_custom_operation`.
- Add comment to `extras_keys` to explain use of default value.

* Address review comments.

* FIx: Use trait upcasing to implement `downcast_ref`.

* Fix: Old review comments
- Expand docstring for `BoxedCustomOperation`.
- Rename unwrapping of `CustomOperation` as `operation` not `gate`.

* Remove: `push_custom_operation`

* Remove: TODO comment in `circuit_writer`

* Fix: Remove Python methods.
- Add errors any time any python specific methods are invoked on any `CustomOperation` instances (to be resolved by Qiskit#15723).

* Remove: `CustomOperationKind`
- Refactor `CustomOperation::kind` to `CustomOperation::is_unitary` which now returns a `bool` value instead of an enumeration to define whether the operation is unitary.

Co-authored-by: Julien Gacon <gaconju@gmail.com>

* Docs: Remove outdated upcasting docstring

* Add: Ability to compare opaque gates
Added trait `ComparableOp` as a way of implementing `Eq` for custom operations.
This trait is automatically implemented if the operation implements `PartialEq`, otherwise the user is responsible for implementing the trait.
The trait contains the function `rich_eq` which promptly receives another `CustomOperation` as the `other` argument and then tries to downcast this instance down to original operation's type and then call `PartialEq::eq`.

* FIx: Update docstring.

* Fix: Use `PartialEq` implictly.
- Witht the addition of `Comparableop` in the previous commit, this approach makes the implementation hidden by turning the trait into a sealed trait and adding an error message whenever it is not implemented.
- This enables `dyn CustomOperation` to be able to implement `PartialEq` without breaking dyn-compatibility rules.
- An explicit error message will be displayed whenever this trait is not implemented, asking the user to implement `PartialEq`.

* Fix: Use `Clone` implictly.
- Add sealed trait `CopyableOp` to implicitly enforce implementing `Clone` for a dyn operation.
- This enables `dyn CustomOperation` to be able to implement `ToOwned` and `Clone` (only for Box<>)` without breaking dyn-compatibility rules.
- Remove requirement to implement `clone_dyn` manually.
- An explicit error message will be displayed whenever this trait is not implemented, asking the user to implement `PartialEq`.

* Test: Extend the test-suite
The following tests cases were added:
- Custom gate with label.
- Gate equality
- Dynamic cloning of a gate with internal data.
- Downcasting into correct and incorrect types.
- Parameterized gate with a matrix.
- Gate with self inverse.
- Multiple custom ops in Circuit.
- OperationRef keeping `Operation` methods.

Previous tests also added extra equality checks.

* Fix: Lint (PI = 3.14)

* Fix: Use `abs_diff_eq!` to compare matrices

* Fix: Epsilon value in matrix comp
- Changed tests from a match to `assert_eq`,

* Docs: Update docstring to remove `clone_dyn`

* Simplify test gate definitions

* Remove partial support of `CustomOperation`

This support was currently only partial, and untested on read.  Since
`CustomOperation` is currently not exposed to userspace, it shouldn't
appear.  We can take the time to consider this more completely in the
future; all QPY versions up to the present v17 fairly strongly assume
that custom gates are defined in Python space, and this change to the
data model may motivate a change in format.

* Remove duplicated assertion messages

* Correct out of date comment

---------

Co-authored-by: Julien Gacon <gaconju@gmail.com>
Co-authored-by: Jake Lishman <jake.lishman@ibm.com>
- The following commits expose `CustomOperation` structs to Python via a wrapper of type `PyCustomOp`.
- Introduce `CustomGate` and `CustomInstruction` python side structs to represent custom rust operations with the correct class hierarchy.
- Unify the names of the inner types within the python `CustomGate` and `CustomInstruction` to `_native_operation`.
- Enable ability to extract operation via `OperationFromPython`.
- Introduce `CustomOperation::create_py_op_view()` to turn any `CustomOperation` into a python view.
- Removed most print statements in favor of python comparisons.
- This implementation uses `OnceLock` to make the initialization of the python counterparts of `::matrix`, `::definition`, and `::params` to be lazily initialized once per call.
- The implementation of `::__array__` includes casting to a different `dtype` which has to use the `cast_array_type` function from the `quantum_info` module. This commit slighhly modifies the function to accept different dimensions.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Changelog: Added Add an "Added" entry in the GitHub Release changelog. mod: circuit Related to the core of the `QuantumCircuit` class or the circuit library on hold Can not fix yet Rust This PR or issue is related to Rust code in the repository

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants