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.
- {pdft-0.2.1 → pdft-0.2.2}/PKG-INFO +1 -1
- {pdft-0.2.1 → pdft-0.2.2}/pyproject.toml +1 -1
- {pdft-0.2.1 → pdft-0.2.2}/src/pdft/__init__.py +1 -1
- {pdft-0.2.1 → pdft-0.2.2}/src/pdft/circuit/builder.py +140 -5
- {pdft-0.2.1 → pdft-0.2.2}/src/pdft/loss.py +7 -1
- {pdft-0.2.1 → pdft-0.2.2}/src/pdft/optimizers/core.py +15 -2
- {pdft-0.2.1 → pdft-0.2.2}/src/pdft/optimizers/loop.py +2 -1
- {pdft-0.2.1 → pdft-0.2.2}/src/pdft/training/adam_step.py +30 -3
- {pdft-0.2.1 → pdft-0.2.2}/src/pdft/training/batched.py +61 -0
- pdft-0.2.2/tests/circuit/test_h_inverse_leg_swap.py +142 -0
- pdft-0.2.2/tests/circuit/test_high_qubit_circuit.py +62 -0
- pdft-0.2.2/tests/circuit/test_legacy_builder.py +146 -0
- pdft-0.2.2/tests/test_loss.py +93 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/training/test_batched.py +233 -0
- pdft-0.2.1/tests/test_loss.py +0 -45
- {pdft-0.2.1 → pdft-0.2.2}/.claude/CLAUDE.md +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/.github/workflows/ci.yml +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/.github/workflows/publish.yml +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/.github/workflows/verify-upstream-pin.yml +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/.gitignore +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/.pre-commit-config.yaml +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/LICENSE +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/Makefile +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/README.md +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/examples/basis_demo.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/examples/mera_demo.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/examples/optimizer_benchmark.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/reference/README.md +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/reference/goldens/adam_trajectory_4x4.npz +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/reference/goldens/basis_roundtrip.npz +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/reference/goldens/basis_roundtrip_hash.txt +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/reference/goldens/compression_roundtrip_4x4.npz +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/reference/goldens/entangled_qft_4x4.npz +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/reference/goldens/ft_mat_roundtrip.npz +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/reference/goldens/loss_values.npz +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/reference/goldens/manifest.json +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/reference/goldens/manifold_project_retract.npz +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/reference/goldens/mera_4x4.npz +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/reference/goldens/phase_extraction_3x3.npz +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/reference/goldens/qft_basis_trained.json +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/reference/goldens/qft_code_16x16.npz +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/reference/goldens/qft_code_4x4.npz +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/reference/goldens/tebd_4x4.npz +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/reference/goldens/train_trajectory_4x4.npz +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/reference/goldens/train_trajectory_4x4_long.npz +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/reference/julia/Manifest.toml +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/reference/julia/Project.toml +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/reference/julia/generate_goldens.jl +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/src/pdft/bases/__init__.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/src/pdft/bases/base.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/src/pdft/bases/block/__init__.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/src/pdft/bases/block/block.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/src/pdft/bases/block/real_rich.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/src/pdft/bases/block/rich.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/src/pdft/bases/circuit/__init__.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/src/pdft/bases/circuit/entangled_qft.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/src/pdft/bases/circuit/mera.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/src/pdft/bases/circuit/qft.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/src/pdft/bases/circuit/tebd.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/src/pdft/circuit/__init__.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/src/pdft/circuit/cache.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/src/pdft/io/__init__.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/src/pdft/io/compression.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/src/pdft/io/serialize.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/src/pdft/manifolds.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/src/pdft/optimizers/__init__.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/src/pdft/optimizers/adam.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/src/pdft/optimizers/gd.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/src/pdft/profiling.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/src/pdft/training/__init__.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/src/pdft/training/eval_loop.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/src/pdft/training/result.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/src/pdft/training/schedules.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/src/pdft/training/single.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/src/pdft/viz/__init__.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/src/pdft/viz/circuit.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/src/pdft/viz/loss.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/__init__.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/bases/__init__.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/bases/block/__init__.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/bases/block/test_block.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/bases/block/test_real_rich.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/bases/block/test_rich.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/bases/circuit/__init__.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/bases/circuit/test_qft.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/bases/conftest.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/bases/test_base.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/bases/test_new_bases.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/bases/test_phase_extraction.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/circuit/__init__.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/circuit/test_cache.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/conftest.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/io/__init__.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/io/conftest.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/io/test_compression.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/io/test_serialize.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/optimizers/__init__.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/optimizers/conftest.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/optimizers/test_adam.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/optimizers/test_gd.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/parity/__init__.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/parity/conftest.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/parity/test_adam.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/parity/test_compression.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/parity/test_long_run.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/parity/test_loss.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/parity/test_manifolds.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/parity/test_new_bases.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/parity/test_qft.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/parity/test_scale.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/parity/test_serialize.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/parity/test_training.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/test_basis_seeded_init.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/test_manifolds.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/test_profiling.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/test_smoke.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/training/__init__.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/training/conftest.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/training/test_eval_loop.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/training/test_integration.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/training/test_single.py +0 -0
- {pdft-0.2.1 → pdft-0.2.2}/tests/viz/__init__.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
-
"""
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|