From 38c129b5b4cc3f620fb7174a3c475d9babb9f4de Mon Sep 17 00:00:00 2001 From: joshuasn Date: Tue, 7 Jul 2026 11:31:06 -0230 Subject: [PATCH 1/8] Initial commit. --- samplomatic/builders/template_state.py | 11 ++++++++++- .../unit/test_builders/test_builders_template.py | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/samplomatic/builders/template_state.py b/samplomatic/builders/template_state.py index 8a1f88b09..fc268b26a 100644 --- a/samplomatic/builders/template_state.py +++ b/samplomatic/builders/template_state.py @@ -14,8 +14,9 @@ from collections.abc import Iterable, Sequence -from qiskit.circuit import ClassicalRegister, Clbit, QuantumCircuit, QuantumRegister, Qubit +from qiskit.circuit import ClassicalRegister, Clbit, Delay, QuantumCircuit, QuantumRegister, Qubit from qiskit.circuit.classical import expr +from qiskit.circuit.classical.expr import Stretch from qiskit.converters import dag_to_circuit from qiskit.dagcircuit import DAGCircuit @@ -84,6 +85,9 @@ def construct_for_circuit(cls, circuit: QuantumCircuit, debug: bool = False) -> qubit_map = {q: idx for idx, q in enumerate(circuit.qubits)} + for stretch in circuit.iter_stretches(): + template_circuit.add_declared_stretch(Stretch.new(stretch.name)) + # quick and dirty heuristic to get the max params roughly correct with a safety factor # TODO: This estimate might not hold for dynamic circuits, where the same qubit will be # collected twice - once in the if branch and another in the else branch. @@ -118,6 +122,11 @@ def append_remapped_gate(self, dag_op_node: DAGOpNode) -> ParamSpec: param_mapping.append([self.param_iter.idx, param]) new_params.append(next(self.param_iter)) new_operation = type(dag_op_node.op)(*new_params) if new_params else dag_op_node.op + elif isinstance(delay := dag_op_node.op, Delay) and isinstance( + duration := delay.duration, Stretch + ): + duration = next(s for s in self.template.iter_stretches() if duration.name == s.name) + new_operation = Delay(duration) else: new_operation = dag_op_node.op diff --git a/test/unit/test_builders/test_builders_template.py b/test/unit/test_builders/test_builders_template.py index 770a7e9a5..17019868d 100644 --- a/test/unit/test_builders/test_builders_template.py +++ b/test/unit/test_builders/test_builders_template.py @@ -33,6 +33,22 @@ def test_qubits(self): assert len(new_state.qubits()) == 1 assert new_state.qubits() == [state.template.qubits[2]] + def test_stretch(self): + """Test that stretches are added appropriately.""" + circuit = QuantumCircuit(2) + x = circuit.add_stretch("x") + circuit.delay(x, 0) + circuit.x(0) + circuit.delay(x, 0) + circuit.measure_all() + + state = TemplateState.construct_for_circuit(circuit) + + assert state.template.num_stretches == 1 + + stretch = next(state.template.iter_stretches()) + assert stretch != circuit.get_stretch("x") + class TestTemplateBuilder: """Test strictly the template aspects of build.""" From c46846c0aedb133a642183ef21dc10a9499774d6 Mon Sep 17 00:00:00 2001 From: joshuasn Date: Tue, 7 Jul 2026 11:32:12 -0230 Subject: [PATCH 2/8] Wrong number. --- changelog.d/384.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/384.added.md diff --git a/changelog.d/384.added.md b/changelog.d/384.added.md new file mode 100644 index 000000000..397cd975f --- /dev/null +++ b/changelog.d/384.added.md @@ -0,0 +1 @@ +Added support for `Delay` operations with `Stretch` durations. From 9a0f0b6d35595ffb161041125a777cd7eb8d07f4 Mon Sep 17 00:00:00 2001 From: joshuasn Date: Tue, 7 Jul 2026 12:47:57 -0230 Subject: [PATCH 3/8] Adding box support. --- samplomatic/builders/build.py | 1 + samplomatic/builders/template_state.py | 27 ++++++++++++++++++++------ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/samplomatic/builders/build.py b/samplomatic/builders/build.py index e3d6b7f6c..0f8674356 100644 --- a/samplomatic/builders/build.py +++ b/samplomatic/builders/build.py @@ -60,6 +60,7 @@ def _build(stream: DAGCircuit, builder: Builder): qubit_remapping = dict(zip(nested_instr.qargs, nested_instr.op.body.qubits)) remapped_template_state = builder.template_state.remap(qubit_remapping, idx) + remapped_template_state.add_stretches(nested_instr.op.body.iter_stretches()) remapped_pre_samplex = builder.samplex_state.remap(remapped_template_state.qubit_map) inner_builder = inner_builder.set_template_state(remapped_template_state).set_samplex_state( remapped_pre_samplex diff --git a/samplomatic/builders/template_state.py b/samplomatic/builders/template_state.py index fc268b26a..7ac4bed04 100644 --- a/samplomatic/builders/template_state.py +++ b/samplomatic/builders/template_state.py @@ -41,12 +41,14 @@ def __init__( qubit_map: dict[Qubit, QubitIndex], param_iter: ParamIter, scope_idx: list[int], + stretch_map: dict[Stretch, Stretch], debug: bool = False, ): self.template: DAGCircuit = template self.qubit_map = qubit_map self.param_iter = param_iter self.scope_idx = scope_idx + self.stretch_map = stretch_map self.debug = debug def remap( @@ -67,7 +69,9 @@ def remap( for parent_scope_qubit, qubit in scoped_qubit_map.items() } scope_idx = self.scope_idx if last_scope_idx is None else self.scope_idx + [last_scope_idx] - return TemplateState(self.template, new_qubit_map, self.param_iter, scope_idx, self.debug) + return TemplateState( + self.template, new_qubit_map, self.param_iter, scope_idx, self.stretch_map, self.debug + ) @classmethod def construct_for_circuit(cls, circuit: QuantumCircuit, debug: bool = False) -> Self: @@ -84,9 +88,11 @@ def construct_for_circuit(cls, circuit: QuantumCircuit, debug: bool = False) -> template_circuit.add_creg(creg) qubit_map = {q: idx for idx, q in enumerate(circuit.qubits)} - + stretch_map = {} for stretch in circuit.iter_stretches(): - template_circuit.add_declared_stretch(Stretch.new(stretch.name)) + new_stretch = Stretch.new(stretch.name) + stretch_map[stretch] = new_stretch + template_circuit.add_declared_stretch(new_stretch) # quick and dirty heuristic to get the max params roughly correct with a safety factor # TODO: This estimate might not hold for dynamic circuits, where the same qubit will be @@ -97,7 +103,7 @@ def construct_for_circuit(cls, circuit: QuantumCircuit, debug: bool = False) -> max_params += circuit.num_parameters param_iter = ParamIter(5 * max_params) - return cls(template_circuit, qubit_map, param_iter, [], debug) + return cls(template_circuit, qubit_map, param_iter, [], stretch_map, debug) def qubits(self, idxs: Iterable[int] | None = None) -> Sequence[Qubit]: """Return the qubits in the template at the given indices. @@ -111,6 +117,16 @@ def qubits(self, idxs: Iterable[int] | None = None) -> Sequence[Qubit]: idxs = self.qubit_map.values() if idxs is None else idxs return [self.template.qubits[i] for i in idxs] + def add_stretches(self, stretches: Iterable[Stretch]): + """Add stretches using the current scope index.""" + prefix = ".".join(str(i) for i in self.scope_idx) + for stretch in stretches: + if stretch in self.stretch_map: + continue + new_stretch = Stretch.new(f"{prefix}.{stretch.name}") + self.stretch_map[stretch] = new_stretch + self.template.add_declared_stretch(new_stretch) + def append_remapped_gate(self, dag_op_node: DAGOpNode) -> ParamSpec: """Remap the parameters and qubits of a gate and append it to the circuit.""" new_params = [] @@ -125,8 +141,7 @@ def append_remapped_gate(self, dag_op_node: DAGOpNode) -> ParamSpec: elif isinstance(delay := dag_op_node.op, Delay) and isinstance( duration := delay.duration, Stretch ): - duration = next(s for s in self.template.iter_stretches() if duration.name == s.name) - new_operation = Delay(duration) + new_operation = Delay(self.stretch_map[duration]) else: new_operation = dag_op_node.op From c9d4a31d9add106fe08c96126b12d1ed318b32aa Mon Sep 17 00:00:00 2001 From: joshuasn Date: Tue, 7 Jul 2026 12:48:13 -0230 Subject: [PATCH 4/8] Updating tests. --- .../test_builders/test_builders_template.py | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/test/unit/test_builders/test_builders_template.py b/test/unit/test_builders/test_builders_template.py index 17019868d..6467a7c5c 100644 --- a/test/unit/test_builders/test_builders_template.py +++ b/test/unit/test_builders/test_builders_template.py @@ -13,6 +13,7 @@ """Test BoxBuilder for Templates""" from qiskit.circuit import Parameter, QuantumCircuit +from qiskit.circuit.classical.expr import Stretch from samplomatic.annotations import Twirl from samplomatic.builders import pre_build @@ -49,6 +50,21 @@ def test_stretch(self): stretch = next(state.template.iter_stretches()) assert stretch != circuit.get_stretch("x") + def test_rescoped_stretch(self): + """Test that stretches with the same name in different scopes do not overlap.""" + circuit = QuantumCircuit(1) + state = TemplateState.construct_for_circuit(circuit) + + new_state = state.remap({state.template.qubits[0]: 0}, 0) + new_new_state = new_state.remap({0: 0}, 1) + new_new_state.add_stretches([Stretch.new("x")]) + new_state.add_stretches([Stretch.new("x")]) + + new_state = state.remap({state.template.qubits[0]: 0}, 2) + new_state.add_stretches([Stretch.new("x")]) + + assert {s.name for s in state.stretch_map.values()} == {"0.x", "0.1.x", "2.x"} + class TestTemplateBuilder: """Test strictly the template aspects of build.""" @@ -156,6 +172,30 @@ def test_box_left_right(self): for idx, (instr, name) in enumerate(zip(template, expected_names)): assert instr.name == name, f"Instruction {idx}" + def test_box_stretches(self): + """Test boxes with stretches.""" + circuit = QuantumCircuit(2) + with circuit.box([Twirl()]): + x = circuit.add_stretch("x") + circuit.delay(x) + circuit.measure_all() + + with circuit.box([Twirl()]): + x = circuit.add_stretch("x") + circuit.delay(x) + circuit.measure_all() + + x = circuit.add_stretch("x") + circuit.delay(x) + circuit.measure_all() + + template_state, _ = pre_build(circuit) + template = template_state.finalize() + + assert template.num_stretches == 3 + assert {s.name for s in template.iter_stretches()} == {"0.x", "1.x", "x"} + assert template.get_stretch("x") != x + def test_box_decomposition(self): """Test decomposition modes of a box.""" circuit = QuantumCircuit(2) From 9c133dfb00928decb3cfa8e354de6927dda2e520 Mon Sep 17 00:00:00 2001 From: joshuasn Date: Tue, 7 Jul 2026 12:53:36 -0230 Subject: [PATCH 5/8] Docstrings. --- samplomatic/builders/template_state.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/samplomatic/builders/template_state.py b/samplomatic/builders/template_state.py index 7ac4bed04..864d497e4 100644 --- a/samplomatic/builders/template_state.py +++ b/samplomatic/builders/template_state.py @@ -33,6 +33,8 @@ class TemplateState: qubit_map: A map from qubits in the source circuit to corresponding qubits in the template. param_iter: An iterator over parameters to use in the circuit being built. scope_idx: The nested index of the scope currently being built. + stretch_map: A map from stretches in the source circuit to corresponding stretches in the + template. """ def __init__( @@ -118,7 +120,14 @@ def qubits(self, idxs: Iterable[int] | None = None) -> Sequence[Qubit]: return [self.template.qubits[i] for i in idxs] def add_stretches(self, stretches: Iterable[Stretch]): - """Add stretches using the current scope index.""" + """Add stretches to the template circuit. + + This method adds new stretches to the template circuit with the same name as those in + ``stretches`` with the scope index prepended. + + Args: + stretches: An iterable of stretches to add. + """ prefix = ".".join(str(i) for i in self.scope_idx) for stretch in stretches: if stretch in self.stretch_map: From 82dceafd53b964cdbe72259a88616d9c72e5a547 Mon Sep 17 00:00:00 2001 From: joshuasn Date: Tue, 7 Jul 2026 12:55:15 -0230 Subject: [PATCH 6/8] Test. --- test/unit/test_builders/test_builders_pre_samplex.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/test_builders/test_builders_pre_samplex.py b/test/unit/test_builders/test_builders_pre_samplex.py index 02fd50c12..cb0e8fac8 100644 --- a/test/unit/test_builders/test_builders_pre_samplex.py +++ b/test/unit/test_builders/test_builders_pre_samplex.py @@ -40,7 +40,7 @@ def get_builder(self, qreg, creg=None): circuit = DAGCircuit() circuit.add_qreg(qreg) circuit.add_creg(creg) - template_state = TemplateState(circuit, qubit_map, ParamIter(), [0]) + template_state = TemplateState(circuit, qubit_map, ParamIter(), [0], {}) pre_samplex = PreSamplex(qubit_map=qubit_map, cregs=[creg]) qubits = QubitPartition.from_elements(qreg) builder = LeftBoxBuilder( From cc9472a5e586161a2d9b8b079dcaf1b27c0721dc Mon Sep 17 00:00:00 2001 From: joshuasn Date: Tue, 7 Jul 2026 13:00:21 -0230 Subject: [PATCH 7/8] Better docs. --- samplomatic/builders/template_state.py | 2 +- test/unit/test_builders/test_builders_template.py | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/samplomatic/builders/template_state.py b/samplomatic/builders/template_state.py index 864d497e4..ce63fb431 100644 --- a/samplomatic/builders/template_state.py +++ b/samplomatic/builders/template_state.py @@ -123,7 +123,7 @@ def add_stretches(self, stretches: Iterable[Stretch]): """Add stretches to the template circuit. This method adds new stretches to the template circuit with the same name as those in - ``stretches`` with the scope index prepended. + ``stretches`` with the scope index prepended if it has not already been added. Args: stretches: An iterable of stretches to add. diff --git a/test/unit/test_builders/test_builders_template.py b/test/unit/test_builders/test_builders_template.py index 6467a7c5c..0b730c368 100644 --- a/test/unit/test_builders/test_builders_template.py +++ b/test/unit/test_builders/test_builders_template.py @@ -57,11 +57,14 @@ def test_rescoped_stretch(self): new_state = state.remap({state.template.qubits[0]: 0}, 0) new_new_state = new_state.remap({0: 0}, 1) - new_new_state.add_stretches([Stretch.new("x")]) - new_state.add_stretches([Stretch.new("x")]) + new_new_state.add_stretches([Stretch.new("x")]) # scope 0.1 + new_state.add_stretches([Stretch.new("x")]) # scope 0 - new_state = state.remap({state.template.qubits[0]: 0}, 2) - new_state.add_stretches([Stretch.new("x")]) + new_state = state.remap({state.template.qubits[0]: 0}, 2) # scope 2 + new_state.add_stretches([stretch := Stretch.new("x")]) + + new_state = new_state.remap({0: 0}, 0) + new_state.add_stretches([stretch]) # scope 2.0, but this stretch added in outer scope assert {s.name for s in state.stretch_map.values()} == {"0.x", "0.1.x", "2.x"} From bfabc4db0032a15c83fa5ca02c23ca033e606acd Mon Sep 17 00:00:00 2001 From: joshuasn Date: Wed, 8 Jul 2026 11:45:52 -0230 Subject: [PATCH 8/8] Use stretches from circuit in outermost scope. --- samplomatic/builders/template_state.py | 5 ++--- test/unit/test_builders/test_builders_template.py | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/samplomatic/builders/template_state.py b/samplomatic/builders/template_state.py index ce63fb431..da99bc8ca 100644 --- a/samplomatic/builders/template_state.py +++ b/samplomatic/builders/template_state.py @@ -92,9 +92,8 @@ def construct_for_circuit(cls, circuit: QuantumCircuit, debug: bool = False) -> qubit_map = {q: idx for idx, q in enumerate(circuit.qubits)} stretch_map = {} for stretch in circuit.iter_stretches(): - new_stretch = Stretch.new(stretch.name) - stretch_map[stretch] = new_stretch - template_circuit.add_declared_stretch(new_stretch) + stretch_map[stretch] = stretch + template_circuit.add_declared_stretch(stretch) # quick and dirty heuristic to get the max params roughly correct with a safety factor # TODO: This estimate might not hold for dynamic circuits, where the same qubit will be diff --git a/test/unit/test_builders/test_builders_template.py b/test/unit/test_builders/test_builders_template.py index 0b730c368..33be3e4fa 100644 --- a/test/unit/test_builders/test_builders_template.py +++ b/test/unit/test_builders/test_builders_template.py @@ -48,7 +48,7 @@ def test_stretch(self): assert state.template.num_stretches == 1 stretch = next(state.template.iter_stretches()) - assert stretch != circuit.get_stretch("x") + assert stretch == circuit.get_stretch("x") def test_rescoped_stretch(self): """Test that stretches with the same name in different scopes do not overlap.""" @@ -197,7 +197,7 @@ def test_box_stretches(self): assert template.num_stretches == 3 assert {s.name for s in template.iter_stretches()} == {"0.x", "1.x", "x"} - assert template.get_stretch("x") != x + assert template.get_stretch("x") == x def test_box_decomposition(self): """Test decomposition modes of a box."""