pdft 0.2.1__tar.gz → 0.2.2__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 (123) hide show
  1. {pdft-0.2.1 → pdft-0.2.2}/PKG-INFO +1 -1
  2. {pdft-0.2.1 → pdft-0.2.2}/pyproject.toml +1 -1
  3. {pdft-0.2.1 → pdft-0.2.2}/src/pdft/__init__.py +1 -1
  4. {pdft-0.2.1 → pdft-0.2.2}/src/pdft/circuit/builder.py +140 -5
  5. {pdft-0.2.1 → pdft-0.2.2}/src/pdft/loss.py +7 -1
  6. {pdft-0.2.1 → pdft-0.2.2}/src/pdft/optimizers/core.py +15 -2
  7. {pdft-0.2.1 → pdft-0.2.2}/src/pdft/optimizers/loop.py +2 -1
  8. {pdft-0.2.1 → pdft-0.2.2}/src/pdft/training/adam_step.py +30 -3
  9. {pdft-0.2.1 → pdft-0.2.2}/src/pdft/training/batched.py +61 -0
  10. pdft-0.2.2/tests/circuit/test_h_inverse_leg_swap.py +142 -0
  11. pdft-0.2.2/tests/circuit/test_high_qubit_circuit.py +62 -0
  12. pdft-0.2.2/tests/circuit/test_legacy_builder.py +146 -0
  13. pdft-0.2.2/tests/test_loss.py +93 -0
  14. {pdft-0.2.1 → pdft-0.2.2}/tests/training/test_batched.py +233 -0
  15. pdft-0.2.1/tests/test_loss.py +0 -45
  16. {pdft-0.2.1 → pdft-0.2.2}/.claude/CLAUDE.md +0 -0
  17. {pdft-0.2.1 → pdft-0.2.2}/.github/workflows/ci.yml +0 -0
  18. {pdft-0.2.1 → pdft-0.2.2}/.github/workflows/publish.yml +0 -0
  19. {pdft-0.2.1 → pdft-0.2.2}/.github/workflows/verify-upstream-pin.yml +0 -0
  20. {pdft-0.2.1 → pdft-0.2.2}/.gitignore +0 -0
  21. {pdft-0.2.1 → pdft-0.2.2}/.pre-commit-config.yaml +0 -0
  22. {pdft-0.2.1 → pdft-0.2.2}/LICENSE +0 -0
  23. {pdft-0.2.1 → pdft-0.2.2}/Makefile +0 -0
  24. {pdft-0.2.1 → pdft-0.2.2}/README.md +0 -0
  25. {pdft-0.2.1 → pdft-0.2.2}/examples/basis_demo.py +0 -0
  26. {pdft-0.2.1 → pdft-0.2.2}/examples/mera_demo.py +0 -0
  27. {pdft-0.2.1 → pdft-0.2.2}/examples/optimizer_benchmark.py +0 -0
  28. {pdft-0.2.1 → pdft-0.2.2}/reference/README.md +0 -0
  29. {pdft-0.2.1 → pdft-0.2.2}/reference/goldens/adam_trajectory_4x4.npz +0 -0
  30. {pdft-0.2.1 → pdft-0.2.2}/reference/goldens/basis_roundtrip.npz +0 -0
  31. {pdft-0.2.1 → pdft-0.2.2}/reference/goldens/basis_roundtrip_hash.txt +0 -0
  32. {pdft-0.2.1 → pdft-0.2.2}/reference/goldens/compression_roundtrip_4x4.npz +0 -0
  33. {pdft-0.2.1 → pdft-0.2.2}/reference/goldens/entangled_qft_4x4.npz +0 -0
  34. {pdft-0.2.1 → pdft-0.2.2}/reference/goldens/ft_mat_roundtrip.npz +0 -0
  35. {pdft-0.2.1 → pdft-0.2.2}/reference/goldens/loss_values.npz +0 -0
  36. {pdft-0.2.1 → pdft-0.2.2}/reference/goldens/manifest.json +0 -0
  37. {pdft-0.2.1 → pdft-0.2.2}/reference/goldens/manifold_project_retract.npz +0 -0
  38. {pdft-0.2.1 → pdft-0.2.2}/reference/goldens/mera_4x4.npz +0 -0
  39. {pdft-0.2.1 → pdft-0.2.2}/reference/goldens/phase_extraction_3x3.npz +0 -0
  40. {pdft-0.2.1 → pdft-0.2.2}/reference/goldens/qft_basis_trained.json +0 -0
  41. {pdft-0.2.1 → pdft-0.2.2}/reference/goldens/qft_code_16x16.npz +0 -0
  42. {pdft-0.2.1 → pdft-0.2.2}/reference/goldens/qft_code_4x4.npz +0 -0
  43. {pdft-0.2.1 → pdft-0.2.2}/reference/goldens/tebd_4x4.npz +0 -0
  44. {pdft-0.2.1 → pdft-0.2.2}/reference/goldens/train_trajectory_4x4.npz +0 -0
  45. {pdft-0.2.1 → pdft-0.2.2}/reference/goldens/train_trajectory_4x4_long.npz +0 -0
  46. {pdft-0.2.1 → pdft-0.2.2}/reference/julia/Manifest.toml +0 -0
  47. {pdft-0.2.1 → pdft-0.2.2}/reference/julia/Project.toml +0 -0
  48. {pdft-0.2.1 → pdft-0.2.2}/reference/julia/generate_goldens.jl +0 -0
  49. {pdft-0.2.1 → pdft-0.2.2}/src/pdft/bases/__init__.py +0 -0
  50. {pdft-0.2.1 → pdft-0.2.2}/src/pdft/bases/base.py +0 -0
  51. {pdft-0.2.1 → pdft-0.2.2}/src/pdft/bases/block/__init__.py +0 -0
  52. {pdft-0.2.1 → pdft-0.2.2}/src/pdft/bases/block/block.py +0 -0
  53. {pdft-0.2.1 → pdft-0.2.2}/src/pdft/bases/block/real_rich.py +0 -0
  54. {pdft-0.2.1 → pdft-0.2.2}/src/pdft/bases/block/rich.py +0 -0
  55. {pdft-0.2.1 → pdft-0.2.2}/src/pdft/bases/circuit/__init__.py +0 -0
  56. {pdft-0.2.1 → pdft-0.2.2}/src/pdft/bases/circuit/entangled_qft.py +0 -0
  57. {pdft-0.2.1 → pdft-0.2.2}/src/pdft/bases/circuit/mera.py +0 -0
  58. {pdft-0.2.1 → pdft-0.2.2}/src/pdft/bases/circuit/qft.py +0 -0
  59. {pdft-0.2.1 → pdft-0.2.2}/src/pdft/bases/circuit/tebd.py +0 -0
  60. {pdft-0.2.1 → pdft-0.2.2}/src/pdft/circuit/__init__.py +0 -0
  61. {pdft-0.2.1 → pdft-0.2.2}/src/pdft/circuit/cache.py +0 -0
  62. {pdft-0.2.1 → pdft-0.2.2}/src/pdft/io/__init__.py +0 -0
  63. {pdft-0.2.1 → pdft-0.2.2}/src/pdft/io/compression.py +0 -0
  64. {pdft-0.2.1 → pdft-0.2.2}/src/pdft/io/serialize.py +0 -0
  65. {pdft-0.2.1 → pdft-0.2.2}/src/pdft/manifolds.py +0 -0
  66. {pdft-0.2.1 → pdft-0.2.2}/src/pdft/optimizers/__init__.py +0 -0
  67. {pdft-0.2.1 → pdft-0.2.2}/src/pdft/optimizers/adam.py +0 -0
  68. {pdft-0.2.1 → pdft-0.2.2}/src/pdft/optimizers/gd.py +0 -0
  69. {pdft-0.2.1 → pdft-0.2.2}/src/pdft/profiling.py +0 -0
  70. {pdft-0.2.1 → pdft-0.2.2}/src/pdft/training/__init__.py +0 -0
  71. {pdft-0.2.1 → pdft-0.2.2}/src/pdft/training/eval_loop.py +0 -0
  72. {pdft-0.2.1 → pdft-0.2.2}/src/pdft/training/result.py +0 -0
  73. {pdft-0.2.1 → pdft-0.2.2}/src/pdft/training/schedules.py +0 -0
  74. {pdft-0.2.1 → pdft-0.2.2}/src/pdft/training/single.py +0 -0
  75. {pdft-0.2.1 → pdft-0.2.2}/src/pdft/viz/__init__.py +0 -0
  76. {pdft-0.2.1 → pdft-0.2.2}/src/pdft/viz/circuit.py +0 -0
  77. {pdft-0.2.1 → pdft-0.2.2}/src/pdft/viz/loss.py +0 -0
  78. {pdft-0.2.1 → pdft-0.2.2}/tests/__init__.py +0 -0
  79. {pdft-0.2.1 → pdft-0.2.2}/tests/bases/__init__.py +0 -0
  80. {pdft-0.2.1 → pdft-0.2.2}/tests/bases/block/__init__.py +0 -0
  81. {pdft-0.2.1 → pdft-0.2.2}/tests/bases/block/test_block.py +0 -0
  82. {pdft-0.2.1 → pdft-0.2.2}/tests/bases/block/test_real_rich.py +0 -0
  83. {pdft-0.2.1 → pdft-0.2.2}/tests/bases/block/test_rich.py +0 -0
  84. {pdft-0.2.1 → pdft-0.2.2}/tests/bases/circuit/__init__.py +0 -0
  85. {pdft-0.2.1 → pdft-0.2.2}/tests/bases/circuit/test_qft.py +0 -0
  86. {pdft-0.2.1 → pdft-0.2.2}/tests/bases/conftest.py +0 -0
  87. {pdft-0.2.1 → pdft-0.2.2}/tests/bases/test_base.py +0 -0
  88. {pdft-0.2.1 → pdft-0.2.2}/tests/bases/test_new_bases.py +0 -0
  89. {pdft-0.2.1 → pdft-0.2.2}/tests/bases/test_phase_extraction.py +0 -0
  90. {pdft-0.2.1 → pdft-0.2.2}/tests/circuit/__init__.py +0 -0
  91. {pdft-0.2.1 → pdft-0.2.2}/tests/circuit/test_cache.py +0 -0
  92. {pdft-0.2.1 → pdft-0.2.2}/tests/conftest.py +0 -0
  93. {pdft-0.2.1 → pdft-0.2.2}/tests/io/__init__.py +0 -0
  94. {pdft-0.2.1 → pdft-0.2.2}/tests/io/conftest.py +0 -0
  95. {pdft-0.2.1 → pdft-0.2.2}/tests/io/test_compression.py +0 -0
  96. {pdft-0.2.1 → pdft-0.2.2}/tests/io/test_serialize.py +0 -0
  97. {pdft-0.2.1 → pdft-0.2.2}/tests/optimizers/__init__.py +0 -0
  98. {pdft-0.2.1 → pdft-0.2.2}/tests/optimizers/conftest.py +0 -0
  99. {pdft-0.2.1 → pdft-0.2.2}/tests/optimizers/test_adam.py +0 -0
  100. {pdft-0.2.1 → pdft-0.2.2}/tests/optimizers/test_gd.py +0 -0
  101. {pdft-0.2.1 → pdft-0.2.2}/tests/parity/__init__.py +0 -0
  102. {pdft-0.2.1 → pdft-0.2.2}/tests/parity/conftest.py +0 -0
  103. {pdft-0.2.1 → pdft-0.2.2}/tests/parity/test_adam.py +0 -0
  104. {pdft-0.2.1 → pdft-0.2.2}/tests/parity/test_compression.py +0 -0
  105. {pdft-0.2.1 → pdft-0.2.2}/tests/parity/test_long_run.py +0 -0
  106. {pdft-0.2.1 → pdft-0.2.2}/tests/parity/test_loss.py +0 -0
  107. {pdft-0.2.1 → pdft-0.2.2}/tests/parity/test_manifolds.py +0 -0
  108. {pdft-0.2.1 → pdft-0.2.2}/tests/parity/test_new_bases.py +0 -0
  109. {pdft-0.2.1 → pdft-0.2.2}/tests/parity/test_qft.py +0 -0
  110. {pdft-0.2.1 → pdft-0.2.2}/tests/parity/test_scale.py +0 -0
  111. {pdft-0.2.1 → pdft-0.2.2}/tests/parity/test_serialize.py +0 -0
  112. {pdft-0.2.1 → pdft-0.2.2}/tests/parity/test_training.py +0 -0
  113. {pdft-0.2.1 → pdft-0.2.2}/tests/test_basis_seeded_init.py +0 -0
  114. {pdft-0.2.1 → pdft-0.2.2}/tests/test_manifolds.py +0 -0
  115. {pdft-0.2.1 → pdft-0.2.2}/tests/test_profiling.py +0 -0
  116. {pdft-0.2.1 → pdft-0.2.2}/tests/test_smoke.py +0 -0
  117. {pdft-0.2.1 → pdft-0.2.2}/tests/training/__init__.py +0 -0
  118. {pdft-0.2.1 → pdft-0.2.2}/tests/training/conftest.py +0 -0
  119. {pdft-0.2.1 → pdft-0.2.2}/tests/training/test_eval_loop.py +0 -0
  120. {pdft-0.2.1 → pdft-0.2.2}/tests/training/test_integration.py +0 -0
  121. {pdft-0.2.1 → pdft-0.2.2}/tests/training/test_single.py +0 -0
  122. {pdft-0.2.1 → pdft-0.2.2}/tests/viz/__init__.py +0 -0
  123. {pdft-0.2.1 → pdft-0.2.2}/tests/viz/test_loss.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pdft
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Python port of ParametricDFT.jl: learning parametric quantum Fourier transforms via manifold optimization.
5
5
  Project-URL: Homepage, https://github.com/zazabap/pdft
6
6
  Project-URL: Issues, https://github.com/zazabap/pdft/issues
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pdft"
7
- version = "0.2.1"
7
+ version = "0.2.2"
8
8
  description = "Python port of ParametricDFT.jl: learning parametric quantum Fourier transforms via manifold optimization."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -41,7 +41,7 @@ if not _os.environ.get("PDFT_DISABLE_COMPILE_CACHE"):
41
41
  except OSError:
42
42
  pass
43
43
 
44
- __version__ = "0.2.1"
44
+ __version__ = "0.2.2"
45
45
  __upstream_ref__ = "nzy1997/ParametricDFT.jl@a201a27e47df2f0f3ab460f83d49b6e5f5d1e9ef"
46
46
 
47
47
  # Slim public re-export hub: most-used names only. Anything else: import
@@ -167,6 +167,92 @@ def build_circuit_einsum(
167
167
  return subscripts, tensor_list, tensor_shapes
168
168
 
169
169
 
170
+ def _axis_of_qubit(q: int, m: int, n: int) -> int:
171
+ """Map a 1-indexed qubit number to its position in the (2,)*N reshape of pic.
172
+
173
+ Yao's little-endian convention (matching `build_circuit_einsum`'s
174
+ `pic_labels` ordering): qubit `m` maps to axis 0, qubit `m-1` to axis 1,
175
+ …, qubit 1 to axis m-1, qubit `m+n` to axis m, …, qubit `m+1` to axis
176
+ m+n-1.
177
+ """
178
+ if 1 <= q <= m:
179
+ return m - q
180
+ if m + 1 <= q <= m + n:
181
+ return m + (m + n - q)
182
+ raise ValueError(f"qubit index {q} out of range (1..{m + n})")
183
+
184
+
185
+ def _stepped_apply(
186
+ program: list[tuple[str, tuple[int, ...]]],
187
+ tensor_index: list[int],
188
+ operands: tuple,
189
+ *,
190
+ m: int,
191
+ n: int,
192
+ inverse: bool,
193
+ ) -> Array:
194
+ """Apply gate program to pic via per-gate `jnp.tensordot` calls.
195
+
196
+ operands = (*tensors, pic) where pic has shape (2,) * (m+n) and tensors
197
+ is the operand list passed to the closure (sorted Hadamard-first by
198
+ `compile_circuit`, matching the convention of the legacy big-einsum
199
+ path so saved checkpoints load with the same axis order).
200
+
201
+ `tensor_index[i]` maps program step i (in temporal order) to its index
202
+ in the operand `tensors` tuple.
203
+ """
204
+ *tensors, pic = operands
205
+ for i, (kind, qubits) in enumerate(program):
206
+ T = tensors[tensor_index[i]]
207
+ if kind == "H":
208
+ (q,) = qubits
209
+ ax = _axis_of_qubit(q, m, n)
210
+ # Forward: H[out, in] · pic[..., ax=in, ...] -> pic[..., ax=out, ...]
211
+ # contract on T's axis 1 (the "in" leg).
212
+ # Inverse: apply the transpose of T. Combined with the caller's
213
+ # conj(T) (loss._scalar_loss conjugates tensors before passing
214
+ # them to the inverse closure), this realises the true adjoint
215
+ # T† = conj(T.T). For symmetric H this collapses to conj(T),
216
+ # but for trained / non-symmetric H tensors the leg-swap is
217
+ # essential — without it the round-trip T† T x ≠ x. The U4
218
+ # handler below uses the same forward/inverse axis convention.
219
+ contract_in = 0 if inverse else 1
220
+ pic = jnp.tensordot(T, pic, axes=[[contract_in], [ax]])
221
+ pic = jnp.moveaxis(pic, 0, ax)
222
+ elif kind == "CP":
223
+ q_ctrl, q_tgt = qubits
224
+ ax_c = _axis_of_qubit(q_ctrl, m, n)
225
+ ax_t = _axis_of_qubit(q_tgt, m, n)
226
+ # T[c, t] is the diagonal of CP. pic *= T broadcast onto (ax_c, ax_t).
227
+ broadcast_shape = [1] * pic.ndim
228
+ broadcast_shape[ax_c] = 2
229
+ broadcast_shape[ax_t] = 2
230
+ # T's axis 0 should land at ax_c, axis 1 at ax_t. If ax_c < ax_t,
231
+ # the natural reshape works; if ax_c > ax_t, we transpose first
232
+ # so the smaller dimension index sees T's first axis.
233
+ T_oriented = T if ax_c < ax_t else T.T
234
+ pic = pic * T_oriented.reshape(broadcast_shape)
235
+ elif kind == "U4":
236
+ q_ctrl, q_tgt = qubits
237
+ ax_c = _axis_of_qubit(q_ctrl, m, n)
238
+ ax_t = _axis_of_qubit(q_tgt, m, n)
239
+ # Forward: T[oc, ot, ic, it] · pic[..., ax_c=ic, ax_t=it, ...]
240
+ # -> pic[..., ax_c=oc, ax_t=ot, ...]
241
+ # Inverse (transpose, not adjoint): T[oc, ot, ic, it]
242
+ # · pic[..., ax_c=oc, ax_t=ot, ...]
243
+ # -> pic[..., ax_c=ic, ax_t=it, ...]
244
+ # The conjugate flip for the true unitary inverse is applied by
245
+ # the caller (loss._scalar_loss conjugates tensors before
246
+ # calling the inverse closure), matching the legacy
247
+ # build_circuit_einsum behaviour.
248
+ contract_axes = [[0, 1], [ax_c, ax_t]] if inverse else [[2, 3], [ax_c, ax_t]]
249
+ pic = jnp.tensordot(T, pic, axes=contract_axes)
250
+ pic = jnp.moveaxis(pic, [0, 1], [ax_c, ax_t])
251
+ else:
252
+ raise AssertionError(f"unknown gate kind: {kind}")
253
+ return pic
254
+
255
+
170
256
  def compile_circuit(
171
257
  gates: list[Gate],
172
258
  m: int,
@@ -174,12 +260,61 @@ def compile_circuit(
174
260
  *,
175
261
  inverse: bool,
176
262
  ) -> tuple[Callable[..., Array], list[Array]]:
177
- """High-level entry: build subscripts and jit-compile the einsum."""
178
- from .cache import optimize_code_cached
263
+ """Build a JIT-compiled stepped circuit closure.
264
+
265
+ Returns ``(code, tensors)`` where ``code(*tensors, pic_reshaped)``
266
+ applies the gates one at a time via :func:`jnp.tensordot`, recycling
267
+ contraction labels across steps so the 52-character a-zA-Z label pool
268
+ of the legacy single-einsum path is never reached. Each step consumes
269
+ at most ~22 wire labels regardless of circuit size.
270
+
271
+ The returned ``tensors`` list preserves the Hadamard-first sort applied
272
+ by the legacy builder, so saved trained checkpoints (which serialize
273
+ tensors in this order) continue to load consistently.
274
+ """
275
+ n_gates = len(gates)
276
+ program: list[tuple[str, tuple[int, ...]]] = [
277
+ (g["kind"], g["qubits"]) for g in gates
278
+ ]
279
+ tensor_list: list[Array] = [g["tensor"] for g in gates]
280
+
281
+ # Hadamard-first sort — preserves the legacy convention for the
282
+ # returned tensor list. `perm[i]` is the original (temporal) gate index
283
+ # of the i-th sorted slot, so `sorted_tensors[i] = original[perm[i]]`.
284
+ import numpy as _np
285
+
286
+ H_np = _np.asarray(HADAMARD)
287
+
288
+ def _is_hadamard(t):
289
+ a = _np.asarray(t)
290
+ return a.shape == (2, 2) and _np.allclose(a, H_np, atol=1e-12)
291
+
292
+ is_not_hadamard = [not _is_hadamard(t) for t in tensor_list]
293
+ perm = sorted(range(n_gates), key=lambda i: is_not_hadamard[i])
294
+ sorted_tensors = [tensor_list[i] for i in perm]
295
+
296
+ # Map temporal gate index → position in the sorted operand tuple.
297
+ # `temporal_to_sorted[j] = i` such that perm[i] = j.
298
+ temporal_to_sorted = [0] * n_gates
299
+ for sorted_pos, original_idx in enumerate(perm):
300
+ temporal_to_sorted[original_idx] = sorted_pos
301
+
302
+ if inverse:
303
+ # Apply gates in reverse temporal order. tensor_index aligns with
304
+ # the reversed program: program_step[i] needs the tensor for the
305
+ # ORIGINAL gate at index (n_gates-1-i).
306
+ program = list(reversed(program))
307
+ tensor_index = [temporal_to_sorted[n_gates - 1 - i] for i in range(n_gates)]
308
+ else:
309
+ tensor_index = [temporal_to_sorted[i] for i in range(n_gates)]
310
+
311
+ @jax.jit
312
+ def code(*operands):
313
+ return _stepped_apply(
314
+ program, tensor_index, operands, m=m, n=n, inverse=inverse,
315
+ )
179
316
 
180
- subscripts, tensors, shapes = build_circuit_einsum(gates, m, n, inverse=inverse)
181
- code = optimize_code_cached(subscripts, *shapes)
182
- return code, tensors
317
+ return code, sorted_tensors
183
318
 
184
319
 
185
320
  def apply_circuit(
@@ -120,7 +120,13 @@ def _scalar_loss(
120
120
  truncated = topk_truncate(pred, loss.k)
121
121
  conj_tensors = [jnp.conj(t) for t in tensors] # type: ignore[arg-type]
122
122
  reconstructed = _apply_circuit(conj_tensors, inverse_code, m, n, truncated) # type: ignore[arg-type]
123
- return jnp.sum(jnp.abs(target - reconstructed) ** 2)
123
+ base = jnp.sum(jnp.abs(target - reconstructed) ** 2)
124
+ # Optional hook for MSELoss subclasses that add regularizers or
125
+ # auxiliary scalar terms while preserving vanilla MSELoss behavior.
126
+ extra_fn = getattr(loss, "_extra_loss", None)
127
+ if extra_fn is not None:
128
+ return base + extra_fn(tensors)
129
+ return base
124
130
  raise TypeError(f"unsupported loss type: {type(loss).__name__}")
125
131
 
126
132
 
@@ -48,13 +48,26 @@ def _common_setup(tensors: list[Array]) -> _OptimizationState:
48
48
  )
49
49
 
50
50
 
51
- def _batched_project(state: _OptimizationState, euclid_grads: list[Array]):
51
+ def _batched_project(
52
+ state: _OptimizationState,
53
+ euclid_grads: list[Array],
54
+ frozen_indices: frozenset[int] | None = None,
55
+ ):
52
56
  """Mirror of upstream src/optimizers.jl:129-149."""
53
57
  rg_batches: dict[AbstractRiemannianManifold, Array] = {}
54
58
  grad_norm_sq = 0.0
55
59
  for manifold, indices in state.manifold_groups.items():
56
60
  pb = state.point_batches[manifold]
57
- gb = stack_tensors(euclid_grads, indices)
61
+ if frozen_indices:
62
+ gb = jnp.stack(
63
+ [
64
+ jnp.zeros_like(euclid_grads[i]) if i in frozen_indices else euclid_grads[i]
65
+ for i in indices
66
+ ],
67
+ axis=-1,
68
+ )
69
+ else:
70
+ gb = stack_tensors(euclid_grads, indices)
58
71
  rg = manifold.project(pb, gb)
59
72
  rg_batches[manifold] = rg
60
73
  grad_norm_sq = grad_norm_sq + float(jnp.real(jnp.sum(jnp.conj(rg) * rg)))
@@ -25,6 +25,7 @@ def optimize(
25
25
  max_iter: int = 100,
26
26
  tol: float = 1e-6,
27
27
  record_loss: bool = False,
28
+ frozen_indices: frozenset[int] | None = None,
28
29
  ) -> tuple[list[Array], list[float]]:
29
30
  """Mirror of upstream src/optimizers.jl:335-412.
30
31
 
@@ -61,7 +62,7 @@ def optimize(
61
62
  warnings.warn("Non-finite gradient — optimizer stopping.", stacklevel=2)
62
63
  return state.current_tensors, trace
63
64
 
64
- rg_batches, grad_norm = _batched_project(state, raw_grads)
65
+ rg_batches, grad_norm = _batched_project(state, raw_grads, frozen_indices=frozen_indices)
65
66
  grad_norm_sq = float(grad_norm) ** 2
66
67
 
67
68
  if opt.max_grad_norm is not None and float(grad_norm) > opt.max_grad_norm:
@@ -25,6 +25,7 @@ def _build_jit_adam_step(
25
25
  beta2: float,
26
26
  eps: float,
27
27
  max_grad_norm: float | None,
28
+ frozen_set: frozenset[int] | None = None,
28
29
  ):
29
30
  """Build a single JIT'd Adam step for `train_basis_batched`'s fast path.
30
31
 
@@ -71,6 +72,17 @@ def _build_jit_adam_step(
71
72
  else:
72
73
  ibs.append(None)
73
74
 
75
+ # Pre-compute per-manifold-group, per-slot frozen masks (static booleans,
76
+ # closed over in the JIT'd step_fn — no recompiles because they're Python).
77
+ # slot_frozen_masks[k] is a tuple of bools, one per slot in manifold group k.
78
+ if frozen_set:
79
+ slot_frozen_masks = [
80
+ tuple(idx in frozen_set for idx in idxs)
81
+ for idxs in indices_list
82
+ ]
83
+ else:
84
+ slot_frozen_masks = [None] * len(indices_list)
85
+
74
86
  @jax.jit
75
87
  def step_fn(tensors_list, m_list, v_list, batch, lr, iter_1based):
76
88
  # Forward + backward; loss comes "for free" alongside grads.
@@ -82,9 +94,19 @@ def _build_jit_adam_step(
82
94
  # Per-manifold project (fused into the JIT'd graph).
83
95
  pb_list = []
84
96
  rg_list = []
85
- for manifold, idxs in zip(manifold_list, indices_list):
97
+ for k, (manifold, idxs) in enumerate(zip(manifold_list, indices_list)):
86
98
  pb = jnp.stack([tensors_list[i] for i in idxs], axis=-1)
87
- gb = jnp.stack([grads[i] for i in idxs], axis=-1)
99
+ slot_mask = slot_frozen_masks[k]
100
+ if slot_mask is not None and any(slot_mask):
101
+ # Zero gradient for frozen slots before projection so Adam's
102
+ # moment buffers stay zero and the tensors don't move.
103
+ gb = jnp.stack(
104
+ [jnp.zeros_like(grads[i]) if slot_mask[k2] else grads[i]
105
+ for k2, i in enumerate(idxs)],
106
+ axis=-1,
107
+ )
108
+ else:
109
+ gb = jnp.stack([grads[i] for i in idxs], axis=-1)
88
110
  rg = manifold.project(pb, gb)
89
111
  pb_list.append(pb)
90
112
  rg_list.append(rg)
@@ -120,11 +142,16 @@ def _build_jit_adam_step(
120
142
 
121
143
  new_m_list.append(new_m)
122
144
  new_v_list.append(new_v)
145
+ slot_mask = slot_frozen_masks[k]
123
146
  for k2, idx in enumerate(idxs):
124
147
  # Use ellipsis so this works for any tensor storage shape:
125
148
  # (2, 2, n) for 2x2 unitaries, (2, 2, 2, 2, n) for 2-qubit
126
149
  # (Unitary2qManifold) gates, etc.
127
- new_tensors[idx] = new_pb[..., k2]
150
+ if slot_mask is not None and slot_mask[k2]:
151
+ # Frozen: restore the original tensor, bit-exactly.
152
+ new_tensors[idx] = tensors_list[idx]
153
+ else:
154
+ new_tensors[idx] = new_pb[..., k2]
128
155
 
129
156
  return new_tensors, new_m_list, new_v_list, loss_val
130
157
 
@@ -13,6 +13,7 @@ The eval+early-stopping bookkeeping is shared via training.eval_loop.
13
13
  from __future__ import annotations
14
14
 
15
15
  import math
16
+ import operator
16
17
  import time
17
18
  from collections.abc import Sequence
18
19
 
@@ -91,6 +92,47 @@ def _validate_batched_args(
91
92
  raise ValueError(f"warmup_frac must be in [0, 1), got {warmup_frac}")
92
93
 
93
94
 
95
+ def _validate_frozen_indices(frozen_indices: "list[int] | None", n_tensors: int) -> frozenset:
96
+ """Validate and normalise ``frozen_indices``.
97
+
98
+ Returns a ``frozenset[int]`` of validated frozen indices (empty set means
99
+ no freezing). Raises ``ValueError`` on any violation.
100
+ """
101
+ if frozen_indices is None or len(frozen_indices) == 0:
102
+ return frozenset()
103
+ seen: set[int] = set()
104
+ for raw_i in frozen_indices:
105
+ if isinstance(raw_i, bool):
106
+ raise ValueError(
107
+ f"frozen_indices contains non-integer index {raw_i!r}; "
108
+ "all indices must be integers."
109
+ )
110
+ try:
111
+ i = operator.index(raw_i)
112
+ except TypeError as exc:
113
+ raise ValueError(
114
+ f"frozen_indices contains non-integer index {raw_i!r}; "
115
+ "all indices must be integers."
116
+ ) from exc
117
+ if i < 0:
118
+ raise ValueError(
119
+ f"frozen_indices contains negative index {i}; "
120
+ f"all indices must be in [0, {n_tensors - 1}]."
121
+ )
122
+ if i >= n_tensors:
123
+ raise ValueError(
124
+ f"frozen_indices contains out-of-range index {i}; "
125
+ f"basis has {n_tensors} tensors (valid range 0..{n_tensors - 1})."
126
+ )
127
+ if i in seen:
128
+ raise ValueError(
129
+ f"frozen_indices contains duplicate index {i}; "
130
+ "each index must appear at most once."
131
+ )
132
+ seen.add(i)
133
+ return frozenset(seen)
134
+
135
+
94
136
  def train_basis_batched(
95
137
  basis,
96
138
  *,
@@ -108,12 +150,29 @@ def train_basis_batched(
108
150
  shuffle: bool = True,
109
151
  seed: int = 0,
110
152
  val_every_k_epochs: int = 1,
153
+ frozen_indices: "list[int] | None" = None,
111
154
  ) -> TrainingResult:
112
155
  """Multi-image, multi-epoch trainer with cosine LR schedule.
113
156
 
114
157
  Mirror of `ParametricDFT.jl/src/training.jl::_train_basis_core` (main).
115
158
  See parameter docs in the original training.py docstring.
159
+
160
+ Parameters
161
+ ----------
162
+ frozen_indices : list[int] | None, optional
163
+ List of integer indices into ``basis.tensors``. Tensors at these
164
+ indices are NOT updated during training — they stay at their initial
165
+ values throughout. Each step computes gradients on all tensors
166
+ normally; the update is then suppressed for frozen indices BEFORE any
167
+ optimizer state is mutated (so Adam's moment buffers for frozen indices
168
+ remain zero). Useful for experiments that train only a subset of a
169
+ circuit's gates.
170
+
171
+ Validation: all indices must satisfy ``0 <= i < len(basis.tensors)``,
172
+ no duplicates are allowed, and an empty list is treated as ``None``
173
+ (no-op). ``ValueError`` is raised on mis-specification.
116
174
  """
175
+ frozen_set = _validate_frozen_indices(frozen_indices, len(list(basis.tensors)))
117
176
  _validate_batched_args(
118
177
  dataset, epochs, batch_size, validation_split, early_stopping_patience, warmup_frac
119
178
  )
@@ -188,6 +247,7 @@ def train_basis_batched(
188
247
  beta2=beta2,
189
248
  eps=eps,
190
249
  max_grad_norm=mgn_eff,
250
+ frozen_set=frozen_set if frozen_set else None,
191
251
  )
192
252
 
193
253
  # Initialise Adam moment buffers ONCE — they persist across all steps,
@@ -302,6 +362,7 @@ def train_basis_batched(
302
362
  max_iter=1,
303
363
  tol=0.0,
304
364
  record_loss=True,
365
+ frozen_indices=frozen_set if frozen_set else None,
305
366
  )
306
367
  loss_history.append(step_trace[-1] if len(step_trace) >= 2 else step_trace[0])
307
368
  global_step += 1
@@ -0,0 +1,142 @@
1
+ """Regression test for the H-gate inverse leg-swap bug.
2
+
3
+ The stepped-tensordot rewrite of `compile_circuit` (commit
4
+ `feat(circuit): stepped tensordot for circuits beyond the 52-label pool`)
5
+ forgot to swap the contracted leg of the Hadamard tensor when
6
+ ``inverse=True``. Combined with `loss._scalar_loss`'s convention of
7
+ passing ``conj(tensors)`` to the inverse closure, this means the
8
+ inverse path applies ``conj(H)`` instead of the proper adjoint
9
+ ``conj(H.T) = H†``.
10
+
11
+ For the default symmetric Hadamard (``[[1, 1], [1, -1]] / sqrt 2``),
12
+ ``conj(H) = H = H†`` so the bug is invisible. For a *trained* (and
13
+ therefore non-symmetric) Hadamard the bug breaks the round-trip:
14
+ ``T† T x ≠ x``. The U4 gate handler in the same function does the
15
+ leg-swap correctly already; this test pins the equivalent contract for
16
+ the H gate.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import jax.numpy as jnp
22
+ import numpy as np
23
+ import pytest
24
+
25
+ import pdft
26
+
27
+
28
+ def _non_symmetric_unitary(seed: int = 0) -> jnp.ndarray:
29
+ """A 2x2 unitary that is NOT real-symmetric (so conj ≠ adjoint).
30
+
31
+ Built as exp(i * theta * X) for a Hermitian X with non-zero off-diagonal
32
+ imag parts. The result is unitary by construction and demonstrably
33
+ non-Hermitian and non-symmetric.
34
+ """
35
+ rng = np.random.default_rng(seed)
36
+ # Random Hermitian generator with complex off-diagonal
37
+ g = rng.normal(size=(2, 2)) + 1j * rng.normal(size=(2, 2))
38
+ H = (g + g.conj().T) / 2 # Hermitian
39
+ # exp(i * H) is unitary; for a non-trivial H it is non-symmetric in general
40
+ eigvals, eigvecs = np.linalg.eigh(H)
41
+ U = eigvecs @ np.diag(np.exp(1j * eigvals)) @ eigvecs.conj().T
42
+ return jnp.asarray(U, dtype=jnp.complex128)
43
+
44
+
45
+ def test_qft_inverse_round_trip_with_non_symmetric_h():
46
+ """Round-trip ``T† T x = x`` must hold to machine precision when one or
47
+ more Hadamard tensors are non-symmetric unitaries (the situation that
48
+ arises after Riemannian-Adam training perturbs the gates off the
49
+ initial real-symmetric H).
50
+ """
51
+ m, n = 3, 3
52
+ basis = pdft.QFTBasis(m=m, n=n)
53
+
54
+ # Replace the FIRST Hadamard tensor with a non-symmetric unitary.
55
+ # QFTBasis sorts tensors Hadamards-first, so tensors[0..m+n-1] are H slots.
56
+ perturbed = list(basis.tensors)
57
+ U_nonsym = _non_symmetric_unitary(seed=42)
58
+ perturbed[0] = U_nonsym
59
+
60
+ # Sanity: the replacement is unitary but not symmetric.
61
+ assert np.allclose(
62
+ np.asarray(U_nonsym) @ np.asarray(U_nonsym).conj().T, np.eye(2), atol=1e-12
63
+ ), "test setup error: replacement matrix must be unitary"
64
+ assert not np.allclose(
65
+ np.asarray(U_nonsym), np.asarray(U_nonsym).T, atol=1e-6
66
+ ), "test setup error: replacement matrix must be non-symmetric"
67
+
68
+ perturbed_basis = pdft.QFTBasis(m=m, n=n, tensors=perturbed)
69
+
70
+ rng = np.random.default_rng(0)
71
+ x = jnp.asarray(
72
+ rng.standard_normal((2**m, 2**n)) + 1j * rng.standard_normal((2**m, 2**n)),
73
+ dtype=jnp.complex128,
74
+ )
75
+
76
+ # Forward must preserve norm (operator is unitary).
77
+ Tx = perturbed_basis.forward_transform(x)
78
+ np.testing.assert_allclose(
79
+ float(jnp.sum(jnp.abs(Tx) ** 2)),
80
+ float(jnp.sum(jnp.abs(x) ** 2)),
81
+ rtol=1e-10,
82
+ err_msg="forward operator is not norm-preserving",
83
+ )
84
+
85
+ # Round-trip must recover x to machine precision.
86
+ rt = perturbed_basis.inverse_transform(Tx)
87
+ np.testing.assert_allclose(
88
+ np.asarray(rt),
89
+ np.asarray(x),
90
+ atol=1e-10,
91
+ err_msg="round-trip T† T x != x — H-gate inverse leg-swap bug",
92
+ )
93
+
94
+
95
+ def test_blocked_inverse_round_trip_with_non_symmetric_h():
96
+ """Same regression for BlockedBasis(QFTBasis, ...) — this is the path
97
+ actually hit by the trained_blocked_8 cell whose val loss the bug
98
+ inflated by ~12x.
99
+ """
100
+ inner = pdft.QFTBasis(m=3, n=3)
101
+ perturbed = list(inner.tensors)
102
+ perturbed[0] = _non_symmetric_unitary(seed=7)
103
+ perturbed_inner = pdft.QFTBasis(m=3, n=3, tensors=perturbed)
104
+
105
+ blocked = pdft.BlockedBasis(inner=perturbed_inner, block_log_m=2, block_log_n=2)
106
+
107
+ rng = np.random.default_rng(1)
108
+ side = 2 ** blocked.m
109
+ x = jnp.asarray(
110
+ rng.standard_normal((side, side)) + 1j * rng.standard_normal((side, side)),
111
+ dtype=jnp.complex128,
112
+ )
113
+
114
+ Tx = blocked.forward_transform(x)
115
+ np.testing.assert_allclose(
116
+ float(jnp.sum(jnp.abs(Tx) ** 2)),
117
+ float(jnp.sum(jnp.abs(x) ** 2)),
118
+ rtol=1e-10,
119
+ )
120
+
121
+ rt = blocked.inverse_transform(Tx)
122
+ np.testing.assert_allclose(
123
+ np.asarray(rt), np.asarray(x), atol=1e-10,
124
+ err_msg="BlockedBasis round-trip broken — H-gate inverse leg-swap bug",
125
+ )
126
+
127
+
128
+ @pytest.mark.parametrize("seed", [0, 1, 2, 3, 4])
129
+ def test_qft_inverse_round_trip_with_non_symmetric_h_random_seeds(seed: int):
130
+ """Robustness: hold across multiple random non-symmetric H replacements."""
131
+ basis = pdft.QFTBasis(m=2, n=2)
132
+ perturbed = list(basis.tensors)
133
+ perturbed[0] = _non_symmetric_unitary(seed=seed)
134
+ perturbed_basis = pdft.QFTBasis(m=2, n=2, tensors=perturbed)
135
+
136
+ rng = np.random.default_rng(seed)
137
+ x = jnp.asarray(
138
+ rng.standard_normal((4, 4)) + 1j * rng.standard_normal((4, 4)),
139
+ dtype=jnp.complex128,
140
+ )
141
+ rt = perturbed_basis.inverse_transform(perturbed_basis.forward_transform(x))
142
+ np.testing.assert_allclose(np.asarray(rt), np.asarray(x), atol=1e-10)
@@ -0,0 +1,62 @@
1
+ """Regression tests for circuits whose flat-einsum representation would
2
+ exceed the 52-character a-zA-Z label pool.
3
+
4
+ The pre-fix `build_circuit_einsum` allocates a fresh label per Hadamard
5
+ output and two fresh labels per U4 output. RealRichBasis at inner_m=5,
6
+ inner_n=5 emits 2(m + m²) = 60 fresh labels — over the limit and raising
7
+ ValueError at construction. The stepped-contraction implementation
8
+ applies one gate per `jnp.tensordot`, recycling labels across steps so
9
+ the limit never bites.
10
+
11
+ These tests pin both:
12
+ - construction succeeds at inner_m=5
13
+ - the forward+inverse roundtrip returns the input image to machine
14
+ precision (a 32×32 image at m=n=5)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import jax.numpy as jnp
20
+ import numpy as np
21
+ import pytest
22
+
23
+ from pdft import RealRichBasis, RichBasis
24
+
25
+
26
+ @pytest.mark.parametrize("BasisCls", [RichBasis, RealRichBasis])
27
+ def test_basis_constructs_at_m5(BasisCls):
28
+ """Construction must succeed at m=n=5 (10 qubits per block, 60 fresh labels)."""
29
+ basis = BasisCls(m=5, n=5)
30
+ assert basis.m == 5
31
+ assert basis.n == 5
32
+
33
+
34
+ @pytest.mark.parametrize("BasisCls", [RichBasis, RealRichBasis])
35
+ def test_basis_forward_inverse_roundtrip_at_m5(BasisCls):
36
+ """Forward then inverse on the initial (untrained) basis must reproduce the input.
37
+
38
+ At step 0 the basis is initialised to a known unitary (QFT-like for RichBasis,
39
+ Walsh-Hadamard ⊗ identity for RealRichBasis), so U U^{-1} = I exactly.
40
+ """
41
+ rng = np.random.default_rng(0)
42
+ pic = jnp.asarray(rng.normal(size=(32, 32)).astype(np.float64))
43
+
44
+ basis = BasisCls(m=5, n=5)
45
+ transformed = basis.forward_transform(pic)
46
+ recovered = basis.inverse_transform(transformed)
47
+
48
+ np.testing.assert_allclose(np.real(recovered), np.asarray(pic), atol=1e-9)
49
+
50
+
51
+ @pytest.mark.parametrize("BasisCls", [RichBasis, RealRichBasis])
52
+ def test_basis_forward_preserves_norm_at_m5(BasisCls):
53
+ """Unitary forward circuits must preserve the Frobenius norm of the input."""
54
+ rng = np.random.default_rng(1)
55
+ pic = jnp.asarray(rng.normal(size=(32, 32)).astype(np.float64))
56
+
57
+ basis = BasisCls(m=5, n=5)
58
+ transformed = basis.forward_transform(pic)
59
+
60
+ norm_in = float(jnp.linalg.norm(pic))
61
+ norm_out = float(jnp.linalg.norm(transformed))
62
+ np.testing.assert_allclose(norm_out, norm_in, rtol=1e-10)