From 7d0e3dcb747949bb2d7172a34135a7872b2ed55f Mon Sep 17 00:00:00 2001 From: Nathaniel Ramm Date: Tue, 21 Jul 2026 00:51:48 +1000 Subject: [PATCH] refactor(primes): explicit MAX_RULES_PER_PARTITION cap; document the two bounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the opaque _sieve(3572) magic (which silently meant '500 primes') with a named MAX_RULES_PER_PARTITION constant (raised to 10_000) and a _first_n_primes(n) helper that sizes the sieve by prime count via the Rosser bound. The table is still computed at import (an instant bounded sieve) — a precomputed literal/file buys nothing, and a table 'up to 2**64' is impossible and the wrong dimension (we index by rule count, not prime magnitude). Document the two independent limits: the intrinsic width-15 combination bound (primorial <= int64, enforced by checked_multiply) vs the rules-per-partition table-size cap (enforced by get_prime). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../engines/accumulator/primes.py | 59 +++++++++++++++++-- tests/accumulator/test_primes.py | 28 ++++++++- 2 files changed, 79 insertions(+), 8 deletions(-) diff --git a/src/mountainash_rules/engines/accumulator/primes.py b/src/mountainash_rules/engines/accumulator/primes.py index f0d233f..2874546 100644 --- a/src/mountainash_rules/engines/accumulator/primes.py +++ b/src/mountainash_rules/engines/accumulator/primes.py @@ -1,12 +1,41 @@ -"""Prime table and utilities for accumulator combination identity.""" +"""Prime table and utilities for accumulator combination identity. + +Combinations are identified by the product of their rules' primes. Two +*independent* limits bound this scheme: + +* **Combination width — intrinsic, ~15.** No single combination can multiply + more than 15 primes without exceeding int64: the primorial of the first 15 + primes is ``614_889_782_588_491_410 < 2**63``, but including the 16th prime + overflows. This bound is inherent to int64 and enforced by + ``checked_multiply`` / ``LatticeWidthExceededError``. It does **not** depend on + the table size. +* **Rules per partition — policy, the table size.** Every rule needs a distinct + prime, so a partition is capped at ``MAX_RULES_PER_PARTITION`` rules. Exceeding + it raises from ``get_prime``. Remediation: split the partition with a + CONTEXT_KEY dimension. + +The table is simply the first ``MAX_RULES_PER_PARTITION`` primes, computed once +at import by a bounded Sieve of Eratosthenes (microseconds). There is no value in +a precomputed literal or a data file: the sieve is already instant, and a table +"up to 2**64" is impossible (~4.2e17 primes ≈ exabytes) and the wrong dimension — +we index by rule count, never by prime magnitude. +""" from __future__ import annotations +import math + _INT64_MAX = (2**63) - 1 +# Maximum rules in a single partition = the prime-table size. Each rule is +# assigned a distinct prime by position; raise this here if a partition +# legitimately needs more rules (cost is an ~instant wider sieve). The separate, +# intrinsic width-15 bound (see module docstring) is unaffected by this value. +MAX_RULES_PER_PARTITION = 10_000 + def _sieve(limit: int) -> list[int]: - """Sieve of Eratosthenes up to `limit`.""" + """All primes up to ``limit`` (Sieve of Eratosthenes).""" is_prime = [True] * (limit + 1) is_prime[0] = is_prime[1] = False for i in range(2, int(limit**0.5) + 1): @@ -16,17 +45,35 @@ def _sieve(limit: int) -> list[int]: return [i for i, v in enumerate(is_prime) if v] -PRIME_TABLE: list[int] = _sieve(3572) +def _first_n_primes(n: int) -> list[int]: + """The first ``n`` primes. + + Sizes the sieve from the prime-counting upper bound (Rosser: + ``p_n < n(ln n + ln ln n)`` for ``n >= 6``) and widens defensively if the + bound was ever too tight. + """ + if n < 1: + return [] + limit = 15 if n < 6 else int(n * (math.log(n) + math.log(math.log(n)))) + 3 + primes = _sieve(limit) + while len(primes) < n: # defensive; the Rosser bound is not exceeded in practice + limit *= 2 + primes = _sieve(limit) + return primes[:n] + + +PRIME_TABLE: list[int] = _first_n_primes(MAX_RULES_PER_PARTITION) def get_prime(index: int) -> int: - """Return the prime at the given 0-based index.""" + """Return the prime at the given 0-based index (a rule's position).""" if index < 0: raise IndexError(f"Prime index must be non-negative, got {index}") if index >= len(PRIME_TABLE): raise IndexError( - f"Prime index {index} exceeds table size {len(PRIME_TABLE)}. " - f"Partition has too many rules." + f"Prime index {index} exceeds the prime table " + f"(MAX_RULES_PER_PARTITION={MAX_RULES_PER_PARTITION}). " + f"Partition has too many rules; split it with a CONTEXT_KEY dimension." ) return PRIME_TABLE[index] diff --git a/tests/accumulator/test_primes.py b/tests/accumulator/test_primes.py index ee1a1c2..880ea6d 100644 --- a/tests/accumulator/test_primes.py +++ b/tests/accumulator/test_primes.py @@ -3,8 +3,10 @@ import pytest from mountainash_rules.engines.accumulator.primes import ( + MAX_RULES_PER_PARTITION, PRIME_TABLE, LatticeWidthExceededError, + _first_n_primes, get_prime, checked_multiply, ) @@ -14,8 +16,9 @@ class TestPrimeTable: def test_first_primes_correct(self): assert PRIME_TABLE[:10] == [2, 3, 5, 7, 11, 13, 17, 19, 23, 29] - def test_table_has_at_least_500_entries(self): - assert len(PRIME_TABLE) >= 500 + def test_table_size_equals_cap(self): + # Invariant: the table is exactly the first MAX_RULES_PER_PARTITION primes. + assert len(PRIME_TABLE) == MAX_RULES_PER_PARTITION def test_500th_prime_is_3571(self): assert PRIME_TABLE[499] == 3571 @@ -27,6 +30,23 @@ def test_all_entries_are_prime(self): assert p % d != 0, f"{p} is not prime" +class TestFirstNPrimes: + def test_empty_for_non_positive(self): + assert _first_n_primes(0) == [] + assert _first_n_primes(-3) == [] + + def test_small_counts(self): + assert _first_n_primes(1) == [2] + assert _first_n_primes(5) == [2, 3, 5, 7, 11] + + def test_returns_exact_count(self): + assert len(_first_n_primes(1000)) == 1000 + + def test_boundary_nth_prime_correct(self): + # 10000th prime is 104729 — exercises the Rosser sieve-sizing bound. + assert _first_n_primes(10000)[-1] == 104729 + + class TestGetPrime: def test_index_zero_returns_two(self): assert get_prime(0) == 2 @@ -41,6 +61,10 @@ def test_negative_index_raises(self): with pytest.raises(IndexError): get_prime(-1) + def test_index_beyond_cap_raises_with_constant_name(self): + with pytest.raises(IndexError, match="MAX_RULES_PER_PARTITION"): + get_prime(MAX_RULES_PER_PARTITION) + class TestCheckedMultiply: def test_small_product_succeeds(self):