Skip to content

Commit 796e743

Browse files
authored
feat: Allow OpenQASM strings in CircuitBinding (#1274)
This will allow customers to run program sets with programs using advanced OpenQASM features like dynamic circuits.
1 parent 34ec586 commit 796e743

3 files changed

Lines changed: 439 additions & 18 deletions

File tree

src/braket/program_sets/circuit_binding.py

Lines changed: 193 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,16 @@
1616
import warnings
1717
from collections import defaultdict
1818
from collections.abc import Mapping, Sequence
19+
from dataclasses import dataclass
1920

21+
from braket.default_simulator.openqasm.parser.openqasm_ast import (
22+
Identifier,
23+
IntegerLiteral,
24+
QASMNode,
25+
QuantumMeasurementStatement,
26+
QubitDeclaration,
27+
)
28+
from braket.default_simulator.openqasm.parser.openqasm_parser import parse
2029
from braket.ir.openqasm import Program
2130

2231
from braket.circuits import Circuit, Gate, Observable
@@ -29,10 +38,79 @@
2938
from braket.registers import QubitSet
3039

3140

41+
@dataclass
42+
class _AngleInjectionPlan:
43+
declarations_offset: int
44+
measure_offset: int
45+
qubit_format: str
46+
qubits: QubitSet
47+
48+
49+
def _span_offset(node: QASMNode, line_offsets: Sequence[int]) -> int:
50+
return line_offsets[node.span.start_line - 1] + node.span.start_column
51+
52+
53+
def _plan_injection(source: str) -> _AngleInjectionPlan:
54+
program = parse(source)
55+
56+
line_offsets = [0]
57+
offset = 0
58+
for line in source.split("\n")[:-1]:
59+
offset += len(line) + 1
60+
line_offsets.append(offset)
61+
62+
declarations_offset = (
63+
_span_offset(program.statements[0], line_offsets) if program.statements else len(source)
64+
)
65+
66+
measure_offset = len(source)
67+
register_name = None
68+
register_size = 0
69+
for stmt in program.statements:
70+
if isinstance(stmt, QubitDeclaration) and register_name is None:
71+
# TODO: support multiple qubit registers
72+
register_name = stmt.qubit.name
73+
# stmt.size is None for a single unindexed qubit
74+
register_size = stmt.size.value if isinstance(stmt.size, IntegerLiteral) else 1
75+
76+
measure_offset = (
77+
min(measure_offset, _span_offset(stmt, line_offsets))
78+
if isinstance(stmt, QuantumMeasurementStatement)
79+
else len(source)
80+
)
81+
82+
qubit_format, qubits = (
83+
(f"{register_name}[{{}}]", QubitSet(range(register_size)))
84+
if register_name
85+
else (
86+
"${}",
87+
QubitSet(
88+
int(node.name[1:])
89+
for node in _walk(program)
90+
if isinstance(node, Identifier) and node.name.startswith("$")
91+
),
92+
)
93+
)
94+
return _AngleInjectionPlan(
95+
declarations_offset=declarations_offset,
96+
measure_offset=measure_offset,
97+
qubit_format=qubit_format,
98+
qubits=qubits,
99+
)
100+
101+
102+
def _walk(node: QASMNode):
103+
yield node
104+
for value in vars(node).values():
105+
for child in value if isinstance(value, list) else [value]:
106+
if isinstance(child, QASMNode):
107+
yield from _walk(child)
108+
109+
32110
class CircuitBinding:
33111
def __init__(
34112
self,
35-
circuit: Circuit,
113+
circuit: Circuit | str,
36114
input_sets: ParameterSetsLike | None = None,
37115
observables: Sequence[Observable | PauliString | str] | Sum | None = None,
38116
):
@@ -51,7 +129,8 @@ def __init__(
51129
Note: Circuits cannot have result types attached.
52130
53131
Args:
54-
circuit (Circuit): The parametrized circuit
132+
circuit (Circuit | str): The parametrized circuit, either as a Circuit object or as
133+
an OpenQASM string.
55134
input_sets (ParameterSetsLike | None): The inputs to the circuit, if specified.
56135
observables (Sequence[Observable | PauliString | str] | Sum | None): The observables
57136
or Hamiltonian to measure, if specified.
@@ -70,11 +149,14 @@ def __init__(
70149
and any(isinstance(obs, Sum) for obs in observables)
71150
):
72151
raise TypeError("Cannot have Sum Hamiltonian in list of observables")
73-
if circuit.result_types:
152+
if isinstance(circuit, Circuit) and circuit.result_types:
74153
raise ValueError("Circuit cannot have result types")
75154
self._circuit = circuit
76155
self._input_sets = ParameterSets(input_sets) if input_sets else None
77156
self._observables = CircuitBinding._to_observables(observables)
157+
self._injection_plan = (
158+
_plan_injection(circuit) if isinstance(circuit, str) and self._observables else None
159+
)
78160

79161
@staticmethod
80162
def _to_observables(
@@ -96,9 +178,9 @@ def _to_observables(
96178
return obs
97179

98180
@property
99-
def circuit(self) -> Circuit:
181+
def circuit(self) -> Circuit | str:
100182
"""
101-
Circuit: The parametrized circuit
183+
Circuit | str: The parametrized circuit, either as a Circuit object or an OpenQASM string.
102184
"""
103185
return self._circuit
104186

@@ -135,23 +217,60 @@ def to_ir(
135217
"""
136218
if not self._observables:
137219
return Program(
138-
source=self._circuit.to_ir(
139-
IRType.OPENQASM, gate_definitions=gate_definitions
140-
).source,
220+
source=self._circuit_openqasm(gate_definitions),
141221
inputs=self._input_sets.as_dict() if self._input_sets else None,
142222
)
143-
# with_euler_angles validates that the observable has valid Euler angle gates
144-
circuit_with_euler_angles = self._circuit.with_euler_angles(self._observables)
145223
euler_angles = self._get_euler_angles()
224+
if isinstance(self._circuit, Circuit):
225+
source = (
226+
self._circuit
227+
.with_euler_angles(self._observables)
228+
.to_ir(IRType.OPENQASM, gate_definitions=gate_definitions)
229+
.source
230+
)
231+
else:
232+
source = _inject_euler_angles(
233+
self._circuit,
234+
self._injection_plan,
235+
self._euler_rotation_targets(),
236+
euler_angles.keys(),
237+
)
146238
return Program(
147-
source=circuit_with_euler_angles.to_ir(
148-
IRType.OPENQASM, gate_definitions=gate_definitions
149-
).source,
239+
source=source,
150240
inputs=(
151241
self._input_sets * euler_angles if self._input_sets else ParameterSets(euler_angles)
152242
).as_dict(),
153243
)
154244

245+
def _circuit_openqasm(
246+
self,
247+
gate_definitions: Mapping[tuple[Gate, QubitSet], PulseSequence] | None,
248+
) -> str:
249+
if isinstance(self._circuit, Circuit):
250+
return self._circuit.to_ir(IRType.OPENQASM, gate_definitions=gate_definitions).source
251+
return self._circuit
252+
253+
def _circuit_qubits(self) -> QubitSet:
254+
if isinstance(self._circuit, Circuit):
255+
return self._circuit.qubits
256+
return self._injection_plan.qubits
257+
258+
def _euler_rotation_targets(self) -> QubitSet:
259+
observables = self._observables
260+
circuit_qubits = self._circuit_qubits()
261+
if isinstance(observables, Sum):
262+
if observables.targets:
263+
# Sum.targets is a per-summand list of QubitSets
264+
return QubitSet().union(*observables.targets)
265+
return circuit_qubits
266+
targets = QubitSet()
267+
for obs in observables:
268+
if obs.targets:
269+
targets |= obs.targets
270+
else:
271+
targets |= circuit_qubits
272+
return targets
273+
155274
def _get_euler_angles(self) -> dict[str, float] | None:
156275
observables = self._observables
157276
return (
@@ -164,7 +283,7 @@ def _get_euler_angles_sum(self, observables: Sum) -> dict[str, float]:
164283
euler_angles = defaultdict(list)
165284
summands = observables.summands
166285
if not observables.targets:
167-
targets = self._circuit.qubits
286+
targets = self._circuit_qubits()
168287
for obs in summands:
169288
for param, angle in obs.get_euler_angles(targets).items():
170289
euler_angles[param].append(angle)
@@ -179,7 +298,7 @@ def _get_euler_angles_sum(self, observables: Sum) -> dict[str, float]:
179298

180299
def _get_euler_angles_list(self, observables: Sequence[Observable]) -> dict[str, float]:
181300
euler_angles = defaultdict(list)
182-
circuit_qubits = self._circuit.qubits
301+
circuit_qubits = self._circuit_qubits()
183302
targets = QubitSet(q for obs in observables for q in (obs.targets or circuit_qubits))
184303
for obs in observables:
185304
if not obs.targets:
@@ -207,12 +326,17 @@ def bind_observables_to_inputs(
207326
well as CompositeEntry.expectation.
208327
209328
Kwargs:
210-
inplace (bool): whether or not to return a new circuit binding or use the same one
211-
add_measure (bool): whether or not to apply Measure instructions to the circuit
329+
inplace (bool): Whether to return a new circuit binding or use the same one
330+
add_measure (bool): Whether to apply Measure instructions to the circuit. Only
331+
applies when the underlying circuit is a `Circuit`; for OpenQASM string
332+
circuits, the source is preserved verbatim aside from injected Euler-angle
333+
rotations.
212334
213335
Returns:
214336
CircuitBinding: A new circuit binding with the observables bound.
215337
"""
338+
if isinstance(self._circuit, str):
339+
return self._bind_observables_to_inputs_str(inplace)
216340
measure = Circuit()
217341
parameters = self._input_sets.as_dict() if self._input_sets else None
218342
if observables := self._observables:
@@ -236,6 +360,33 @@ def bind_observables_to_inputs(
236360
return self
237361
return CircuitBinding(self._circuit + measure, input_sets=parameters)
238362

363+
def _bind_observables_to_inputs_str(self, inplace: bool) -> CircuitBinding:
364+
source = self._circuit
365+
parameters = self._input_sets.as_dict() if self._input_sets else None
366+
if observables := self._observables:
367+
if isinstance(observables, Sum):
368+
warnings.warn(
369+
"Binding a Sum discards information on observable weights; please "
370+
"distribute your observable in advance using observable.summands.",
371+
stacklevel=2,
372+
)
373+
euler_angles = self._get_euler_angles()
374+
source = _inject_euler_angles(
375+
source,
376+
self._injection_plan,
377+
self._euler_rotation_targets(),
378+
euler_angles.keys(),
379+
)
380+
parameters = self._input_sets * euler_angles if parameters else euler_angles
381+
if inplace:
382+
self._circuit = source
383+
# Observables are now bound into the source, so no further injection plan is needed.
384+
self._injection_plan = None
385+
self._observables = None
386+
self._input_sets = parameters
387+
return self
388+
return CircuitBinding(source, input_sets=parameters)
389+
239390
def __len__(self):
240391
input_sets = self._input_sets
241392
observables = self._observables
@@ -260,3 +411,28 @@ def __repr__(self):
260411
f"input_sets={self._input_sets}, "
261412
f"observables={self._observables})"
262413
)
414+
415+
416+
def _inject_euler_angles(
417+
source: str,
418+
plan: _AngleInjectionPlan,
419+
targets: QubitSet,
420+
parameter_names: Sequence[str],
421+
) -> str:
422+
rotations = []
423+
for q in targets:
424+
theta, phi, omega = euler_angle_parameter_names(q)
425+
formatted = plan.qubit_format.format(int(q))
426+
rotations.extend([
427+
f"rz({theta}) {formatted};",
428+
f"rx({phi}) {formatted};",
429+
f"rz({omega}) {formatted};",
430+
])
431+
for offset, statements in (
432+
(plan.measure_offset, rotations),
433+
(plan.declarations_offset, [f"input float {name};" for name in parameter_names]),
434+
):
435+
block = "\n".join(statements)
436+
prefix = "" if offset == 0 or source[offset - 1] == "\n" else "\n"
437+
source = f"{source[:offset]}{prefix}{block}\n{source[offset:]}"
438+
return source

0 commit comments

Comments
 (0)