Skip to content

Conversation

@erichulburd
Copy link
Collaborator

@erichulburd erichulburd commented Jul 13, 2025

Closes #447

New Features

  1. Parsing and construction of sequence gate definitions.
  2. Expansion of sequence gate definitions with ability to filter by gate name.
  3. Query a source map for sequence gate definition expansion.

Refactor

  1. Refactor Program::expand_calibrations_with_source_map to return SourceMap<InstructionIndex, InstructionTarget<CalibrationExpansion>>.
  2. Refactor Python source map to a single type, shared across calibration and sequence gate definition expansion. Namely, we use enum InstructionTargetShim to cover all known instruction targets and return InstructionSourceMap = SourceMap<std::ops::Range<usize>, InstructionTargetSim>.

Notes on approach

There's a lot of ways to slice this pie, but the goals I decided on were to:

  • Expose only a single source map to Python.
  • Support a source map structure in quil-py that can be extended to support Python constructed source maps without a breaking change (or at least none other than handling an additional InstructionTargetShim variant.
  • Support a source map structure in quil-py that is forward compatible with passes that excise instructions or operate on multiple source instructions (accomplished by using a SourceIndex type of std::ops::Range<usize>).

@github-actions
Copy link

github-actions bot commented Jul 13, 2025

PR Preview Action v1.6.2
Preview removed because the pull request was closed.
2025-11-06 00:01 UTC

Copy link

@windsurf-bot windsurf-bot bot left a comment

Choose a reason for hiding this comment

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

Other comments (7)

💡 To request another review, post a new comment with "/windsurf-review".

@erichulburd erichulburd changed the title Draft: feat: defgate as sequence feat: defgate as sequence Jul 22, 2025
Copy link
Contributor

@antalsz antalsz left a comment

Choose a reason for hiding this comment

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

The biggest thing I find lacking from this PR is guidance on how it's going to be entered. Can you write up an example in a doc comment somewhere showing how you'd perform full program expansion, soup to nuts?

The other thing I find surprising is that there's no "just expand everything" option. There are two almost-identical expansion operations which return completely disjoint source map types, when I'd expect you'd mostly just want to do all the expansion at once. It feels like it would be possible to unify those operations and provide an expand function, which would avoid the need to merge source maps.

The problem, of course, is that it's ambiguous which operation should "win" if there are conflicting DEFGATE AS SEQUENCE and DEFCALs. I can see four approaches:

  • Error if a single instruction could be expanded with both a SEQUENCE and a DEFCAL.
  • Provide a way of configuring what should happen if there's an ambiguity: prefer the SEQUENCE, prefer the DEFCAL, or error.
  • As the former, but per-gate.
  • Provide exactly one resolution b behavior – probably "prefer the SEQUENCE". Document it.

Those are arranged in order of preference.

@antalsz antalsz changed the title feat: defgate as sequence feat!: defgate as sequence Aug 5, 2025
@erichulburd
Copy link
Collaborator Author

Can you write up an example in a doc comment somewhere showing how you'd perform full program expansion, soup to nuts?

The motivation for this work is to define a cycle's calibration and unitary definition within the same program. In this context, calibration and gate definition expansion are orthogonal to each other:

DECLARE params REAL[2]

DEFCAL SOME_CYCLE(%theta, %phi) 0:
  # ...

DEFGATE SOME_CYCLE(%theta, %phi) a b AS SEQUENCE:
  RZ(theta) a
  RX(pi / 2) a
  RZ(phi) b
  RX(pi / 2) b

SOME_CYCLE(params[0], params[1]) 0 1
  • When I want to run this program on the QPU, I would expand the calibrations.
  • When I want to simulate this program, I would expand the gate sequence definition.

Gate sequences could also be used as a convenience function for describing a program compactly:

DECLARE params REAL[3]

DEFCAL RZ(%theta) 0:
  # ...

DEFCAL RZ(pi/2) 0:
  # ...

DEFGATE ZXZXZ(%theta, %psi, %phi) a:
  RZ(%theta) a
  RZ(pi / 2) a
  RZ(%psi) a
  RZ(pi / 2) a
  RZ(%phi) a

ZXZXZ(params[0], params[1], params[2]) 0

Note that the order of desired expansion is reversed here. In the first example, we want to expand calibrations and not expand gate sequences. In this example, we must expand the gate sequence definition before calibrations. There is no presumption to make on behalf of the user here, so if they want to expand everything, they should programmatically do just that.

I can certainly add a documentation example with these examples.

@erichulburd erichulburd force-pushed the 447-defgate_as_sequence branch 2 times, most recently from 6bdae28 to 8ce18b0 Compare August 13, 2025 20:29
@erichulburd erichulburd requested a review from antalsz August 21, 2025 20:42
Copy link
Contributor

@antalsz antalsz left a comment

Choose a reason for hiding this comment

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

Apologies for the delay, but the resulting changes look great – I have some very minor comments, but then I think we're good to merge! 🚀

Copy link
Contributor

Choose a reason for hiding this comment

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

This file appears to be duplicated later in a Rust and a Python doc comment. Perhaps it should just live in those doc comments? On the Rust side, it's possible to include a file in a doc comment, but I don't know if it is or not in Python. A note to the developers to keep things in sync would also be nice, I think – again, this is the sort of thing that #462 will help with.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I'll make the comment and take this file out.

I didn't intend to version this file, but the reason I wrote it in the first place was because I couldn't get the doctests to actually run through pytest:

_______________________________________________________________________ ERROR collecting quil/validation/identifier.py ________________________________________________________________________
.venv/lib/python3.11/site-packages/_pytest/runner.py:344: in from_call
    result: TResult | None = func()
                             ^^^^^^
.venv/lib/python3.11/site-packages/_pytest/runner.py:389: in collect
    return list(collector.collect())
           ^^^^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.11/site-packages/_pytest/doctest.py:566: in collect
    module = self.obj
             ^^^^^^^^
.venv/lib/python3.11/site-packages/_pytest/python.py:280: in obj
    self._obj = obj = self._getobj()
                      ^^^^^^^^^^^^^^
.venv/lib/python3.11/site-packages/_pytest/python.py:551: in _getobj
    return importtestmodule(self.path, self.config)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.11/site-packages/_pytest/python.py:498: in importtestmodule
    mod = import_path(
.venv/lib/python3.11/site-packages/_pytest/pathlib.py:595: in import_path
    module_file = mod.__file__
                  ^^^^^^^^^^^^
E   AttributeError: module 'quil.validation.identifier' has no attribute '__file__'
=================================================================================== short test summary info ===================================================================================
ERROR quil/validation/identifier.py - AttributeError: module 'quil.validation.identifier' has no attribute '__file__'

I believe this is a fundamental issue with stubs and extension modules, but it's been a while since I looked into it.

@erichulburd erichulburd force-pushed the 447-defgate_as_sequence branch from ef4b0ab to ee6f91e Compare October 2, 2025 18:55
Copy link
Contributor

@asaites asaites left a comment

Choose a reason for hiding this comment

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

Thank you for your hard work on this! And my apologies it's taken me so long to review it.

The bones here are good! I particularly like the idea of uniting the source maps, but there are some rough spots to the API that would benefit from some massaging for long-term maintainability. I need a bit more time to digest some aspects of the code, and I still intend to help rebase this on the merged crates (which should help simplify some aspects). I didn't want to make you wait longer, though, so I'm submitting this initial review.

Comment on lines 2030 to 2060
@final
class GateSignature:
"""A signature for a gate definition; this does not include the gate definition content.
To get a signature from a definition, use `GateDefinition.signature`.
"""
def __new__(cls, name: str, gate_parameters: List[str], qubit_parameters: List[str], gate_type: GateType) -> Self: ...

@final
class DefGateSequence:
"""A sequence of gates that make up a defined gate (i.e. with `DEFGATE ... AS SEQUENCE`)."""

def __new__(cls, qubits: List[str], gates: List[Gate]) -> Self:
"""Creates a new `DefGateSequence` with the given qubits and gates.
:param qubits: A list of qubit names that the gates in the sequence will act on.
:param gates: A list of `Gate` objects that make up the sequence. Each gate must reference
qubits in the `qubits` list by name. They may not specify a fixed qubit.
"""
...

@property
def qubits(self) -> List[str]:
"""Returns the list of qubit variable names in the gate signature."""
...

@property
def gates(self) -> List[Gate]:
"""Returns the list of `Gate` objects that make up the sequence."""
...

Copy link
Contributor

Choose a reason for hiding this comment

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

When this branch gets updated to be compatible with the merged quil-py/quil-rs crates, these files will be generated automatically from the Rust source, so this documentation will need to move to their Rust counterparts (with quil-rs specifically, not wrappers for them in quil-py).

@asaites asaites force-pushed the 447-defgate_as_sequence branch 2 times, most recently from 07c11d5 to e3d5005 Compare October 17, 2025 02:04
@asaites asaites force-pushed the 447-defgate_as_sequence branch from e3d5005 to 043e9e2 Compare October 17, 2025 16:22
Copy link
Contributor

@asaites asaites left a comment

Choose a reason for hiding this comment

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

I've made some suggestions based on the changes, and implemented them here. I think there's still room for improvement on the implementation details of the defgate_sequence_expansion, particularly regarding the allocation of Vecs that likely could be done by passing along a target vec to fill -- but if you're happy with the API, then we can address that if it becomes a performance problem in the future.

}
}
GateSpecification::Sequence(sequence) => {
for gate in sequence.gates.iter() {
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit/clippy suggestion:

Suggested change
for gate in sequence.gates.iter() {
for gate in &sequence.gates {

let gate_parameters = gate
.parameters
.iter()
.cloned()
Copy link
Contributor

Choose a reason for hiding this comment

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

These don't actually need to be cloned().

Suggested change
.cloned()

f,
parameters
.iter()
.map(|p| p.as_ref())
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit:

Suggested change
.map(|p| p.as_ref())
.map(AsRef::as_ref)

gate_expansion_stack: &mut IndexSet<String>,
) -> Result<Vec<Instruction>, DefGateSequenceExpansionError> {
let mut target_instructions = vec![];
for source_instruction in source_instructions.iter() {
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
for source_instruction in source_instructions.iter() {
for source_instruction in source_instructions {

where
TargetIndex: SourceMapIndexable<QueryIndex>,
{
self.entries
Copy link
Contributor

Choose a reason for hiding this comment

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

Obviously this was here before, but while we're making this change, I think this would be a lot cleaner as the (equivalent) filter followed by map:

        self.entries
            .iter()
            .filter(|&entry| entry.target_location().contains(target_index))
            .map(SourceMapEntry::source_location)
            .collect()

fn gate_sequence_from_instruction(
&self,
instruction: &Instruction,
gate_expansion_stack: &mut IndexSet<String>,
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
gate_expansion_stack: &mut IndexSet<String>,
gate_expansion_stack: &IndexSet<String>,

Comment on lines 143 to 144
let mut gate_expansion_stack = gate_expansion_stack.clone();
gate_expansion_stack.insert(gate_sequence_signature.name().to_string());
Copy link
Contributor

Choose a reason for hiding this comment

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

Since you're constructing a new one here and then dropping it at the end of the block, it doesn't actually need to be a &mut reference parameter to the function. That said, it probably makes more sense to keep it that way, but avoid the clone here. In order to do that, you'd need to check the result of this insert and (if it's true), you'd pop the name back off the stack once the recursive call finishes. But since that might be easy to mess up, I'd actually recommend making a little wrapper and giving it a method that manages that for the caller:

struct ExpansionStack(IndexSet<String>);

impl ExpansionStack {
    fn new() -> Self {
        Self(IndexSet::new())
    }

    /// Check if the name is in the stack and, if so, return an error.
    fn check(&self, name: impl AsRef<str>) -> Result<(), DefGateSequenceExpansionError> {
        if self.0.contains(name.as_ref()) {
            let cycle = self.0.iter().cloned().collect();
            Err(DefGateSequenceExpansionError::CyclicSequenceGateDefinition(cycle))
        } else {
            Ok(())
        }
    }

    /// Execute a closure with an gate added to the stack.
    fn with_gate_sequence<F, R>(&mut self, name: String, f: F) -> R
    where
        F: FnOnce(&mut Self) -> R,
    {
        let must_pop = self.0.insert(name);
        let result = f(self);
        if must_pop {
            self.0.pop();
        }
        result
    }
}

Then these signatures would accept &mut ExpansionStack and use something like this:

let mut recursive_source_map = SourceMap::default();
let recursive_target_gate_instructions = gate_expansion_stack.with_gate_sequence(
    gate_sequence_signature.name().to_string(),
    |gate_expansion_stack| self.expand_with_source_map_impl(
        &target_gate_instructions,
        &mut recursive_source_map,
        gate_expansion_stack,
    )
)?;

.iter()
.filter(|(name, _)| !filter(name))
{
for (gate_name, (j, _)) in gate_sequence_definitions.iter() {
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
for (gate_name, (j, _)) in gate_sequence_definitions.iter() {
for (gate_name, (j, _)) in &gate_sequence_definitions {

Comment on lines 159 to 174
#[derive(Debug)]
#[cfg_attr(feature = "stubs", gen_stub_pyclass)]
#[pyclass(module = "quil.program", frozen)]
pub struct DefGateExpansionFilter {
filter: Py<PyFunction>,
on_error: Py<PyFunction>,
}

#[pymethods]
impl DefGateExpansionFilter {
#[new]
#[pyo3(signature = (/, filter, on_error))]
fn new(filter: Py<PyFunction>, on_error: Py<PyFunction>) -> Self {
Self { filter, on_error }
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Although this works, I recommend we just accept a single Callable for filtering, and not have any an on_error of any sort. Here's my reasoning:

  • The filter_instructions method already works this way, and from an API perspective, it'd be nicer for these to have the same form. Changing the existing one is a more impactful than having this match it.
  • For a similar reason, we should probably call this predicate rather than filter; that's also a little nicer since filter is a Python builtin function.
  • Since this function comes from the user, if there's an error while calling it, it's almost certainly a bug in their application and something they should address.
  • If they do have a reason to pass a filter that might return an invalid value, they have other options for handling it.
  • Calling on_error could cause a panic just as well.
  • If our code fails for reasons unrelated to calling their function, we certainly want a noisy crash.

So my recommendation is to just accept a predicate argument and panic on mistakes.

* style: use same predicate semantics across API

Currently `filter_instructions` uses the name "predicate",
an ordinary Python `Callable`, and bubbles errors as `PanicException`s.
This edits the new defgate functions to use the same naming and semantics.

It also introduces a `call_predicate` function
to simplify the common logic associated with calling those predicates,
and refactors the relevant to code to make use of it.

* style: refactor defgate seq expander with stack

* style: apply clippy suggestions

* chore: regenerate stubs

* style: remove line

* fix: self import for python 3.10

---------

Co-authored-by: Alexander Saites <[email protected]>
@asaites asaites merged commit 889793a into main Nov 5, 2025
19 checks passed
@asaites asaites deleted the 447-defgate_as_sequence branch November 5, 2025 23:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet