Algorithm Search⌘K

Elliptic curve primality proving in Python

Proves n prime with a chain of elliptic curves of known order, built by complex multiplication. The proof is a certificate that is fast to check; it is the fastest general proof in practice.

File
ecpp.py, 694 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
ecpp.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/ecpp/. The authors are listed in prov/5ZT65R.jsonl.

"""Atkin-Morain elliptic curve primality proving (ECPP).

ecpp(n) returns a primality certificate (a list of Step records) when n is
prime, and None when n is composite. The answer is never wrong: a None comes
with a proof that n is composite, and a certificate is a proof that n is
prime, which verify_certificate checks with the Goldwasser-Kilian theorem.
Randomness affects only the running time. The last number of a chain is
below 2^64 and is proved prime by the strong test to the first 12 prime
bases, which is exact below 2^64.

Each step finds a fundamental discriminant D < 0 with 4n = u^2 + |D| v^2, an
order m of a curve with complex multiplication by D that splits as m = k q
with q a probable prime above (n^(1/4) + 1)^2, builds the curve from a root of
the Hilbert class polynomial H_D modulo n, and checks [k]P != O and [m]P = O
for a point P. Then the primality of n follows from the primality of q.

Cost, with lg n binary digits and arbitrary precision integers (Python int):
heuristically O((log n)^2) discriminants and orders per step, each order
costing a probable prime test of O((log n)^3), and O(log n) steps, so
O((log n)^5) to O((log n)^6) in all; no part of this is proved.
"""

import math
import random
from decimal import Decimal, localcontext
from typing import NamedTuple

LEAF = 2**64
TRIAL_BOUND = 2**16
MAX_ABS_DISCRIMINANT = 10000
MAX_CLASS_NUMBER = 40
POINT_TRIES = 20
FIRST_12_PRIMES = (2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37)


class Composite(Exception):
    """Raised when the current number is proved composite."""


class Step(NamedTuple):
    """One link of a certificate: the curve y^2 = x^3 + a x + b modulo n,
    the point P = (x, y), and the order m = k q with [k]P != O, [m]P = O."""

    n: int
    a: int
    b: int
    x: int
    y: int
    k: int
    q: int


def primes_up_to(m: int) -> list[int]:
    """The primes p <= m, by the sieve of Eratosthenes."""
    if m < 2:
        return []
    sieve = bytearray([1]) * (m + 1)
    sieve[0] = sieve[1] = 0
    for i in range(2, math.isqrt(m) + 1):
        if sieve[i]:
            sieve[i * i :: i] = bytearray(len(range(i * i, m + 1, i)))
    return [i for i in range(m + 1) if sieve[i]]


SMALL_PRIMES = primes_up_to(TRIAL_BOUND)


# ---------------------------------------------------------------- integers


def jacobi(a: int, n: int) -> int:
    """The Jacobi symbol (a/n) for odd n >= 1."""
    if n < 1 or n % 2 == 0:
        raise ValueError("n must be odd and positive")
    a %= n
    result = 1
    while a:
        while a % 2 == 0:
            a //= 2
            if n % 8 in (3, 5):
                result = -result
        a, n = n, a
        if a % 4 == 3 and n % 4 == 3:
            result = -result
        a %= n
    return result if n == 1 else 0


def is_strong_probable_prime(n: int, base: int) -> bool:
    """The strong (Miller-Rabin) test of the odd number n > 2 to one base."""
    d, s = n - 1, 0
    while d % 2 == 0:
        d //= 2
        s += 1
    x = pow(base, d, n)
    if x in (1, n - 1):
        return True
    for _ in range(s - 1):
        x = x * x % n
        if x == n - 1:
            return True
    return False


def is_prime_below_leaf(n: int) -> bool:
    """Exact primality for 0 <= n < 2^64: the strong test to the first 12
    primes has no composite liar below 2^64 (Sorenson and Webster 2017)."""
    if n >= LEAF:
        raise ValueError("n must be below 2^64")
    if n < 2:
        return False
    for p in FIRST_12_PRIMES:
        if n % p == 0:
            return n == p
    return all(is_strong_probable_prime(n, p) for p in FIRST_12_PRIMES)


def is_probable_prime(n: int) -> bool:
    """Exact below 2^64; above, the strong test to the first 12 primes."""
    if n < LEAF:
        return is_prime_below_leaf(n)
    if n % 2 == 0 or not is_strong_probable_prime(n, 2):
        return False
    return all(is_strong_probable_prime(n, p) for p in FIRST_12_PRIMES[1:])


def inverse(x: int, n: int) -> int:
    """x^(-1) modulo n; raises Composite if gcd(x, n) is a proper factor."""
    x %= n
    g = math.gcd(x, n)
    if g != 1:
        if g == n:
            raise ZeroDivisionError("x is 0 modulo n")
        raise Composite(g)
    return pow(x, -1, n)


def sqrt_mod(a: int, n: int) -> int | None:
    """Tonelli-Shanks: a root of y^2 = a modulo n, correct when n is an odd
    prime and (a/n) = 1. For other n it may return None or a wrong value, so
    the caller checks the answer."""
    a %= n
    if a == 0:
        return 0
    if n % 4 == 3:
        return pow(a, (n + 1) // 4, n)
    q, s = n - 1, 0
    while q % 2 == 0:
        q //= 2
        s += 1
    z = 2
    while True:
        j = jacobi(z, n)
        if j == -1:
            break
        if j == 0:
            raise Composite(math.gcd(z, n))
        z += 1
    m, c, t, r = s, pow(z, q, n), pow(a, q, n), pow(a, (q + 1) // 2, n)
    while t != 1:
        i, t2 = 0, t
        while t2 != 1:
            t2 = t2 * t2 % n
            i += 1
            if i == m:
                return None
        b = pow(c, 1 << (m - i - 1), n)
        m, c = i, b * b % n
        t, r = t * c % n, r * b % n
    return r


def large_enough(q: int, n: int) -> bool:
    """Exact test of q > (n^(1/4) + 1)^2, that is n < (q^(1/2) - 1)^4."""
    if q <= 1:
        return False
    rest = q * q + 6 * q + 1 - n
    return rest > 0 and rest * rest > 16 * (q + 1) ** 2 * q


def cornacchia(d: int, n: int) -> tuple[int, int] | None:
    """Modified Cornacchia (Crandall and Pomerance, Algorithm 2.3.13): u, v
    with u^2 + d v^2 = 4n, for -D = d, D = 0 or 1 modulo 4 and (D/n) = 1. For
    a prime n the answer is None exactly when no solution exists. Raises
    Composite if no square root of D modulo n is found."""
    disc = -d % n
    x0 = sqrt_mod(disc, n)
    if x0 is None or x0 * x0 % n != disc:
        raise Composite()
    if (x0 + d) % 2:
        x0 = n - x0
    a, b, c = 2 * n, x0, math.isqrt(4 * n)
    while b > c:
        a, b = b, a % b
    t = 4 * n - b * b
    if t % d:
        return None
    v = math.isqrt(t // d)
    if v * v != t // d:
        return None
    return b, v


# ------------------------------------------------- discriminants and H_D


def _squarefree(m: int) -> bool:
    p = 2
    while p * p <= m:
        if m % (p * p) == 0:
            return False
        p += 1
    return True


def is_fundamental(D: int) -> bool:
    """D < 0 is a fundamental discriminant."""
    if D >= 0:
        return False
    if D % 4 == 1:
        return _squarefree(-D)
    if D % 4 == 0:
        return (D // 4) % 4 in (2, 3) and _squarefree(-D // 4)
    return False


def reduced_forms(D: int) -> list[tuple[int, int, int]]:
    """The reduced forms (a, b, c), b^2 - 4ac = D: |b| <= a <= c, and b >= 0
    when |b| = a or a = c. Their number is the class number h(D)."""
    forms = []
    a = 1
    while 3 * a * a <= -D:
        for b in range(-a + 1, a + 1):
            num = b * b - D
            if num % (4 * a):
                continue
            c = num // (4 * a)
            if c < a or (b < 0 and c == a):
                continue
            forms.append((a, b, c))
        a += 1
    return forms


_DISCRIMINANTS: list[int] = []


def discriminants() -> list[int]:
    """Fundamental D with |D| <= 10000 and h(D) <= 40, by h then |D|. The
    class numbers come from one pass over all reduced forms."""
    if not _DISCRIMINANTS:
        count: dict[int, int] = {}
        a = 1
        while 3 * a * a <= MAX_ABS_DISCRIMINANT:
            for b in range(-a + 1, a + 1):
                c = a
                while 4 * a * c - b * b <= MAX_ABS_DISCRIMINANT:
                    if not (b < 0 and c == a):
                        D = b * b - 4 * a * c
                        count[D] = count.get(D, 0) + 1
                    c += 1
            a += 1
        found = sorted((h, -D) for D, h in count.items() if h <= MAX_CLASS_NUMBER and is_fundamental(D))
        _DISCRIMINANTS.extend(-d for _, d in found)
    return _DISCRIMINANTS


def _pi() -> Decimal:
    """pi to the current precision (the recipe of the decimal module)."""
    with localcontext() as ctx:
        ctx.prec += 2
        lasts, t, s, n, na, d, da = 0, Decimal(3), Decimal(3), 1, 0, 0, 24
        while s != lasts:
            lasts = s
            n, na = n + na, na + 8
            d, da = d + da, da + 32
            t = (t * n) / d
            s += t
    return +s


def _cos_sin(x: Decimal) -> tuple[Decimal, Decimal]:
    """cos x and sin x by their Taylor series, for |x| <= 4."""
    with localcontext() as ctx:
        ctx.prec += 4
        cos_sum, sin_sum = Decimal(0), Decimal(0)
        term, k = Decimal(1), 0
        while term:
            if k % 2 == 0:
                cos_sum += term if k % 4 == 0 else -term
            else:
                sin_sum += term if k % 4 == 1 else -term
            k += 1
            term = term * x / k
            if abs(term) < Decimal(10) ** (-ctx.prec - 2):
                break
    return +cos_sum, +sin_sum


def _cmul(u: tuple[Decimal, Decimal], v: tuple[Decimal, Decimal]) -> tuple[Decimal, Decimal]:
    return (u[0] * v[0] - u[1] * v[1], u[0] * v[1] + u[1] * v[0])


def _cdiv(u: tuple[Decimal, Decimal], v: tuple[Decimal, Decimal]) -> tuple[Decimal, Decimal]:
    norm = v[0] * v[0] + v[1] * v[1]
    return ((u[0] * v[0] + u[1] * v[1]) / norm, (u[1] * v[0] - u[0] * v[1]) / norm)


def _j_value(a: int, b: int, D: int, pi: Decimal, digits: int) -> tuple[Decimal, Decimal]:
    """j((-b + i sqrt|D|) / (2a)) as j = (256 f + 1)^3 / f, f = q (P(q^2)/P(q))^24,
    with q = exp(2 pi i tau) and P(q) = prod (1 - q^k), the Euler series."""
    one = (Decimal(1), Decimal(0))
    decay = pi * Decimal(-D).sqrt() / a
    rho = (-decay).exp()
    c, s = _cos_sin(-pi * b / a)
    q = (rho * c, rho * s)
    top = int(Decimal(digits) * Decimal(10).ln() / decay) + 3
    powers = [one, q]
    for _ in range(2 * top + 2):
        powers.append(_cmul(powers[-1], q))

    def euler(step: int) -> tuple[Decimal, Decimal]:
        total = [Decimal(1), Decimal(0)]
        k = 1
        while (k * (3 * k - 1) // 2) * step < len(powers):
            sign = -1 if k % 2 else 1
            for e in (k * (3 * k - 1) // 2, k * (3 * k + 1) // 2):
                if e * step < len(powers):
                    total[0] += sign * powers[e * step][0]
                    total[1] += sign * powers[e * step][1]
            k += 1
        return (total[0], total[1])

    ratio = _cdiv(euler(2), euler(1))
    r24 = one
    for _ in range(24):
        r24 = _cmul(r24, ratio)
    f = _cmul(q, r24)
    g = (256 * f[0] + 1, 256 * f[1])
    return _cdiv(_cmul(_cmul(g, g), g), f)


_HILBERT: dict[int, list[int]] = {}


def hilbert_class_polynomial(D: int) -> list[int]:
    """H_D(X), coefficients from X^0 up (Crandall and Pomerance, Algorithm
    7.5.8), from the j-values of the reduced forms in high precision."""
    if D in _HILBERT:
        return _HILBERT[D]
    forms = reduced_forms(D)
    size = sum(math.pi * math.sqrt(-D) / a for a, _, _ in forms) / math.log(10)
    digits = int(size) + len(forms) + 40
    with localcontext() as ctx:
        ctx.prec = digits
        pi = _pi()
        poly = [(Decimal(1), Decimal(0))]
        for a, b, _ in forms:
            j = _j_value(a, b, D, pi, digits)
            neg = (-j[0], -j[1])
            new = [(Decimal(0), Decimal(0))] * (len(poly) + 1)
            for i, coef in enumerate(poly):
                new[i + 1] = (new[i + 1][0] + coef[0], new[i + 1][1] + coef[1])
                prod = _cmul(coef, neg)
                new[i] = (new[i][0] + prod[0], new[i][1] + prod[1])
            poly = new
        result = []
        tolerance = Decimal(10) ** -10
        for re, im in poly:
            near = int(re.to_integral_value())
            if abs(im) > tolerance or abs(re - near) > tolerance:
                raise ArithmeticError("precision too low for H_D")
            result.append(near)
    _HILBERT[D] = result
    return result


# -------------------------------------------------- polynomials modulo n


def _trim(f: list[int]) -> list[int]:
    while f and f[-1] == 0:
        f.pop()
    return f


def _poly_mul(f: list[int], g: list[int], n: int) -> list[int]:
    """f g modulo n, by Kronecker substitution."""
    if not f or not g:
        return []
    width = 2 * n.bit_length() + max(len(f), len(g)).bit_length() + 1
    big_f = 0
    for c in reversed(f):
        big_f = (big_f << width) | c
    big_g = 0
    for c in reversed(g):
        big_g = (big_g << width) | c
    prod = big_f * big_g
    mask = (1 << width) - 1
    out = []
    for _ in range(len(f) + len(g) - 1):
        out.append((prod & mask) % n)
        prod >>= width
    return _trim(out)


def _monic(f: list[int], n: int) -> list[int]:
    lead = inverse(f[-1], n)
    return [c * lead % n for c in f]


def _poly_rem(f: list[int], g: list[int], n: int) -> list[int]:
    """f modulo the monic polynomial g, over Z_n."""
    f = list(f)
    dg = len(g) - 1
    for i in range(len(f) - 1, dg - 1, -1):
        c = f[i] % n
        if c:
            for t in range(dg):
                f[i - dg + t] -= c * g[t]
        f[i] = 0
    return _trim([c % n for c in f[:dg]])


def _poly_gcd(f: list[int], g: list[int], n: int) -> list[int]:
    """Monic gcd over Z_n; raises Composite if a leading coefficient is not
    invertible."""
    f, g = _trim(list(f)), _trim(list(g))
    while g:
        g = _monic(g, n)
        f, g = g, _poly_rem(f, g, n)
    return _monic(f, n) if f else f


def _poly_pow(base: list[int], e: int, g: list[int], n: int) -> list[int]:
    """base^e modulo (g, n), g monic, by left-to-right binary powering."""
    result = [1]
    for bit in bin(e)[2:]:
        result = _poly_rem(_poly_mul(result, result, n), g, n)
        if bit == "1":
            result = _poly_rem(_poly_mul(result, base, n), g, n)
    return result


def find_root(poly: list[int], n: int, rng: random.Random, tries: int = 64) -> int | None:
    """A root modulo n of poly, by the Cantor-Zassenhaus split, or None. A
    returned value r always has poly(r) = 0 modulo n."""
    h = _trim([c % n for c in poly])
    if len(h) < 2:
        return None
    h = _monic(h, n)
    # The distinct linear factors of h: g = gcd(h, X^n - X).
    x_n = _poly_pow([0, 1], n, h, n)
    x_n += [0] * (2 - len(x_n))
    x_n[1] -= 1
    g = _poly_gcd(h, _trim([c % n for c in x_n]), n)
    while len(g) > 2:
        # Split g by gcd(g, (X + t)^((n-1)/2) - 1) for a random t.
        for _ in range(tries):
            w = _poly_pow([rng.randrange(n), 1], (n - 1) // 2, g, n) or [0]
            w[0] -= 1
            d = _poly_gcd(g, _trim([c % n for c in w]), n)
            if 1 < len(d) < len(g):
                g = d
                break
        else:
            return None
    if len(g) != 2:
        return None
    root = -g[0] % n
    value = 0
    for c in reversed(poly):
        value = (value * root + c) % n
    return root if value == 0 else None


# ------------------------------------------------- curves modulo n


Point = tuple[int, int] | None


def ec_add(p: Point, r: Point, a: int, n: int) -> Point:
    """p + r on y^2 = x^3 + a x + b over Z_n (None is O). Every step is valid
    modulo each prime factor of n, or Composite is raised."""
    if p is None:
        return r
    if r is None:
        return p
    x1, y1 = p
    x2, y2 = r
    if x1 == x2:
        if (y1 + y2) % n == 0:
            return None
        if y1 != y2:
            # For a prime n, equal x forces y1 = y2 or y1 = -y2.
            g = math.gcd(y1 - y2, n)
            raise Composite(g) if 1 < g < n else Composite()
        lam = (3 * x1 * x1 + a) * inverse(2 * y1, n) % n
    else:
        lam = (y2 - y1) * inverse(x2 - x1, n) % n
    x3 = (lam * lam - x1 - x2) % n
    return (x3, (lam * (x1 - x3) - y1) % n)


def ec_mul(k: int, p: Point, a: int, n: int) -> Point:
    """[k]p for k >= 1, by left-to-right double and add."""
    result: Point = None
    for bit in bin(k)[2:]:
        result = ec_add(result, result, a, n)
        if bit == "1":
            result = ec_add(result, p, a, n)
    return result


def curve_orders(d: int, n: int, u: int, v: int) -> list[int]:
    """The orders of the curves with complex multiplication by D = -d over
    F_n, n prime, given u^2 + d v^2 = 4n."""
    if d == 4:
        return [n + 1 + u, n + 1 - u, n + 1 + 2 * v, n + 1 - 2 * v]
    if d == 3:
        s, t = (u + 3 * v) // 2, (u - 3 * v) // 2
        return [n + 1 + u, n + 1 - u, n + 1 + s, n + 1 - s, n + 1 + t, n + 1 - t]
    return [n + 1 + u, n + 1 - u]


def _nonresidue(n: int, noncube: bool) -> int | None:
    """The least g with (g/n) = -1 and, if asked, g^((n-1)/3) != 1."""
    for g in range(2, min(n, 10**5)):
        j = jacobi(g, n)
        if j == 0:
            raise Composite(math.gcd(g, n))
        if j == -1 and (not noncube or pow(g, (n - 1) // 3, n) != 1):
            return g
    return None


def twists(D: int, j: int, n: int) -> list[tuple[int, int]] | None:
    """The curves (a, b) with j-invariant j (CM by D) up to isomorphism over
    F_n, n prime: 6 for D = -3, 4 for D = -4, 2 otherwise (Algorithm 7.5.9)."""
    if D == -3:
        g = _nonresidue(n, True)
        return None if g is None else [(0, -pow(g, e, n) % n) for e in range(6)]
    g = _nonresidue(n, False)
    if g is None:
        return None
    if D == -4:
        return [(-pow(g, e, n) % n, 0) for e in range(4)]
    if j % n in (0, 1728 % n):
        return None
    c = j * inverse(j - 1728, n) % n
    a, b = -3 * c % n, 2 * c % n
    return [(a, b), (a * g * g % n, b * g * g * g % n)]


def random_point(a: int, b: int, n: int, rng: random.Random, tries: int = 200) -> tuple[int, int] | None:
    """A point (x, y) on y^2 = x^3 + a x + b modulo n with y != 0, or None
    after the given number of tries (for a prime n each try succeeds with
    probability about 1/2)."""
    for _ in range(tries):
        x = rng.randrange(n)
        rhs = (x * x * x + a * x + b) % n
        j = jacobi(rhs, n)
        if j == 1:
            y = sqrt_mod(rhs, n)
            if y is None or y * y % n != rhs:
                raise Composite()
            return (x, y)
        if j == 0 and rhs:
            raise Composite(math.gcd(rhs, n))
    return None


def split_order(m: int, n: int, bad: set[int]) -> tuple[int, int] | None:
    """m = k q with k > 1, q a probable prime above (n^(1/4) + 1)^2 and not
    in bad, after removing the primes up to min(2^16, n^(1/2)) from m."""
    limit = min(TRIAL_BOUND, math.isqrt(n))
    q = m
    for p in SMALL_PRIMES:
        if p > limit:
            break
        while q % p == 0:
            q //= p
    k = m // q
    if k == 1 or q in bad or not large_enough(q, n) or not is_probable_prime(q):
        return None
    return k, q


# ------------------------------------------------------------- the test


def _step(n: int, rng: random.Random, bad: set[int]) -> Step:
    """One Atkin-Morain step for n >= 2^64 (or the leaf bound): returns a
    Step whose q is a smaller probable prime, or raises Composite."""
    for D in discriminants():
        d = -D
        if d >= 4 * n or jacobi(D, n) != 1:  # @line L7
            continue
        solution = cornacchia(d, n)
        if solution is None:
            continue
        u, v = solution
        j = None
        for m in curve_orders(d, n, u, v):  # @line L8
            split = split_order(m, n, bad)
            if split is None:
                continue
            k, q = split
            if j is None:
                j = find_root(hilbert_class_polynomial(D), n, rng)  # @line L9
                if j is None:
                    break
            curves = twists(D, j, n)
            if curves is None:
                break
            refuted = 0
            for a, b in curves:  # @line L10
                g = math.gcd((4 * a**3 + 27 * b * b) % n, n)
                if g != 1:
                    raise Composite(g)
                for _ in range(POINT_TRIES):
                    point = random_point(a, b, n, rng)  # @line L11
                    if point is None:
                        break
                    x, y = point
                    u_point = ec_mul(k, point, a, n)
                    if u_point is None:
                        continue
                    if ec_mul(q, u_point, a, n) is None:  # @line L12
                        return Step(n, a, b, x, y, k, q)
                    refuted += 1
                    break
            if refuted == len(curves):
                raise Composite()  # @line L13
    raise RuntimeError("no suitable discriminant in the table")  # @line L14


def ecpp(n: int, rng: random.Random | None = None, leaf: int = LEAF) -> list[Step] | None:
    """A certificate of primality for n (a list of Step records, empty when
    n < leaf), or None if n is composite. The leaf bound must lie in
    [50, 2^64]; smaller values only make the chains longer."""
    if not 50 <= leaf <= LEAF:
        raise ValueError("leaf must be in [50, 2^64]")
    rng = rng if rng is not None else random.Random()
    chain: list[Step] = []  # @line L1
    bad: set[int] = set()
    while True:  # @line L2
        try:
            if n < leaf:  # @line L3
                if is_prime_below_leaf(n):
                    return chain  # @line L4
                raise Composite()  # @line L5
            if n % 2 == 0 or n % 3 == 0 or math.isqrt(n) ** 2 == n or not is_strong_probable_prime(n, 2):
                raise Composite()  # @line L6
            step = _step(n, rng, bad)
            chain.append(step)
            n = step.q
        except Composite:
            if not chain:
                return None  # @line L15
            bad.add(n)  # @line L16
            n = chain.pop().n
        except RuntimeError:
            if not chain:
                raise
            bad.add(n)
            n = chain.pop().n


def verify_certificate(n: int, chain: list[Step]) -> bool:
    """Check a certificate with the Goldwasser-Kilian theorem: for each step,
    n coprime to 6, a nonsingular curve, P on it, q > (n^(1/4) + 1)^2,
    [k]P != O and [q]([k]P) = O; and the last number prime below 2^64."""
    for st in chain:
        if st.n != n or n < 5 or math.gcd(n, 6) != 1 or st.k < 1:
            return False
        if math.gcd((4 * st.a**3 + 27 * st.b**2) % n, n) != 1:
            return False
        if (st.y * st.y - st.x**3 - st.a * st.x - st.b) % n:
            return False
        if not large_enough(st.q, n):
            return False
        try:
            u_point = ec_mul(st.k, (st.x % n, st.y % n), st.a % n, n)
            if u_point is None or ec_mul(st.q, u_point, st.a % n, n) is not None:
                return False
        except (Composite, ZeroDivisionError):
            return False
        n = st.q
    return n < LEAF and is_prime_below_leaf(n)
test_ecpp.py 317 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/ecpp/. The authors are listed in prov/5ZT65R.jsonl.

import math
import random
from collections import Counter
from decimal import Decimal, localcontext

import pytest

from ecpp import (
    LEAF,
    Composite,
    Step,
    cornacchia,
    curve_orders,
    discriminants,
    ec_add,
    ec_mul,
    ecpp,
    find_root,
    hilbert_class_polynomial,
    inverse,
    is_prime_below_leaf,
    is_probable_prime,
    large_enough,
    random_point,
    reduced_forms,
    split_order,
    sqrt_mod,
    twists,
    verify_certificate,
)

LIMIT = 10**5


def sieve(m: int) -> bytearray:
    is_p = bytearray([1]) * (m + 1)
    is_p[0] = is_p[1] = 0
    for i in range(2, math.isqrt(m) + 1):
        if is_p[i]:
            is_p[i * i :: i] = bytearray(len(range(i * i, m + 1, i)))
    return is_p


def is_prime_by_trial_division(n: int) -> bool:
    if n < 2:
        return False
    i = 2
    while i * i <= n:
        if n % i == 0:
            return False
        i += 1
    return True


def check_proof(n: int, rng: random.Random, leaf: int = LEAF) -> list[Step]:
    chain = ecpp(n, rng, leaf)
    assert chain is not None
    assert verify_certificate(n, chain)
    numbers = [n] + [st.q for st in chain]
    assert all(a > b for a, b in zip(numbers, numbers[1:]))
    assert numbers[-1] < leaf
    return chain


def next_probable_prime(n: int) -> int:
    while not is_probable_prime(n):
        n += 1
    return n


def count_points(a: int, b: int, p: int) -> int:
    squares = [0] * p
    for y in range(p):
        squares[y * y % p] += 1
    return 1 + sum(squares[(x * x * x + a * x + b) % p] for x in range(p))


def test_all_small_n_against_trial_division() -> None:
    is_p = sieve(LIMIT)
    rng = random.Random(1)
    for n in range(LIMIT + 1):
        chain = ecpp(n, rng, leaf=50)
        assert (chain is not None) == bool(is_p[n]), n
        if chain is not None:
            assert verify_certificate(n, chain), n
    for n in range(0, LIMIT + 1, 7):
        assert (ecpp(n, rng) is not None) == bool(is_p[n])
    assert all(is_prime_below_leaf(n) == bool(is_p[n]) for n in range(LIMIT + 1))
    assert is_prime_by_trial_division(99991) and ecpp(99991, rng, leaf=50)


def test_special_inputs() -> None:
    rng = random.Random(2)
    for n in (-7, -1, 0, 1, 4, 6, 9, 49, 2**64, 2**200, 3 * (2**127 - 1)):
        assert ecpp(n, rng) is None
    for n in (2, 3, 5, 7, 37, 2**61 - 1, LEAF - 59):
        assert ecpp(n, rng) == []
        assert verify_certificate(n, [])
    for n in (101**20, (10**9 + 7) ** 3, (2**89 - 1) ** 2, (2**127 - 1) ** 3):
        assert ecpp(n, rng) is None
    with pytest.raises(ValueError):
        ecpp(101, rng, leaf=10)
    with pytest.raises(ValueError):
        ecpp(101, rng, leaf=LEAF + 1)


def test_composites_that_pass_the_base_2_test() -> None:
    rng = random.Random(3)
    carmichael = (561, 1105, 1729, 2465, 2821, 6601, 8911, 41041, 62745, 63973, 75361)
    strong_base_2 = (2047, 3277, 4033, 4681, 8321, 15841, 29341, 42799, 49141, 52633, 65281)
    for n in carmichael + strong_base_2 + (3825123056546413051, 318665857834031151167461):
        assert ecpp(n, rng, leaf=50) is None, n
    # Composite Mersenne and Fermat numbers are strong pseudoprimes to the
    # base 2, so they reach the curve steps.
    for n in (2**67 - 1, 2**71 - 1, 2**101 - 1, 2**64 + 1, 2**128 + 1, 2**256 + 1):
        assert pow(2, n - 1, n) == 1
        assert ecpp(n, rng) is None, n
    k = 10**59 + 3
    while True:
        p1, p2, p3 = 6 * k + 1, 12 * k + 1, 18 * k + 1
        if all(is_probable_prime(p) for p in (p1, p2, p3)):
            break
        k += 1
    big = p1 * p2 * p3
    assert pow(2, big - 1, big) == 1
    assert ecpp(big, rng) is None


def test_primes_with_proofs() -> None:
    rng = random.Random(4)
    for n in (10**20 + 39, 2**89 - 1, 2**127 - 1, 10**30 + 57, 10**100 + 267):
        check_proof(n, rng)
    check_proof(2**521 - 1, rng)
    check_proof(next_probable_prime(10**199), rng)
    for n in (1009, 100003, 999983, 10**12 + 39):
        check_proof(n, rng, leaf=50)


def test_large_composites_and_random_inputs() -> None:
    rng = random.Random(5)
    p = next_probable_prime(10**150 + rng.randrange(10**149))
    q = next_probable_prime(10**150 + rng.randrange(10**149))
    assert ecpp(p * q, rng) is None
    assert ecpp(p * p, rng) is None
    for _ in range(20):
        n = rng.randrange(10**299, 10**300) | 1
        assert (ecpp(n, rng) is not None) == is_probable_prime(n)
    for _ in range(40):
        n = rng.randrange(2**64, 2**130)
        chain = ecpp(n, rng)
        assert (chain is not None) == is_probable_prime(n)
        if chain is not None:
            assert verify_certificate(n, chain)


def test_hilbert_class_polynomials() -> None:
    assert hilbert_class_polynomial(-3) == [0, 1]
    assert hilbert_class_polynomial(-4) == [-1728, 1]
    assert hilbert_class_polynomial(-7) == [3375, 1]
    assert hilbert_class_polynomial(-8) == [-8000, 1]
    assert hilbert_class_polynomial(-11) == [32768, 1]
    assert hilbert_class_polynomial(-163) == [640320**3, 1]
    assert hilbert_class_polynomial(-15) == [-121287375, 191025, 1]
    assert hilbert_class_polynomial(-20) == [-681472000, -1264000, 1]
    assert hilbert_class_polynomial(-23) == [12771880859375, -5151296875, 3491750, 1]


def test_class_numbers_of_the_table() -> None:
    table = discriminants()
    h = Counter(len(reduced_forms(D)) for D in table)
    # The number of imaginary quadratic fields of class number 1 to 8
    # (Watkins 2004); all of them have |D| < 10000.
    assert [h[i] for i in range(1, 9)] == [9, 18, 16, 54, 25, 51, 31, 131]
    assert table[:9] == [-3, -4, -7, -8, -11, -19, -43, -67, -163]


def test_cm_orders_match_point_counts() -> None:
    rng = random.Random(6)
    for p in (1009, 1201, 2003, 2999):
        tried = 0
        for D in discriminants()[:80]:
            d = -D
            if d >= 4 * p or (D % p) == 0 or pow(D % p, (p - 1) // 2, p) != 1:
                continue
            sol = cornacchia(d, p)
            if sol is None:
                continue
            u, v = sol
            assert u * u + d * v * v == 4 * p
            j = find_root(hilbert_class_polynomial(D), p, rng)
            assert j is not None
            curves = twists(D, j, p)
            assert curves is not None
            counts = sorted(count_points(a, b, p) for a, b in curves)
            assert counts == sorted(curve_orders(d, p, u, v)), (p, D)
            tried += 1
        assert tried >= 3


def test_cornacchia_and_square_roots() -> None:
    primes = [p for p in range(5, 3000) if is_prime_by_trial_division(p)]
    for p in primes:
        for d in (3, 4, 7, 8, 15, 20, 23, 163):
            if d >= 4 * p or pow(-d % p, (p - 1) // 2, p) != 1:
                continue
            sol = cornacchia(d, p)
            exists = any(
                (4 * p - d * v * v) >= 0 and math.isqrt(4 * p - d * v * v) ** 2 == 4 * p - d * v * v
                for v in range(1, math.isqrt(4 * p // d) + 1)
            )
            assert (sol is not None) == exists, (p, d)
            if sol:
                assert sol[0] ** 2 + d * sol[1] ** 2 == 4 * p
        for a in range(1, 30):
            if pow(a, (p - 1) // 2, p) == 1:
                y = sqrt_mod(a, p)
                assert y is not None and y * y % p == a % p


def test_large_enough_is_exact() -> None:
    for t in range(2, 200):
        n = t**4
        assert not large_enough((t + 1) ** 2, n)
        assert large_enough((t + 1) ** 2 + 1, n)
    rng = random.Random(7)
    with localcontext() as ctx:
        ctx.prec = 80
        for _ in range(2000):
            n = rng.randrange(10, 10**40)
            bound = (Decimal(n).sqrt().sqrt() + 1) ** 2
            base = int(bound)
            for q in range(base - 3, base + 4):
                assert large_enough(q, n) == (Decimal(q) > bound), (q, n)


def test_worked_example() -> None:
    n = 100003
    assert n % 3 == 1
    assert cornacchia(3, n) == (632, 14)
    assert 632**2 + 3 * 14**2 == 4 * n
    assert curve_orders(3, n, 632, 14) == [100636, 99372, 100341, 99667, 100299, 99709]
    assert split_order(100636, n, set()) is None
    assert split_order(99372, n, set()) is None
    assert split_order(100341, n, set()) == (9, 11149)
    assert 352 < (n**0.25 + 1) ** 2 < 353
    curves = twists(-3, 0, n)
    assert curves == [(0, (-(2**e)) % n) for e in range(6)]
    assert [count_points(a, b, n) for a, b in curves] == [100636, 100341, 99709, 99372, 99667, 100299]
    x, y = 55125, 32871
    assert (y * y - x**3 + 2) % n == 0
    u_point = ec_mul(9, (x, y), 0, n)
    assert u_point == (31794, 59883)
    assert ec_mul(11149, u_point, 0, n) is None
    rng = random.Random(8)
    p = random_point(0, n - 1, n, rng)
    assert p is not None
    assert ec_mul(100636, p, 0, n) is None
    assert ec_mul(100341, p, 0, n) is not None
    # The second step and the leaf.
    m = 11149
    assert cornacchia(3, m) == (211, 5)
    assert split_order(11361, m, set()) == (21, 541)
    assert ec_mul(541, ec_mul(21, (5866, 10392), 0, m), 0, m) is None
    assert ec_mul(21, (5866, 10392), 0, m) == (6858, 3248)
    chain = [Step(n, 0, n - 2, x, y, 9, 11149), Step(m, 0, 1149, 5866, 10392, 21, 541)]
    assert verify_certificate(n, chain)
    assert ecpp(n, random.Random(0), leaf=1000) == chain


def test_verifier_rejects_bad_certificates() -> None:
    rng = random.Random(9)
    n = 10**30 + 57
    chain = check_proof(n, rng)
    st = chain[0]
    assert not verify_certificate(n + 2, chain)
    assert not verify_certificate(n, [st._replace(k=st.k + 1)] + chain[1:])
    assert not verify_certificate(n, [st._replace(y=st.y + 1)] + chain[1:])
    assert not verify_certificate(n, [st._replace(q=st.q + 2)] + chain[1:])
    # A point of small order with a tiny q: [m]P = O holds, but q is too small.
    small = Step(100003, 0, 100001, 55125, 32871, 100341 // 3, 3)
    assert not verify_certificate(100003, [small])
    # The leaf must be prime.
    fake = Step(100003, 0, 100001, 55125, 32871, 9, 11149)
    assert verify_certificate(100003, [fake])
    assert not verify_certificate(100003 * 3, [fake._replace(n=100003 * 3)])


def test_curve_arithmetic_finds_factors() -> None:
    p, q = 1000003, 1000033
    n = p * q
    with pytest.raises(Composite) as info:
        inverse(p * 5, n)
    assert info.value.args[0] == p
    # Two points whose x-coordinates agree modulo p only.
    x1 = 17
    x2 = x1 + p * 3
    with pytest.raises(Composite) as info:
        ec_add((x1, 5), (x2, 9), 1, n)
    assert info.value.args[0] == p
    a, b = 2, 3
    rng = random.Random(10)
    pt = None
    while pt is None:
        x = rng.randrange(n)
        rhs = (x**3 + a * x + b) % n
        yp = sqrt_mod(rhs % p, p)
        yq = sqrt_mod(rhs % q, q)
        if yp is not None and yq is not None and yp * yp % p == rhs % p and yq * yq % q == rhs % q:
            y = (yp * q * pow(q, -1, p) + yq * p * pow(p, -1, q)) % n
            pt = (x, y)
    order_p = count_points(a, b, p)
    with pytest.raises(Composite) as info:
        ec_mul(order_p, pt, a, n)
    assert info.value.args[0] in (p, q)

What the next level needs

  • the tests did not run in this build

Read the full page about Elliptic curve primality proving