QCut 2.2.0__tar.gz → 2.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. {qcut-2.2.0 → qcut-2.3.0}/CHANGELOG.rst +31 -0
  2. {qcut-2.2.0 → qcut-2.3.0}/PKG-INFO +50 -33
  3. {qcut-2.2.0 → qcut-2.3.0}/QCut/execution/backend_utility.py +60 -35
  4. {qcut-2.2.0 → qcut-2.3.0}/QCut/execution/circuit_knitting.py +129 -47
  5. {qcut-2.2.0 → qcut-2.3.0}/QCut/options.py +19 -3
  6. {qcut-2.2.0 → qcut-2.3.0}/QCut/utils/circuit_utils.py +130 -1
  7. {qcut-2.2.0 → qcut-2.3.0}/QCut.egg-info/PKG-INFO +50 -33
  8. {qcut-2.2.0 → qcut-2.3.0}/QCut.egg-info/SOURCES.txt +2 -0
  9. {qcut-2.2.0 → qcut-2.3.0}/QCut.egg-info/requires.txt +0 -1
  10. {qcut-2.2.0 → qcut-2.3.0}/QCut.egg-info/scm_file_list.json +2 -0
  11. qcut-2.3.0/QCut.egg-info/scm_version.json +8 -0
  12. {qcut-2.2.0 → qcut-2.3.0}/README.md +44 -26
  13. {qcut-2.2.0 → qcut-2.3.0}/docs/Options.rst +31 -2
  14. qcut-2.3.0/docs/_static/images/diagram/qcut-diagram-left-logo.png +0 -0
  15. {qcut-2.2.0 → qcut-2.3.0}/docs/examples/AutomaticCuts.rst +2 -1
  16. {qcut-2.2.0 → qcut-2.3.0}/docs/index.rst +2 -0
  17. {qcut-2.2.0 → qcut-2.3.0}/pyproject.toml +7 -6
  18. qcut-2.3.0/tests/test_deduplication.py +406 -0
  19. {qcut-2.2.0 → qcut-2.3.0}/tests/test_observables.py +3 -2
  20. {qcut-2.2.0 → qcut-2.3.0}/tests/test_parallel.py +13 -3
  21. {qcut-2.2.0 → qcut-2.3.0}/tests/test_qcut_backend_utils.py +49 -1
  22. {qcut-2.2.0 → qcut-2.3.0}/uv.lock +204 -189
  23. qcut-2.2.0/QCut.egg-info/scm_version.json +0 -8
  24. {qcut-2.2.0 → qcut-2.3.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  25. {qcut-2.2.0 → qcut-2.3.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  26. {qcut-2.2.0 → qcut-2.3.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  27. {qcut-2.2.0 → qcut-2.3.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  28. {qcut-2.2.0 → qcut-2.3.0}/.github/dependabot.yml +0 -0
  29. {qcut-2.2.0 → qcut-2.3.0}/.github/workflows/ci.yml +0 -0
  30. {qcut-2.2.0 → qcut-2.3.0}/.github/workflows/publish_docs.yml +0 -0
  31. {qcut-2.2.0 → qcut-2.3.0}/.github/workflows/publish_to_pypi.yml +0 -0
  32. {qcut-2.2.0 → qcut-2.3.0}/.github/workflows/tag_and_release.yml +0 -0
  33. {qcut-2.2.0 → qcut-2.3.0}/.gitignore +0 -0
  34. {qcut-2.2.0 → qcut-2.3.0}/CONTRIBUTING.md +0 -0
  35. {qcut-2.2.0 → qcut-2.3.0}/LICENSE +0 -0
  36. {qcut-2.2.0 → qcut-2.3.0}/QCut/QCutFind/__init__.py +0 -0
  37. {qcut-2.2.0 → qcut-2.3.0}/QCut/QCutFind/combine_subcircuits.py +0 -0
  38. {qcut-2.2.0 → qcut-2.3.0}/QCut/QCutFind/cut_finding.py +0 -0
  39. {qcut-2.2.0 → qcut-2.3.0}/QCut/QCutFind/graph_circuit_utils.py +0 -0
  40. {qcut-2.2.0 → qcut-2.3.0}/QCut/QCutFind/metis.py +0 -0
  41. {qcut-2.2.0 → qcut-2.3.0}/QCut/QCutFind/refine.py +0 -0
  42. {qcut-2.2.0 → qcut-2.3.0}/QCut/__init__.py +0 -0
  43. {qcut-2.2.0 → qcut-2.3.0}/QCut/cutcircuit.py +0 -0
  44. {qcut-2.2.0 → qcut-2.3.0}/QCut/cutlocation.py +0 -0
  45. {qcut-2.2.0 → qcut-2.3.0}/QCut/cutting/__init__.py +0 -0
  46. {qcut-2.2.0 → qcut-2.3.0}/QCut/cutting/circuit_preparation.py +0 -0
  47. {qcut-2.2.0 → qcut-2.3.0}/QCut/cutting/consolidate.py +0 -0
  48. {qcut-2.2.0 → qcut-2.3.0}/QCut/errors/__init__.py +0 -0
  49. {qcut-2.2.0 → qcut-2.3.0}/QCut/errors/qcuterror.py +0 -0
  50. {qcut-2.2.0 → qcut-2.3.0}/QCut/execution/__init__.py +0 -0
  51. {qcut-2.2.0 → qcut-2.3.0}/QCut/execution/basis_transform.py +0 -0
  52. {qcut-2.2.0 → qcut-2.3.0}/QCut/execution/distribution.py +0 -0
  53. {qcut-2.2.0 → qcut-2.3.0}/QCut/execution/move_routing.py +0 -0
  54. {qcut-2.2.0 → qcut-2.3.0}/QCut/execution/observables.py +0 -0
  55. {qcut-2.2.0 → qcut-2.3.0}/QCut/execution/parallel.py +0 -0
  56. {qcut-2.2.0 → qcut-2.3.0}/QCut/execution/postprocess.py +0 -0
  57. {qcut-2.2.0 → qcut-2.3.0}/QCut/execution/probabilities.py +0 -0
  58. {qcut-2.2.0 → qcut-2.3.0}/QCut/execution/qcutresult.py +0 -0
  59. {qcut-2.2.0 → qcut-2.3.0}/QCut/qpd/__init__.py +0 -0
  60. {qcut-2.2.0 → qcut-2.3.0}/QCut/qpd/bundle.py +0 -0
  61. {qcut-2.2.0 → qcut-2.3.0}/QCut/qpd/qpd.py +0 -0
  62. {qcut-2.2.0 → qcut-2.3.0}/QCut/qpd/qpd_analytic.py +0 -0
  63. {qcut-2.2.0 → qcut-2.3.0}/QCut/qpd/qpd_gates.py +0 -0
  64. {qcut-2.2.0 → qcut-2.3.0}/QCut/qpd/qpd_generate.py +0 -0
  65. {qcut-2.2.0 → qcut-2.3.0}/QCut/qpd/qpd_joint.py +0 -0
  66. {qcut-2.2.0 → qcut-2.3.0}/QCut/qpd/qpd_locc.py +0 -0
  67. {qcut-2.2.0 → qcut-2.3.0}/QCut/qpd/qpd_operations.py +0 -0
  68. {qcut-2.2.0 → qcut-2.3.0}/QCut/utils/__init__.py +0 -0
  69. {qcut-2.2.0 → qcut-2.3.0}/QCut.egg-info/dependency_links.txt +0 -0
  70. {qcut-2.2.0 → qcut-2.3.0}/QCut.egg-info/top_level.txt +0 -0
  71. {qcut-2.2.0 → qcut-2.3.0}/benchmarks/QCutVsAddon.ipynb +0 -0
  72. {qcut-2.2.0 → qcut-2.3.0}/docs/Acknowledgement.rst +0 -0
  73. {qcut-2.2.0 → qcut-2.3.0}/docs/Changelog.rst +0 -0
  74. {qcut-2.2.0 → qcut-2.3.0}/docs/Examples.rst +0 -0
  75. {qcut-2.2.0 → qcut-2.3.0}/docs/License.rst +0 -0
  76. {qcut-2.2.0 → qcut-2.3.0}/docs/Makefile +0 -0
  77. {qcut-2.2.0 → qcut-2.3.0}/docs/Notebooks.rst +0 -0
  78. {qcut-2.2.0 → qcut-2.3.0}/docs/QCut.rst +0 -0
  79. {qcut-2.2.0 → qcut-2.3.0}/docs/Theory.rst +0 -0
  80. {qcut-2.2.0 → qcut-2.3.0}/docs/Usage.rst +0 -0
  81. {qcut-2.2.0 → qcut-2.3.0}/docs/_static/images/circ1.png +0 -0
  82. {qcut-2.2.0 → qcut-2.3.0}/docs/_static/images/circ10.png +0 -0
  83. {qcut-2.2.0 → qcut-2.3.0}/docs/_static/images/circ11.png +0 -0
  84. {qcut-2.2.0 → qcut-2.3.0}/docs/_static/images/circ1_old.png +0 -0
  85. {qcut-2.2.0 → qcut-2.3.0}/docs/_static/images/circ2.png +0 -0
  86. {qcut-2.2.0 → qcut-2.3.0}/docs/_static/images/circ2_old.png +0 -0
  87. {qcut-2.2.0 → qcut-2.3.0}/docs/_static/images/circ3.png +0 -0
  88. {qcut-2.2.0 → qcut-2.3.0}/docs/_static/images/circ3_old.png +0 -0
  89. {qcut-2.2.0 → qcut-2.3.0}/docs/_static/images/circ4.png +0 -0
  90. {qcut-2.2.0 → qcut-2.3.0}/docs/_static/images/circ4_odl.png +0 -0
  91. {qcut-2.2.0 → qcut-2.3.0}/docs/_static/images/circ5.png +0 -0
  92. {qcut-2.2.0 → qcut-2.3.0}/docs/_static/images/circ6.png +0 -0
  93. {qcut-2.2.0 → qcut-2.3.0}/docs/_static/images/circ7.png +0 -0
  94. {qcut-2.2.0 → qcut-2.3.0}/docs/_static/images/circ8.png +0 -0
  95. {qcut-2.2.0 → qcut-2.3.0}/docs/_static/images/circ9.png +0 -0
  96. {qcut-2.2.0 → qcut-2.3.0}/docs/_static/images/logos/qcut-banner-transparent.png +0 -0
  97. {qcut-2.2.0 → qcut-2.3.0}/docs/_static/images/logos/qcut-banner-transparent.svg +0 -0
  98. {qcut-2.2.0 → qcut-2.3.0}/docs/_static/images/logos/qcut-banner.png +0 -0
  99. {qcut-2.2.0 → qcut-2.3.0}/docs/_static/images/logos/qcut-banner.svg +0 -0
  100. {qcut-2.2.0 → qcut-2.3.0}/docs/_static/images/logos/qcut-banner@2x.png +0 -0
  101. {qcut-2.2.0 → qcut-2.3.0}/docs/_static/images/logos/qcut-logo-1024.png +0 -0
  102. {qcut-2.2.0 → qcut-2.3.0}/docs/_static/images/logos/qcut-logo-2048.png +0 -0
  103. {qcut-2.2.0 → qcut-2.3.0}/docs/_static/images/logos/qcut-logo-transparent-2048.png +0 -0
  104. {qcut-2.2.0 → qcut-2.3.0}/docs/_static/images/logos/qcut-logo-transparent.svg +0 -0
  105. {qcut-2.2.0 → qcut-2.3.0}/docs/_static/images/logos/qcut-logo.svg +0 -0
  106. {qcut-2.2.0 → qcut-2.3.0}/docs/_static/images/probs1.png +0 -0
  107. {qcut-2.2.0 → qcut-2.3.0}/docs/_templates/page-toc.html +0 -0
  108. {qcut-2.2.0 → qcut-2.3.0}/docs/conf.py +0 -0
  109. {qcut-2.2.0 → qcut-2.3.0}/docs/examples/GateCuts.rst +0 -0
  110. {qcut-2.2.0 → qcut-2.3.0}/docs/examples/ParallelBackends.rst +0 -0
  111. {qcut-2.2.0 → qcut-2.3.0}/docs/examples/WireCuts.rst +0 -0
  112. {qcut-2.2.0 → qcut-2.3.0}/docs/make.bat +0 -0
  113. {qcut-2.2.0 → qcut-2.3.0}/docs/notebooks/QCutBasicUsage.ipynb +0 -0
  114. {qcut-2.2.0 → qcut-2.3.0}/docs/notebooks/QCutOptionsExample.ipynb +0 -0
  115. {qcut-2.2.0 → qcut-2.3.0}/docs/notebooks/QCutProbabilities.ipynb +0 -0
  116. {qcut-2.2.0 → qcut-2.3.0}/docs/notebooks/QCutQAOAExample.ipynb +0 -0
  117. {qcut-2.2.0 → qcut-2.3.0}/docs/theory/CZ_derivation.md +0 -0
  118. {qcut-2.2.0 → qcut-2.3.0}/docs/theory/General_2q_derivation.md +0 -0
  119. {qcut-2.2.0 → qcut-2.3.0}/docs/theory/Joint_rotation_derivation.md +0 -0
  120. {qcut-2.2.0 → qcut-2.3.0}/docs/theory/LOCC_wire_derivation.md +0 -0
  121. {qcut-2.2.0 → qcut-2.3.0}/docs/theory/Probability_reconstruction.md +0 -0
  122. {qcut-2.2.0 → qcut-2.3.0}/docs/theory/SWAP_derivation.md +0 -0
  123. {qcut-2.2.0 → qcut-2.3.0}/docs/theory/Wire_derivation.md +0 -0
  124. {qcut-2.2.0 → qcut-2.3.0}/setup.cfg +0 -0
  125. {qcut-2.2.0 → qcut-2.3.0}/tag-from-pipeline.sh +0 -0
  126. {qcut-2.2.0 → qcut-2.3.0}/tests/__init__.py +0 -0
  127. {qcut-2.2.0 → qcut-2.3.0}/tests/solutions_1q.py +0 -0
  128. {qcut-2.2.0 → qcut-2.3.0}/tests/solutions_automatic_cuts.py +0 -0
  129. {qcut-2.2.0 → qcut-2.3.0}/tests/test_1q_QCut.py +0 -0
  130. {qcut-2.2.0 → qcut-2.3.0}/tests/test_automatic_cuts.py +0 -0
  131. {qcut-2.2.0 → qcut-2.3.0}/tests/test_consolidate.py +0 -0
  132. {qcut-2.2.0 → qcut-2.3.0}/tests/test_cut_circuit.py +0 -0
  133. {qcut-2.2.0 → qcut-2.3.0}/tests/test_distribution.py +0 -0
  134. {qcut-2.2.0 → qcut-2.3.0}/tests/test_gate_cut.py +0 -0
  135. {qcut-2.2.0 → qcut-2.3.0}/tests/test_generic_gate_cuts.py +0 -0
  136. {qcut-2.2.0 → qcut-2.3.0}/tests/test_iqm_backend.py +0 -0
  137. {qcut-2.2.0 → qcut-2.3.0}/tests/test_joint_cuts.py +0 -0
  138. {qcut-2.2.0 → qcut-2.3.0}/tests/test_locc_wire_cuts.py +0 -0
  139. {qcut-2.2.0 → qcut-2.3.0}/tests/test_new_gate_cuts.py +0 -0
  140. {qcut-2.2.0 → qcut-2.3.0}/tests/test_probabilities.py +0 -0
  141. {qcut-2.2.0 → qcut-2.3.0}/tests/test_qcuterror.py +0 -0
  142. {qcut-2.2.0 → qcut-2.3.0}/tests/test_qpd_analytic.py +0 -0
  143. {qcut-2.2.0 → qcut-2.3.0}/tests/test_qpd_generate.py +0 -0
  144. {qcut-2.2.0 → qcut-2.3.0}/tests/test_sampling.py +0 -0
  145. {qcut-2.2.0 → qcut-2.3.0}/tox.ini +0 -0
@@ -2,6 +2,37 @@
2
2
  Changelog
3
3
  =========
4
4
 
5
+ **Version 2.3.0**
6
+ =================
7
+
8
+ Remove Qiskit-Aer dependency
9
+ ----------------------------
10
+
11
+ - :code:`run()` and :code:`run_cut_circuit()` no longer default to :code:`AerSimulator()`;
12
+ pass a backend or sampler. :code:`qiskit-aer` is no longer installed with QCut, so
13
+ install it separately to simulate.
14
+ - :code:`transpile_subcircuits()` and :code:`transpile_experiments()` read a backend's
15
+ public :code:`target` rather than :code:`_target`, which Aer never fills in. Transpiling
16
+ for :code:`AerSimulator` works, and :code:`run_cut_circuit()` transpiles only for a
17
+ backend with a coupling map.
18
+
19
+ Deduplicated experiment circuits
20
+ --------------------------------
21
+
22
+ - A run now submits each *distinct* experiment circuit once and shares its result with
23
+ every slot it fills.
24
+ - :code:`shots` still means shots per circuit, so a run spends that many on each
25
+ distinct circuit and the total drops by the same factor. What the old total bought
26
+ was re-measuring circuits already measured. Sharing a sample between groups leaves the estimate unbiased,
27
+ since the groups are summed rather than multiplied.
28
+ - :code:`estimate_run()` counts the circuits and shots that are actually submitted, so
29
+ it still matches the run exactly.
30
+ - :code:`CutOptions(deduplicate=False)` restores one submission per slot.
31
+ :code:`CutExperiment.num_circuits` is unchanged: it still counts the circuits the
32
+ decomposition calls for.
33
+ - Experiments whose wire cuts communicate are unaffected for now. Their first wave
34
+ already shares circuits between the groups that differ only in the label they answer.
35
+
5
36
  **Version 2.2.0**
6
37
  =================
7
38
 
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: QCut
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: QCut is a quantum circuit knitting package built on top of qiskit for performing gate cuts and resetless wire cuts allowing simulation of larger quantum circuits on smaller quantum devices or simulators at the cost of a circuit overhead.
5
- Author-email: Joonas Nivala <joonas.nivala@gmail.com>
5
+ Author-email: Joonas Nivala <joonas.nivala@csc.fi>, FiQCI <servicedesk@csc.fi>
6
6
  License: Apache License
7
7
  Version 2.0, January 2004
8
8
  http://www.apache.org/licenses/
@@ -205,10 +205,10 @@ License: Apache License
205
205
  See the License for the specific language governing permissions and
206
206
  limitations under the License.
207
207
 
208
- Project-URL: homepage, https://github.com/JooNiv/QCut
209
- Project-URL: documentation, https://jooniv.github.io/QCut/
210
- Project-URL: repository, https://github.com/JooNiv/QCut.git
211
- Project-URL: changelog, https://github.com/JooNiv/QCut/blob/main/CHANGELOG.rst
208
+ Project-URL: homepage, https://github.com/FiQCI/QCut
209
+ Project-URL: documentation, https://fiqci.fi/QCut/
210
+ Project-URL: repository, https://github.com/FiQCI/QCut.git
211
+ Project-URL: changelog, https://github.com/FiQCI/QCut/blob/main/CHANGELOG.rst
212
212
  Classifier: Programming Language :: Python :: 3 :: Only
213
213
  Classifier: Programming Language :: Python :: 3.11
214
214
  Classifier: Programming Language :: Python :: 3.12
@@ -219,7 +219,6 @@ Description-Content-Type: text/markdown
219
219
  License-File: LICENSE
220
220
  Requires-Dist: qiskit<3.0,>=1.1
221
221
  Requires-Dist: numpy
222
- Requires-Dist: qiskit-aer<0.18
223
222
  Requires-Dist: pymetis==2025.2.2
224
223
  Provides-Extra: dev
225
224
  Requires-Dist: pytest; extra == "dev"
@@ -258,7 +257,7 @@ Dynamic: license-file
258
257
 
259
258
  # QCut
260
259
 
261
- ![QCut Banner](docs/_static/images/logos/qcut-banner.png)
260
+ <img src="docs/_static/images/diagram/qcut-diagram-left-logo.png" alt="QCut Diagram" width="1000">
262
261
 
263
262
  QCut is a quantum circuit knitting package built on top of qiskit for performing gate cuts and resetless wire cuts allowing simulation of larger quantum circuits on smaller quantum devices or simulators at the cost of a circuit overhead. QCut has been designed and tested to work with IQM's qpus, and the Finnish Quantum Computing Infrastructure ([FiQCI](https://fiqci.fi/)).
264
263
 
@@ -281,6 +280,8 @@ uv add QCut
281
280
 
282
281
  If using other than the default Qiskit version (newest) it is recommended to install Qiskit first before installing QCut.
283
282
 
283
+ QCut does not install a simulator. The examples below run on [Qiskit Aer](https://github.com/Qiskit/qiskit-aer), which needs `uv pip install qiskit-aer`.
284
+
284
285
  **IQM hardware and fake backends:**
285
286
 
286
287
  ```bash
@@ -297,6 +298,7 @@ After installing you can simply import what you need:
297
298
 
298
299
  ```python
299
300
  from iqm.qiskit_iqm import IQMFakeAdonis
301
+
300
302
  backend = IQMFakeAdonis()
301
303
  ```
302
304
 
@@ -342,7 +344,10 @@ from qiskit import QuantumCircuit, transpile
342
344
  from qiskit.circuit.library import CXGate
343
345
  from qiskit.quantum_info import SparsePauliOp
344
346
  from qiskit_aer import AerSimulator
345
- from qiskit.primitives import StatevectorEstimator as Estimator, BackendEstimatorV2 as BackendEstimator
347
+ from qiskit.primitives import (
348
+ StatevectorEstimator as Estimator,
349
+ BackendEstimatorV2 as BackendEstimator,
350
+ )
346
351
  from iqm.qiskit_iqm import IQMFakeAdonis
347
352
  ```
348
353
 
@@ -352,11 +357,11 @@ from iqm.qiskit_iqm import IQMFakeAdonis
352
357
  circuit = QuantumCircuit(5)
353
358
 
354
359
  for qubit in range(5):
355
- circuit.ry(0.4 + 0.3*qubit, qubit)
356
- circuit.cx(0,1)
357
- circuit.cx(1,2)
358
- circuit.cx(2,3)
359
- circuit.cx(3,4)
360
+ circuit.ry(0.4 + 0.3 * qubit, qubit)
361
+ circuit.cx(0, 1)
362
+ circuit.cx(1, 2)
363
+ circuit.cx(2, 3)
364
+ circuit.cx(3, 4)
360
365
 
361
366
  circuit.draw("mpl")
362
367
  ```
@@ -371,12 +376,12 @@ Note that here we don't insert any measurements. Measurements will be automatica
371
376
  marked_circuit = QuantumCircuit(5)
372
377
 
373
378
  for qubit in range(5):
374
- marked_circuit.ry(0.4 + 0.3*qubit, qubit)
375
- marked_circuit.cx(0,1)
379
+ marked_circuit.ry(0.4 + 0.3 * qubit, qubit)
380
+ marked_circuit.cx(0, 1)
376
381
  marked_circuit.append(**cutGate(CXGate(), 1, 2))
377
- marked_circuit.cx(2,3)
382
+ marked_circuit.cx(2, 3)
378
383
  marked_circuit.append(cut(), [3])
379
- marked_circuit.cx(3,4)
384
+ marked_circuit.cx(3, 4)
380
385
 
381
386
  marked_circuit.decompose(gates_to_decompose=["CutGate"]).draw("mpl")
382
387
  ```
@@ -527,8 +532,8 @@ finder's own options and how it chooses.
527
532
  Two helpers, `transpile_subcircuits()` and `transpile_experiments()` , differing in when they run:
528
533
 
529
534
  ```python
530
- fake = IQMFakeAdonis() #noisy
531
- sim = AerSimulator() #ideal
535
+ fake = IQMFakeAdonis() # noisy
536
+ sim = AerSimulator() # ideal
532
537
  ```
533
538
 
534
539
  `transpile_circuits()` does whichever of the two the thing it is given calls for. Each
@@ -570,12 +575,18 @@ The cost of an experiment run can be checked before submission:
570
575
  print(ck.estimate_run(cut_experiment, shots=4096, max_batch_size=40))
571
576
  ```
572
577
 
573
- `Experiment will run 144 circuits in 4 jobs with a total of 589824 shots. See the returned object for the breakdown.`
578
+ `Experiment will run 31 circuits in 1 jobs with a total of 126976 shots. See the returned object for the breakdown.`
574
579
 
575
580
  ```python
576
- {'job1': JobEstimate(circuits=40, shots=4096), 'job2': JobEstimate(circuits=40, shots=4096), ...}
581
+ {"job1": JobEstimate(circuits=31, shots=4096)}
577
582
  ```
578
583
 
584
+ 31 circuits, not the 144 the decomposition calls for: a subcircuit's experiment circuit
585
+ depends only on the QPD terms of the cuts touching it and on the measurement setting
586
+ over its own qubits, so most of them repeat. Each distinct one is submitted once and its
587
+ result shared, which is where the saving comes from. `shots` still means shots per
588
+ circuit. Pass `CutOptions(deduplicate=False)` to submit one circuit per slot instead.
589
+
579
590
  Exact for non-cummunicating runs. Runs whose wire cuts communicate get executed in waves so that
580
591
  each wave after the first spends its shots on what the one before it measured. For these jobs
581
592
  the circuit count is exact and job and shot counts are estimates.
@@ -604,8 +615,11 @@ Comparing against the exact and noisy expectation values of the original circuit
604
615
 
605
616
  ```python
606
617
  estimator = Estimator()
607
- exact_expvals = [e.data.evs for e in
608
- estimator.run([(x) for x in zip([circuit] * len(observables), observables)]).result()
618
+ exact_expvals = [
619
+ e.data.evs
620
+ for e in estimator.run(
621
+ [(x) for x in zip([circuit] * len(observables), observables)]
622
+ ).result()
609
623
  ]
610
624
 
611
625
  tr = transpile(circuit, backend=fake)
@@ -615,8 +629,11 @@ tr_obs_separate = [
615
629
  ]
616
630
 
617
631
  fake_estimator = BackendEstimator(backend=fake)
618
- exps = [e.data.evs for e in
619
- fake_estimator.run([(x) for x in zip([tr] * len(tr_obs_separate), tr_obs_separate)]).result()
632
+ exps = [
633
+ e.data.evs
634
+ for e in fake_estimator.run(
635
+ [(x) for x in zip([tr] * len(tr_obs_separate), tr_obs_separate)]
636
+ ).result()
620
637
  ]
621
638
  ```
622
639
 
@@ -682,19 +699,19 @@ for how the distribution is reconstructed.
682
699
  The result is a mapping, so it indexes and plots like a dict, and carries three views:
683
700
 
684
701
  ```python
685
- probs['00'] # 0.8658
686
- probs.quasi_probabilities() # the same values, as a plain dict
687
- probs.nearest_probabilities() # closest true distribution, negatives projected away
688
- probs.counts() # scaled by the shots the experiment ran at
689
- probs.counts(shots=1000) # or by any other shots
702
+ probs["00"] # 0.8658
703
+ probs.quasi_probabilities() # the same values, as a plain dict
704
+ probs.nearest_probabilities() # closest true distribution, negatives projected away
705
+ probs.counts() # scaled by the shots the experiment ran at
706
+ probs.counts(shots=1000) # or by any other shots
690
707
  ```
691
708
 
692
709
  The three dict views report, by default, the ten most likely bitstrings. Pass `top` for a different
693
710
  number, or `top=None` for all of them:
694
711
 
695
712
  ```python
696
- probs.quasi_probabilities(top=50) # the fifty most likely
697
- probs.counts(shots=1000, top=None) # the whole distribution, as before
713
+ probs.quasi_probabilities(top=50) # the fifty most likely
714
+ probs.counts(shots=1000, top=None) # the whole distribution, as before
698
715
  ```
699
716
 
700
717
  Note that :code:`counts()` sums to :code:`shots` only with :code:`top=None` and
@@ -8,7 +8,8 @@ import logging
8
8
  from collections import defaultdict
9
9
 
10
10
  from qiskit import QuantumCircuit, transpile
11
- from qiskit.circuit import Gate
11
+ from qiskit.circuit import Gate, Instruction
12
+ from qiskit.circuit.library import get_standard_gate_name_mapping
12
13
  from qiskit.transpiler import PassManager, Target
13
14
 
14
15
  from QCut.cutcircuit import CutCircuit, CutExperiment
@@ -96,11 +97,15 @@ def _add_block_gates(target, coupling_map, members: dict[str, list[str]]) -> set
96
97
  is free to hand the marker its qubits either way round. Qiskit can turn a ``cz`` the
97
98
  right way because it knows the gate is symmetric; a marker is opaque, so an
98
99
  unsupported orientation is a transpiler error rather than something it can fix.
100
+
101
+ Without a coupling map every pair is coupled, so the marker is declared global.
99
102
  """
100
- loci = {}
101
- for first, second in coupling_map.get_edges():
102
- loci[(first, second)] = None
103
- loci[(second, first)] = None
103
+ loci = None
104
+ if coupling_map is not None:
105
+ loci = {}
106
+ for first, second in coupling_map.get_edges():
107
+ loci[(first, second)] = None
108
+ loci[(second, first)] = None
104
109
  for name, group in members.items():
105
110
  target.add_instruction(
106
111
  Gate(num_qubits=len(group), name=name, params=[], label=name),
@@ -235,6 +240,40 @@ def _placeholder_gates(cut_circuit) -> dict[str, Gate]:
235
240
  return gates
236
241
 
237
242
 
243
+ def _target_of(backend) -> Target:
244
+ """A backend's target, read through the public ``target``.
245
+
246
+ Raises:
247
+ QCutError: the backend has no target to transpile for.
248
+ """
249
+ target = getattr(backend, "target", None)
250
+ if target is None:
251
+ raise QCutError(
252
+ f"{backend} has no target to transpile for. Pass a backend that has one."
253
+ )
254
+ return target
255
+
256
+
257
+ def _basis(target: Target) -> tuple[list[str], dict[str, Instruction]]:
258
+ """The operations a target names, and those of them qiskit would not recognise."""
259
+ standard = get_standard_gate_name_mapping()
260
+ names: set[str] = set()
261
+ custom: dict[str, Instruction] = {}
262
+ for operation in target.operations:
263
+ if not isinstance(operation, Instruction):
264
+ continue
265
+ names.add(operation.name)
266
+ if operation.name not in standard:
267
+ custom[operation.name] = operation
268
+ return sorted(names), custom
269
+
270
+
271
+ def has_topology(backend) -> bool:
272
+ """Whether running on ``backend`` needs its circuits transpiled first."""
273
+ target = getattr(backend, "target", None)
274
+ return target is not None and target.build_coupling_map() is not None
275
+
276
+
238
277
  def _iqm_transpiler_for(backend):
239
278
  """Return IQM's transpiler if it is installed and ``backend`` is one of its own.
240
279
 
@@ -312,27 +351,16 @@ def transpile_subcircuits(
312
351
  # the multi-qubit operation it is rather than as unrelated single-qubit ones.
313
352
  spans, block_members = _block_spans(cut_circuit)
314
353
 
315
- target = Target()
316
-
317
- try:
318
- basis_gates = list(
319
- {
320
- i[0].name
321
- for i in backend._target.instructions
322
- if isinstance(i[0].name, str)
323
- }
324
- )
325
-
326
- except Exception as e:
327
- raise ValueError(f"Error accessing backend target instructions: {e}")
328
-
329
- target = target.from_configuration(
354
+ backend_target = _target_of(backend)
355
+ coupling_map = backend_target.build_coupling_map()
356
+ basis_gates, backend_gates = _basis(backend_target)
357
+ target = Target.from_configuration(
330
358
  num_qubits=backend.num_qubits,
331
- coupling_map=backend._coupling_map,
359
+ coupling_map=coupling_map,
332
360
  basis_gates=basis_gates + list(custom_gates.keys()),
333
- custom_name_mapping=custom_gates,
361
+ custom_name_mapping={**backend_gates, **custom_gates},
334
362
  )
335
- block_names = _add_block_gates(target, backend._coupling_map, block_members)
363
+ block_names = _add_block_gates(target, coupling_map, block_members)
336
364
 
337
365
  # Fenced first: a placeholder is a single-qubit gate as far as the transpiler is
338
366
  # concerned, so without barriers it gets commuted past the others and the experiment
@@ -551,20 +579,17 @@ def transpile_circuits(
551
579
  for circuit in circuits
552
580
  ]
553
581
 
554
- # A simulator has no target to build one from, and needs none: it takes the
555
- # circuits as they are, so it is handed itself instead.
582
+ # An ideal simulator has no topology to build a target from, and needs none: it
583
+ # takes the circuits as they are, so it is handed itself instead.
556
584
  target = None
557
- if getattr(backend, "_target", None) is not None:
558
- target = Target().from_configuration(
585
+ if has_topology(backend):
586
+ backend_target = _target_of(backend)
587
+ basis_gates, backend_gates = _basis(backend_target)
588
+ target = Target.from_configuration(
559
589
  num_qubits=backend.num_qubits,
560
- coupling_map=backend._coupling_map,
561
- basis_gates=sorted(
562
- {
563
- item[0].name
564
- for item in backend._target.instructions
565
- if isinstance(item[0].name, str)
566
- }
567
- ),
590
+ coupling_map=backend_target.build_coupling_map(),
591
+ basis_gates=basis_gates,
592
+ custom_name_mapping=backend_gates,
568
593
  )
569
594
 
570
595
  return [
@@ -17,12 +17,11 @@ from qiskit.circuit import (
17
17
  from qiskit.circuit.library import get_standard_gate_name_mapping
18
18
  from qiskit.converters import circuit_to_dag, dag_to_circuit
19
19
  from qiskit.primitives import BaseSamplerV2
20
- from qiskit_aer import AerSimulator
21
20
 
22
21
  from QCut.cutcircuit import CutCircuit, CutExperiment
23
22
  from QCut.cutting.circuit_preparation import get_locations_and_subcircuits
24
23
  from QCut.errors.qcuterror import QCutError
25
- from QCut.execution.backend_utility import transpile_subcircuits
24
+ from QCut.execution.backend_utility import has_topology, transpile_subcircuits
26
25
  from QCut.execution.basis_transform import (
27
26
  _combine_pauli_ops,
28
27
  _get_obs_subcircuits,
@@ -56,7 +55,12 @@ from QCut.qpd.qpd_operations import (
56
55
  qpd_for_bundle,
57
56
  sample_qpd_combinations,
58
57
  )
59
- from QCut.utils.circuit_utils import _remove_obsm, _remove_obsm_2, compact_qpd_register
58
+ from QCut.utils.circuit_utils import (
59
+ _remove_obsm,
60
+ _remove_obsm_2,
61
+ circuit_fingerprint,
62
+ compact_qpd_register,
63
+ )
60
64
 
61
65
  logger: logging.Logger = logging.getLogger(__name__)
62
66
 
@@ -906,6 +910,13 @@ def _run_communicating(cut_experiment, shots, backend, max_batch_size, run_optio
906
910
  The budget is not divided evenly between the waves. Sharing makes a measuring shot
907
911
  worth more than a preparing one, so wave zero takes :data:`MEASURE_SHARE` of the run
908
912
  and the later waves divide the rest. The total is unchanged either way.
913
+
914
+ TODO: the waves after the first do not share circuits at all. ``_later_wave_jobs``
915
+ gives every group a job of its own, where :func:`_deduplicate` would collapse the
916
+ groups holding the same circuit as wave zero's ``plan.keys`` already does. It is not
917
+ the same change as the plain path's, because each group carries its own shot
918
+ allocation by then, so the shared job would have to run at their sum and be scaled
919
+ back per group.
909
920
  """
910
921
  plan = cut_experiment.plan
911
922
  results: list[list[dict[int, CircuitResult]]] = [
@@ -1049,6 +1060,83 @@ def _projected_wave_jobs(cut_experiment, wave: int, shots: int):
1049
1060
  ]
1050
1061
 
1051
1062
 
1063
+ #: One slot of an experiment: which group, which measurement setting, which subcircuit.
1064
+ Slot = tuple[int, int, int]
1065
+
1066
+
1067
+ def _runnable(cut_experiment) -> tuple[list, list]:
1068
+ """Split an experiment's slots into the ones worth submitting and the ones not.
1069
+
1070
+ A QPD term that measures nothing on a subcircuit leaves a circuit with no
1071
+ measurements at all. There is nothing to run, and its counts are known: every shot
1072
+ reads zero. Those are filled in directly rather than submitted.
1073
+
1074
+ Returns:
1075
+ The slots to submit, as ``(slot, circuit)``, and the empty ones, as
1076
+ ``(slot, num_clbits)``. Both in group, setting, subcircuit order.
1077
+ """
1078
+ runnable: list[tuple[Slot, QuantumCircuit]] = []
1079
+ empty: list[tuple[Slot, int]] = []
1080
+ for group_idx, circuit_group in enumerate(cut_experiment.experiments):
1081
+ for obs_idx, obs_group in enumerate(circuit_group):
1082
+ for sub_idx, subcircuit in obs_group.items():
1083
+ slot = (group_idx, obs_idx, sub_idx)
1084
+ if _has_measurements(subcircuit):
1085
+ runnable.append((slot, subcircuit))
1086
+ else:
1087
+ empty.append((slot, subcircuit.num_clbits))
1088
+ return runnable, empty
1089
+
1090
+
1091
+ def _deduplicate(runnable: list, enabled: bool) -> list[tuple[list, QuantumCircuit]]:
1092
+ """Group slots filled by the same circuit, so it is only submitted once.
1093
+
1094
+ A subcircuit's experiment circuit depends on the QPD terms of the cuts touching that
1095
+ subcircuit and on the measurement setting restricted to its own qubits, neither of
1096
+ which most of an experiment's groups change. The slot count grows exponentially in
1097
+ the number of pieces and the distinct count linearly, so almost all of a large run
1098
+ is the same handful of circuits submitted over and over.
1099
+
1100
+ The key carries the subcircuit index as well as the fingerprint. The estimator takes
1101
+ one factor per subcircuit from each group and multiplies them, so two factors of a
1102
+ single product sharing a sample would give ``E[X**2]`` where ``E[X]**2`` is meant.
1103
+ Keying on the subcircuit makes that impossible, since a group holds each subcircuit
1104
+ once. Sharing between groups is a different matter: groups are summed rather than
1105
+ multiplied, so it leaves the estimate unbiased and moves only its variance.
1106
+
1107
+ Two subcircuits of a symmetric circuit really can produce identical experiment
1108
+ circuits, so this is not a theoretical concern.
1109
+
1110
+ Returns:
1111
+ ``(slots, circuit)`` per distinct circuit, in the order first seen, so two runs
1112
+ of one experiment submit the same circuits in the same order.
1113
+ """
1114
+ if not enabled:
1115
+ return [([slot], circuit) for slot, circuit in runnable]
1116
+
1117
+ cache: dict = {}
1118
+ distinct: dict[tuple, tuple[list, QuantumCircuit]] = {}
1119
+ for slot, circuit in runnable:
1120
+ key = (slot[2], circuit_fingerprint(circuit, cache))
1121
+ hit = distinct.get(key)
1122
+ if hit is None:
1123
+ distinct[key] = ([slot], circuit)
1124
+ else:
1125
+ hit[0].append(slot)
1126
+ return list(distinct.values())
1127
+
1128
+
1129
+ def _submission(cut_experiment) -> tuple[list[tuple[list, QuantumCircuit]], list]:
1130
+ """What a run of this experiment actually submits, and what it fills in itself."""
1131
+ runnable, empty = _runnable(cut_experiment)
1132
+ jobs = _deduplicate(runnable, cut_experiment.options.deduplicate)
1133
+ if len(jobs) < len(runnable):
1134
+ logger.info(
1135
+ f"{len(runnable)} experiment circuits hold {len(jobs)} distinct ones"
1136
+ )
1137
+ return jobs, empty
1138
+
1139
+
1052
1140
  def estimate_run(
1053
1141
  cut_experiment: CutExperiment,
1054
1142
  shots: int = DEFAULT_SHOTS,
@@ -1079,18 +1167,14 @@ def estimate_run(
1079
1167
  cap = _backend_shot_cap(backend) if backend is not None else None
1080
1168
 
1081
1169
  if cut_experiment.plan is None:
1082
- runnable = [
1083
- circuit
1084
- for group in cut_experiment.experiments
1085
- for obs_group in group
1086
- for circuit in obs_group.values()
1087
- if _has_measurements(circuit)
1088
- ]
1170
+ # Counted through the same pair the run uses, so an estimate cannot drift from
1171
+ # what is submitted.
1172
+ jobs, _empty = _submission(cut_experiment)
1089
1173
  return RunEstimate(
1090
1174
  _named(
1091
1175
  [
1092
- JobEstimate(len(runnable[start : start + max_batch_size]), shots)
1093
- for start in range(0, len(runnable), max_batch_size)
1176
+ JobEstimate(len(jobs[start : start + max_batch_size]), shots)
1177
+ for start in range(0, len(jobs), max_batch_size)
1094
1178
  ]
1095
1179
  ),
1096
1180
  exact=True,
@@ -1163,7 +1247,7 @@ def run_experiments( # noqa: C901
1163
1247
 
1164
1248
  """
1165
1249
  if backend is None:
1166
- backend = AerSimulator()
1250
+ raise QCutError("a backend or sampler to run the circuits on is needed")
1167
1251
 
1168
1252
  if cut_experiment.plan is not None:
1169
1253
  results = _run_communicating(
@@ -1172,26 +1256,19 @@ def run_experiments( # noqa: C901
1172
1256
  return RawResult(results, shots, cut_experiment)
1173
1257
 
1174
1258
  results: list[list[dict[int, CircuitResult]]] = [
1175
- [{} for _ in group] for group in cut_experiment.experiments
1259
+ # Seeded in subcircuit order, because the estimator reads a group's subcircuits
1260
+ # back in the order these were filled, and deduplicated circuits fill them in
1261
+ # the order they were submitted instead. The same reason _run_communicating
1262
+ # seeds its own.
1263
+ [{sub: CircuitResult({}) for sub in sorted(obs_group)} for obs_group in group]
1264
+ for group in cut_experiment.experiments
1176
1265
  ]
1177
1266
 
1178
- runnable: list[tuple[tuple[int, int, int], QuantumCircuit]] = []
1179
- empty_locations: list[tuple[tuple[int, int, int], int]] = []
1180
- for group_idx, circuit_group in enumerate(cut_experiment.experiments):
1181
- for obs_idx, obs_group in enumerate(circuit_group):
1182
- for sub_idx, subcircuit in obs_group.items():
1183
- key = (group_idx, obs_idx, sub_idx)
1184
- if _has_measurements(subcircuit):
1185
- runnable.append((key, subcircuit))
1186
- else:
1187
- empty_locations.append((key, subcircuit.num_clbits))
1267
+ jobs, empty_locations = _submission(cut_experiment)
1188
1268
 
1189
- num_batches = len(runnable) // max_batch_size + (
1190
- 1 if len(runnable) % max_batch_size else 0
1191
- )
1269
+ num_batches = len(jobs) // max_batch_size + (1 if len(jobs) % max_batch_size else 0)
1192
1270
  logger.info(
1193
- f"Running {len(runnable)} circuits on the"
1194
- f" backend {backend} with {shots} shots each"
1271
+ f"Running {len(jobs)} circuits on the backend {backend} with {shots} shots each"
1195
1272
  )
1196
1273
  logger.info(
1197
1274
  "Circuits will be split into "
@@ -1200,8 +1277,8 @@ def run_experiments( # noqa: C901
1200
1277
  )
1201
1278
  # Submitted before any is collected, so every batch queues at once.
1202
1279
  submitted = []
1203
- for start in range(0, len(runnable), max_batch_size):
1204
- batch = runnable[start : start + max_batch_size]
1280
+ for start in range(0, len(jobs), max_batch_size):
1281
+ batch = jobs[start : start + max_batch_size]
1205
1282
  job = _submit(
1206
1283
  backend,
1207
1284
  [circ for _, circ in batch],
@@ -1214,9 +1291,13 @@ def run_experiments( # noqa: C901
1214
1291
 
1215
1292
  for job, batch in submitted:
1216
1293
  result = _result(job)
1217
- for index, (key, _circ) in enumerate(batch):
1218
- group_idx, obs_idx, sub_idx = key
1219
- results[group_idx][obs_idx][sub_idx] = CircuitResult(result, index)
1294
+ for index, (slots, _circ) in enumerate(batch):
1295
+ # One result object for the circuit, shared by every slot it fills. It is
1296
+ # frozen and holds the job's result by reference, so there is nothing to
1297
+ # copy and nothing that could be mutated through one slot.
1298
+ circuit_result = CircuitResult(result, index)
1299
+ for group_idx, obs_idx, sub_idx in slots:
1300
+ results[group_idx][obs_idx][sub_idx] = circuit_result
1220
1301
 
1221
1302
  for (group_idx, obs_idx, sub_idx), num_clbits in empty_locations:
1222
1303
  results[group_idx][obs_idx][sub_idx] = CircuitResult(
@@ -1229,7 +1310,7 @@ def run_experiments( # noqa: C901
1229
1310
  def run_cut_circuit(
1230
1311
  cut_circuit: CutCircuit,
1231
1312
  observables: ObservablesLike | None = None,
1232
- backend=AerSimulator(),
1313
+ backend=None,
1233
1314
  max_batch_size: int = 100,
1234
1315
  options: CutOptions | None = None,
1235
1316
  shots: int = DEFAULT_SHOTS,
@@ -1243,7 +1324,9 @@ def run_cut_circuit(
1243
1324
  operations and cut locations
1244
1325
  observables: the observables to estimate, taken as qiskit's estimator takes
1245
1326
  them. See :func:`get_experiment_circuits`.
1246
- backend: backend to use for running experiment circuits (optional)
1327
+ backend: backend or V2 sampler to run the experiment circuits on. A device
1328
+ with a coupling map gets the subcircuits transpiled for it first. A
1329
+ sampler is not transpiled for, so it must take the circuits as built.
1247
1330
  max_batch_size (int): maximum number of circuits submitted per backend.run
1248
1331
  call (optional)
1249
1332
  options (CutOptions): configuration, overriding what the CutCircuit carries
@@ -1269,16 +1352,13 @@ def run_cut_circuit(
1269
1352
  if options is not None:
1270
1353
  cut_circuit.options = options
1271
1354
 
1272
- if not isinstance(backend, AerSimulator):
1273
- transpiled_subcircuits = transpile_subcircuits(
1274
- cut_circuit, backend, optimization_level=3
1275
- )
1355
+ if backend is None:
1356
+ raise QCutError("a backend or sampler to run the circuits on is needed")
1276
1357
 
1277
- cut_experiment = get_experiment_circuits(
1278
- transpiled_subcircuits, observables, qubits
1279
- )
1280
- else:
1281
- cut_experiment = get_experiment_circuits(cut_circuit, observables, qubits)
1358
+ if has_topology(backend):
1359
+ cut_circuit = transpile_subcircuits(cut_circuit, backend, optimization_level=3)
1360
+
1361
+ cut_experiment = get_experiment_circuits(cut_circuit, observables, qubits)
1282
1362
 
1283
1363
  results = run_experiments(
1284
1364
  cut_experiment,
@@ -1297,7 +1377,7 @@ def run_cut_circuit(
1297
1377
  def run(
1298
1378
  circuit: QuantumCircuit,
1299
1379
  observables: ObservablesLike | None = None,
1300
- backend=AerSimulator(),
1380
+ backend=None,
1301
1381
  max_batch_size: int = 100,
1302
1382
  options: CutOptions | None = None,
1303
1383
  shots: int = DEFAULT_SHOTS,
@@ -1310,7 +1390,9 @@ def run(
1310
1390
  circuit (QuantumCircuit): circuit with cut experiments
1311
1391
  observables: the observables to estimate, taken as qiskit's estimator takes
1312
1392
  them. See :func:`get_experiment_circuits`.
1313
- backend: backend to use for running experiment circuits (optional)
1393
+ backend: backend or V2 sampler to run the experiment circuits on. A device
1394
+ with a coupling map gets the subcircuits transpiled for it first. A
1395
+ sampler is not transpiled for, so it must take the circuits as built.
1314
1396
  max_batch_size (int): maximum number of circuits submitted per backend.run
1315
1397
  call (optional)
1316
1398
  options (CutOptions): configuration for the run (optional)