Skip to content

Commit 8419760

Browse files
JerryChen97Copilot
authored andcommitted
Fix Pow/Pow2 eigvals branch cut on Python 3.14 (#10139)
> [!NOTE] > Drafted with AI assistance (GitHub Copilot CLI), reviewed before opening. **Context:** `Pow.eigvals()` / `Pow2.eigvals()` return the complex conjugate of the correct eigenvalues for non-integer `z`, on Python 3.14 only. Regression from #9802, which changed the cast idiom to `((1 + 0j) * value) ** self.z`. Python 3.14 implements C99 mixed-mode real/complex arithmetic ([python/cpython#69639](python/cpython#69639)), so `complex * float` is now componentwise instead of promoting the float to `complex(x, +0.0)`: | | `(1+0j) * -1.0` | `** 0.5` | |---|---|---| | 3.13 | `(-1+0j)` | `+1j` | | 3.14 | `(-1-0j)` | `-1j` | That `-0.0` puts `-1` on the wrong side of the branch cut of `**`. It is specific to multiplication — `-1.0 + 0j` and `complex(-1.0)` are unaffected. All six `core-tests (*, 3.14)` shards fail on `main` (9 failures, scattered by `pytest-split`): https://github.com/PennyLaneAI/pennylane/actions/runs/34502136095 **Description of the Change:** Restore the cast-based form in `pow.py` and `pow2.py`: ```python return math.cast(self.base.eigvals(), "complex128") ** self.z ``` Both tests now also assert `eigvals() == np.diag(qml.matrix(op))`, so branch-cut flips are caught on any Python version rather than only against a hardcoded literal. **Benefits:** `qml.eigvals` was silently returning wrong values for fractional powers of operators with negative eigenvalues, affecting anything that pairs eigvals with diagonalizing gates. The NaN fix from #9802 (#9632) still holds. **Possible Drawbacks:** `eigvals()` now returns a tensor rather than a `list`. This matches the documented `Returns: tensor_like` contract, and nothing depends on the list form. **Related GitHub Issues:** Regression from #9802. --- Verified on 3.14 and 3.13: 182 passed across `test_pow_op.py` + `test_pow2.py` (9 failed on 3.14 before). Wider `tests/ops/op_math/` + `test_eigvals.py` run on 3.14 shows no new failures. Follow-up: eight other live uses of the `(1 + 0j) *` promotion idiom (`channel.py`, `qchem_ops.py`, `parametric_ops_single_qubit.py`, `controlled_ops.py`) are benign today, but 3.14 changes signed-zero behaviour there too. [sc-130383] --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent e159bd3 commit 8419760

5 files changed

Lines changed: 11 additions & 2 deletions

File tree

‎doc/releases/changelog-dev.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1470,6 +1470,7 @@
14701470

14711471
* Fix `qp.eigvals` returns `NaN` for a legal fractional power operator.
14721472
[(#9802)](https://github.com/PennyLaneAI/pennylane/pull/9802)
1473+
[(#10139)](https://github.com/PennyLaneAI/pennylane/pull/10139)
14731474

14741475
* Fixed the decomposition rule of :class:`~.QROM` so that it can be captured and compiled with
14751476
Catalyst. Tracing the ``clean`` branch previously raised a ``TracerIntegerConversionError``

‎pennylane/ops/op_math/pow.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,9 @@ def diagonalizing_gates(self):
337337

338338
def eigvals(self):
339339
base_eigvals = self.base.eigvals()
340-
return [((1 + 0j) * value) ** self.z for value in base_eigvals]
340+
is_single_precision = math.get_dtype_name(base_eigvals) in ("float32", "complex64")
341+
complex_dtype = "complex64" if is_single_precision else "complex128"
342+
return math.cast(base_eigvals, complex_dtype) ** self.z
341343

342344
# pylint: disable=arguments-renamed, invalid-overridden-method
343345
@property

‎pennylane/ops/op_math/pow2.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,9 @@ def compute_diagonalizing_gates(base, z): # pylint: disable=unused-argument
243243
@override
244244
def eigvals(self):
245245
base_eigvals = self.base.eigvals()
246-
return [((1 + 0j) * value) ** self.z for value in base_eigvals]
246+
is_single_precision = math.get_dtype_name(base_eigvals) in ("float32", "complex64")
247+
complex_dtype = "complex64" if is_single_precision else "complex128"
248+
return math.cast(base_eigvals, complex_dtype) ** self.z
247249

248250
# pylint: disable=arguments-renamed, invalid-overridden-method
249251
@property

‎tests/ops/op_math/test_pow2.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,8 @@ def test_eigvals_fractional_power_negative_eigenvalue_2(z, power_method):
356356
expected_eigvals = np.array([1.0**z, (-1.0 + 0j) ** z])
357357

358358
assert np.allclose(eigvals, expected_eigvals)
359+
# the eigenvalues must sit on the same branch of ``**`` as the matrix
360+
assert np.allclose(eigvals, np.diag(qp.matrix(op)))
359361

360362

361363
# pylint: disable-next=too-few-public-methods

‎tests/ops/op_math/test_pow_op.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,8 @@ def test_eigvals_fractional_power_negative_eigenvalue(self, z, power_method):
446446
expected_eigvals = np.array([1.0**z, (-1.0 + 0j) ** z])
447447

448448
assert np.allclose(eigvals, expected_eigvals)
449+
# the eigenvalues must sit on the same branch of ``**`` as the matrix
450+
assert np.allclose(eigvals, np.diag(qp.matrix(op)))
449451

450452

451453
class TestSimplify:

0 commit comments

Comments
 (0)