The Rejection Rate of ML-DSA Signing: Correcting FIPS-204

Cryptography
Author

Kris Kwiatkowski

Published

October 2, 2026

Introduction

FIPS-204 [1] Table 1 gives the expected number of iterations of the ML-DSA signing loop, and Appendix C uses those figures to derive a loop bound for implementations that choose to impose one. NIST’s own errata [2] already corrects this. The discrepancy is small, but it is not noise and not a rounding artefact. The corrected numbers are recorded in the errata, but not how they were obtained. This work supplies that derivation in full. In summary, the figures in Table 1 of [1] account for one of the two rejection points in the signing loop. The missing one is a consequence of public-key compression, not of security. That is plausibly why a number computed for the security analysis was reused as a performance number. Correcting it changes the expected iteration counts by 1-2%.

The change does not affect the security of ML-DSA. The rejection sampling still does what the security proof needs it to do, only the published cost of it is incorrect.

This post is the companion analysis referenced from the IETF draft Adapting Constrained Devices for Post-Quantum Cryptography [3], and supplies the numbers used in Benchmarking ML-DSA Signature Generation [4].

NoteCredit

The discrepancy was first reported on pqc-forum by Hanno Becker, with analysis from Markku-Juhani Saarinen [5], and had been observed earlier in measurements by Mike Hamburg and colleagues in March 2024 [6]. Yan, Luo and Wang [7] independently published the \(P_{ct_0}\) result in closed form and analysed the hint-rejection term under an unconditioned model; they did not carry either correction through to the repetition counts. This post arrives at the same \(P_{ct_0}\) values, gives a conditioned hint-rejection model that corrects their unconditioned one by 13-32%, and completes the accounting into the Table 1 figures and the Appendix C loop bound.

Where the Signing Loop Rejects

ML-DSA is built on Fiat-Shamir with Aborts. A signing attempt produces a candidate signature, checks it against several bounds, and discards the attempt if any bound is violated. Algorithm 7 of [1] has this shape, with irrelevant lines elided:

ML-DSA.Sign_internal(\(sk\), \(M'\), \(rnd\))

Note the structure: lines 25-28 sit inside the else branch, reached only by attempts that already survived line 23. Section 6 uses this to condition the hint-rejection probability on line 23 having passed.

There are four rejection conditions in total, and they fall into two quite different categories:

Line Condition Character
23 \(\lVert z \rVert_\infty \ge \gamma_1 - \beta\) security-mandatory
23 \(\lVert r_0 \rVert_\infty \ge \gamma_2 - \beta\) security-mandatory
28 \(\#\{i : h_i = 1\} > \omega\) encoding (compression)
28 \(\lVert c \cdot t_0 \rVert_\infty \ge \gamma_2\) hint correctness (compression)
Table 1: The four rejection conditions of ML-DSA.Sign_internal.

The two on line 23 are required for the scheme to be secure. Without them the distribution of \(z\) would depend on the private key \(s_1\), and an attacker watching signatures could recover it. The rejection is exactly what makes the output distribution independent of the secret - it is the “Aborts” in Fiat-Shamir with Aborts.

The two on line 28 have nothing to do with security. ML-DSA publishes only the high part of \(t\), dropping \(d = 13\) low bits into \(t_0\), and the signer must transmit hints so the verifier can reconstruct what was dropped. If an attempt needs more hints than the signature format reserves room for, the signature is perfectly secure but cannot be encoded, so it gets discarded. The fourth condition similarly protects the correctness of the hint mechanism.

The Dilithium [8] specification scopes this precisely: it describes its Equation (5) as “the probability that Step 21 passes” - the line-23 checks only - and bounds the hint-weight rejection separately and only loosely, “between 1 and 2%”, with no formula given. FIPS-204 reproduces the resulting number without that qualifier, stating that Table 1 gives “the expected repetitions in the rejection sampling loop” as a whole.

Correcting the Acceptance Probability

Write \(p\) for the probability that a single attempt is accepted. Because the checks happen in two groups, with the second evaluated only if the first passed:

\[ \begin{aligned} p &= P(\text{line 23 passes}) \cdot P(\text{line 28 passes} \mid \text{line 23 passed}) \\ &= P_{norm} \cdot (1 - P_{ct_{0}}) \cdot (1 - P_{hint}) \end{aligned} \]

where \(P_{norm}\) is the probability of surviving both line-23 checks, \(P_{ct0}\) the probability that \(\lVert c \cdot t_0 \rVert_\infty\) reaches \(\gamma_2\), and \(P_{hint}\) the probability of producing more than \(\omega\) hints. The three terms turn out to need three completely different techniques: exact counting, a closed-form distribution, and simulation.

The Security Checks

Line 23 rejects unless every coefficient of \(z\) and every coefficient of \(r0\) is inside its bound. Both are handled by the same counting argument.

The z check

The condition is \(\lVert z \rVert_\infty \lt \gamma_1 - \beta\) , where \(z = y + c \cdot s1\). Take one coefficient \(z_i = y_i + (c \cdot s_1)_i\). The only randomness is \(y_i\), which \(ExpandMask\) (Algorithm 34, [1]) draws uniformly from \(2 \cdot γ1\) possible values. The permitted range in which \(z_i\) may land in, is an open interval \((-(\gamma_1 - \beta), \gamma_1 - \beta)\), hence there are \(2(\gamma_1 - \beta) - 1 \quad\) admissible values.

This count does not depend on the value of \((c \cdot s_1)_i\). The per-coefficient probability is therefore the same whatever the secret key happens to be:

\[ P(\text{coefficient of } z \text{ passes}) = \frac{2(\gamma_1 - \beta) - 1}{2\gamma_1} \]

This factor is exact. It is a counting argument over a distribution that is uniform by construction, with no approximation and no assumption.

The \(r_0\) check

The condition is \(\lVert r_0 \rVert_\infty \lt \gamma_2 - \beta\), with \(r_0 = LowBits(w - c \cdot s_2)\) taking one of \(2 \cdot \gamma_2\) possible values. The identical count gives

\[ P(\text{coefficient of } r_0 \text{ passes}) = \frac{2(\gamma_2 - \beta) - 1}{2\gamma_2} \]

This factor is not exact. It assumes the low bits of \(w - c \cdot s_2\) are uniformly distributed over their residues. They are not sampled - they are a deterministic function of \(A\), \(y\), \(c\) and \(s_2\) - so this is a modeling assumption (a heuristic), not a derived fact. It is resting on the expectation that modular reduction of a pseudorandom quantity scrambles the low-order bits.

Putting them together

An attempt passes line 23 only if every coefficient passes. One violation anywhere kills the attempt. There are \(256 \cdot l\) coefficients in \(z\) and \(256 \cdot k\) in \(r_0\), treated as independent, so:

\[ P_{norm} = \left[\frac{2(\gamma_1-\beta)-1}{2\gamma_1}\right]^{256 \ell} \cdot \left[\frac{2(\gamma_2-\beta)-1}{2\gamma_2}\right]^{256 k} \]

Show the computation
import pandas as pd
from IPython.display import Markdown

"""ML-DSA parameter sets, as given in Table 1 of FIPS 204."""

from dataclasses import dataclass


@dataclass
class MLDSAParams:
    name: str
    n: int       # ring degree, always 256
    q: int       # modulus
    tau: int     # number of +-1's in the challenge poly c
    gamma1: int  # y coefficient range
    gamma2: int  # low-bits rounding range
    k: int       # rows of A (dimension of w, s2, t)
    l: int       # columns of A (dimension of s1, z)
    eta: int     # secret key coefficient range
    d: int       # dropped bits of t
    omega: int   # maximum number of hints

    @property
    def beta(self) -> int:
        return self.tau * self.eta


# FIPS-204 Table 1
PARAMS = [
    MLDSAParams("ML-DSA-44", n=256, q=8380417, tau=39, gamma1=2**17,
                gamma2=(8380417 - 1) // 88, k=4, l=4, eta=2, d=13, omega=80),
    MLDSAParams("ML-DSA-65", n=256, q=8380417, tau=49, gamma1=2**19,
                gamma2=(8380417 - 1) // 32, k=6, l=5, eta=4, d=13, omega=55),
    MLDSAParams("ML-DSA-87", n=256, q=8380417, tau=60, gamma1=2**19,
                gamma2=(8380417 - 1) // 32, k=8, l=7, eta=2, d=13, omega=75),
]


"""P_norm: the probability of passing the line-23 validity checks."""

import math

def p_norm_exact(p) -> float:
    """
    p is an MLDSAParams instance (see params.py); this module has no
    import-time dependency on it, so it can be included as-is (and so it
    stays executable whether imported as a package module or spliced
    inline into a notebook cell).

    This computes probability that both z-check and r0-check passes.
    Each coefficient of z = y + c*s1 is in the good range for exactly 2(gamma1-beta)-1 of the 2*gamma1 possible y values, independent of the value of the c*s1 coefficient. The r0 term is treated analogously: we assume its low bits are uniformly distributed over their 2gamma2 possible residues, and compute the probability under that assumption.

    The infinity-norm check requires that every coefficient of z be within gamma1 - beta in absolute value, and every coefficient of r0 be within gamma2 - beta in absolute value.
    """
    g1, g2, b = p.gamma1, p.gamma2, p.beta

    # Exact norm-check product:
    #   p_norm =   [(2(gamma1-beta)-1)/(2 gamma1)]^(256 l)
    #            * [(2(gamma2-beta)-1)/(2 gamma2)]^(256 k)
    base_z = (2*(g1-b)-1) / (2*g1) # z
    base_r = (2*(g2-b)-1) / (2*g2) # r0
    p_norm = base_z**(p.n*p.l) * base_r**(p.n*p.k)
    return p_norm


def p_norm_eq5(p) -> float:
    """Equation (5) of the Dilithium spec: first-order approximation."""
    return math.exp(-256 * p.beta * (p.l / p.gamma1 + p.k / p.gamma2))



df = pd.DataFrame([
    {
        "**Variant**": p.name,
        r"**$P_{\mathrm{norm}}$**": f"{p_norm_exact(p):.5f}",
        "**Equation (5)**": f"{p_norm_eq5(p):.5f}",
        "**Overstatement**": f"{100*(p_norm_eq5(p)/p_norm_exact(p) - 1):.1f}%",
    }
    for p in PARAMS
])

Markdown(df.to_markdown(index=False, disable_numparse=True, colalign=("left", "right", "right", "right")))
Variant \(P_{\mathrm{norm}}\) Equation (5) Overstatement
ML-DSA-44 0.23272 0.23502 1.0%
ML-DSA-65 0.19539 0.19631 0.5%
ML-DSA-87 0.25809 0.25961 0.6%
Table 2: Probability of passing the line-23 validity checks, per attempt. The exact product is compared against Equation (5) of the Dilithium (v3.1) [8] specification, which approximates it.

The compounding effect is counterintuitive and merits a closer look. Each coefficient is rejected with probability well under 0.1% - between roughly one in 1,200 and one in 4,300. But there are between two and four thousand coefficients, and all must pass at once. Since \((1-x)^N \approx e^{-Nx}\) and \(Nx \approx 1.4\), survival collapses to about a quarter. The same compounding explains Equation (5)’s error: a per-coefficient discrepancy of only \(1/(2\gamma)\) - a few parts per million - accumulates over thousands of coefficients into roughly 0.5-1% in \(P_{norm}\).

Two separate problems have now been isolated. Equation (5) is an approximation of \(P_{norm}\), good to about a percent - that is a precision issue, and the exact product fixes it. But Equation (5) is also only \(P_{norm}\), whereas FIPS-204 presents it as the whole loop - that is a scoping issue, and fixing it needs the two remaining terms.

The Hint-Correctness Check

This is the smallest of the three terms, and it has the cleanest answer. It comes before \(P_{hint}\) because both depend on the distribution of a coefficient of \(c \cdot t_0\), which is derived here.

What the check does

ML-DSA compresses its public key. The full value \(t = A \cdot s_1 + s_2\) is split by \(Power2Round\) (Algorithm 35 of FIPS-204 [1]) into a high part \(t_1\), which is published, and a low part \(t_0\), which is not. The verifier therefore cannot compute the high bits of \(A \cdot z - c \cdot t\) directly, and the signer sends hints recording where the missing \(c \cdot t_0\) term causes a carry across a high-bit boundary.

The verifier does not know \(t_0\), so the value it computes is off by \(c \cdot t_0\). One hint bit per coefficient can correct this only if \(c \cdot t_0\) is small enough that it never carries a coefficient more than one high-bits boundary away. Dilithium’s Lemma 1 [8] makes this precise: \(\mathrm{UseHint}\) recovers the signer’s high bits exactly when the perturbation has infinity norm at most \(\alpha/2 = \gamma_2\), so each coefficient of \(c \cdot t_0\) must stay within that bound. So before trusting the hint, the signer checks \(\lVert c \cdot t_0 \rVert_\infty < \gamma_2\), and if the check fails it discards the attempt, because the signature might not verify.

The distribution of a coefficient of \(c \cdot t_0\)

\(Power2Round\) defines \(t_0 = t \bmod^{\pm} 2^d\), where \(\bmod^{\pm}\) is the centred representative. So the coefficients of \(t_0\) lie in

\[ (-2^{d-1},\ 2^{d-1}] = (-4096,\ 4096] \quad \text{for } d = 13 \]

That is \(2^d = 8192\) consecutive integers, centred on zero. The centring is not incidental - it makes the distribution essentially zero-mean, which is what lets \(c \cdot t_0\) be treated as a zero-mean sum. Assuming these coefficients are uniform over their 8192 values, the variance of a uniform over \(N\) consecutive integers is \((N^2-1)/12\), so

\[ \mathrm{Var}(t_{0,i}) = \frac{2^{2d}-1}{12} \approx 5{,}592{,}405 \qquad \sigma(t_{0,i}) \approx 2365 \]

The challenge polynomial \(c\), produced by \(SampleInBall\) (Algorithm 29 of FIPS-204 [1]), has exactly \(\tau\) non-zero coefficients, each \(+1\) or \(-1\), the rest zero. So each coefficient of the product \(c·t_0\) is a signed sum of exactly \(τ\) coefficients of \(t_0\).

Two facts about this sum matter.

  • Its spread. Each coefficient of \(c \cdot t_0\) adds up \(\tau\) independent coefficients of \(t_0\), so their variances add:

    \[ \mathrm{Var} = \tau \cdot \frac{2^{2d}-1}{12} \qquad \sigma = \sqrt{\mathrm{Var}} \approx 2^d \sqrt{\tau/12} \]

    That is 14768, 16554 and 18318 for the three parameter sets - a typical coefficient of \(c \cdot t_0\) is in the tens of thousands. The threshold \(\gamma_2\) is 6.4, 15.8 and 14.3 times larger than that, so the check rarely fails. But \(\sigma\) alone does not say how rarely.

  • Its range. Each term is at most \(2^{d-1} = 4096\) in absolute value, so

    \[|(c \cdot t_0)_i| \le \tau \cdot 2^{d-1}\]

    which is 159744, 200704 and 245760. For ML-DSA-65 and ML-DSA-87 this maximum is below \(\gamma_2 = 261888\): the check cannot fail, and \(P_{ct_0} = 0\) exactly. For ML-DSA-44 the maximum exceeds \(\gamma_2 = 95232\), so the tail has to be computed.

Use of Irwin-Hall distribution

Yan, Luo and Wang [7] give this same closed form for \(\|c \cdot t_0\|_\infty\) using the Irwin-Hall distribution, including the \(7.335 \times 10^{-9}\) value for ML-DSA-44 and the observation that the check is inactive for the other two parameter sets. What follows restates that derivation for completeness.

The Irwin-Hall distribution describes how a sum of uniform random numbers is distributed: given \(n\) dice-like uniform values summed together, it gives the probability that the total is at most \(x\). For \(n = 2\), the two dice are rolled and the results added. Some totals are more likely than others - 7 comes up six ways out of thirty-six, 12 only one way - and some totals are flatly impossible: no roll can total 1, and none can total 13. A sum of uniform random values has a shape, and that shape has a hard edge at each end.

The Irwin-Hall distribution is the continuous version of the same thing: instead of dice faces, take \(n\) values drawn uniformly from \([0, 1]\) and add them. The sum lands somewhere in \([0, n]\) - never below 0, never above \(n\).

NoteIrwin-Hall, unlike the normal distribution, has bounded support

The Irwin-Hall distribution has bounded support, that means every sample lies in \([0, n]\), where \(n\) is the number of summed uniform values.

In other words, it has a hard edge. Values outside \([0, n]\) have probability zero, not just small probability. A normal distribution lacks this: its tails never reach zero, so every interval, no matter how extreme, is assigned some probability - including a nonzero probability for a value above 13, on the order of \(10^{-53}\).

Most distributions require numerical integration to evaluate the fraction of probability lying below a threshold \(x\). The Irwin-Hall distribution instead has a closed form:

\[ P(\mathrm{IH}_n \le x) = \frac{1}{n!}\sum_{j=0}^{\lfloor x \rfloor} (-1)^j \binom{n}{j} (x-j)^n \]

Here \(n\) is the number of values summed - \(\tau\), the number of non-zero coefficients in the challenge - and \(x\) is the fixed threshold of interest. A coefficient of \(c \cdot t_0\) is a sum of uniform values - \(\tau\) coefficients of \(t_0\), added with \(\pm\) signs - so the Irwin-Hall distribution applies to it directly, not merely by analogy, up to the discrete-to-continuous correction below. For ML-DSA-65 and ML-DSA-87, the threshold lies past the end of the distribution’s support, so the probability is exactly zero.

Two adjustments are needed before the formula applies. First, the signs of \(c\) can be ignored: \(t_0 \in (-2^{d-1}, 2^{d-1}]\) is symmetric about zero up to its single boundary point, a 1-in-\(2^d\) effect negligible at this precision, so negating it leaves its distribution essentially unchanged. Second, Irwin-Hall is defined for uniforms on [0,1], whereas the coefficients of \(t_0\) are uniform on an interval of width \(2^d\). Dividing through by that width converts between the two, up to a shift of \(\frac{1}{2}\) per term, which puts the threshold at \(x = \frac{\gamma_2}{2^d} + \frac{\tau}{2}\) in Irwin-Hall units.

This remains an approximation in one respect that the two adjustments above do not remove: \(t_0\)’s coefficients are discrete integers, while the Irwin-Hall distribution is continuous. Evaluating its CDF in exact rational arithmetic (below) makes that evaluation exact; it does not make the continuous distribution an exact model of the discrete sum. No rigorous bound on the resulting error is given here. What supports the result instead is that it reproduces Yan, Luo and Wang’s independently published figure [7], though they make the same continuous relaxation, so this is not independent verification of it.

Show the computation
import pandas as pd

"""ML-DSA parameter sets, as given in Table 1 of FIPS 204."""

from dataclasses import dataclass


@dataclass
class MLDSAParams:
    name: str
    n: int       # ring degree, always 256
    q: int       # modulus
    tau: int     # number of +-1's in the challenge poly c
    gamma1: int  # y coefficient range
    gamma2: int  # low-bits rounding range
    k: int       # rows of A (dimension of w, s2, t)
    l: int       # columns of A (dimension of s1, z)
    eta: int     # secret key coefficient range
    d: int       # dropped bits of t
    omega: int   # maximum number of hints

    @property
    def beta(self) -> int:
        return self.tau * self.eta


# FIPS-204 Table 1
PARAMS = [
    MLDSAParams("ML-DSA-44", n=256, q=8380417, tau=39, gamma1=2**17,
                gamma2=(8380417 - 1) // 88, k=4, l=4, eta=2, d=13, omega=80),
    MLDSAParams("ML-DSA-65", n=256, q=8380417, tau=49, gamma1=2**19,
                gamma2=(8380417 - 1) // 32, k=6, l=5, eta=4, d=13, omega=55),
    MLDSAParams("ML-DSA-87", n=256, q=8380417, tau=60, gamma1=2**19,
                gamma2=(8380417 - 1) // 32, k=8, l=7, eta=2, d=13, omega=75),
]



"""P_ct0: the hint-correctness check, computed exactly via Irwin-Hall."""

from fractions import Fraction
from math import comb, factorial


def p_ct0(p):
    """
    p is an MLDSAParams instance (see params.py); this module has no
    import-time dependency on it, so it can be included as-is (and so it
    stays executable whether imported as a package module or spliced
    inline into a notebook cell).

    P(||c*t0||inf >= gamma2), the first condition on FIPS 204 Alg 7 line 28.

    Returns (probability, threshold x, support upper bound tau).
    Evaluated in rational arithmetic: the alternating binomial sum has
    terms of magnitude ~1e40 while the answer is ~1e-12, so floating
    point cancellation destroys the result completely.
    """

    # Rescale by 2^d: (c*t0)_i / 2^d ~ IH_tau - tau/2
    n = p.tau
    x = Fraction(p.gamma2, 2**p.d) + Fraction(n, 2)

    # Each coefficient of c*t0 is a signed sum of tau coefficients of t0,
    # each bounded by 2^(d-1). If gamma2 exceeds that, the check cannot fire.
    if p.gamma2 > n * 2**(p.d - 1):
        return 0.0, float(x), n

    # Irwin-Hall CDF, in exact rational arithmetic (the alternating sum
    # cancels catastrophically in floating point).
    cdf = sum(Fraction((-1)**k) * comb(n, k) * (x - k)**n
              for k in range(int(x) + 1)) / factorial(n)

    sf = float(1 - cdf)          # one-sided tail
    return p.n * p.k * 2 * sf, float(x), n    # two-sided, union over 256*k coefficients



rows = []
for p in PARAMS:
    val, x, n = p_ct0(p)
    rows.append({
        "Variant": p.name,
        "Threshold x": f"{x:.3f}",
        "Irwin-Hall support": f"[0, {n}]",
        r"$P_{\mathrm{ct_{0}}}$": "0" if val == 0 else f"{val:.2e}",
    })
df = pd.DataFrame(rows)

from IPython.display import Markdown
Markdown(df.to_markdown(index=False, disable_numparse=True, colalign=("left", "right", "right", "right")))
Variant Threshold x Irwin-Hall support \(P_{\mathrm{ct_{0}}}\)
ML-DSA-44 31.125 [0, 39] 7.33e-09
ML-DSA-65 56.469 [0, 49] 0
ML-DSA-87 61.969 [0, 60] 0
Table 3: \(P_{ct_0}\), computed via the Irwin-Hall distribution. For ML-DSA-65 and ML-DSA-87 the threshold falls outside the support of the distribution, so the tail is exactly zero.

For ML-DSA-65 and ML-DSA-87 the threshold lands beyond the support of the distribution - 56.47 against a maximum of 49, and 61.97 against a maximum of 60 - and the tail beyond the support of a distribution is exactly zero. For those two parameter sets, the condition cannot fire at all, independently confirmed by the ACVP test-vector specification [9], which restricts the check to ML-DSA-44.

For ML-DSA-44, adding the \(256k\) per-coefficient tail probabilities gives \(7.334967\times10^{-9}\); computing \(1\) minus the probability that every coefficient stays within the threshold - exact only under an assumed independence between coefficients - gives \(7.334961\times10^{-9}\) instead. The two agree to six significant figures, three more than the \(7.33\times10^{-9}\) reported, so the simpler sum is used.

In theory, this check could be skipped for ML-DSA-65 and -87, but in practice omitting it creates a deviation from FIPS-204.

The Hint-Weight Rejection

Line 27 of the algorithm computes a vector of hints, one bit per coefficient, so \(h\) holds \(256 \cdot k\) bits. Line 28 rejects when the number of 1 bits exceeds \(\omega\) - the number of hint positions the signature format reserves space for. Writing \(N\) for that count:

\[ P_{hint} = P(N > \omega) \]

Unlike \(P_{\text{norm}}\) and \(P_{c \cdot t_0}\), which ask whether any coefficient crosses a threshold, \(P_{\text{hint}}\) asks whether the count of hint-producing coefficients exceeds \(\omega\). That is a tail of a sum of non-identically distributed indicators across thousands of coefficients, which does not factorize. This is structurally harder, and it is why this term resisted a closed-form treatment.

Condition to fire single hint

\(N\) is a count of individual coefficients that fired, so computing anything about it starts with a single one. Here we analyse how likely is a hint at one position.

\(\mathrm{MakeHint}(z, r)\) returns 1 when \(\mathrm{HighBits}(r)\) differs from \(\mathrm{HighBits}(r + z)\). Line 27 invokes it as

\[ h = \mathrm{MakeHint}(-c \cdot t_0,\ w - c \cdot s_2 + c \cdot t_0) \]

so \(r = w - c \cdot s_2 + c \cdot t_0\) and \(r + z = w - c \cdot s_2\). A hint at position \(i\) therefore fires exactly when adding \((c \cdot t_0)_i\) changes the high bits of \((w - c \cdot s_2)_i\) - that is, when its low part is pushed out of \((-\gamma_2,\ \gamma_2]\).

That low part is \(r_0\), already checked on line 23. Lines 25-28 live inside the else branch, reached only when line 23 already passed. So by the time hints are computed, \(r_0\) is not free to take any value in \((-\gamma_2,\ \gamma_2]\); it is already confined to \(|r_0| < \gamma_2 - \beta\).

With that constraint in place, the probability that a single coefficient fires can be computed directly. Take one coefficient and write \(a = |(c \cdot t_0)_i|\), \(a > 0\), and consider first the case \((c \cdot t_0)_i = +a\). The hint fires when adding it pushes the low part out of \((-\gamma_2, \gamma_2]\):

\[ r_0 + a > \gamma_2 \qquad \text{i.e.} \qquad r_0 > \gamma_2 - a \]

So the question is how many values of \(r_0\) satisfy that. Unconditionally \(r_0\) could be anywhere in \((-\gamma_2, \gamma_2]\) - but line 23 has already rejected everything with \(|r_0| \ge \gamma_2 - \beta\), so the largest value still in play is \(\gamma_2 - \beta - 1\). The firing values are therefore the integers in a window with a lower edge at \(\gamma_2 - a + 1\) and an upper edge at \(\gamma_2 - \beta - 1\):

\[ \underbrace{ \overbrace{\rule{0pt}{10pt}\quad -(\gamma_2-\beta)+1 \quad\cdots\quad \gamma_2 - a}^{\textstyle \text{no hint}} \;\Big|\; \overbrace{\rule{0pt}{10pt}\quad \gamma_2-a+1 \quad\cdots\quad \gamma_2-\beta-1 \quad}^{\textstyle \text{hint fires}} }_{\textstyle 2(\gamma_2-\beta)-1 \ \text{values permitted by line 23}} \]

Counting the right-hand block:

\[ (\gamma_2 - \beta - 1) - (\gamma_2 - a + 1) + 1 = a - \beta - 1 \]

That count, \(a - \beta - 1\), is for the case \((c \cdot t_0)_i = +a\); once \(a \le \beta + 1\) it is zero, and no hint is possible at all. The mirror case \((c \cdot t_0)_i = -a\) pushes \(r_0\) out of the interval on the other side, \(r_0 - a \le -\gamma_2\), and the identical argument against that boundary gives a count of \(a - \beta\) instead - one larger, because \((-\gamma_2, \gamma_2]\) is open at \(-\gamma_2\) but closed at \(\gamma_2\), so the two boundaries are not interchangeable. Since the coefficients of \(t_0\) are symmetric about zero up to a negligible 1-in-\(2^d\) boundary effect (Section 5), the sign of \((c \cdot t_0)_i\) is, to the same precision, a fair coin flip given its magnitude \(a\), so the probability that a single coefficient fires is the average of the two counts, divided by the number of values \(r_0\) can take:

\[ P(\text{hint at } i \mid |(c \cdot t_0)_i| = a) = \frac{\tfrac{1}{2}\bigl[\max(0,\ a - \beta - 1) + \max(0,\ a - \beta)\bigr]}{2(\gamma_2 - \beta) - 1} = \frac{\max(0,\ a - \beta - \frac{1}{2})}{2(\gamma_2 - \beta) - 1} \]

Take ML-DSA-65, where \(\gamma_2 = 261888\) and \(\beta = 196\). After line 23, \(r_0\) lies somewhere in \([-261691,\ 261691]\) - that is \(2(\gamma_2-\beta)-1 = 523383\) permitted values.

A coefficient with \(a = 1000\): if \((c \cdot t_0)_i = +1000\), it fires when \(r_0 > 261888 - 1000 = 260888\), so the firing values are \(260889 \ldots 261691\) - that is \(803\) of them, and indeed \(a - \beta - 1 = 1000 - 197 = 803\). If \((c \cdot t_0)_i = -1000\) instead, the mirror argument gives one more, \(a - \beta = 804\). Averaging over the two equally likely signs, the probability is \(\frac{1}{2}(803 + 804) / 523383 = 803.5 / 523383 \approx 0.001535\).

A coefficient with \(a = 150\) never fires. It would need \(r_0 > 261738\), but line 23 already threw away everything above \(261691\). Here \(a < \beta\), so the formula correctly gives zero.

As a consequence, no hint is possible unless \(|(c \cdot t_0)_i|\) exceeds \(\beta\) - which is why the mean hint count sits well below \(\omega\) in every parameter set. Ignoring line 23 and using \(\frac{a}{2 \gamma_2}\) instead is the unconditioned model Yan, Luo and Wang [7] use for this term; the table below compares the two on shared \((c, t_0)\) draws.

Show the computation
import pandas as pd
from IPython.display import Markdown

"""ML-DSA parameter sets, as given in Table 1 of FIPS 204."""

from dataclasses import dataclass


@dataclass
class MLDSAParams:
    name: str
    n: int       # ring degree, always 256
    q: int       # modulus
    tau: int     # number of +-1's in the challenge poly c
    gamma1: int  # y coefficient range
    gamma2: int  # low-bits rounding range
    k: int       # rows of A (dimension of w, s2, t)
    l: int       # columns of A (dimension of s1, z)
    eta: int     # secret key coefficient range
    d: int       # dropped bits of t
    omega: int   # maximum number of hints

    @property
    def beta(self) -> int:
        return self.tau * self.eta


# FIPS-204 Table 1
PARAMS = [
    MLDSAParams("ML-DSA-44", n=256, q=8380417, tau=39, gamma1=2**17,
                gamma2=(8380417 - 1) // 88, k=4, l=4, eta=2, d=13, omega=80),
    MLDSAParams("ML-DSA-65", n=256, q=8380417, tau=49, gamma1=2**19,
                gamma2=(8380417 - 1) // 32, k=6, l=5, eta=4, d=13, omega=55),
    MLDSAParams("ML-DSA-87", n=256, q=8380417, tau=60, gamma1=2**19,
                gamma2=(8380417 - 1) // 32, k=8, l=7, eta=2, d=13, omega=75),
]



# 5,000,000 trials per parameter set, paired comparison on shared draws.
# Reproduced with:
#   python3 -m mldsa_rejection.run_phint --trials 5000000 --compare-unconditioned
#
# variant       cond       uncond     overstatement
# ML-DSA-44  0.01454+-0.00005  0.01644+-0.00006  13.1% (SE~0.6%)
# ML-DSA-65  0.00379+-0.00003  0.00500+-0.00003  32.0% (SE~1.3%)
# ML-DSA-87  0.00776+-0.00004  0.00922+-0.00004  18.9% (SE~0.8%)
PHINT_COMPARE = {
    "ML-DSA-44": (0.01454, 0.00005, 0.01644, 0.00006),
    "ML-DSA-65": (0.00379, 0.00003, 0.00500, 0.00003),
    "ML-DSA-87": (0.00776, 0.00004, 0.00922, 0.00004),
}

df = pd.DataFrame([
    {
        "**Variant**": p.name,
        "**Conditioned**": f"{PHINT_COMPARE[p.name][0]:.5f} ± {PHINT_COMPARE[p.name][1]:.5f}",
        "**Unconditioned**": f"{PHINT_COMPARE[p.name][2]:.5f} ± {PHINT_COMPARE[p.name][3]:.5f}",
        "**Overstatement**": f"{100*(PHINT_COMPARE[p.name][2]/PHINT_COMPARE[p.name][0] - 1):.1f}%",
    }
    for p in PARAMS
])

Markdown(df.to_markdown(index=False, disable_numparse=True, colalign=("left", "right", "right", "right")))
Variant Conditioned Unconditioned Overstatement
ML-DSA-44 0.01454 ± 0.00005 0.01644 ± 0.00006 13.1%
ML-DSA-65 0.00379 ± 0.00003 0.00500 ± 0.00003 31.9%
ML-DSA-87 0.00776 ± 0.00004 0.00922 ± 0.00004 18.8%
Table 4: Conditioned (this note) vs. unconditioned [7] P_hint, compared on shared (c, t0) draws. 5,000,000 trials per parameter set.

The unconditioned model overstates \(P_{hint}\) by 13 to 32 percent across the three parameter sets, not a fixed amount - the gap depends on how much of the conditioned window line 23 has already consumed, which varies by parameter set.

There is no simple formula for \(P(N > \omega)\). \(N\) is a sum of \(256 \cdot k\) indicator variables, but three features rule out the standard routes to a closed form: the per-coefficient probabilities differ (ruling out a binomial tail), those probabilities are themselves random since \(c \cdot t_0\) varies with the key and the challenge (ruling out a fixed-probability model), and all 256 coefficients of one \(c \cdot t_0\) polynomial share the same \(t_0\) and \(c\), so they are correlated rather than independent (ruling out a Poisson-binomial treatment).

The usual escape - a normal approximation to \(N\), using its true mean and variance - is measurably wrong, and not by a fixed amount:

Show the computation
import pandas as pd
from IPython.display import Markdown

"""ML-DSA parameter sets, as given in Table 1 of FIPS 204."""

from dataclasses import dataclass


@dataclass
class MLDSAParams:
    name: str
    n: int       # ring degree, always 256
    q: int       # modulus
    tau: int     # number of +-1's in the challenge poly c
    gamma1: int  # y coefficient range
    gamma2: int  # low-bits rounding range
    k: int       # rows of A (dimension of w, s2, t)
    l: int       # columns of A (dimension of s1, z)
    eta: int     # secret key coefficient range
    d: int       # dropped bits of t
    omega: int   # maximum number of hints

    @property
    def beta(self) -> int:
        return self.tau * self.eta


# FIPS-204 Table 1
PARAMS = [
    MLDSAParams("ML-DSA-44", n=256, q=8380417, tau=39, gamma1=2**17,
                gamma2=(8380417 - 1) // 88, k=4, l=4, eta=2, d=13, omega=80),
    MLDSAParams("ML-DSA-65", n=256, q=8380417, tau=49, gamma1=2**19,
                gamma2=(8380417 - 1) // 32, k=6, l=5, eta=4, d=13, omega=55),
    MLDSAParams("ML-DSA-87", n=256, q=8380417, tau=60, gamma1=2**19,
                gamma2=(8380417 - 1) // 32, k=8, l=7, eta=2, d=13, omega=75),
]



# 5,000,000 trials per parameter set. Reproduced with:
#   python3 -m mldsa_rejection.run_phint --trials 5000000 --compare-normal
#
# variant     mean   sigma   p_true    p_normal   underestimate
# ML-DSA-44   63.06   7.74  0.01454    0.01211       16.7%
# ML-DSA-65   38.23   6.12  0.00379    0.00239       37.0%
# ML-DSA-87   56.75   7.45  0.00776    0.00590       23.9%
PHINT_NORMAL = {
    "ML-DSA-44": (63.06, 7.74, 0.01454, 0.01211),
    "ML-DSA-65": (38.23, 6.12, 0.00379, 0.00239),
    "ML-DSA-87": (56.75, 7.45, 0.00776, 0.00590),
}

df = pd.DataFrame([
    {
        "**Variant**": p.name,
        "**Mean N**": f"{PHINT_NORMAL[p.name][0]:.2f}",
        r"**$\sigma$**": f"{PHINT_NORMAL[p.name][1]:.2f}",
        "**True**": f"{PHINT_NORMAL[p.name][2]:.5f}",
        "**Normal**": f"{PHINT_NORMAL[p.name][3]:.5f}",
        "**Underestimate**": f"{100*(1 - PHINT_NORMAL[p.name][3]/PHINT_NORMAL[p.name][2]):.1f}%",
    }
    for p in PARAMS
])

Markdown(df.to_markdown(index=False, disable_numparse=True, colalign=("left", "right", "right", "right", "right", "right")))
Variant Mean N \(\sigma\) True Normal Underestimate
ML-DSA-44 63.06 7.74 0.01454 0.01211 16.7%
ML-DSA-65 38.23 6.12 0.00379 0.00239 36.9%
ML-DSA-87 56.75 7.45 0.00776 0.00590 24.0%
Table 5: Simulated \(P_{hint}\) vs. a mean/variance-matched normal approximation. 5,000,000 trials per parameter set.

The mean is accurate - a normal with the right mean and variance gets that part right - but the tail is not: \(N\)’s true distribution is right-skewed, so a normal with the same mean and variance underestimates the tail, by 17 to 37 percent depending on the parameter set. Instead we estimate \(P_{\text{hint}}\) by simulation.

Monte Carlo

Each trial simulates one signing attempt that has already passed line 23:

  1. Sample \(c\): \(\tau\) positions among 256, each \(\pm 1\), rest zero
  2. Sample \(t_0\): \(k\) polynomials of 256 coefficients, uniform on \((-2^{d-1},\ 2^{d-1}]\)
  3. Compute \(c \cdot t_0\) in \(\mathbb{Z}[X]/(X^{256}+1)\)
  4. For each of the \(256 \cdot k\) coefficients, compute its hint probability and draw the Bernoulli outcome
  5. \(N\) = number of hints; record whether \(N > \omega\)

\(P_{hint}\) is the fraction of trials in which \(N\) exceeded \(\omega\). The product in step 3 is computed by negacyclic FFT, values stay below \(\tau \cdot 2^{d-1} = 245{,}760\), far under \(q/2\), so no modular reduction is needed and double precision is amply accurate.

Show the computation
import pandas as pd
from IPython.display import Markdown

"""ML-DSA parameter sets, as given in Table 1 of FIPS 204."""

from dataclasses import dataclass


@dataclass
class MLDSAParams:
    name: str
    n: int       # ring degree, always 256
    q: int       # modulus
    tau: int     # number of +-1's in the challenge poly c
    gamma1: int  # y coefficient range
    gamma2: int  # low-bits rounding range
    k: int       # rows of A (dimension of w, s2, t)
    l: int       # columns of A (dimension of s1, z)
    eta: int     # secret key coefficient range
    d: int       # dropped bits of t
    omega: int   # maximum number of hints

    @property
    def beta(self) -> int:
        return self.tau * self.eta


# FIPS-204 Table 1
PARAMS = [
    MLDSAParams("ML-DSA-44", n=256, q=8380417, tau=39, gamma1=2**17,
                gamma2=(8380417 - 1) // 88, k=4, l=4, eta=2, d=13, omega=80),
    MLDSAParams("ML-DSA-65", n=256, q=8380417, tau=49, gamma1=2**19,
                gamma2=(8380417 - 1) // 32, k=6, l=5, eta=4, d=13, omega=55),
    MLDSAParams("ML-DSA-87", n=256, q=8380417, tau=60, gamma1=2**19,
                gamma2=(8380417 - 1) // 32, k=8, l=7, eta=2, d=13, omega=75),
]


"""P_hint for ML-DSA: probability that a signing attempt is rejected on line 28
of FIPS 204 Algorithm 7 because it produced more than omega hints.

Monte Carlo. Per-coefficient hint probability is conditioned on line 23
having passed (|r0| < gamma2 - beta). a = |(c*t0)_i| alone does not determine
whether the hint fires: the count is a-beta-1 if (c*t0)_i = +a, a-beta if
(c*t0)_i = -a (the window (-gamma2, gamma2] is open at one end, closed at the
other, so the two signs are not interchangeable). Since t0 is symmetric about
zero, the sign is a fair coin flip given a, so the per-coefficient probability
is the average of the two:

    P(hint | a) = max(0, a - beta - 1/2) / (2*(gamma2 - beta) - 1),  a = |(c*t0)_i|
"""

import math

import numpy as np

# No import of params.py here: this module has no import-time dependency
# on it, so it stays executable whether imported as a package module or
# spliced inline into a notebook cell. run_simulation() below takes the
# parameter list as an explicit argument instead of defaulting to it.

PSI = np.exp(1j * np.pi * np.arange(256) / 256)
IPSI = np.conj(PSI)


def p_hint(k, tau, d, gamma2, beta, omega, trials, batch=2000, seed=19820116):
    """Monte Carlo estimate of P(N > omega), N = number of hints that fire."""
    rng = np.random.default_rng(seed)
    half = 2 ** (d - 1)
    denom = 2 * (gamma2 - beta) - 1
    total_hints = rejected = done = ct0_rej = 0

    while done < trials:
        b = min(batch, trials - done)

        # Step 1: sample c
        c = np.zeros((b, 256))
        # Spread tau's among 256 positions
        pos = np.argsort(rng.random((b, 256)), axis=1)[:, :tau]
        # Map {0,1} to {-1,+1} and put in chosen positions.
        np.put_along_axis(c, pos,
                          (rng.integers(0, 2, size=(b, tau)) * 2 - 1).astype(float),
                          axis=1)

        # Step 2: sample t0 uniform on (-2^(d-1), 2^(d-1)];
        t0 = rng.integers(-half + 1, half + 1, size=(b, k, 256)).astype(float)

        # Step 3: compute c*t0 in Z[X]/(X^256+1) via negacyclic FFT
        ct0 = np.real(np.fft.ifft(np.fft.fft(t0 * PSI, axis=2)
                                  * np.fft.fft(c * PSI, axis=1)[:, None, :],
                                  axis=2) * IPSI)
        # Number of rejected ct0. We expect 0
        ct0_rej += int((np.abs(np.rint(ct0)).max(axis=(1, 2)) >= gamma2).sum())
        assert np.abs(ct0 - np.rint(ct0)).max() < 1e-6, "FFT lost integer precision"

        # Step 4: compute hint probability
        prob = np.maximum(0, np.abs(np.rint(ct0)) - beta - 0.5) / denom
        n_hints = (rng.random(prob.shape) < prob).sum(axis=(1, 2))
        total_hints += int(n_hints.sum())

        # Step 5: Number of rejected hints
        rejected += int((n_hints > omega).sum())
        done += b

    est = rejected / done
    return total_hints / done, est, math.sqrt(est * (1 - est) / done), ct0_rej


def p_hint_compare(k, tau, d, gamma2, beta, omega, trials, batch=2000, seed=19820116):
    """Monte Carlo comparison of P_hint under the conditioned model (this note)
    against the unconditioned model Yan, Luo and Wang use for this term, on
    shared (c, t0, u) draws so the comparison is a paired one and not itself
    noisy on top of the two individual estimates.

    Conditioned:   P(hint at i | a) = max(0, a - beta - 1/2) / (2*(gamma2-beta) - 1)
    Unconditioned: P(hint at i | a) = min(1, a / (2*gamma2))

    Returns (p_cond, p_uncond, se_cond, se_uncond).
    """
    rng = np.random.default_rng(seed)
    half = 2 ** (d - 1)
    denom_cond = 2 * (gamma2 - beta) - 1
    denom_uncond = 2 * gamma2
    rejected_cond = rejected_uncond = done = 0

    while done < trials:
        b = min(batch, trials - done)

        c = np.zeros((b, 256))
        pos = np.argsort(rng.random((b, 256)), axis=1)[:, :tau]
        np.put_along_axis(c, pos,
                          (rng.integers(0, 2, size=(b, tau)) * 2 - 1).astype(float),
                          axis=1)
        t0 = rng.integers(-half + 1, half + 1, size=(b, k, 256)).astype(float)
        ct0 = np.real(np.fft.ifft(np.fft.fft(t0 * PSI, axis=2)
                                  * np.fft.fft(c * PSI, axis=1)[:, None, :],
                                  axis=2) * IPSI)
        a = np.abs(np.rint(ct0))

        prob_cond = np.maximum(0, a - beta - 0.5) / denom_cond
        prob_uncond = np.minimum(1, a / denom_uncond)

        u = rng.random(a.shape)
        n_cond = (u < prob_cond).sum(axis=(1, 2))
        n_uncond = (u < prob_uncond).sum(axis=(1, 2))

        rejected_cond += int((n_cond > omega).sum())
        rejected_uncond += int((n_uncond > omega).sum())
        done += b

    p_cond = rejected_cond / done
    p_uncond = rejected_uncond / done
    se_cond = math.sqrt(p_cond * (1 - p_cond) / done)
    se_uncond = math.sqrt(p_uncond * (1 - p_uncond) / done)
    return p_cond, p_uncond, se_cond, se_uncond


def p_hint_normal_compare(k, tau, d, gamma2, beta, omega, trials, batch=2000, seed=19820116):
    """Compare the true (simulated) P_hint against a normal approximation
    that uses N's correct mean and variance (both estimated from the same
    trials), to quantify how much the normal approximation underestimates
    the tail - the correlation between coefficients of one c*t0 polynomial
    (Step 3 in the 'why there is no usable formula' argument) fattens the
    true upper tail beyond what a normal with the right variance predicts.

    Returns (mean, sigma, p_true, p_normal).
    """
    rng = np.random.default_rng(seed)
    half = 2 ** (d - 1)
    denom = 2 * (gamma2 - beta) - 1
    done = 0
    sum_n = sum_n2 = 0.0
    rejected = 0

    while done < trials:
        b = min(batch, trials - done)
        c = np.zeros((b, 256))
        pos = np.argsort(rng.random((b, 256)), axis=1)[:, :tau]
        np.put_along_axis(c, pos,
                          (rng.integers(0, 2, size=(b, tau)) * 2 - 1).astype(float),
                          axis=1)
        t0 = rng.integers(-half + 1, half + 1, size=(b, k, 256)).astype(float)
        ct0 = np.real(np.fft.ifft(np.fft.fft(t0 * PSI, axis=2)
                                  * np.fft.fft(c * PSI, axis=1)[:, None, :],
                                  axis=2) * IPSI)
        a = np.abs(np.rint(ct0))
        prob = np.maximum(0, a - beta - 0.5) / denom
        n_hints = (rng.random(prob.shape) < prob).sum(axis=(1, 2))
        sum_n += n_hints.sum()
        sum_n2 += (n_hints.astype(np.float64) ** 2).sum()
        rejected += int((n_hints > omega).sum())
        done += b

    mean = sum_n / done
    sigma = math.sqrt(sum_n2 / done - mean ** 2)
    p_true = rejected / done
    # continuity-corrected normal tail
    z = (omega + 0.5 - mean) / sigma
    p_normal = 0.5 * math.erfc(z / math.sqrt(2))
    return mean, sigma, p_true, p_normal


def mean_hint_count(k, tau, d, gamma2, beta):
    """ Mean hint count E[N] for ML-DSA, analytically.

    N is the number of 1 bits in the hint vector h (FIPS 204 Alg 7, line 27).
    Its mean follows from linearity of expectation, which holds whether or not
    the coefficients are independent -- unlike the tail P(N > omega), which
    depends on the joint distribution and has no tractable closed form.

        E[N] = sum over coefficients of P(hint at i)
            = 256*k * E[ max(0, |c*t0_i| - beta - 1/2) ] / (2*(gamma2-beta) - 1)

    The per-coefficient probability is conditioned on line 23 having passed,
    which confines r0 to |r0| < gamma2 - beta, and averaged over the sign of
    c*t0_i (a fair coin flip given its magnitude, by t0's symmetry). See the
    companion note.

    A coefficient of c*t0 is a sum of tau terms +-t0_j with t0_j uniform over
    2^d values, so it is zero-mean with

        sigma = 2^d * sqrt(tau/12)      and      E|X| = sigma * sqrt(2/pi)

    Since beta/sigma is between 0.005 and 0.012 for all parameter sets, |X|
    exceeds beta in all but a negligible fraction of cases, so

    E[ max(0, |X| - beta - 1/2) ] ~= E|X| - beta - 1/2
    """

    sigma = 2 ** d * math.sqrt(tau / 12)        # spread of a c*t0 coefficient
    e_abs = sigma * math.sqrt(2 / math.pi)      # E|X| for a zero-mean Gaussian
    per   = (e_abs - beta - 0.5) / (2 * (gamma2 - beta) - 1)
    return 256 * k * per, sigma, e_abs, per


def run_simulation(trials, params):
    """Run the Monte Carlo for every parameter set and print a summary table.

    `params` is the list of MLDSAParams to run (see params.py) - passed in
    explicitly rather than defaulted, so this module stays decoupled from
    params.py.
    """
    phint = dict()
    for p in params:
        sim, est, se, ct0_rej = p_hint(p.k, p.tau, p.d, p.gamma2, p.beta, p.omega, trials)
        print(f"{p.name}: P_hint = {est:.5f} +- {se:.5f} - {sim:.2f} - {ct0_rej}")
        phint[p.name] = [sim, est, se]

    hdr = (f"{'variant':<12}{'omega':>7}{'sigma':>9}{'E|X|':>9}{'per-coeff':>12}"
        f"{'E[N]':>9}{'simulated':>11}")
    print(hdr)
    print("-" * len(hdr))
    for p in params:
        lam, sigma, e_abs, per = mean_hint_count(p.k, p.tau, p.d, p.gamma2, p.beta)
        print(f"{p.name:<12}{p.omega:7d}{sigma:9.0f}{e_abs:9.0f}{per:12.5f}"
              f"{lam:9.2f}{phint[p.name][0]:11.1f}")
    return phint



# Running 100M trials per parameter set at site-build time would be
# impolite - it takes on the order of hours. Regenerated after the
# conditioned per-coefficient formula moved from a-beta-1 to a-beta-1/2
# (see @sec-hint) with:
#   python3 -m mldsa_rejection.run_phint --trials 20000000 --json result.json
#
# variant            trials  E[N] sim  E[N] analytic    P_hint   std err  seconds
# -------------------------------------------------------------------------------
# ML-DSA-44        20000000     63.07          62.98   0.01458   0.00003    480.1
# ML-DSA-65        20000000     38.23          38.19   0.00380   0.00001    714.7
# ML-DSA-87        20000000     56.75          56.70   0.00785   0.00002    917.6
P_HINT = {
    "ML-DSA-44": (63.07, 0.01458, 0.00003),
    "ML-DSA-65": (38.23, 0.00380, 0.00001),
    "ML-DSA-87": (56.75, 0.00785, 0.00002),
}

df = pd.DataFrame([
    {
        "**Variant**": p.name,
        "**Mean hint count**": f"{P_HINT[p.name][0]:.1f}",
        r"$\omega$": p.omega,
        r"$P_{\mathrm{hint}}$": f"{P_HINT[p.name][1]:.5f} ± {P_HINT[p.name][2]:.5f}",
    }
    for p in PARAMS
])

Markdown(df.to_markdown(index=False, disable_numparse=True, colalign=("left", "left", "left", "left")))
Variant Mean hint count \(\omega\) \(P_{\mathrm{hint}}\)
ML-DSA-44 63.1 80 0.01458 ± 0.00003
ML-DSA-65 38.2 55 0.00380 ± 0.00001
ML-DSA-87 56.8 75 0.00785 ± 0.00002
Table 6: \(P_{hint}\), estimated by Monte Carlo. 20,000,000 trials per parameter set; the stated uncertainty is the binomial standard error.

The mean hint counts sit comfortably below \(\omega\) in every case - the parameter sets were chosen that way - and it is only the upper tail of the count that spills over. Two sanity checks came out right: the simulated means match the analytic prediction, and in a separate, earlier 100-million-trial run the simulation recorded zero occurrences of the \(\lVert c \cdot t_0 \rVert_{\infty} \ge \gamma_2\) condition for ML-DSA-65 and ML-DSA-87, and exactly one for ML-DSA-44 - consistent with the \(P_{ct_0}\) figures above, where that condition is identically zero for the first two and on the order of \(10^{-9}\) per signature for the third. (This check does not depend on the hint-probability formula, so it is unaffected by which run produced it; the 20-million-trial run behind Table 6 above recorded zero such occurrences for all three, also consistent with that rate.)

Results and Verification

Combining \(P_{norm}\), \(P_{ct_0}\) and \(P_{hint}\) from the previous sections gives the per-attempt acceptance probability and the corrected expected iteration count for each parameter set:

Variant \(P_{\mathrm{norm}}\) \(P_{\mathrm{ct_{0}}}\) \(P_{\mathrm{hint}}\) \(p\) \(\frac{1}{p}\)
ML-DSA-44 0.23272 7.3e-09 0.01458 0.22933 4.360
ML-DSA-65 0.19539 0 0.00380 0.19465 5.137
ML-DSA-87 0.25809 0 0.00785 0.25606 3.905
Table 7: Per-attempt results, combining the three terms derived above.

The derived values agree with a real measurement done by using [10] to 0.14%, 0.06% and 0.05% - in each case well within the measurement’s own standard error. That is consistent with the modelling assumptions - the two uniformity heuristics and the independence assumptions in the Monte Carlo - but the measurement’s precision is not fine enough to distinguish this derivation from a cruder one; it rules out the uncorrected FIPS-204 figures, not the size of the correction.

Variant Derived Measured FIPS-204
ML-DSA-44 4.360 4.354 ± 0.009 4.25
ML-DSA-65 5.137 5.134 ± 0.010 5.1
ML-DSA-87 3.905 3.903 ± 0.008 3.85
Table 8: Comparison against the figures published in FIPS-204 Table 1 and against a real mldsa-ref measurement.

“Measured” is a real end-to-end run with 64 keys, 3,125 signatures each. The standard error above treats each signature as independent, which understates the true uncertainty since \(P_{hint}\) depends on the key through \(t_0\) (see Limitations).

Given \(p\), the number of attempts until acceptance is geometrically distributed, and the full distribution follows directly, with no further derivation or assumption beyond what already goes into \(p\).

Iterations ML-DSA-44 ML-DSA-65 ML-DSA-87
1 22.93% 19.46% 25.61%
2 40.61% 35.14% 44.66%
3 54.23% 47.77% 58.83%
4 64.72% 57.93% 69.37%
5 72.81% 66.12% 77.21%
6 79.05% 72.72% 83.05%
7 83.85% 78.03% 87.39%
8 87.56% 82.30% 90.62%
9 90.41% 85.75% 93.02%
10 92.61% 88.52% 94.81%
11 94.30% 90.76% 96.14%
12 95.61% 92.56% 97.13%
Table 9: Probability of completing the signing process within a given number of iterations.

Every variant reaches at least 90% completion within 11 iterations, but the tail runs long: ML-DSA-65 needs 22 iterations to reach 99%, against an expected 5.14 - more than four times as many. For anyone budgeting worst-case latency or energy on a constrained device, this tail column, not the mean, is the one that matters.

Target ML-DSA-44 ML-DSA-65 ML-DSA-87
90% 9 11 8
95% 12 14 11
99% 18 22 16
Table 10: Iterations required to reach a given probability of completing the signing process.

The agreement claimed above is the load-bearing evidence for the modelling assumptions, so it should not have to be taken on trust. The panel below runs mldsa-ref [10] compiled to WebAssembly and measures the acceptance probability in the browser, against the derived value from the table above.

Loading WebAssembly…

The key count is the most informative control to vary. \(P_{hint}\) depends on the key through \(t_0\), so the per-attempt acceptance probability is not identical for every key, and an average taken over too few keys estimates those keys’ value rather than the population’s. That kind of bias shrinks when keys are added.

The panel separates the two effects rather than conflating them. With several keys it reports the observed spread of per-key means, the part of that spread sampling alone would produce at the chosen sample size, and therefore how much genuine key-to-key variation remains. At a few hundred signatures per key, that remaining variation is typically indistinguishable from noise. Raising the signature count while holding the key count fixed is what lets real variation separate from sampling noise. The hint-rejection count is a useful diagnostic in its own right: the precision of \(P_{hint}\) depends only on the number of rejections observed, not on the number of signatures run, with relative error \(\frac{1}{\sqrt{R}}\) for \(R\) rejections observed.

The panel’s precision is bounded by its sample size. Ten thousand browser signatures give a standard error of roughly 0.04 on the mean, sufficient to confirm the derived value but not to contest it. The 200,000-signature setting reduces that to about 0.009, at the cost of several minutes of CPU time.

The Appendix C Loop Bound

FIPS-204 lets implementations bound the number of loop iterations, and Appendix C derives the smallest bound whose probability of being reached is at most \(2^{-256}\). It solves \((\frac{5.1 - 1}{5.1})^n \le 2^{-256}\), using the rounded Table 1 figure 5.1, and obtains \(n = 814\).

NIST’s errata [2] replaces the input with the corrected rounded figure, 5.14, and by the same method obtains \(n = 821\). Both 814 and 821 are therefore derived by rounding the acceptance probability to three significant digits before taking the ceiling; since rounding the probability down rounds \(n\) up, this can only push the bound higher, never lower. Carrying the full-precision acceptance probability through the same inequality instead gives \(n = 820\) - one iteration tighter than the errata, and the value used in the table below.

An implementation capping at 814, the pre-errata figure, reaches the cap with probability \(2^{-254.2}\) - not a security concern, since all of 814, 820 and 821 are far beyond any reachable computation, but short of the stated \(2^{-256}\) guarantee. Implementations that bound the loop should use 821, the errata value; 820 is the tighter figure under key averaging only, and neither is a per-key guarantee.

Quantity Value
Binding acceptance probability 0.19465
Corrected bound 820
FIPS-204 Appendix C 814
Failure probability at 814 2^-254.2
Failure probability at 820 2^-256.1
Table 11: Corrected loop bound. ML-DSA-65 is the binding case, having the lowest acceptance probability.

Status Against the Errata. NIST’s errata [2], dated 31 July 2026, already records both corrections: the Table 1 Repetitions row becomes 4.36 / 5.14 / 3.91, matching the values derived above, and the Table 3 loop bound becomes 821. The errata states the cause as line 28 of Algorithm 7 not having been accounted for, but gives neither a derivation nor an account of why its own figure differs from the full-precision value obtained here. The one open point is the loop bound itself: for the key-averaged \(p\) computed here, 820 rather than 821 meets the stated \(2^{-256}\) target at full precision, for the reason given in Section 8 - though, as noted there, 820 is not a per-key guarantee. The other three rows of Table 3 concern unrelated loops (RejBoundedPoly, RejNTTPoly, SampleInBall) and are unaffected either way.

Conclusion

The error in FIPS-204 Table 1 is small in magnitude, affecting runtime rather than security, but it illustrates a general hazard in how numbers travel between documents. The derivation yields two general observations:

  • \(P_{norm}\)’s \(z\)-coefficient factor is exact counting; its \(r_0\)-coefficient factor rests on a uniformity heuristic. \(P_{ct_0}\) is a closed-form distribution whose bounded support does most of the work. \(P_{hint}\) is a correlated count with no tractable closed form, and requires simulation.

  • The single largest correction to \(P_{hint}\) came from observing that lines 25-28 sit inside an else branch, so the low part \(r_0\) had already been constrained by line 23. That is a fact about control flow, not about probability, and it moves the answer by 13 to 32 %.

Every number in this derivation comes from the mldsa_rejection package. The package is archived unmodified alongside this note [11].

References

[1]
National Institute of Standards and Technology, “Module-Lattice-Based Digital Signature Standard,” U.S. Department of Commerce, Federal Information Processing Standards Publication FIPS 204, Aug. 2024. doi: 10.6028/NIST.FIPS.204.
[2]
National Institute of Standards and Technology, “FIPS 204 potential updates.” Jul. 31, 2026. Available: https://csrc.nist.gov/files/pubs/fips/204/final/docs/fips-204-potential-updates.xlsx
[3]
T. Reddy, D. Wing, B. Salter, and K. Kwiatkowski, “Adapting Constrained Devices for Post-Quantum Cryptography,” Internet Engineering Task Force, Internet-Draft draft-ietf-pquip-pqc-hsm-constrained, 2026. Available: https://datatracker.ietf.org/doc/draft-ietf-pquip-pqc-hsm-constrained/
[4]
K. Kwiatkowski, “Benchmarking ML-DSA Signature Generation: Understanding Rejection Sampling Performance.” Feb. 2026. Available: https://amongbytes.com/posts/20260223-mldsa-benchmarking/
[5]
H. Becker and M.-J. O. Saarinen, “ML-DSA signing failure rate.” NIST pqc-forum mailing list, Jul. 2026. Available: https://groups.google.com/a/list.nist.gov/g/pqc-forum/c/BF7a9So7-1Y/m/uKFsXZx6BAAJ
[6]
M. Hamburg and Q. Xiao, “Test vectors for the Dilithium \(c \cdot t_0\) check.” NIST pqc-forum mailing list, Mar. 2024. Available: https://groups.google.com/a/list.nist.gov/g/pqc-forum/c/G8Zf0hC-uu0/m/Kb3qNJb0AwAJ
[7]
L. Yan, M. Luo, and M. Wang, “Speedup signing: Pre-rejection sampling towards dilithium,” Cybersecurity, vol. 8, no. 1, p. 10, 2025, doi: 10.1186/s42400-024-00325-6.
[8]
S. Bai et al., “CRYSTALS-Dilithium Algorithm Specifications and Supporting Documentation (Version 3.1).” Feb. 2021. Available: https://pq-crystals.org/dilithium/data/dilithium-specification-round3-20210208.pdf
[9]
C. Celi et al., “ACVP ML-DSA JSON specification.” Internet-Draft draft-celi-acvp-ml-dsa, 2026. Available: https://pages.nist.gov/ACVP/draft-celi-acvp-ml-dsa.html
[10]
K. Kwiatkowski, “MLDSA-ref: A reference implementation of ML-DSA digital signature.” Oct. 2026. Available: https://github.com/kriskwiatkowski/mldsa-ref
[11]
K. Kwiatkowski, “Rejection rate of ML-DSA signing.” Zenodo, 2026. doi: 10.5281/zenodo.23278885.