dense-evolution 8.1.76__tar.gz → 8.1.78__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 (146) hide show
  1. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/PKG-INFO +1 -1
  2. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/__init__.py +1 -1
  3. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/backends/mps.py +470 -30
  4. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/native_hf/bridge.py +18 -2
  5. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution.egg-info/PKG-INFO +1 -1
  6. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/pyproject.toml +1 -1
  7. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/README.md +0 -0
  8. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/autodiff.py +0 -0
  9. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/backends/__init__.py +0 -0
  10. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/backends/chunk/__init__.py +0 -0
  11. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/backends/chunk/_engine_imports.py +0 -0
  12. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/backends/chunk/circuit_chunker.py +0 -0
  13. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/backends/chunk/core.py +0 -0
  14. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/backends/chunk/disk_overflow.py +0 -0
  15. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/backends/chunk/geometry.py +0 -0
  16. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/backends/chunk/guard.py +0 -0
  17. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/backends/chunk/kernels.py +0 -0
  18. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/backends/statevector.py +0 -0
  19. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/chunk.py +0 -0
  20. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/circuits/__init__.py +0 -0
  21. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/circuits/compiler.py +0 -0
  22. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/circuits/diagram.py +0 -0
  23. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/circuits/gates.py +0 -0
  24. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/circuits/parser.py +0 -0
  25. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/circuits/qft.py +0 -0
  26. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/circuits/random_circuit.py +0 -0
  27. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/circuits/registry.py +0 -0
  28. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/circuits/topology.py +0 -0
  29. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/circuits/trotter.py +0 -0
  30. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/circuits/uccsd.py +0 -0
  31. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/cli.py +0 -0
  32. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/compiler.py +0 -0
  33. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/config.py +0 -0
  34. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/drawing.py +0 -0
  35. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/entropy.py +0 -0
  36. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/fermions.py +0 -0
  37. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/gates.py +0 -0
  38. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/harrison_tb.py +0 -0
  39. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/healing.py +0 -0
  40. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/interop/__init__.py +0 -0
  41. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/interop/qiskit_pennylane.py +0 -0
  42. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/measurement.py +0 -0
  43. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/mitigation/__init__.py +0 -0
  44. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/mitigation/healing.py +0 -0
  45. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/mitigation/kl_divergence.py +0 -0
  46. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/mitigation/magic_entropy.py +0 -0
  47. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/mitigation/magic_entropy_shadows.py +0 -0
  48. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/mitigation/renyi.py +0 -0
  49. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/mitigation/stabilizer_renyi_entropy.py +0 -0
  50. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/mitigation/zne.py +0 -0
  51. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/mps.py +0 -0
  52. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/native_hf/__init__.py +0 -0
  53. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/native_hf/assembly.py +0 -0
  54. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/native_hf/basis.py +0 -0
  55. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/native_hf/boys.py +0 -0
  56. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/native_hf/cartesian.py +0 -0
  57. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/native_hf/coulomb.py +0 -0
  58. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/native_hf/gaussians.py +0 -0
  59. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/native_hf/kinetic.py +0 -0
  60. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/native_hf/libcint_bridge.py +0 -0
  61. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/native_hf/overlap.py +0 -0
  62. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/native_hf/scf.py +0 -0
  63. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/noise/__init__.py +0 -0
  64. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/noise/coherent_attack.py +0 -0
  65. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/noise/cosmic_ray.py +0 -0
  66. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/noise/density_matrix_channels.py +0 -0
  67. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/noise/differentiable.py +0 -0
  68. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/noise/kraus/__init__.py +0 -0
  69. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/noise/kraus/amplitude_damping.py +0 -0
  70. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/noise/kraus/bitflip.py +0 -0
  71. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/noise/kraus/combined.py +0 -0
  72. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/noise/kraus/depolarizing.py +0 -0
  73. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/noise/kraus/ideal.py +0 -0
  74. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/noise/kraus/phaseflip.py +0 -0
  75. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/noise/kraus_channels.py +0 -0
  76. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/noise/oscillating.py +0 -0
  77. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/observables.py +0 -0
  78. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/parser.py +0 -0
  79. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/physics/__init__.py +0 -0
  80. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/physics/entropy.py +0 -0
  81. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/physics/fermions.py +0 -0
  82. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/physics/observables.py +0 -0
  83. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/physics/qec.py +0 -0
  84. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/physics/states.py +0 -0
  85. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/qec.py +0 -0
  86. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/qft.py +0 -0
  87. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/random_circuit.py +0 -0
  88. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/registry.py +0 -0
  89. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/simulator.py +0 -0
  90. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/solvers/__init__.py +0 -0
  91. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/solvers/autodiff.py +0 -0
  92. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/solvers/harrison_tb.py +0 -0
  93. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/solvers/vhd_tb.py +0 -0
  94. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/states.py +0 -0
  95. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/topology.py +0 -0
  96. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/trotter.py +0 -0
  97. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/utils/__init__.py +0 -0
  98. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/utils/drawing.py +0 -0
  99. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/utils/measurement.py +0 -0
  100. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution/vhd_tb.py +0 -0
  101. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution.egg-info/SOURCES.txt +0 -0
  102. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution.egg-info/dependency_links.txt +0 -0
  103. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution.egg-info/entry_points.txt +0 -0
  104. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution.egg-info/requires.txt +0 -0
  105. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/dense_evolution.egg-info/top_level.txt +0 -0
  106. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/license.md +0 -0
  107. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/research/local_site/__init__.py +0 -0
  108. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/research/local_site/app/__init__.py +0 -0
  109. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/research/local_site/app/server.py +0 -0
  110. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/setup.cfg +0 -0
  111. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/tools/dashboard/core/__init__.py +0 -0
  112. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/tools/dashboard/core/circuit_builder_component.py +0 -0
  113. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/tools/dashboard/core/circuit_diagram.py +0 -0
  114. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/tools/dashboard/core/engine.py +0 -0
  115. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/tools/dashboard/core/graphical_builder.py +0 -0
  116. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/tools/dashboard/core/hamiltonians.py +0 -0
  117. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/tools/dashboard/core/mitigation.py +0 -0
  118. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/tools/dashboard/core/noise_tools.py +0 -0
  119. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/tools/dashboard/core/qasm_library.py +0 -0
  120. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/tools/dashboard/core/qmmm.py +0 -0
  121. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/tools/dashboard/core/state_visuals.py +0 -0
  122. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/tools/dashboard/core/system_limits.py +0 -0
  123. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/tools/dashboard/core/vector_healing.py +0 -0
  124. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/tools/dashboard/core/visuals.py +0 -0
  125. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/tools/dashboard/core/vqe.py +0 -0
  126. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/tools/dashboard/core/wormhole.py +0 -0
  127. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/tools/ia_utils/__init__.py +0 -0
  128. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/tools/ia_utils/adversarial_vector_attack.py +0 -0
  129. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/tools/ia_utils/vector_healing.py +0 -0
  130. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/tools/mcp_server/__init__.py +0 -0
  131. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/tools/mcp_server/client.py +0 -0
  132. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/tools/mcp_server/config.py +0 -0
  133. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/tools/mcp_server/models.py +0 -0
  134. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/tools/mcp_server/molecules.py +0 -0
  135. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/tools/mcp_server/server.py +0 -0
  136. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/tools/mcp_server/tools/__init__.py +0 -0
  137. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/tools/mcp_server/tools/chemistry_tools.py +0 -0
  138. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/tools/mcp_server/tools/circuit_tools.py +0 -0
  139. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/tools/mcp_server/tools/mitigation_tools.py +0 -0
  140. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/tools/mcp_server/tools/noise_tools.py +0 -0
  141. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/tools/mcp_server/tools/system_tools.py +0 -0
  142. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/tools/mcp_server/tools/wormhole_tools.py +0 -0
  143. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/tools/mcp_server/utils/__init__.py +0 -0
  144. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/tools/mcp_server/utils/cache.py +0 -0
  145. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/tools/mcp_server/utils/images.py +0 -0
  146. {dense_evolution-8.1.76 → dense_evolution-8.1.78}/tools/mcp_server/utils/truncation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dense-evolution
3
- Version: 8.1.76
3
+ Version: 8.1.78
4
4
  Summary: High-performance quantum simulation toolkit -- Statevector/MPS engines with JIT compilation, noise modeling, VQE, QEC, quantum chemistry, and agent-native tooling
5
5
  Author-email: Salvatore Pennacchio <jtatopenn@libero.it>
6
6
  License: Business Source License 1.1
@@ -57,7 +57,7 @@ from .physics.qec import (pauli_commutes, compute_syndrome, erasure_aware_decode
57
57
  blind_minimum_weight_decode, decode_with_erasure_fallback,
58
58
  counts_in_intervals_dimension, nearest_coset_decode)
59
59
 
60
- __version__ = "8.1.76"
60
+ __version__ = "8.1.78"
61
61
 
62
62
  __all__ = [
63
63
  "__version__",
@@ -50,7 +50,9 @@ DenseSVSimulator has -- it is not a universal replacement, it is
50
50
  complementary.
51
51
  """
52
52
 
53
+ import dataclasses
53
54
  import warnings
55
+ from functools import partial
54
56
  from typing import List, Optional, Tuple
55
57
 
56
58
  import jax
@@ -120,6 +122,18 @@ def _vectorized_chi_search_jax(S: jnp.ndarray, eps: float, jsd_budget: float, ma
120
122
 
121
123
  norm_full = jnp.sum(S ** 2) + 1e-15
122
124
  p_full = (S ** 2) / norm_full
125
+ # Real bug (found via a real-machine bisection down to 5e34a1e, not
126
+ # guessed): under jax.jit, _jsd_vectors_jax's own internal eps=1e-12
127
+ # mask does not reliably zero JSD contributions from p_full's
128
+ # already-negligible tail (~1e-18, from squaring SVD noise-floor
129
+ # singular values ~1e-9) when batched via jax.vmap across multiple
130
+ # candidates at once -- eager execution of the exact same formula on
131
+ # the exact same array gives the correct (zero) JSD there, jit does
132
+ # not. Zeroing p_full's own negligible tail here, before it ever
133
+ # reaches the vmapped JSD computation, verified to fix this under
134
+ # jit (confirmed both eager and jit now agree) without changing any
135
+ # value large enough to matter physically.
136
+ p_full = jnp.where(p_full > 1e-12, p_full, 0.0)
123
137
 
124
138
  candidates = jnp.arange(1, max_possible + 1)
125
139
  idx = jnp.arange(n)
@@ -298,6 +312,27 @@ def _pad_lambda(lam: jnp.ndarray, max_bond: int) -> jnp.ndarray:
298
312
  return out.at[:n].set(lam)
299
313
 
300
314
 
315
+ @partial(jax.jit, static_argnums=(1, 2))
316
+ def _pad_all_gammas(gammas, max_bond: int, dtype) -> jnp.ndarray:
317
+ """Batches the whole per-gamma padding+stacking loop into one
318
+ compiled call instead of n_qubits separate eager _pad_gamma
319
+ dispatches -- profiling run_circuit_jit directly found this eager
320
+ loop (50-51 separate JAX dispatches for N=50) cost 52-55% of total
321
+ run_circuit_jit time, present equally on the fuse_gates=True and
322
+ default paths, diluting the real speedup ratio between them.
323
+ Persistent-compiled-cache wrapper, keyed by the tuple of gamma
324
+ shapes + max_bond + dtype -- valid across the whole process
325
+ lifetime, same principle as _jit_mps_1q_matrix/_jit_mps_2q_matrix."""
326
+ return jnp.stack([_pad_gamma(g, max_bond).astype(dtype) for g in gammas])
327
+
328
+
329
+ @partial(jax.jit, static_argnums=(1, 2))
330
+ def _pad_all_lambdas(lambdas, max_bond: int, dtype) -> jnp.ndarray:
331
+ """Same persistent-compiled-cache fix as _pad_all_gammas, for
332
+ lambdas."""
333
+ return jnp.stack([_pad_lambda(l, max_bond).astype(dtype) for l in lambdas])
334
+
335
+
301
336
  def _compile_mps_ops(ops, n_qubits: int) -> List[List[float]]:
302
337
  """Pre-compile-time (pure Python, outside any jit region) translation
303
338
  of a circuit -- list of (name, *args) tuples/lists, same convention as
@@ -370,6 +405,161 @@ def _compile_mps_ops(ops, n_qubits: int) -> List[List[float]]:
370
405
  return rows
371
406
 
372
407
 
408
+ def _embed_1q_matrix(mat1: np.ndarray, qubit: int, pair: Tuple[int, int]) -> np.ndarray:
409
+ """Embeds a 2x2 single-qubit matrix into the 4x4 space of a 2-qubit
410
+ pair (a, b), acting as identity on the other qubit -- kron(mat1, I2)
411
+ if qubit is the pair's first (more significant) qubit, kron(I2, mat1)
412
+ otherwise, matching _mps_2q_matrix's own convention (its (4,4)
413
+ matrices are built with qubit0 as the more significant index before
414
+ reshaping to (2,2,2,2))."""
415
+ eye2 = np.eye(2, dtype=mat1.dtype)
416
+ a, _ = pair
417
+ return np.kron(mat1, eye2) if qubit == a else np.kron(eye2, mat1)
418
+
419
+
420
+ @partial(jax.jit, static_argnums=(2,))
421
+ def _jit_mps_1q_matrix(g_id, param, dtype):
422
+ """Persistent-compiled-cache wrapper around _mps_1q_matrix, keyed by
423
+ JAX on (g_id, param, dtype) across the whole process lifetime, not
424
+ just within one _fuse_compiled_rows call -- without this, each
425
+ separate run_circuit_jit(fuse_gates=True) call pays a real XLA
426
+ compile cost again for the same gate/parameter combination, since
427
+ _mps_1q_matrix's own jax.lax.switch is never wrapped in @jax.jit."""
428
+ return _mps_1q_matrix(g_id, param, dtype)
429
+
430
+
431
+ @partial(jax.jit, static_argnums=(2,))
432
+ def _jit_mps_2q_matrix(g_id, param, dtype):
433
+ """Same persistent-compiled-cache fix as _jit_mps_1q_matrix, for
434
+ _mps_2q_matrix."""
435
+ return _mps_2q_matrix(g_id, param, dtype)
436
+
437
+
438
+ def _fuse_compiled_rows(rows: List[List[float]], dtype) -> List[tuple]:
439
+ """Host-side (pure Python/NumPy, outside any jit region), exact --
440
+ fuses a chain of consecutive _compile_mps_ops rows into one matrix
441
+ via matrix multiplication, growing the active qubit pair as needed:
442
+ a 1-qubit gate on a qubit already inside the current chain's pair
443
+ gets embedded into that same 4x4 space (kron with identity on the
444
+ other qubit) rather than breaking the chain -- this is what actually
445
+ fuses a CX-RZ-CX triple (RZ acts on only one of CX's two qubits, so a
446
+ naive "exact qubit set match" rule never fuses anything). A chain
447
+ starting on a single qubit that is later joined by a 2-qubit gate
448
+ touching it also grows into the 2-qubit space the same way. Any gate
449
+ touching a qubit OUTSIDE the current chain's pair ends the chain.
450
+
451
+ Runs on _compile_mps_ops's OWN output rows, so CCX decomposition and
452
+ SWAP-chain expansion for non-adjacent gates have already happened --
453
+ fusion composes correctly with both by construction (a SWAP is just
454
+ another 2-qubit gate, fusable like any other), verified directly
455
+ against the eager reference on both a non-adjacent-gate circuit and
456
+ a CCX circuit, not merely assumed.
457
+
458
+ Returns a list of ('1q', q, matrix_2x2) / ('2q', q1, q2, matrix_4x4)
459
+ entries. transpose_flag is resolved into the matrix itself here, so
460
+ the fused representation never needs it downstream.
461
+
462
+ Memoizes row_matrix by (g_id, param, transpose_flag) -- most rows in
463
+ a real circuit share the same small set of distinct gate/parameter
464
+ combinations (e.g. one N=50 TFIM circuit has 985 rows but only 3
465
+ distinct ones), and each cache miss dispatches a real JAX call
466
+ (_mps_1q_matrix/_mps_2q_matrix) that is not free to skip. Without
467
+ this, a real (not tiny-example) circuit pays a full eager JAX
468
+ dispatch per row on every single run_circuit_jit(fuse_gates=True)
469
+ call, not just the first -- measured directly: an unmemoized version
470
+ of this function made "warm" (post-compile) calls nearly as slow as
471
+ "cold" ones (10.7s vs 12.9s on a 74-gate circuit), because the
472
+ matrix-reconstruction cost, not recompilation, dominated."""
473
+ matrix_cache: dict = {}
474
+
475
+ def row_matrix(row):
476
+ g_id, q1, q2, param, transpose_flag = row
477
+ key = (g_id, param, transpose_flag)
478
+ cached = matrix_cache.get(key)
479
+ if cached is not None:
480
+ return cached, (int(q1), int(q2)) if g_id >= 20 else (int(q1),)
481
+ g_id_arr = jnp.asarray(g_id).astype(jnp.int32)
482
+ if g_id >= 20:
483
+ mat4 = np.asarray(_jit_mps_2q_matrix(g_id_arr, jnp.asarray(param), dtype))
484
+ if transpose_flag > 0.5:
485
+ mat4 = np.transpose(mat4, (1, 0, 3, 2))
486
+ mat = mat4.reshape(4, 4)
487
+ else:
488
+ mat = np.asarray(_jit_mps_1q_matrix(g_id_arr, jnp.asarray(param), dtype))
489
+ matrix_cache[key] = mat
490
+ return mat, (int(q1), int(q2)) if g_id >= 20 else (int(q1),)
491
+
492
+ fused = []
493
+ i = 0
494
+ n = len(rows)
495
+ while i < n:
496
+ mat, qubits = row_matrix(rows[i])
497
+ pair = qubits if len(qubits) == 2 else None
498
+ active_qubit = qubits[0]
499
+ j = i + 1
500
+ while j < n:
501
+ nmat, nqubits = row_matrix(rows[j])
502
+ n_is_2q = len(nqubits) == 2
503
+ if pair is None:
504
+ if not n_is_2q:
505
+ if nqubits[0] != active_qubit:
506
+ break
507
+ mat = nmat @ mat
508
+ j += 1
509
+ continue
510
+ if active_qubit not in nqubits:
511
+ break
512
+ pair = nqubits
513
+ mat = _embed_1q_matrix(mat, active_qubit, pair)
514
+ mat = nmat @ mat
515
+ j += 1
516
+ continue
517
+ if n_is_2q:
518
+ if nqubits != pair:
519
+ break
520
+ else:
521
+ if nqubits[0] not in pair:
522
+ break
523
+ nmat = _embed_1q_matrix(nmat, nqubits[0], pair)
524
+ mat = nmat @ mat
525
+ j += 1
526
+ if pair is not None:
527
+ fused.append(('2q', pair[0], pair[1], mat.reshape(2, 2, 2, 2)))
528
+ else:
529
+ fused.append(('1q', active_qubit, mat))
530
+ i = j
531
+ return fused
532
+
533
+
534
+ def _fused_entries_to_arrays(fused: List[tuple], dtype):
535
+ """Turns _fuse_compiled_rows's output into the parallel arrays
536
+ jax.lax.scan needs -- every step carries BOTH a 1q and a 2q matrix
537
+ slot (one is an unused identity placeholder) so shapes stay uniform
538
+ across the whole scanned sequence, same principle _mps_1q_matrix/
539
+ _mps_2q_matrix's own switch already relies on (all branches traced,
540
+ only one path's real work executed)."""
541
+ is_2q, q1s, q2s, mats2q, mats1q = [], [], [], [], []
542
+ eye2 = np.eye(2, dtype=dtype)
543
+ eye4 = np.eye(4, dtype=dtype).reshape(2, 2, 2, 2)
544
+ for entry in fused:
545
+ if entry[0] == '2q':
546
+ _, a, b, mat = entry
547
+ is_2q.append(True)
548
+ q1s.append(a)
549
+ q2s.append(b)
550
+ mats2q.append(mat)
551
+ mats1q.append(eye2)
552
+ else:
553
+ _, a, mat = entry
554
+ is_2q.append(False)
555
+ q1s.append(a)
556
+ q2s.append(0)
557
+ mats2q.append(eye4)
558
+ mats1q.append(mat)
559
+ return (jnp.asarray(is_2q), jnp.asarray(q1s, dtype=jnp.int32), jnp.asarray(q2s, dtype=jnp.int32),
560
+ jnp.asarray(np.stack(mats2q)), jnp.asarray(np.stack(mats1q)))
561
+
562
+
373
563
  _SVD_BUCKETS = (2, 4, 8, 16, 32, 64, 128, 256, 512, 1024)
374
564
 
375
565
 
@@ -542,6 +732,125 @@ def _build_mps_runner(n_qubits: int, max_bond: int, eps: float, jsd_budget: floa
542
732
  return run
543
733
 
544
734
 
735
+ def _build_fused_mps_runner(n_qubits: int, max_bond: int, eps: float, jsd_budget: float):
736
+ """Factory for run_circuit_jit(ops, fuse_gates=True) -- same bucketed-
737
+ SVD dispatch as _build_mps_runner, except every step's gate matrix is
738
+ taken directly from a pre-fused matrix stream (built host-side by
739
+ _fuse_compiled_rows/_fused_entries_to_arrays from _compile_mps_ops's
740
+ own output) instead of being reconstructed inside the JIT via
741
+ _mps_1q_matrix/_mps_2q_matrix -- gate-ID dispatch is removed from the
742
+ traced program entirely, not just extended. Fusing consecutive
743
+ same-qubit-pair gates into one matrix means fewer, larger scan steps:
744
+ a real, measured ~2x GPU speedup on top of the bucketed dispatch
745
+ alone (see Dense-Evolution-Discovery's mps_gate_blocking_redesign_v2
746
+ experiment), on top of jax.lax.scan's own per-step GPU dispatch
747
+ overhead being the real bottleneck the bucketed dispatch alone
748
+ couldn't remove.
749
+
750
+ Returns the same (chi_new, jsd_val, trunc_err, entanglement_entropy)
751
+ diagnostic tuple _build_mps_runner does, one entry per FUSED step
752
+ rather than per original gate -- run_circuit_jit populates
753
+ self._bond_history/jsd_per_bond/truncation_errors/entanglement_entropy
754
+ from this at that coarser granularity when fuse_gates=True, an
755
+ explicit, documented trade-off (see MPSSimulator.run_circuit_jit's
756
+ own docstring), not a silent behavior change."""
757
+ buckets = _bucket_sizes(max_bond)
758
+ bucket_arr = jnp.array(buckets)
759
+
760
+ def step(carry, xs):
761
+ gammas, lambdas, real_chi = carry
762
+ dtype = gammas.dtype
763
+ is_2q, q1, q2, mat2q, mat1q = xs
764
+ q1 = q1.astype(jnp.int32)
765
+ q2 = q2.astype(jnp.int32)
766
+ real_dtype = _real_dtype_for(dtype)
767
+
768
+ def branch_1q(c):
769
+ gammas_, lambdas_, real_chi_ = c
770
+ new_g = jnp.einsum('ij,ljr->lir', mat1q, gammas_[q1])
771
+ new_carry = (gammas_.at[q1].set(new_g), lambdas_, real_chi_)
772
+ diag = (jnp.asarray(0, dtype=jnp.int32), jnp.asarray(0.0, dtype=real_dtype),
773
+ jnp.asarray(0.0, dtype=real_dtype), jnp.asarray(0.0, dtype=real_dtype))
774
+ return new_carry, diag
775
+
776
+ def branch_2q(c):
777
+ gammas_, lambdas_, real_chi_ = c
778
+ gate_2q = mat2q
779
+
780
+ chi_l_real = real_chi_[q1]
781
+ chi_m_real = real_chi_[q2]
782
+ chi_r_real = real_chi_[q2 + 1]
783
+ input_min = jnp.maximum(jnp.maximum(chi_l_real, chi_r_real), chi_m_real)
784
+ output_bound = jnp.minimum(chi_l_real * 2, chi_r_real * 2)
785
+ bound = jnp.maximum(input_min, output_bound)
786
+ ge_mask = bucket_arr >= bound
787
+ bucket_idx = jnp.where(jnp.any(ge_mask), jnp.argmax(ge_mask), len(buckets) - 1)
788
+
789
+ def make_branch(B):
790
+ def branch_fn(_):
791
+ g1 = gammas_[q1][:B, :, :B]
792
+ g2 = gammas_[q2][:B, :, :B]
793
+ lam_l = lambdas_[q1][:B]
794
+ lam_m = lambdas_[q2][:B]
795
+ lam_r = lambdas_[q2 + 1][:B]
796
+ theta = jnp.einsum('l,lik,k,kjr,r->lijr', lam_l, g1, lam_m, g2, lam_r)
797
+ theta_new = jnp.einsum('abcd,ecdf->eabf', gate_2q, theta)
798
+ theta_mat = theta_new.reshape(B * 2, 2 * B)
799
+
800
+ U, S, Vh = jnp.linalg.svd(theta_mat, full_matrices=False)
801
+ chi_new, jsd_val = _vectorized_chi_search_jax(S, eps, jsd_budget, min(B, max_bond))
802
+ col_mask = jnp.arange(B) < chi_new
803
+
804
+ norm_full = jnp.sqrt(jnp.sum(S ** 2) + 1e-30)
805
+ S_norm_full = S / (norm_full + 1e-30)
806
+ trunc_err = jnp.sqrt(jnp.sum(jnp.where(jnp.arange(2 * B) >= chi_new, S_norm_full ** 2, 0.0)))
807
+
808
+ S_kept_masked = jnp.where(col_mask, S[:B], 0.0)
809
+ kept_norm = jnp.sqrt(jnp.sum(S_kept_masked ** 2) + 1e-30)
810
+ S_fixed = jnp.where(col_mask, S_kept_masked / (kept_norm + 1e-30), 0.0)
811
+
812
+ lam_l_inv = jnp.where(lam_l > eps, 1.0 / lam_l, 0.0)
813
+ lam_r_inv = jnp.where(lam_r > eps, 1.0 / lam_r, 0.0)
814
+
815
+ U_masked = jnp.where(col_mask[None, :], U[:, :B], 0.0)
816
+ Vh_masked = jnp.where(col_mask[:, None], Vh[:B, :], 0.0)
817
+
818
+ new_g1 = jnp.einsum('l,lir->lir', lam_l_inv, U_masked.reshape(B, 2, B))
819
+ new_g2 = jnp.einsum('ljr,r->ljr', Vh_masked.reshape(B, 2, B), lam_r_inv)
820
+
821
+ p_dist = S_fixed ** 2
822
+ ee = -jnp.sum(jnp.where(p_dist > 1e-20, p_dist * jnp.log2(jnp.where(p_dist > 1e-20, p_dist, 1.0)), 0.0))
823
+
824
+ return (_pad_gamma(new_g1, max_bond), _pad_gamma(new_g2, max_bond),
825
+ _pad_lambda(S_fixed, max_bond), chi_new.astype(jnp.int32),
826
+ jsd_val.astype(real_dtype), trunc_err.astype(real_dtype), ee.astype(real_dtype))
827
+
828
+ return branch_fn
829
+
830
+ branches = [make_branch(B) for B in buckets]
831
+ new_g1_p, new_g2_p, S_fixed_p, chi_new, jsd_val, trunc_err, ee = jax.lax.switch(
832
+ bucket_idx, branches, operand=None)
833
+
834
+ new_gammas = gammas_.at[q1].set(new_g1_p).at[q2].set(new_g2_p)
835
+ new_lambdas = lambdas_.at[q2].set(S_fixed_p)
836
+ new_real_chi = real_chi_.at[q2].set(chi_new)
837
+
838
+ new_carry = (new_gammas, new_lambdas, new_real_chi)
839
+ diag = (chi_new, jsd_val, trunc_err, ee)
840
+ return new_carry, diag
841
+
842
+ new_carry, diag = jax.lax.cond(is_2q, branch_2q, branch_1q, carry)
843
+ return new_carry, diag
844
+
845
+ @jax.jit
846
+ def run(gammas, lambdas, real_chi, xs):
847
+ (final_gammas, final_lambdas, final_real_chi), diag = jax.lax.scan(
848
+ step, (gammas, lambdas, real_chi), xs)
849
+ return final_gammas, final_lambdas, final_real_chi, diag
850
+
851
+ return run
852
+
853
+
545
854
  class MPSSimulator:
546
855
  """
547
856
  Matrix Product State simulator with adaptive SVD-truncated bond
@@ -634,6 +943,7 @@ class MPSSimulator:
634
943
  # for this instance's lifetime), never rebuilt per call. Same
635
944
  # caching pattern as Chunk.__init__'s self._multi_chunk_runner.
636
945
  self._mps_runner = None
946
+ self._fused_mps_runner = None
637
947
 
638
948
  for _ in range(n_qubits):
639
949
  g = jnp.zeros((1, 2, 1), dtype=dtype)
@@ -947,7 +1257,37 @@ class MPSSimulator:
947
1257
  )
948
1258
 
949
1259
  # ── JIT-fused whole-circuit execution ─────────────────────────────
950
- def run_circuit_jit(self, ops: List) -> None:
1260
+ def _record_diag_bookkeeping(self, diag, q1_ids: np.ndarray, is_2q_mask: np.ndarray) -> None:
1261
+ """Shared by both run_circuit_jit paths: jax.lax.scan's stacked
1262
+ per-step diagnostics (diag) replace the eager path's Python
1263
+ list.append()s inside the loop -- same final content, populated
1264
+ differently. Only 2-qubit steps count (is_2q_mask), same as
1265
+ _bond_history/jsd_per_bond only ever growing on 2-qubit gates in
1266
+ the eager path."""
1267
+ chi_history, jsd_history, trunc_err_history, entropy_history = (
1268
+ np.asarray(diag[0]), np.asarray(diag[1]), np.asarray(diag[2]), np.asarray(diag[3]))
1269
+ for i in np.nonzero(is_2q_mask)[0]:
1270
+ chi_new = int(chi_history[i])
1271
+ jsd_val = float(jsd_history[i])
1272
+ self._bond_history.append(chi_new)
1273
+ self.jsd_per_bond.append(jsd_val)
1274
+ self.truncation_errors.append(float(trunc_err_history[i]))
1275
+ q1 = q1_ids[i]
1276
+ if q1 < len(self.entanglement_entropy):
1277
+ self.entanglement_entropy[q1] = float(entropy_history[i])
1278
+ if jsd_val > self.jsd_budget:
1279
+ if self.budget_violations == 0:
1280
+ warnings.warn(
1281
+ f"MPSSimulator: bond dimension capped at max_bond={self.chi}, "
1282
+ f"jsd_budget={self.jsd_budget:.1e} not honored "
1283
+ f"(jsd={jsd_val:.2e}) -- results may be unreliable, "
1284
+ f"consider raising max_bond.",
1285
+ UserWarning,
1286
+ stacklevel=2,
1287
+ )
1288
+ self.budget_violations += 1
1289
+
1290
+ def run_circuit_jit(self, ops: List, fuse_gates: bool = False) -> None:
951
1291
  """Runs an entire circuit through a single jax.lax.scan-fused,
952
1292
  @jax.jit-compiled kernel instead of one eager Python call per gate
953
1293
  -- the eager path (apply_gate_1q/apply_gate_2q/_apply_nonlocal_2q,
@@ -972,10 +1312,50 @@ class MPSSimulator:
972
1312
  -- list of (name, *args) tuples/lists. Unlike that method, SWAP is
973
1313
  never decomposed into 3xCX (kept as one real gate, see
974
1314
  _compile_mps_ops's docstring for why that matters here).
1315
+
1316
+ fuse_gates: opt-in, default False. When True, consecutive gates
1317
+ acting on the same (or a growing) qubit pair are fused into one
1318
+ matrix on the host before compiling (exact -- matrix
1319
+ multiplication, no approximation), cutting the number of scan
1320
+ steps and measurably faster on GPU (~2x on top of the bucketed
1321
+ SVD dispatch alone, see Dense-Evolution-Discovery's
1322
+ mps_gate_blocking_redesign_v2 experiment for the full validation,
1323
+ including verification against non-adjacent-gate and CCX
1324
+ circuits). The trade-off: self._bond_history/jsd_per_bond/
1325
+ truncation_errors/entanglement_entropy get one entry per FUSED
1326
+ step instead of per original gate -- real diagnostics, just
1327
+ coarser-grained. Defaults to False so existing behavior and
1328
+ per-gate bookkeeping granularity are unchanged unless requested.
975
1329
  """
976
- compiled_rows = _compile_mps_ops(ops, self.n)
977
1330
  dtype = self.gammas[0].dtype
978
1331
  lambda_dtype = self.lambdas[0].dtype
1332
+
1333
+ if fuse_gates:
1334
+ compiled_rows = _compile_mps_ops(ops, self.n)
1335
+ fused = _fuse_compiled_rows(compiled_rows, dtype) if compiled_rows else []
1336
+
1337
+ if self._fused_mps_runner is None:
1338
+ self._fused_mps_runner = _build_fused_mps_runner(self.n, self.chi, self.eps, self.jsd_budget)
1339
+
1340
+ gammas_padded = _pad_all_gammas(tuple(self.gammas), self.chi, dtype)
1341
+ lambdas_padded = _pad_all_lambdas(tuple(self.lambdas), self.chi, lambda_dtype)
1342
+ real_chi_initial = jnp.asarray(self._real_chi, dtype=jnp.int32)
1343
+
1344
+ if fused:
1345
+ xs = _fused_entries_to_arrays(fused, dtype)
1346
+ final_gammas, final_lambdas, final_real_chi, diag = self._fused_mps_runner(
1347
+ gammas_padded, lambdas_padded, real_chi_initial, xs)
1348
+
1349
+ self.gammas = [final_gammas[i] for i in range(self.n)]
1350
+ self.lambdas = [final_lambdas[i] for i in range(self.n + 1)]
1351
+ self._real_chi = np.asarray(final_real_chi)
1352
+
1353
+ q1_ids = np.asarray([entry[1] for entry in fused])
1354
+ is_2q_mask = np.asarray([entry[0] == '2q' for entry in fused])
1355
+ self._record_diag_bookkeeping(diag, q1_ids, is_2q_mask)
1356
+ return
1357
+
1358
+ compiled_rows = _compile_mps_ops(ops, self.n)
979
1359
  ops_dtype = _real_dtype_for(dtype)
980
1360
 
981
1361
  if compiled_rows:
@@ -997,38 +1377,11 @@ class MPSSimulator:
997
1377
  self.lambdas = [final_lambdas[i] for i in range(self.n + 1)]
998
1378
  self._real_chi = np.asarray(final_real_chi)
999
1379
 
1000
- # Bookkeeping parity: jax.lax.scan's stacked per-step diagnostics
1001
- # (diag) replace the eager path's Python list.append()s inside the
1002
- # loop -- same final content, populated differently. Only 2-qubit
1003
- # steps (g_id >= 20, includes SWAP -- the eager _apply_nonlocal_2q
1004
- # path routes its SWAPs through apply_gate_2q too, so its history
1005
- # lists grow on those as well, not just the "real" gate) count.
1006
1380
  if compiled_rows:
1007
- chi_history, jsd_history, trunc_err_history, entropy_history = (
1008
- np.asarray(diag[0]), np.asarray(diag[1]), np.asarray(diag[2]), np.asarray(diag[3]))
1009
1381
  g_ids = np.asarray([row[0] for row in compiled_rows])
1010
1382
  q1_ids = np.asarray([int(row[1]) for row in compiled_rows])
1011
1383
  is_2q_mask = g_ids >= 20
1012
- for i in np.nonzero(is_2q_mask)[0]:
1013
- chi_new = int(chi_history[i])
1014
- jsd_val = float(jsd_history[i])
1015
- self._bond_history.append(chi_new)
1016
- self.jsd_per_bond.append(jsd_val)
1017
- self.truncation_errors.append(float(trunc_err_history[i]))
1018
- q1 = q1_ids[i]
1019
- if q1 < len(self.entanglement_entropy):
1020
- self.entanglement_entropy[q1] = float(entropy_history[i])
1021
- if jsd_val > self.jsd_budget:
1022
- if self.budget_violations == 0:
1023
- warnings.warn(
1024
- f"MPSSimulator: bond dimension capped at max_bond={self.chi}, "
1025
- f"jsd_budget={self.jsd_budget:.1e} not honored "
1026
- f"(jsd={jsd_val:.2e}) -- results may be unreliable, "
1027
- f"consider raising max_bond.",
1028
- UserWarning,
1029
- stacklevel=2,
1030
- )
1031
- self.budget_violations += 1
1384
+ self._record_diag_bookkeeping(diag, q1_ids, is_2q_mask)
1032
1385
 
1033
1386
 
1034
1387
  _PAULI_MATRICES = {
@@ -1119,3 +1472,90 @@ def mps_pauli_sum_expectation(mps: "MPSSimulator", terms) -> complex:
1119
1472
  for coeff, pauli_terms in terms
1120
1473
  )
1121
1474
  return raw_sum / norm_sq
1475
+
1476
+
1477
+ @dataclasses.dataclass
1478
+ class BondConvergenceResult:
1479
+ bonds: List[int]
1480
+ chi_used: List[int]
1481
+ avg_jsd: List[float]
1482
+ budget_violations: List[int]
1483
+ values: List[List[complex]]
1484
+ diffs: List[List[float]]
1485
+ verdicts: List[str]
1486
+
1487
+
1488
+ def bond_convergence(
1489
+ ops: List, n_qubits: int, observables: list, bonds: List[int],
1490
+ tol: float = 1e-3, **mps_kwargs,
1491
+ ) -> BondConvergenceResult:
1492
+ """Runs the same circuit at every value in `bonds` (increasing) and
1493
+ checks whether the reported observables have actually converged with
1494
+ respect to bond dimension, instead of trusting a single run's own
1495
+ internal diagnostics.
1496
+
1497
+ Requires len(bonds) >= 3. Two bonds give exactly one discrepancy,
1498
+ which is a single number with no way to tell whether it is still
1499
+ shrinking toward `tol` or has already stalled -- measured on a
1500
+ 40-qubit, 4-layer brickwall circuit, chi=4->8->32 gave |<Z0>|
1501
+ discrepancies of ~4.7e-2 then ~1.2e-2 (chi_used never hit its own
1502
+ cap, so this is a real not_converged, not an artifact of running out
1503
+ of bond dimension): a two-bond check (chi=4 vs 8) would see only the
1504
+ first number and have no basis to call it anything, while three
1505
+ bonds show a trend that is decreasing but still two orders of
1506
+ magnitude above any reasonable `tol`.
1507
+
1508
+ A verdict of "converged" additionally requires the successive
1509
+ discrepancies to be monotonically non-increasing, not just that the
1510
+ last one is below `tol` -- a single small discrepancy proves nothing
1511
+ about the trend on its own, which is the same failure mode as the
1512
+ two-bond case above, one level up. (Ties count as non-increasing: an
1513
+ exactly-converged observable, e.g. a GHZ chain whose bond dimension
1514
+ never needs to grow, produces identical values -- and therefore
1515
+ zero discrepancies -- at every bond, which must count as converged.)
1516
+
1517
+ avg_jsd and budget_violations (from the underlying MPSSimulator runs)
1518
+ are reported per bond for context only, never used to decide the
1519
+ verdict -- a low average JSD is computed per truncation step and says
1520
+ nothing about whether the specific observable being tracked has
1521
+ settled down as `max_bond` grows.
1522
+
1523
+ If max_bond_used() at the highest bond still equals that bond's cap,
1524
+ the truncation never had headroom below max_bond at any cut, so no
1525
+ tolerance can be certified from this data: every observable's verdict
1526
+ becomes "undecidable" regardless of its own discrepancies.
1527
+ """
1528
+ if len(bonds) < 3:
1529
+ raise ValueError(f"bond_convergence needs at least 3 bonds to detect a trend, got {len(bonds)}")
1530
+
1531
+ chi_used, avg_jsd, budget_violations = [], [], []
1532
+ values = [[] for _ in observables]
1533
+ for bond in bonds:
1534
+ mps = MPSSimulator(n_qubits=n_qubits, max_bond=bond, **mps_kwargs)
1535
+ mps.run_circuit_jit(ops)
1536
+ chi_used.append(mps.max_bond_used())
1537
+ avg_jsd.append(mps.avg_jsd())
1538
+ budget_violations.append(mps.budget_violations)
1539
+ for obs_idx, obs in enumerate(observables):
1540
+ values[obs_idx].append(mps_pauli_expectation(mps, obs))
1541
+
1542
+ diffs = [
1543
+ [abs(vals[i + 1] - vals[i]) for i in range(len(vals) - 1)]
1544
+ for vals in values
1545
+ ]
1546
+
1547
+ undecidable = chi_used[-1] >= bonds[-1]
1548
+ verdicts = []
1549
+ for d in diffs:
1550
+ if undecidable:
1551
+ verdicts.append("undecidable")
1552
+ elif all(d[i + 1] <= d[i] for i in range(len(d) - 1)) and d[-1] < tol:
1553
+ verdicts.append("converged")
1554
+ else:
1555
+ verdicts.append("not_converged")
1556
+
1557
+ return BondConvergenceResult(
1558
+ bonds=list(bonds), chi_used=chi_used, avg_jsd=avg_jsd,
1559
+ budget_violations=budget_violations, values=values, diffs=diffs,
1560
+ verdicts=verdicts,
1561
+ )
@@ -9,13 +9,23 @@ instead of reimplementing them.
9
9
  """
10
10
 
11
11
  import numpy as np
12
- import pennylane as qml
13
- import pennylane.qchem.observable_hf as _pl_observable
14
12
 
15
13
  from dense_evolution.native_hf.basis import build_molecule_shells
16
14
  from dense_evolution.native_hf.assembly import build_overlap_matrix, build_core_hamiltonian, build_repulsion_tensor
17
15
  from dense_evolution.native_hf.scf import run_scf, HFResult
18
16
 
17
+ # pennylane is installed in this test suite's own environment, so the
18
+ # except branch below (and the `if qml is None` check further down) is
19
+ # only exercised by tests/unit/test_imports.py, which blocks pennylane
20
+ # in a separate subprocess -- invisible to same-process coverage, so
21
+ # excluded rather than chased with an artificial same-process test.
22
+ try:
23
+ import pennylane as qml
24
+ import pennylane.qchem.observable_hf as _pl_observable
25
+ except ModuleNotFoundError: # pragma: no cover
26
+ qml = None
27
+ _pl_observable = None
28
+
19
29
  _BOHR_PER_ANGSTROM = 1.0 / 0.52917721067
20
30
 
21
31
 
@@ -64,6 +74,12 @@ def build_qubit_hamiltonian(
64
74
  latter useful for e.g. reporting the SCF energy alongside the
65
75
  post-mapping ground-state energy.
66
76
  """
77
+ if qml is None: # pragma: no cover -- see the try/except above
78
+ raise ModuleNotFoundError(
79
+ "build_qubit_hamiltonian requires pennylane. "
80
+ "Install it with: pip install dense-evolution[pennylane]"
81
+ )
82
+
67
83
  geometry_bohr = np.asarray(geometry_angstrom) * _BOHR_PER_ANGSTROM
68
84
  shells = build_molecule_shells(atomic_numbers, geometry_bohr, basis_name)
69
85
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dense-evolution
3
- Version: 8.1.76
3
+ Version: 8.1.78
4
4
  Summary: High-performance quantum simulation toolkit -- Statevector/MPS engines with JIT compilation, noise modeling, VQE, QEC, quantum chemistry, and agent-native tooling
5
5
  Author-email: Salvatore Pennacchio <jtatopenn@libero.it>
6
6
  License: Business Source License 1.1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "dense-evolution"
7
- version = "8.1.76"
7
+ version = "8.1.78"
8
8
  description = "High-performance quantum simulation toolkit -- Statevector/MPS engines with JIT compilation, noise modeling, VQE, QEC, quantum chemistry, and agent-native tooling"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"