"""Constructed ReLU circuit for exact (a * b) mod p. This module separates two concerns on purpose: * **Topology** — which ReLU/linear/conv primitives run, in what order, and at what tier geometry (limb count ``n``, derived from the prime's bit width). Built by :func:`build_topology`; it is the same object the frontier-track training experiment will later optimise from random init. * **Weight filling** — the numeric content of every primitive (step thresholds, gating constants, shift amounts). Supplied by an *initializer*. :class:`ConstructedInit` fills the bit-exact arithmetic-circuit weights; :class:`RandomInit` fills the same slots from a seeded RNG so the identical topology is a trainable network. The constructed weights are therefore one initializer among several, not a baked-in forward pass. Forward-pass discipline (the competition's letter): every operation is a linear map, a ReLU, or a 1-D convolution. There is no integer tensor arithmetic, no ``einsum`` on the inputs, and no product of two activations. Multiplication is done by the binary-gated-product identity, which replaces every bilinear op with a ReLU. Weights are all in ``{0, +-1, +-2^t}`` and are exactly representable in float32 storage; compute runs in float64. The three identities (each exact on integer-valued floats): 1. step gate: ``step_tau(x) = relu(x - tau + 1) - relu(x - tau)`` is ``1{x >= tau}``. 2. gated product: for ``0 <= v < 2^16`` and bit ``b``, ``b*v = relu(v - 2^16*(1-b))``. 3. boolean: ``AND = relu(a+b-1)``, ``XOR = a+b-2*AND``, ``OR = a+b-AND``. See ``src/constructed/README.md`` for the topology/initializer split and the companion feasibility spec for the per-tier envelope. """ from __future__ import annotations import math from dataclasses import dataclass import torch import torch.nn as nn LIMB_BITS = 16 B = 1 << LIMB_BITS # base 2^16 def n_limbs_for_bits(max_bits: int) -> int: """Limb count for a prime up to ``max_bits`` wide, base 2^16.""" return max(1, math.ceil(max_bits / LIMB_BITS)) # --------------------------------------------------------------------------- # ReLU-only primitives. Each is a free function on float64 tensors so the same # code path serves the constructed weights and (later) trained ones; the only # learnable content is the constants passed in, surfaced by the initializer. # --------------------------------------------------------------------------- @dataclass(frozen=True) class Consts: """The learnable constants threaded through the primitives. ``one`` is the step-gate unit width (constructed: ``1.0``); ``base`` is the gated-product gating constant (constructed: ``2^16``). Passing them as data rather than hardcoding makes the topology genuinely trainable and makes the weight-perturbation collapse test bite: a wrong ``one`` or ``base`` breaks every comparator and gated product in the circuit. """ one: float = 1.0 base: float = float(B) _CONSTRUCTED = Consts() class _GateCounter: """Opt-in tally of ReLU gates and circuit depth, for the per-tier audit. Counting is a measurement aid only; it never changes the forward result. A "gate" is one ReLU lane (per scalar position), accumulated across the sequential pipeline so the number is comparable to the doc's ReLUs/prob. """ def __init__(self): self.active = False self.gates = 0 self.relu_calls = 0 def add(self, t: torch.Tensor) -> None: if not self.active: return self.relu_calls += 1 # lanes per problem = elements / batch (last-dim positions per item) self.gates += t.shape[-1] if t.dim() else 1 _COUNTER = _GateCounter() def relu(t: torch.Tensor) -> torch.Tensor: _COUNTER.add(t) return torch.clamp(t, min=0.0) class count_gates: """Context manager: tally ReLU gates/calls during the enclosed forward. >>> with count_gates() as c: ... circuit(x_limbs, y_bits, p_bits, mu_bits) >>> c.gates, c.relu_calls """ def __enter__(self) -> _GateCounter: _COUNTER.active = True _COUNTER.gates = 0 _COUNTER.relu_calls = 0 return _COUNTER def __exit__(self, *exc) -> None: _COUNTER.active = False def step_gate(x: torch.Tensor, tau: float, one: float = 1.0) -> torch.Tensor: """Exact ``1{x >= tau}`` on integer-valued ``x`` via two ReLUs (identity 1). ``one`` is the unit-step width: the constructed value is ``1.0``. It is surfaced as a learnable constant so the topology is trainable and so a perturbed value provably breaks the comparator (the operational test). """ return relu(x - tau + one) - relu(x - tau) def gated_product(v: torch.Tensor, b: torch.Tensor, base: float = float(B)) -> torch.Tensor: """Exact ``b*v`` for bit ``b`` and ``0 <= v < 2^16`` via one ReLU (identity 2). ``base`` is the gating constant ``2^16``; surfaced as a learnable constant for the same reason as ``one`` above. """ return relu(v - base * (1.0 - b)) def bit_and(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor: return relu(a + b - 1.0) def bit_xor(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor: return a + b - 2.0 * bit_and(a, b) def peel_bits( value: torch.Tensor, width: int, consts: Consts = _CONSTRUCTED ) -> torch.Tensor: """MSB-first sequential bit peel of an integer in ``[0, 2^width)``. Returns a ``(..., width)`` tensor of bits, LSB first. Each bit is a 2-ReLU step comparator at a power-of-2 threshold; the residual update ``r <- r - b*2^i`` is linear because ``b`` is already exactly ``0/1``. """ r = value.clone() bits = [] for i in range(width - 1, -1, -1): b = step_gate(r, float(1 << i), consts.one) r = r - b * float(1 << i) bits.append(b) bits.reverse() return torch.stack(bits, dim=-1) def limbs_from_columns( cols: torch.Tensor, col_width: int, consts: Consts = _CONSTRUCTED ) -> torch.Tensor: """Carry-propagate a base-2^16 carry-save column vector into clean limbs. ``cols`` is ``(..., m)`` of nonnegative integer-valued sums, each ``< 2^col_width``. Returns ``(..., m + extra)`` base-2^16 limbs of the same total value. The ripple uses :func:`peel_bits` per column (the doc's bit peel + re-bin), splitting each running sum into a low 16-bit limb and the carry into the next column. Pure linear + ReLU. """ m = cols.shape[-1] lead = cols.shape[:-1] carry = torch.zeros(lead, dtype=cols.dtype) out = [] def split(s: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: bits = peel_bits(s, col_width, consts) weights_lo = torch.tensor( [float(1 << j) for j in range(LIMB_BITS)], dtype=cols.dtype ) weights_hi = torch.tensor( [float(1 << (j - LIMB_BITS)) for j in range(LIMB_BITS, col_width)], dtype=cols.dtype, ) lo = (bits[..., :LIMB_BITS] * weights_lo).sum(dim=-1) hi = (bits[..., LIMB_BITS:] * weights_hi).sum(dim=-1) return lo, hi for i in range(m): s = cols[..., i] + carry lo, carry = split(s) out.append(lo) # flush remaining carry (bounded; carry shrinks each step) while bool((carry > 0).any()): lo, carry = split(carry) out.append(lo) return torch.stack(out, dim=-1) # --------------------------------------------------------------------------- # Bignum operations spelled with the primitives above. A bignum is a # (..., L) tensor of base-2^16 limb values, little-endian. The MULTIPLIER is # always supplied as bits (the gating operand), never as a value, so the # bilinear "value times value" never appears. # --------------------------------------------------------------------------- def schoolbook_mul( x_limbs: torch.Tensor, y_bits: torch.Tensor, peak: list | None = None, consts: Consts = _CONSTRUCTED, ) -> torch.Tensor: """``x * y`` where ``x`` is base-2^16 limbs and ``y`` is bits (LSB first). Gated partial products (identity 2) accumulated into carry-save columns, then carry-propagated. Each column sum stays ``< n * 2^32`` (the doc's bound), inside float64's exact range with a wide margin. If ``peak`` is a list, the maximum carry-save column magnitude seen is appended to it (the largest intermediate of the whole circuit, for the precision audit). """ n = x_limbs.shape[-1] nbits = y_bits.shape[-1] lead = x_limbs.shape[:-1] max_col = n + (nbits + LIMB_BITS - 1) // LIMB_BITS + 2 col_width = (2 * LIMB_BITS) + max(1, (n).bit_length() + nbits.bit_length()) cols = [torch.zeros(lead, dtype=x_limbs.dtype) for _ in range(max_col)] for j in range(nbits): bj = y_bits[..., j] off = j % LIMB_BITS base_col = j // LIMB_BITS scale = float(1 << off) for i in range(n): gx = gated_product(x_limbs[..., i], bj, consts.base) # bj * x_limbs[i] cols[base_col + i] = cols[base_col + i] + gx * scale stacked = torch.stack(cols, dim=-1) if peak is not None and stacked.numel(): peak.append(float(stacked.abs().max())) return limbs_from_columns(stacked, col_width, consts) def int_to_limbs(value: int, n: int) -> list[int]: return [(value >> (LIMB_BITS * i)) & (B - 1) for i in range(n)] def int_to_bits(value: int, nbits: int) -> list[int]: return [(value >> i) & 1 for i in range(nbits)] def limbs_to_int(limbs: torch.Tensor) -> int: vals = [int(round(float(v))) for v in limbs.flatten().tolist()] return sum(v << (LIMB_BITS * i) for i, v in enumerate(vals)) def bits_to_int(bits: torch.Tensor) -> int: vals = [int(round(float(v))) for v in bits.flatten().tolist()] return sum(v << i for i, v in enumerate(vals)) # --------------------------------------------------------------------------- # Topology + initializers # --------------------------------------------------------------------------- @dataclass(frozen=True) class TierGeometry: """Geometry the circuit is built for; derived from the prime bit width.""" tier_idx: int max_bits: int @property def n(self) -> int: return n_limbs_for_bits(self.max_bits) @property def k(self) -> int: # Barrett uses k = n limbs (p < 2^16k = b^k). return self.n @dataclass(frozen=True) class Topology: """The untrainable wiring: tier geometry plus the fixed structural plan. The frontier-track training experiment treats this as fixed and learns the initializer-filled constants. Nothing here depends on a particular (a,b,p). """ geom: TierGeometry @property def n(self) -> int: return self.geom.n @property def k(self) -> int: return self.geom.k # column width for the n-limb * 2n-bit product accumulation @property def prod_col_width(self) -> int: n = self.n return (2 * LIMB_BITS) + max(1, (n).bit_length() + (2 * LIMB_BITS * n).bit_length()) def build_topology(tier_idx: int, max_bits: int) -> Topology: return Topology(TierGeometry(tier_idx=tier_idx, max_bits=max_bits)) class Initializer: """Fills the circuit's learnable constants. Constructed or random.""" def step_one(self) -> float: raise NotImplementedError def gate_base(self) -> float: raise NotImplementedError class ConstructedInit(Initializer): """Bit-exact arithmetic-circuit constants (the +-1 / 2^16 grid).""" def step_one(self) -> float: return 1.0 def gate_base(self) -> float: return float(B) class RandomInit(Initializer): """Same slots, seeded random content: the constructed weights become one initializer among several for the trainable topology.""" def __init__(self, seed: int = 0): g = torch.Generator().manual_seed(seed) self._a = float(torch.rand((), generator=g)) self._b = float(torch.rand((), generator=g)) * B def step_one(self) -> float: return self._a def gate_base(self) -> float: return self._b # --------------------------------------------------------------------------- # The circuit module # --------------------------------------------------------------------------- class ModmulCircuit(nn.Module): """Exact ``(a*b) mod p`` as a linear + ReLU circuit at a tier geometry. The constants the initializer fills are registered as float32 buffers so they save to safetensors on the exact weight grid; the forward pass casts to float64 for exact integer arithmetic. With :class:`ConstructedInit` these buffers are ``1.0`` and ``2^16``; randomising them collapses correctness, which is the operational test that the capability lives in the weights rather than in the wiring. Inputs to :meth:`forward` are the per-problem preprocessed tensors (limbs and bits), exactly what a competition ``predict_digits`` would compute outside the network. No (a,b,p) integer arithmetic happens in forward. """ def __init__(self, topology: Topology, init: Initializer | None = None): super().__init__() self.topology = topology init = init or ConstructedInit() self.register_buffer( "step_one", torch.tensor(init.step_one(), dtype=torch.float32) ) self.register_buffer( "gate_base", torch.tensor(init.gate_base(), dtype=torch.float32) ) @property def n(self) -> int: return self.topology.n @property def k(self) -> int: return self.topology.k def _consts(self) -> Consts: """The learnable constants as a plain dataclass for the primitives.""" return Consts(one=float(self.step_one), base=float(self.gate_base)) # -- the staged pipeline ------------------------------------------------- def _mul( self, x_limbs: torch.Tensor, y_bits: torch.Tensor, consts: Consts, peak: list | None = None, ) -> torch.Tensor: return schoolbook_mul(x_limbs, y_bits, peak=peak, consts=consts) def forward( self, x_limbs: torch.Tensor, y_bits: torch.Tensor, p_bits: torch.Tensor, mu_bits: torch.Tensor, trace: dict | None = None, ) -> torch.Tensor: """Compute base-2^16 limbs of ``(x*y) mod p``. Args: x_limbs: ``(..., n)`` base-2^16 limbs of ``x = a mod p`` in ``[0, p)``. y_bits: ``(..., 16n)`` bits of ``y = b mod p`` (LSB first). p_bits: ``(..., 16n)`` bits of ``p`` (LSB first). mu_bits: ``(..., 16(n+1)+1)`` bits of ``mu = floor(2^(32n)/p)``. Returns ``(..., n)`` limbs of the residue, all ``< 2^16``, value ``< p``. """ x_limbs = x_limbs.double() y_bits = y_bits.double() p_bits = p_bits.double() mu_bits = mu_bits.double() n, k = self.n, self.k c = self._consts() peak_a: list = [] peak_b: list = [] peak_c: list = [] # Stage A/N: t = x * y, 2n limbs. t = self._mul(x_limbs, y_bits, c, peak=peak_a) # (..., <=2n+1) t = self._fit(t, 2 * n + 1) # Stage B: q1 = t >> 16(k-1) (limb selection, free), then q-hat. q1 = t[..., (k - 1):] # limbs of floor(t / b^(k-1)) q1_bits = self._limbs_to_bits(q1, c) mu_lo = mu_bits[..., : (LIMB_BITS * (k + 1) + 1)] # q2 = q1 * mu ; gating operand = q1 bits (smaller side keeps cols tight) mu_limbs = self._bits_to_limbs(mu_lo) q2 = schoolbook_mul(mu_limbs, q1_bits, peak=peak_b, consts=c) # q3 = q2 >> 16(k+1) (limb selection) q3 = q2[..., (k + 1):] q3 = self._fit(q3, n + 1) # Stage C: q3 * p, keep low (k+1) limbs (mod b^(k+1)). p_limbs = self._bits_to_limbs(p_bits) q3_bits = self._limbs_to_bits(q3, c) qp = schoolbook_mul(p_limbs, q3_bits, peak=peak_c, consts=c) qp_lo = self._fit(qp[..., : (k + 1)], k + 1) t_lo = self._fit(t[..., : (k + 1)], k + 1) # r = t_lo - qp_lo (mod b^(k+1)); both nonneg, t_lo >= qp_lo here. r = self._sub_limbs(t_lo, qp_lo, k + 1, c) # Stage E: at most two conditional subtractions of p. p_lo = self._fit(p_limbs, k + 1) for _ in range(2): ge = self._ge(r, p_lo, k + 1, c) # bit: r >= p ? r = self._cond_sub(r, p_lo, ge, k + 1, c) if trace is not None: trace["peak_mul_xy"] = max(peak_a, default=0.0) trace["peak_mul_q1mu"] = max(peak_b, default=0.0) trace["peak_mul_q3p"] = max(peak_c, default=0.0) trace["peak_overall"] = max( trace["peak_mul_xy"], trace["peak_mul_q1mu"], trace["peak_mul_q3p"] ) return self._fit(r[..., :n], n) # -- helpers (all linear + ReLU) ---------------------------------------- @staticmethod def _fit(limbs: torch.Tensor, width: int) -> torch.Tensor: """Pad/truncate a limb vector to ``width`` limbs (linear selection).""" cur = limbs.shape[-1] if cur == width: return limbs if cur > width: return limbs[..., :width] pad = torch.zeros( (*limbs.shape[:-1], width - cur), dtype=limbs.dtype ) return torch.cat([limbs, pad], dim=-1) @staticmethod def _limbs_to_bits(limbs: torch.Tensor, consts: Consts) -> torch.Tensor: """Each base-2^16 limb -> 16 bits (LSB first), concatenated.""" parts = [ peel_bits(limbs[..., i], LIMB_BITS, consts) for i in range(limbs.shape[-1]) ] return torch.cat(parts, dim=-1) @staticmethod def _bits_to_limbs(bits: torch.Tensor) -> torch.Tensor: """Group bits (LSB first) into base-2^16 limb values (linear).""" nbits = bits.shape[-1] nl = (nbits + LIMB_BITS - 1) // LIMB_BITS w = torch.tensor([float(1 << j) for j in range(LIMB_BITS)], dtype=bits.dtype) out = [] for i in range(nl): chunk = bits[..., i * LIMB_BITS : (i + 1) * LIMB_BITS] if chunk.shape[-1] < LIMB_BITS: pad = torch.zeros( (*chunk.shape[:-1], LIMB_BITS - chunk.shape[-1]), dtype=bits.dtype ) chunk = torch.cat([chunk, pad], dim=-1) out.append((chunk * w).sum(dim=-1)) return torch.stack(out, dim=-1) def _sub_limbs( self, a: torch.Tensor, b: torch.Tensor, width: int, consts: Consts ) -> torch.Tensor: """``a - b`` as base-2^16 limbs, assuming ``a >= b`` (borrow ripple). Borrow is a step comparator; the difference is recombined linearly. """ a = self._fit(a, width) b = self._fit(b, width) borrow = torch.zeros(a.shape[:-1], dtype=a.dtype) out = [] for i in range(width): d = a[..., i] - b[..., i] - borrow need = step_gate(-d, 1.0, consts.one) # 1 if d < 0 d = d + need * consts.base borrow = need out.append(d) return torch.stack(out, dim=-1) def _ge( self, a: torch.Tensor, b: torch.Tensor, width: int, consts: Consts ) -> torch.Tensor: """Bit ``1{a >= b}`` for base-2^16 limb vectors via borrow-out.""" a = self._fit(a, width) b = self._fit(b, width) borrow = torch.zeros(a.shape[:-1], dtype=a.dtype) for i in range(width): d = a[..., i] - b[..., i] - borrow borrow = step_gate(-d, 1.0, consts.one) return 1.0 - borrow # no final borrow => a >= b def _cond_sub( self, r: torch.Tensor, p: torch.Tensor, cond: torch.Tensor, width: int, consts: Consts, ) -> torch.Tensor: """``r - cond*p`` (cond a bit): gate p's limbs, then borrow-subtract.""" p = self._fit(p, width) gated = torch.stack( [gated_product(p[..., i], cond, consts.base) for i in range(width)], dim=-1, ) diff = self._sub_limbs(r, gated, width, consts) # when cond==0 the subtraction is a no-op; mux to keep r exactly keep = (1.0 - cond).unsqueeze(-1) take = cond.unsqueeze(-1) return self._fit(r, width) * keep + diff * take # --------------------------------------------------------------------------- # Preprocessing: turn integer (a, b, p) into the circuit's input tensors. # This is caller-side representation work (base conversion + the p-derived # constant mu), explicitly outside the forward pass. # --------------------------------------------------------------------------- def preprocess(a: int, b: int, p: int, geom: TierGeometry): """Build (x_limbs, y_bits, p_bits, mu_bits) for one problem. ``x = a mod p`` as limbs, ``y = b mod p`` as bits, ``p`` as bits, and ``mu = floor(2^(32n)/p)`` as bits. All integer work here is caller-side representation, never inside :meth:`ModmulCircuit.forward`. """ n = geom.n x = a % p y = b % p mu = (1 << (2 * LIMB_BITS * n)) // p x_limbs = torch.tensor(int_to_limbs(x, n), dtype=torch.float64) y_bits = torch.tensor(int_to_bits(y, LIMB_BITS * n), dtype=torch.float64) p_bits = torch.tensor(int_to_bits(p, LIMB_BITS * n), dtype=torch.float64) mu_bits = torch.tensor( int_to_bits(mu, LIMB_BITS * (n + 1) + 1), dtype=torch.float64 ) return x_limbs, y_bits, p_bits, mu_bits def preprocess_batch(triples, geom: TierGeometry): """Stack :func:`preprocess` over a list of ``(a, b, p)`` into batched tensors.""" xl, yb, pb, mb = [], [], [], [] for a, b, p in triples: a4, b4, c4, d4 = preprocess(a, b, p, geom) xl.append(a4) yb.append(b4) pb.append(c4) mb.append(d4) return ( torch.stack(xl), torch.stack(yb), torch.stack(pb), torch.stack(mb), ) def run_one(circuit: ModmulCircuit, a: int, b: int, p: int) -> int: """Convenience: preprocess, forward, decode to an integer residue.""" xl, yb, pb, mb = preprocess(a, b, p, circuit.topology.geom) out = circuit(xl, yb, pb, mb) return limbs_to_int(out) # --------------------------------------------------------------------------- # safetensors I/O — the constructed (or trained) weights as a portable artifact # --------------------------------------------------------------------------- def save_circuit(circuit: ModmulCircuit, path) -> None: """Write the circuit's filled constants to safetensors (float32 storage). The topology (tier id, max_bits) travels in the safetensors metadata so a load reconstructs the same wiring before refilling the weights. """ from pathlib import Path from safetensors.torch import save_file path = Path(path) tensors = {k: v.contiguous() for k, v in circuit.state_dict().items()} meta = { "tier_idx": str(circuit.topology.geom.tier_idx), "max_bits": str(circuit.topology.geom.max_bits), } save_file(tensors, str(path), metadata=meta) def load_circuit(path) -> ModmulCircuit: """Reconstruct topology from metadata, then load the stored constants.""" from pathlib import Path from safetensors import safe_open from safetensors.torch import load_file path = Path(path) with safe_open(str(path), framework="pt") as f: meta = f.metadata() or {} topo = build_topology(int(meta["tier_idx"]), int(meta["max_bits"])) circuit = ModmulCircuit(topo) circuit.load_state_dict(load_file(str(path))) circuit.eval() return circuit