Skip to content

Improving Circuit API - #587

Open
matulni wants to merge 70 commits into
TeamGraphix:masterfrom
matulni:new-circuit-api
Open

matulni wants to merge 70 commits into
TeamGraphix:masterfrom
matulni:new-circuit-api

Conversation

@matulni

@matulni matulni commented Aug 19, 2026 •

Copy link
Copy Markdown
Contributor

This PR addresses #580. The PR comprises three blocks:

Block 1: Introduction of ancilla qubits

Concerns graphix.transpiler.py only.

Now it's possible to distinguish between input and ancilla qubits in circuits.

  • Two new attributes are added to the Circuit class:

    • ancillas: int to indicate the number of ancilla qubits. Ancilla qubits are indexed from range(width, width + ancillas - 1), where width is the number of input qubits (existing attribute).
    • ancilla_state: State to indicate the state of the ancilla qubits.
  • Supported operations on circuits with ancillas:

    • Simulation: always
    • Transpilation to pattern or causal flow: only if ancilla_state is a BasicState:
      • If ancilla_state is BasicState.PLUS, ancilla qubits are represented as non-input nodes in the open graph.
      • Any other BasicState is transpiled into BasicState.PLUS by prepending the appropriate gates to the circuit (via Circuit.transpile_ancilla_state). This is unavoidable because transpilation is carried out by first constructing an open graph with causal flow, and non-input nodes in OpenGraph always represent BasicState.PLUS qubits, even if Graphix supports N commands with an arbitrary state.
  • QASM 3 export: only if ancilla_state is a BasicState. Same mechanism as for transpilation but transforming to state |0>.

  • Note: this addition is a first step towards implementing circuit compilation of PDDAGs representing isometries.

Block 2: Introduction of conditional instructions

Concerns graphix.instruction.py and graphix.transpiler.py.

Now it's possible to represent conditional instructions in circuits.

  • A new instruction CONDINSTR is added. This instruction bundles a sequence of instructions (possibly incuding other CONDINSTR) and a qubit domain (set[int]). The instruction sequence is applied when the XOR of the measurement outcomes of the qubits in domain evaluates to 1.

  • Two side modifications:

    • InstructionVisitor

      • InstructionVisitor now has an additional method visit_domain. The method CONDINSTR.visit visits the instruction's domain and all the instructions in the instruction sequence.

      • Introduced a new _InstructionValidatorVisitor(InstructionVisitor) class. This visitor reimplements the method visit_qubit and visit_domain so that calling visit for a given instruction checks the qubit validity. As a result:

        1. Valid-qubit sanity checks are factorized into a single function.

        2. There is a generic way to check the validity of any InstructionKind, without knowing which specific instruction it is. This allows to verify the validity of conditional instructions containing a sequence of other instructions.

      • Checking that multi-qubit gates don't act on repeated qubits is delegated to the method visit of the corresponding instructions instead of the Circuit methods.

    • graphix.utils.check_kind

      • The function graphix.utils.check_kind has been slightly modified to fix an error due to the self-reference in the definition of CONDINSTR. This modification is an LLM suggestion, and I don't understand it fully.
  • Supported operations on circuits with conditional instructions:

    • Simulation: always.

      • Simulation is very inefficient: all qubits are instantianted at the beginning.
      • Exisiting simulation logic is encapsulated in a new function simulate_instructions that is called recursively for CONDINSTR.
      • Changes in simulation are minimal to support testing conditional instructions and keeping the PR as small as possible. In a future PR we'll address the following:
        • Simulation logic should be moved to simulator module.
        • Currently we are rewriting some code exisiting in MeasureMethod.
        • How to handle classical output results ?
          • Now keeping classical_outputs to achieve a minimal PR.
          • Ideally we should find a common interface for pattern and circuit simulation.
    • Transpilation to pattern or causal flow: always.

      • Required logic is encapsulated in Circuit.transpile_condinstr that is called from exisiting transpilation methods when there is any conditional instruction in the circuit.
      • Conditional instructions are replaced by controlled instructions, using measurements of their domain qubits in the $Z$ basis as control conditions. Measurements are moved to the end of the circuit. When the domain of the conditional instruction comprises various qubits (whose measurement outputs are xored), they are combined by chaining CNOT gates).
    • QASM 3 export: always (implemented by @thierry-martinez).

Block 3: Introduction of a Pattern.to_circuit method.

Concerns graphix.pattern.py

  • This method requires the changes in block 1 and block 2.
  • Logic is similar to existing Pattern.to_qasm3, with two differences:
    • Swap gates are added at the end of circuit to ensure that active qubits are in the same order as output nodes in the pattern. This guarantees that a pattern and its circuit produce the same state up to a phase.
    • Qubits are labelled with consecutive integers starting from 0. In the qasm export, we label qubits with the same node labels.
    • Is it worth keeping Pattern.to_qasm3?

@codecov

codecov Bot commented Aug 19, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.17444% with 9 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.91%. Comparing base (93a803c) to head (f3a3b42).

Files with missing lines Patch % Lines
graphix/pattern.py 93.05% 5 Missing ⚠️
graphix/qasm3_exporter.py 96.87% 2 Missing ⚠️
graphix/instruction.py 97.05% 1 Missing ⚠️
graphix/transpiler.py 99.68% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #587      +/-   ##
==========================================
+ Coverage   89.71%   89.91%   +0.20%     
==========================================
  Files          49       49              
  Lines        8293     8430     +137     
==========================================
+ Hits         7440     7580     +140     
+ Misses        853      850       -3     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…sable

This commit fixes the export of circuit measurements to OpenQASM
and makes the exporter more composable.

Exporting circuit measurements is tested with a round-trip using
TeamGraphix/graphix-qasm-parser#16.
The version of `graphix-qasm-parser` is set in
`.github/qasm-parser-requirements.txt` for both nox and coverage.

The gate `RZZ` does not belong to the OpenQASM standard set of gates
and was incorrectly exported as `crz`.
See TeamGraphix/graphix-qasm-parser#12 for more details.
`uv` ignores `pip`-installed packages.
@matulni
matulni marked this pull request as ready for review September 23, 2026 20:34

@thierry-martinez thierry-martinez left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Very nice work. Some minor comments.

Comment thread graphix/instruction.py Outdated
Comment on lines +320 to +331
@override
def visit(self, visitor: InstructionVisitor, *, copy: bool = False) -> CNOT:
target = visitor.visit_qubit(self.target)
control = visitor.visit_qubit(self.control)
if target == control:
raise ValueError(f"Target and control qubits cannot be the same. Qubit index: {target}.")
if copy:
return CNOT(target, control)
self.target = target
self.control = control
return self

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The check for target != control should be performed in the base method ControlledSingleTargetInstruction.visit, and we don't need a specific override for CNOT.

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.

Done in 980319b

Comment thread graphix/instruction.py Outdated
class CONDINSTR(_KindChecker, BaseInstruction):
"""Base class for conditional circuit instructions.

Condional measurements are not well defined since they would result in circuits with an indeterminate number of qubits. Therefore, the attribute ``instructions`` cannot contain instances of `Instruction.M`.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
Condional measurements are not well defined since they would result in circuits with an indeterminate number of qubits. Therefore, the attribute ``instructions`` cannot contain instances of `Instruction.M`.
Conditional measurements are not well defined since they would result in circuits with an indeterminate number of qubits. Therefore, the attribute ``instructions`` cannot contain instances of `Instruction.M`.

Comment thread graphix/instruction.py Outdated

# Needed to specify dataclass attributes in CONDINSTR,
# so it cannot be inside TYPE_CHECKING block.
InstructionTypeWithoutMandCONDINSTR = (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We only need InstructionTypeWithoutM, not InstructionTypeWithoutMandCONDINSTR. To define InstructionTypeWithoutM directly, even if CONDINSTR is not yet defined and recursively depends on InstructionTypeWithoutM, we can quote the definition:

InstructionTypeWithoutM: TypeAlias = """(
    I
    | X
    | Y
    | Z
    | H
    | S
    | SDG
    | T
    | TDG
    | SX
    | SXDG
    | J
    | P
    | RX
    | RY
    | RZ
    | U
    | CJ
    | CP
    | CRX
    | CRY
    | CRZ
    | CU
    | CNOT
    | CY
    | CZ
    | CCX
    | RZZ
    | SWAP
    | CSWAP
    | GPHASE
    | CONDINSTR
)"""

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 didn't know that, thank you. Done in 980319b

Comment thread graphix/pattern.py Outdated
case Clifford.SDG:
circuit.sdg(qubit)
case _:
for clifford in cmd.clifford.hsz:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Using clifford.qasm3 would lead to more compact decompositions and avoid the special cases for x, y, and sdg.

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.

Done in 980319b

Comment thread graphix/pattern.py Outdated
Comment on lines +1599 to +1600
# The following code adds SWAP gates so that qubits are in the same
# order that output nodes.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
# The following code adds SWAP gates so that qubits are in the same
# order that output nodes.
# The following code adds SWAP gates so that qubits are in the same
# order as the output nodes.

Comment thread graphix/transpiler.py Outdated
Comment thread graphix/transpiler.py Outdated
-----
.. The instruction sequence is applied when the XOR of the measurement outcomes of the qubits in ``domain`` evaluates to ``1``.

.. Condional measurements are not well defined since they would result in circuits with an indeterminate number of qubits. Therefore, the parameter ``instrs`` cannot contain instances of `Instruction.M`. This is statically ensured by the type checker.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
.. Condional measurements are not well defined since they would result in circuits with an indeterminate number of qubits. Therefore, the parameter ``instrs`` cannot contain instances of `Instruction.M`. This is statically ensured by the type checker.
.. Conditional measurements are not well defined since they would result in circuits with an indeterminate number of qubits. Therefore, the parameter ``instrs`` cannot contain instances of `Instruction.M`. This is statically ensured by the type checker.

Comment thread graphix/transpiler.py Outdated
Comment thread graphix/instruction.py
@matulni

matulni commented Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

Hi @thierry-martinez, thank you for the review. I addressed all your comments. Further, I changed the instruction methods of Circuit so that they return self and we can chain methods. Instead of

qc = Circuit(2)
qc.h(0)
qc.cnot(0, 1)

Now we can also do:

qc = Circuit(2).h(0).cnot(0, 1)

I noticed that I never removed the Pattern.to_qasm3 method. I will do that in a separate PR.

Comment thread graphix/pattern.py Outdated
case "y":
circuit.y(qubit)
case "z":
circuit.y(qubit)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
circuit.y(qubit)
circuit.z(qubit)

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.

Good catch, fixed in 11607ed

Comment thread graphix/pattern.py Outdated
Comment on lines +1553 to +1564
match bloch.plane:
case Plane.XY:
circuit.rz(qubit, -bloch.angle)
circuit.m(qubit, Axis.X)
case Plane.XZ:
circuit.ry(qubit, -bloch.angle)
circuit.m(qubit, Axis.Z)
case Plane.YZ:
circuit.rx(qubit, bloch.angle)
circuit.m(qubit, Axis.Z)
case _:
assert_never(bloch.plane)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The following may be more compact and better explain the intention:

Suggested change
match bloch.plane:
case Plane.XY:
circuit.rz(qubit, -bloch.angle)
circuit.m(qubit, Axis.X)
case Plane.XZ:
circuit.ry(qubit, -bloch.angle)
circuit.m(qubit, Axis.Z)
case Plane.YZ:
circuit.rx(qubit, bloch.angle)
circuit.m(qubit, Axis.Z)
case _:
assert_never(bloch.plane)
circuit.r(qubit, bloch.plane.orth, Sign.plus_if(bloch.plane == Plane.YZ) * bloch.angle)
circuit.m(qubit, bloch.plane.cos)

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.

Very nice!! Done in 11607ed

@pranav97nair pranav97nair left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for this very nice work, Mateo! Looks good to me, I have just made some very minor comments.

Comment thread graphix/qasm3_exporter.py
The initial state for each input node. Only |0⟩ or |+⟩ states are supported.
"""
return "".join(pattern_to_qasm3_lines(pattern, input_state=input_state))
return "\n".join(pattern_to_qasm3_lines(pattern, input_state=input_state))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice!

Comment thread graphix/pattern.py Outdated
Returns
-------
Circuit
Quantum circuit represented as a set of instructions.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I would probably change this to "sequence of instructions" just to be as precise as possible.

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.

Done in e8dddc4

Comment thread graphix/transpiler.py
"""
assert control in self.active_qubits
assert target in self.active_qubits
assert control != target

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

So if I understand correctly, these checks for multi-qubit gates are now moved into graphix/insturction.py ?

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.

Yes, the visit method of instructions checks that qubit indices are consistent with each other. For instance, for CCX we have:

@override
    def visit(self, visitor: InstructionVisitor, *, copy: bool = False) -> CCX:
        u, v = self.controls
        target = visitor.visit_qubit(self.target)
        controls = (visitor.visit_qubit(u), visitor.visit_qubit(v))
        if controls[0] == controls[1]:
            raise ValueError(f"Control qubits cannot be the same. Qubit index: {controls[0]}.")
        for i, c in enumerate(controls):
            if target == c:
                raise ValueError(f"Target and control-{i} qubits cannot be the same. Qubit index: {target}.")
        ...

Crucially, in transpiler.py we defined a instruction visitor InstructionValidatorVisitor that is called every time we add a new instruction (in method Circuit.add we have self.instruction.append(instr.visit(self._visitor))).
The visitor also ensures that no gates are applied on measured qubits (note that this check cannot be encapsulated in the visit method, because it needs information about other gates).

Comment thread graphix/transpiler.py
return f"Circuit(width={self.width}, instr={self.instruction}, ancillas={self.ancillas}, ancilla_state={self.ancilla_state!r})"

def cnot(self, control: int, target: int) -> None:
def cnot(self, control: int, target: int) -> Self:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Was there a specific motivation to have the circuit methods return self ?

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.

Yes. Now gate methods return the circuit itself and we can chain methods. Instead of

qc = Circuit(2)
qc.h(0)
qc.cnot(0, 1)

Now we can also do:

qc = Circuit(2).h(0).cnot(0, 1)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Right, great!

Comment thread graphix/transpiler.py
Comment on lines +1120 to +1137
case InstructionKind.CONDINSTR:
if instr.domain:
domain_list = list(instr.domain)
for cbit in domain_list:
meas_instr = measurements[cbit]
assert cbit == meas_instr.target
if meas_instr.axis == Axis.X:
circuit.h(cbit)
elif meas_instr.axis == Axis.Y:
circuit.rx(cbit, ANGLE_PI / 2)
measurements[cbit] = Instruction.M(cbit, Axis.Z) # Record updated measurement
cnot_instr = [
Instruction.CNOT(control=control, target=target)
for control, target in itertools.pairwise(domain_list)
]
circuit.extend(cnot_instr)
circuit.extend(insert_control(domain_list[-1], instr.instructions))
circuit.extend(cnot_instr[::-1])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Awesome!

Comment thread tests/test_pattern.py
) # Tested in test_transpile.TestTranspilerUnitGates.test_instructions()
circuit_test = pattern.to_circuit()

if circuit_test.nqubit > 20:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Just to be sure - this is a matter of compute power, right? There is no explicit 20-qubit limit on simulation in the codebase?

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.

yes there is no limit. Something to keep in mind though is that Circuit.simulate is extremely inefficient to simulate circuits with feedforward because it instantiates all qubits (inputs and ancillas) at the very beginning. Roughly speaking, it is as bad as standardizing the pattern before simulating. Some gates (notably 3-qubit gates) need a rather large number of ancilla qubits, so doing a naive simulation of the Circuit with mid-circuit measurements fails, hence the limit.
I added a comment in 0a61233

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Okay makes sense!

Comment thread tests/test_pattern.py
) # Tested in test_transpile.TestTranspilerUnitGates.test_instructions()
circuit_test = pattern.to_circuit()

# Simulatin circuits with feedforward is extremely inefficient because we instantiate

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
# Simulatin circuits with feedforward is extremely inefficient because we instantiate
# Simulating circuits with feedforward is extremely inefficient because we instantiate

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants