Skip to content

feat: qudit noise model (2/5) (#1854)#1859

Draft
bramathon wants to merge 4 commits into
masterfrom
1854-qudit-noise-model
Draft

feat: qudit noise model (2/5) (#1854)#1859
bramathon wants to merge 4 commits into
masterfrom
1854-qudit-noise-model

Conversation

@bramathon

@bramathon bramathon commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

Part of the effort to land the experimental noise model and simulation module (originally PR #1848, branch nonbreaking-noise-model) as a reviewable stack. This is PR 2 of 5, stacked on #1858 — review/merge in order. Closes #1854.

This PR introduces the new quax-backed qudit noise model under pyquil/noise/:

  • Channel, MeasurementChannel, ResetChannel, and CycleChannel (_channels.py), and the new NoiseModel container plus DepolarizingNoiseModel / CompositeNoiseModel and fidelity estimators (_noise_model.py).
  • The historical pyquil.noise public API is preserved: pyquil/noise.py is moved to pyquil/noise/_legacy_noise.py and re-exported from pyquil/noise/__init__.py, with the legacy NoiseModel/KrausModel now marked deprecated in favor of the quax-based model.

It also adds qudit DefGate support in quilbase.py (matrix dimensions may now be any perfect power, e.g. 3, 9, …, not just powers of two), which the qutrit noise channels and later simulators rely on, and enables float64 (jax_enable_x64) for the test suite.

A handful of ResetChannel tests verify behavior end-to-end through the density-matrix simulator; since that simulator lands in the next PR, those specific assertions are deferred to #1855 (they are restored there). Everything else in the noise module is fully tested here (test_noise_model.py).

Stack

  1. Drop Python 3.9 #1852 — Drop Python 3.9
  2. Qudit Noise model #1854 — Qudit noise model (this PR)
  3. Experimental exact simulators #1855 — Experimental exact simulators
  4. Trajectory simulator #1856 — Trajectory simulator
  5. Dynamic shape trajectory simulator #1857 — Dynamic-shape trajectory simulator

Tracked in #1863.

@github-actions

github-actions Bot commented Jul 4, 2026

Copy link
Copy Markdown

🐰 Bencher Report

Projectpyquil
Branch1854-qudit-noise-model
Testbedci-runner-linux

⚠️ WARNING: No Threshold found!

Without a Threshold, no Alerts will ever be generated.

Click here to create a new Threshold
For more information, see the Threshold documentation.
To only post results if a Threshold exists, set the --ci-only-thresholds flag.

Click to view all benchmark results
BenchmarkLatencyseconds (s)
test/benchmarks/test_program.py::test_copy_everything_except_instructions📈 view plot
⚠️ NO THRESHOLD
9.66 s
test/benchmarks/test_program.py::test_instructions📈 view plot
⚠️ NO THRESHOLD
2.54 s
test/benchmarks/test_program.py::test_iteration📈 view plot
⚠️ NO THRESHOLD
2.55 s
🐰 View full continuous benchmarking report in Bencher

@asaites asaites self-assigned this Jul 13, 2026
@bramathon
bramathon force-pushed the 1852-drop-python-39 branch from f1884bc to 414f175 Compare July 15, 2026 18:08
Base automatically changed from 1852-drop-python-39 to master July 16, 2026 19:06

@asaites asaites left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've made several comments, which I hope will be useful. I might spend a little more time looking through _noise_model.py, but for the most part, I think I've gone through this pretty thoroughly (and am doing my best to keep up with the physics).

Most of my comments are more style than anything, but I have a few concerns. Overall, though, this looks pretty solid!

Comment thread test/unit/test_noise.py
Comment thread pyquil/quilbase.py Outdated
Comment on lines +770 to +776
if rows < 2:
return 0
for base in range(2, rows + 1):
k = int(round(math.log(rows, base)))
if base**k == rows:
return k
return 1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might not matter much for the expected values of rows/reasonable values of len(self.matrix), but it is very slow for large numbers. As a much faster alternative, you can calculate it by iteratively checking if $\sqrt[p]{r} \in \mathbb{Z}$ for $p \in \text{PRIME}$, stopping when $\sqrt[p]{r} &lt; 2$. If so, it's a factor of k, and you can reduce the problem to finding the solution for that new base, then multiply its factors by the one you've found.

Concretely:

def _int_n_root(x: int, n: int) -> int | None:
    """Return the largest integer less than or equal to n-th root of x."""
    # This helper quickly checks if `x` is a perfect `n-th` root,
    # and will do so without the numerical instability
    # of the floating point check.
    # There are faster algorithms for very large numbers,
    # but this is already O(log(x)), and simple to implement.

    low, high = 1, x
    while low <= high:
        mid = (low + high) // 2
        if (mid_pow_n := mid ** n) == x:
            return mid
        elif mid_pow_n < x:
            low = mid + 1
        else:
            high = mid - 1
    return low - 1

SMALL_PRIMES = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61]

def find_k(r: int) -> tuple[int, int]:
    """For positive integer `r`, return integers `(b, k)` such that `r = b**k`
    and `k` is the largest integer for any integer `b >= 1` that satisfies this relation.

    If `r` is not a perfect power, this function returns `(r, 1)`.
    """
    if r < 2:
        return r, 1

    # Check if `r` is a perfect p-th power of a small prime `p`.
    # When it is, reduce `r` to that p-th root and build up the exponent `k`.

    base, k = r, 1
    primes = iter(SMALL_PRIMES)
    p = next(primes, None)
    while p:
        if (b := _int_n_root(base, p)) < 2:
            return base, k
        elif b ** p != base:
            p = next(primes, None)
        else:
            k *= p
            base = b

    raise ValueError("r is too large to efficiently find k")

This approach is $O(\log^{2}{n})$ versus $O(n^2)$.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On further thought, this entire concept has a huge problem - it doesn't support quarts. Besides qubits and qutrits this the most likely qudit size. I think I'll need to add a option to specify the qudit dims

Comment thread pyquil/noise/_legacy_noise.py Outdated
Comment thread pyquil/noise/_channels.py Outdated
Comment thread pyquil/noise/_channels.py Outdated
Comment thread pyquil/noise/_channels.py
Comment thread pyquil/noise/_channels.py Outdated
Comment thread pyquil/noise/_channels.py Outdated
Comment thread pyquil/noise/_channels.py Outdated
Comment thread pyquil/noise/_noise_model.py Outdated
bramathon and others added 2 commits July 17, 2026 08:46
Introduce the new noise model under pyquil/noise/ (Channel, MeasurementChannel,
ResetChannel, CycleChannel and the quax-based NoiseModel), preserving the legacy
pyquil.noise API via _legacy_noise.py. Add qudit DefGate support in quilbase and
enable float64 for tests. Part of splitting PR #1848 into a reviewable stack.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@bramathon
bramathon force-pushed the 1854-qudit-noise-model branch from a2e40e8 to 65aa919 Compare July 17, 2026 09:52
@github-actions

Copy link
Copy Markdown

☂️ Python Coverage

current status: ❌

Overall Coverage

Lines Covered Coverage Threshold Status
8174 7101 87% 87% 🔴

New Files

File Coverage Status
pyquil/noise/init.py 100% 🟢
pyquil/noise/_channels.py 78% 🔴
pyquil/noise/_noise_model.py 76% 🔴
TOTAL 85% 🔴

Modified Files

File Coverage Status
pyquil/quilbase.py 94% 🟢
TOTAL 94% 🟢

updated for commit: 65aa919 by action🐍

Comment thread pyquil/quilbase.py Outdated
For a matrix of dimension d^k, returns k where d is the smallest
integer base >= 2 such that rows = d^k.
"""
decomposition = _integer_base_and_exponent(len(self.matrix))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't work if the exponent is composite.

Comment thread pyquil/quilbase.py Outdated
"""
decomposition = _integer_base_and_exponent(len(self.matrix))
if decomposition is None:
return 0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this be 1?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this should only happen if n < 2 which means 0 qubits. But on further thought, this should probably just be an error.

Comment thread pyquil/quilbase.py Outdated
return None
# Find the smallest prime factor; it is the qudit dimension (base).
base = n
for p in range(2, int(math.isqrt(n)) + 1):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The whole point of isqrt is that it returns an integer, so there's no need to wrap it in int.

This method requires less work than the previous approach, but still does significantly more work than the one I proposed. Again, I'm not sure how large realistic values of n are, so maybe this doesn't matter much, my previous suggestion was based on that context, and this doesn't much address that concern.

Comment thread pyquil/noise/_channels.py Outdated
num_qubits = int(round(np.log2(hilbert_dim)))
hilbert_dim = round(float(np.sqrt(shape[0]))) if superoperator else int(shape[0])
num_qubits = round(float(np.log2(hilbert_dim)))
if 2**num_qubits != hilbert_dim:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why have you added float conversions?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thought it was a type error but maybe not.

@bramathon

Copy link
Copy Markdown
Collaborator Author

I've done a substantial refactor to add support for Lindbladian channels. The way this works is as follows:

ChannelProtocol defines the protocol for a channel which provides a superoperator associated with a gate. There are two concrete classes for this: Channel and SuperopChannel. The Channel has the _LindbladianBacked mixin, which means that it not only has a superoperator, but a lindbladian which generates it. This is now our canonical Channel while SuperopChannel is relegated to special use cases where we don't know the Lindbladian.

ResetChannel receives the same treatment, becoming _LindbladianBacked and with two concrete implementations.

CycleChannel and MeasureChannel are unchanged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Qudit Noise model

2 participants