From 6c91e6509f4116e3fe67214750a3fcba3cdea1d4 Mon Sep 17 00:00:00 2001
From: cce <51567+cce@users.noreply.github.com>
Date: Fri, 19 Jun 2026 15:20:56 -0400
Subject: [PATCH 01/37] add f128 deterministic sortition implementation
---
f128.go | 431 +++++++++++++++++++++++++++++++++++++++++++++++++++
f128_test.go | 154 ++++++++++++++++++
sortition.go | 24 +++
3 files changed, 609 insertions(+)
create mode 100644 f128.go
create mode 100644 f128_test.go
diff --git a/f128.go b/f128.go
new file mode 100644
index 0000000..67f5723
--- /dev/null
+++ b/f128.go
@@ -0,0 +1,431 @@
+// Copyright (C) 2019-2023 Algorand, Inc.
+// This file is part of go-algorand
+//
+// go-algorand is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// go-algorand is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with go-algorand. If not, see .
+
+package sortition
+
+import (
+ "math"
+ "math/big"
+ "math/bits"
+)
+
+// f128 is a minimal NON-NEGATIVE binary floating-point value with a 128-bit
+// mantissa, used for a fast, allocation-free, deterministic binomial CDF (see
+// SelectF128). It is a value type -- arithmetic returns new f128s on the stack,
+// never the heap -- and uses only integer ops (math/bits), so it is bit-identical
+// on every platform (no hardware FP, no FMA, no libm).
+//
+// value = (hi<<64 | lo) * 2^exp
+//
+// The 128-bit mantissa is normalized so bit 127 (the MSB of hi) is set, or the
+// value is zero (hi==lo==0). All sortition quantities (p, 1-p, ratio, pmf, cdf,
+// factors) are >= 0, so there is no sign bit. Arithmetic ROUNDS TO NEAREST, TIES
+// TO EVEN (matching math/big.Float). Round-to-nearest is required, not merely
+// nicer: a VRF near the maximum makes the ratio round to exactly 1.0, and only
+// round-to-nearest lets the accumulated cdf reach 1.0 (truncation asymptotes just
+// below it and the walk runs to `money`). SelectF128 is fuzz-checked against a
+// math/big.Float reference (see f128_test.go), including that case.
+type f128 struct {
+ hi, lo uint64
+ exp int
+}
+
+// f128MantBits is the f128 mantissa width; the big.Float setup constants are
+// formed at this same precision so they round to f128 exactly.
+const f128MantBits = 128
+
+func (a f128) isZero() bool { return a.hi == 0 && a.lo == 0 }
+
+func shl128(hi, lo uint64, n uint) (uint64, uint64) {
+ switch {
+ case n == 0:
+ return hi, lo
+ case n < 64:
+ return hi<>(64-n), lo << n
+ case n < 128:
+ return lo << (n - 64), 0
+ default:
+ return 0, 0
+ }
+}
+
+func shr128(hi, lo uint64, n uint) (uint64, uint64) {
+ switch {
+ case n == 0:
+ return hi, lo
+ case n < 64:
+ return hi >> n, lo>>n | hi<<(64-n)
+ case n < 128:
+ return 0, hi >> (n - 64)
+ default:
+ return 0, 0
+ }
+}
+
+// shr128gs shifts hi:lo right by n (1..128), returning the result plus the round
+// bit (the most-significant shifted-out bit, at position n-1) and sticky (any
+// lower shifted-out bit). Used for round-to-nearest on exponent alignment.
+func shr128gs(hi, lo uint64, n uint) (rhi, rlo uint64, round, sticky bool) {
+ rhi, rlo = shr128(hi, lo, n)
+ switch {
+ case n <= 64:
+ round = (lo>>(n-1))&1 != 0
+ if n >= 2 {
+ sticky = lo&((uint64(1)<<(n-1))-1) != 0
+ }
+ case n < 128:
+ m := n - 64
+ round = (hi>>(m-1))&1 != 0
+ if m >= 2 {
+ sticky = hi&((uint64(1)<<(m-1))-1) != 0
+ }
+ sticky = sticky || lo != 0
+ default: // n == 128
+ round = hi&(1<<63) != 0
+ sticky = (hi&^(uint64(1)<<63) != 0) || lo != 0
+ }
+ return rhi, rlo, round, sticky
+}
+
+// shl192 shifts a 192-bit value (a2:a1:a0) left by n < 192.
+func shl192(a2, a1, a0 uint64, n uint) (uint64, uint64, uint64) {
+ switch {
+ case n == 0:
+ return a2, a1, a0
+ case n < 64:
+ return a2<>(64-n), a1<>(64-n), a0 << n
+ case n < 128:
+ n -= 64
+ if n == 0 {
+ return a1, a0, 0
+ }
+ return a1<>(64-n), a0 << n, 0
+ default:
+ return a0 << (n - 128), 0, 0
+ }
+}
+
+// norm128 normalizes a 128-bit mantissa (shifts MSB to bit 127). No bits are
+// dropped, so no rounding is needed; used for exact conversions.
+func norm128(hi, lo uint64, exp int) f128 {
+ if hi == 0 && lo == 0 {
+ return f128{}
+ }
+ var s int
+ if hi != 0 {
+ s = bits.LeadingZeros64(hi)
+ } else {
+ s = 64 + bits.LeadingZeros64(lo)
+ }
+ if s != 0 {
+ hi, lo = shl128(hi, lo, uint(s))
+ exp -= s
+ }
+ return f128{hi, lo, exp}
+}
+
+// roundNE rounds the normalized 128-bit mantissa hi:lo to nearest, ties to even,
+// given the round bit and sticky of the discarded tail, and renormalizes on
+// carry-out. hi:lo must already be normalized (bit 127 set).
+func roundNE(hi, lo uint64, roundBit, sticky bool, exp int) f128 {
+ if roundBit && (sticky || lo&1 != 0) {
+ var c uint64
+ lo, c = bits.Add64(lo, 1, 0)
+ hi, c = bits.Add64(hi, 0, c)
+ if c != 0 { // mantissa overflowed to 2^128 -> renormalize to 2^127
+ return f128{1 << 63, 0, exp + 1}
+ }
+ }
+ return f128{hi, lo, exp}
+}
+
+// norm192 rounds a 192-bit value (r2:r1:r0) * 2^exp to an f128 (top 128 bits,
+// round to nearest even using the remaining bits).
+func norm192(r2, r1, r0 uint64, exp int) f128 {
+ if r2 == 0 && r1 == 0 && r0 == 0 {
+ return f128{}
+ }
+ var lz int
+ switch {
+ case r2 != 0:
+ lz = bits.LeadingZeros64(r2)
+ case r1 != 0:
+ lz = 64 + bits.LeadingZeros64(r1)
+ default:
+ lz = 128 + bits.LeadingZeros64(r0)
+ }
+ s2, s1, s0 := shl192(r2, r1, r0, uint(lz))
+ roundBit := s0&(1<<63) != 0
+ sticky := s0&^(uint64(1)<<63) != 0
+ return roundNE(s2, s1, roundBit, sticky, exp+64-lz)
+}
+
+func f128FromUint64(u uint64) f128 {
+ if u == 0 {
+ return f128{}
+ }
+ s := bits.LeadingZeros64(u)
+ return f128{u << uint(s), 0, -(s + 64)}
+}
+
+func f128FromFloat64(f float64) f128 {
+ if f <= 0 {
+ return f128{}
+ }
+ b := math.Float64bits(f)
+ mant := b & (1<<52 - 1)
+ exp := int((b >> 52) & 0x7ff)
+ if exp == 0 { // subnormal: value = mant * 2^-1074
+ return norm128(0, mant, -1074)
+ }
+ // normal: significand (mant|2^52) in [2^52,2^53); MSB (bit 52) -> bit 127.
+ return f128{(mant | 1<<52) << 11, 0, exp - 1150}
+}
+
+var f128bigMask = new(big.Int).SetUint64(math.MaxUint64)
+
+// f128FromBigFloat converts a (one-time, setup) big.Float constant to f128,
+// rounding to nearest even at 128 bits (so it matches a 128-bit big.Float).
+func f128FromBigFloat(x *big.Float) f128 {
+ if x.Sign() <= 0 {
+ return f128{}
+ }
+ r := new(big.Float).SetPrec(128).Set(x) // round to 128-bit mantissa, nearest-even
+ m := new(big.Float).SetPrec(256)
+ e := r.MantExp(m) // r = m * 2^e, m in [0.5,1), 128-bit
+ m.SetMantExp(m, 128) // m * 2^128 = exact 128-bit integer
+ bi, _ := m.Int(nil)
+ lo := new(big.Int).And(bi, f128bigMask).Uint64()
+ hi := new(big.Int).Rsh(bi, 64).Uint64()
+ return norm128(hi, lo, e-128)
+}
+
+// mul returns a*b rounded to nearest even.
+func (a f128) mul(b f128) f128 {
+ if a.isZero() || b.isZero() {
+ return f128{}
+ }
+ hhHi, hhLo := bits.Mul64(a.hi, b.hi)
+ hlHi, hlLo := bits.Mul64(a.hi, b.lo)
+ lhHi, lhLo := bits.Mul64(a.lo, b.hi)
+ llHi, llLo := bits.Mul64(a.lo, b.lo)
+ p0 := llLo
+ p1, cA := bits.Add64(llHi, hlLo, 0)
+ p1, cB := bits.Add64(p1, lhLo, 0)
+ cp1 := cA + cB
+ p2, cC := bits.Add64(hhLo, hlHi, 0)
+ p2, cD := bits.Add64(p2, lhHi, 0)
+ p2, cE := bits.Add64(p2, cp1, 0)
+ p3 := hhHi + cC + cD + cE
+ if p3&(1<<63) != 0 { // product >= 2^255: mantissa p3:p2, tail p1:p0
+ roundBit := p1&(1<<63) != 0
+ sticky := (p1&^(uint64(1)<<63) != 0) || p0 != 0
+ return roundNE(p3, p2, roundBit, sticky, a.exp+b.exp+128)
+ }
+ // product in [2^254, 2^255): shift left 1 to normalize
+ hi := p3<<1 | p2>>63
+ lo := p2<<1 | p1>>63
+ roundBit := p1&(1<<62) != 0
+ sticky := (p1&^(uint64(3)<<62) != 0) || p0 != 0
+ return roundNE(hi, lo, roundBit, sticky, a.exp+b.exp+127)
+}
+
+// divU returns a/u for a small unsigned integer u (the per-step denominator j),
+// rounded to nearest even.
+func (a f128) divU(u uint64) f128 {
+ if a.isZero() || u == 0 {
+ return f128{}
+ }
+ // (M/u)*2^64 = q2:q1:q0 (integer part q2:q1, next 64 frac bits q0).
+ q2, r := bits.Div64(0, a.hi, u)
+ q1, r := bits.Div64(r, a.lo, u)
+ q0, rem := bits.Div64(r, 0, u)
+ if rem != 0 {
+ q0 |= 1 // mark sticky for the division remainder
+ }
+ return norm192(q2, q1, q0, a.exp-64)
+}
+
+// add returns a+b (both non-negative), rounded to nearest even.
+func (a f128) add(b f128) f128 {
+ if a.isZero() {
+ return b
+ }
+ if b.isZero() {
+ return a
+ }
+ if a.exp < b.exp {
+ a, b = b, a
+ }
+ diff := uint(a.exp - b.exp)
+ if diff > 128 {
+ return a // b is below the round bit
+ }
+ bhi, blo, round, sticky := shr128gs(b.hi, b.lo, diff)
+ slo, c := bits.Add64(a.lo, blo, 0)
+ shi, c2 := bits.Add64(a.hi, bhi, c)
+ exp := a.exp
+ if c2 != 0 { // carry into bit 128: shift right 1, recompute round/sticky
+ sticky = sticky || round
+ round = slo&1 != 0
+ slo = slo>>1 | shi<<63
+ shi = shi>>1 | 1<<63
+ exp++
+ }
+ return roundNE(shi, slo, round, sticky, exp)
+}
+
+func (a f128) cmp(b f128) int {
+ az, bz := a.isZero(), b.isZero()
+ switch {
+ case az && bz:
+ return 0
+ case az:
+ return -1
+ case bz:
+ return 1
+ case a.exp != b.exp:
+ if a.exp < b.exp {
+ return -1
+ }
+ return 1
+ case a.hi != b.hi:
+ if a.hi < b.hi {
+ return -1
+ }
+ return 1
+ case a.lo != b.lo:
+ if a.lo < b.lo {
+ return -1
+ }
+ return 1
+ }
+ return 0
+}
+
+// toBigFloat returns the exact value as a big.Float (debug/inspection only).
+func (a f128) toBigFloat() *big.Float {
+ hiF := new(big.Float).SetPrec(256).SetUint64(a.hi)
+ hiF.SetMantExp(hiF, 64)
+ m := new(big.Float).SetPrec(256).Add(hiF, new(big.Float).SetPrec(256).SetUint64(a.lo))
+ return m.SetMantExp(m, a.exp)
+}
+
+// intPow returns base^e by exponentiation by squaring (integer exponent).
+func (base f128) intPow(e uint64) f128 {
+ result := f128FromUint64(1)
+ b := base
+ for e > 0 {
+ if e&1 == 1 {
+ result = result.mul(b)
+ }
+ e >>= 1
+ if e > 0 {
+ b = b.mul(b)
+ }
+ }
+ return result
+}
+
+// binomialF128 evaluates the CDF of Binomial(money trials, success probability p)
+// in software f128 -- the counterpart of boost::math::binomial_distribution.
+// cdf(j) returns P(X <= j). Boost computes that as ibetac(j+1, n-j, p); this
+// accumulates the IDENTICAL value as the running sum of the binomial PMF:
+//
+// pmf(0) = (1-p)^money
+// pmf(j) = pmf(j-1) * (money-j+1)/j * p/(1-p)
+// cdf(j) = pmf(0) + pmf(1) + ... + pmf(j)
+//
+// cdf MUST be called with j = 0, 1, 2, ... in increasing order (as the walk
+// below does); each call advances the running PMF/CDF using only f128 software
+// arithmetic, so cdf(j) is bit-reproducible on every platform.
+type binomialF128 struct {
+ money uint64
+ pq f128 // p/(1-p), the per-step PMF multiplier
+ pmf f128 // pmf(at): the current term
+ cum f128 // cdf(at) = P(X <= at)
+ at uint64 // index that pmf/cum currently hold
+}
+
+// newBinomialF128 constructs the Binomial(money, p) CDF evaluator -- the analogue
+// of constructing binomial_distribution(n=money, p). The PMF-recurrence
+// constants 1-p and p/(1-p) are formed once in big.Float (exact for the float64
+// p) and rounded to f128. Returns nil for the degenerate p >= 1 (all probability
+// mass at j == money), which the caller handles.
+func newBinomialF128(p float64, money uint64) *binomialF128 {
+ pb := new(big.Float).SetPrec(f128MantBits).SetFloat64(p)
+ qb := new(big.Float).SetPrec(f128MantBits).Sub(new(big.Float).SetPrec(f128MantBits).SetInt64(1), pb)
+ if qb.Sign() <= 0 { // p >= 1
+ return nil
+ }
+ pq := f128FromBigFloat(new(big.Float).SetPrec(f128MantBits).Quo(pb, qb))
+ pmf0 := f128FromBigFloat(qb).intPow(money) // (1-p)^money
+ return &binomialF128{money: money, pq: pq, pmf: pmf0, cum: pmf0, at: 0}
+}
+
+func (b *binomialF128) cdf(j uint64) f128 {
+ for b.at < j {
+ b.at++
+ // pmf(at) = pmf(at-1) * (money-at+1)/at * p/(1-p)
+ b.pmf = f128FromUint64(b.money - b.at + 1).divU(b.at).mul(b.pq).mul(b.pmf)
+ b.cum = b.cum.add(b.pmf)
+ }
+ return b.cum
+}
+
+// binomialCDFWalkF128 is the pure-Go, deterministic counterpart of the C++
+// sortition_binomial_cdf_walk in sortition.cpp. It has the SAME signature and the
+// SAME walk -- place the two side by side:
+//
+// C++ sortition.cpp Go this function
+// ------------------------------------------ -------------------------------------------
+// uint64_t sortition_binomial_cdf_walk( func binomialCDFWalkF128(
+// double n, double p, double ratio, n, p, ratio float64, money uint64) uint64 {
+// uint64_t money) {
+// binomial_distribution dist(n, p); dist := newBinomialF128(p, money)
+// for (uint64_t j = 0; j < money; j++) { for j := uint64(0); j < money; j++ {
+// double boundary = cdf(dist, j); boundary := dist.cdf(j)
+// if (ratio <= boundary) { if rf.cmp(boundary) <= 0 {
+// return j; return j
+// } }
+// } }
+// return money; return money
+// } }
+//
+// The only difference is HOW the binomial CDF P(X<=j) is obtained: Boost computes
+// cdf(dist, j) = ibetac(j+1, n-j, p) afresh each step in hardware double, whereas
+// dist.cdf(j) returns the identical value as the running PMF sum in software f128
+// (see binomialF128), making the result bit-reproducible on every platform. (n is
+// unused: the trial count is the exact uint64 `money`; Boost needs n only as the
+// double argument used to construct dist.)
+func binomialCDFWalkF128(n float64, p float64, ratio float64, money uint64) uint64 {
+ _ = n
+ dist := newBinomialF128(p, money)
+ if dist == nil { // p >= 1: cdf(j)==0 for j.
+
+package sortition
+
+import (
+ "bytes"
+ "math"
+ "math/big"
+ "math/rand"
+ "testing"
+)
+
+// selectBigOracle is an independent, "obviously correct" reference for SelectF128:
+// the SAME binomial-CDF walk, but every arithmetic step uses math/big.Float (Go's
+// standard arbitrary-precision float, round-to-nearest-even) at the f128 mantissa
+// width instead of the hand-rolled f128. SelectF128 is fuzz-checked to be
+// bit-identical to this (FuzzSelectF128), which is what justifies trusting the
+// hand-rolled integer arithmetic in f128.go. Mirrors binomialCDFWalkF128 exactly.
+func selectBigOracle(money uint64, totalMoney uint64, expectedSize float64, vrfOutput Digest) uint64 {
+ binomialP := expectedSize / float64(totalMoney)
+ t := &big.Int{}
+ t.SetBytes(vrfOutput[:])
+ h := new(big.Float).SetPrec(precision).SetInt(t)
+ cratio, _ := new(big.Float).Quo(h, maxFloat).Float64()
+
+ const prec = f128MantBits
+ p := new(big.Float).SetPrec(prec).SetFloat64(binomialP)
+ q := new(big.Float).SetPrec(prec).Sub(new(big.Float).SetPrec(prec).SetInt64(1), p)
+ if q.Sign() <= 0 { // p >= 1
+ if cratio <= 0 {
+ return 0
+ }
+ return money
+ }
+ pq := new(big.Float).SetPrec(prec).Quo(p, q)
+ ratio := new(big.Float).SetPrec(prec).SetFloat64(cratio)
+ pmf := bigIntPow(q, money, prec) // (1-p)^money
+ cdf := new(big.Float).SetPrec(prec).Set(pmf)
+ if cdf.Cmp(ratio) >= 0 {
+ return 0
+ }
+ for j := uint64(1); j < money; j++ {
+ factor := new(big.Float).SetPrec(prec).Quo(
+ new(big.Float).SetPrec(prec).SetUint64(money-j+1),
+ new(big.Float).SetPrec(prec).SetUint64(j))
+ step := new(big.Float).SetPrec(prec).Mul(factor, pq)
+ pmf = new(big.Float).SetPrec(prec).Mul(pmf, step)
+ cdf = new(big.Float).SetPrec(prec).Add(cdf, pmf)
+ if cdf.Cmp(ratio) >= 0 {
+ return j
+ }
+ }
+ return money
+}
+
+func bigIntPow(base *big.Float, e uint64, prec uint) *big.Float {
+ result := new(big.Float).SetPrec(prec).SetInt64(1)
+ b := new(big.Float).SetPrec(prec).Set(base)
+ for e > 0 {
+ if e&1 == 1 {
+ result = new(big.Float).SetPrec(prec).Mul(result, b)
+ }
+ e >>= 1
+ if e > 0 {
+ b = new(big.Float).SetPrec(prec).Mul(b, b)
+ }
+ }
+ return result
+}
+
+// FuzzSelectF128 differentially fuzzes the two independent deterministic
+// implementations -- SelectF128 (hand-rolled f128 integer arithmetic) and
+// selectBigOracle (math/big.Float) -- which must return the same count. A bug in
+// the f128 code would have to be mirrored in big.Float to escape this. Run:
+//
+// go test -run x -fuzz FuzzSelectF128
+func FuzzSelectF128(f *testing.F) {
+ seedVRF := append(bytes.Repeat([]byte{0xff}, 7), make([]byte, 25)...) // ratio rounds to 1.0
+ // the two ratio->1.0 cases native fuzzing found while developing f128:
+ f.Add(uint64(1954), uint64(1999999999999964), 1500.0, seedVRF)
+ f.Add(uint64(1141), uint64(1000), 250.0, seedVRF)
+ f.Add(uint64(0), uint64(2_000_000_000_000_000), 20.0, make([]byte, 32))
+ f.Add(uint64(1000), uint64(1000), 1000.0, make([]byte, 32)) // p == 1
+
+ f.Fuzz(func(t *testing.T, money, total uint64, expected float64, vrf []byte) {
+ if total == 0 || math.IsNaN(expected) || math.IsInf(expected, 0) ||
+ expected < 0 || expected > float64(total) {
+ return
+ }
+ money %= 3001 // bound the walk so each fuzz exec stays fast
+ var d Digest
+ copy(d[:], vrf)
+ got := SelectF128(money, total, expected, d)
+ want := selectBigOracle(money, total, expected, d)
+ if got != want {
+ t.Fatalf("SelectF128=%d != big.Float oracle=%d (money=%d total=%d expected=%g vrf=%x)",
+ got, want, money, total, expected, d)
+ }
+ })
+}
+
+// TestF128AgreesWithCurrent shows that SelectF128 (pure Go) returns the same
+// selection count as the current C++ Select (Boost, hardware double) on realistic
+// random inputs. They are designed to agree everywhere except at knife-edge VRF
+// outputs (ratios within ~2^-53 of a CDF boundary), where the current libm-double
+// value and the deterministic value can differ by 1 -- precisely the
+// last-bit non-determinism the f128 path is meant to remove. Random ratios
+// essentially never hit those, so agreement is ~100%.
+func TestF128AgreesWithCurrent(t *testing.T) {
+ rng := rand.New(rand.NewSource(1))
+ const total = uint64(2_000_000_000_000_000)
+ committees := []float64{20, 1500, 2990, 6000}
+ const n = 200000
+ match, considered, diffs := 0, 0, 0
+ for i := 0; i < n; i++ {
+ exp := committees[rng.Intn(len(committees))]
+ mean := 0.05 + rng.Float64()*30
+ money := uint64(mean * float64(total) / exp)
+ if money == 0 {
+ continue
+ }
+ var d Digest
+ rng.Read(d[:])
+ considered++
+ cpp := Select(money, total, exp, d)
+ f := SelectF128(money, total, exp, d)
+ if cpp == f {
+ match++
+ continue
+ }
+ diffs++
+ if diffs <= 10 {
+ t.Logf("knife-edge diff: money=%d exp=%g cpp=%d f128=%d", money, exp, cpp, f)
+ }
+ }
+ t.Logf("SelectF128 vs C++ Select: %d/%d agree (%d differ, all knife edges)", match, considered, diffs)
+ if match*1000 < considered*999 { // < 99.9% would indicate a real bug, not knife edges
+ t.Errorf("agreement %d/%d too low -- likely a bug, not knife-edge rounding", match, considered)
+ }
+}
diff --git a/sortition.go b/sortition.go
index 8ea34b9..eb69926 100644
--- a/sortition.go
+++ b/sortition.go
@@ -58,6 +58,30 @@ func Select(money uint64, totalMoney uint64, expectedSize float64, vrfOutput Dig
return uint64(C.sortition_binomial_cdf_walk(C.double(binomialN), C.double(binomialP), C.double(cratio), C.uint64_t(money)))
}
+// SelectF128 is a pure-Go, cgo-free, deterministic equivalent of Select. Compare
+// it line-by-line with Select above: the two function bodies are IDENTICAL except
+// the final call -- where Select invokes the C++ sortition_binomial_cdf_walk
+// (Boost, hardware double), SelectF128 invokes binomialCDFWalkF128 (software
+// f128, see f128.go). Both perform the same binomial-CDF walk and return the same
+// selection count; SelectF128's result is additionally bit-reproducible on every
+// platform/toolchain (no libm, no FMA, no hardware floating point).
+func SelectF128(money uint64, totalMoney uint64, expectedSize float64, vrfOutput Digest) uint64 {
+ binomialN := float64(money)
+ binomialP := expectedSize / float64(totalMoney)
+
+ t := &big.Int{}
+ t.SetBytes(vrfOutput[:])
+
+ h := big.Float{}
+ h.SetPrec(precision)
+ h.SetInt(t)
+
+ ratio := big.Float{}
+ cratio, _ := ratio.Quo(&h, maxFloat).Float64()
+
+ return binomialCDFWalkF128(binomialN, binomialP, cratio, money)
+}
+
func init() {
var b int
var err error
From b479649d55beae63b40ec42a1a232d0e9e79f5a6 Mon Sep 17 00:00:00 2001
From: cce <51567+cce@users.noreply.github.com>
Date: Tue, 21 Jul 2026 06:20:13 -0400
Subject: [PATCH 02/37] add FuzzF128Ops
---
f128.go | 8 +++++++
f128_test.go | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++
sortition.go | 17 +++++++++++---
3 files changed, 86 insertions(+), 3 deletions(-)
diff --git a/f128.go b/f128.go
index 67f5723..60006a7 100644
--- a/f128.go
+++ b/f128.go
@@ -411,6 +411,14 @@ func (b *binomialF128) cdf(j uint64) f128 {
// (see binomialF128), making the result bit-reproducible on every platform. (n is
// unused: the trial count is the exact uint64 `money`; Boost needs n only as the
// double argument used to construct dist.)
+//
+// Precondition: money is within the sortition domain -- at most the total online
+// microalgo supply (~10^16 < 2^54). The f128 exponent is a plain int; for money
+// in that range the exponent of (1-p)^money cannot overflow it (its magnitude is
+// bounded by roughly the mean money*p, a committee size). money far beyond the
+// supply (>~2^57) is outside the domain -- Boost's Select cannot evaluate it
+// either -- and would eventually overflow the int exponent; behavior is undefined
+// there.
func binomialCDFWalkF128(n float64, p float64, ratio float64, money uint64) uint64 {
_ = n
dist := newBinomialF128(p, money)
diff --git a/f128_test.go b/f128_test.go
index f78e58d..1e53cc8 100644
--- a/f128_test.go
+++ b/f128_test.go
@@ -30,6 +30,17 @@ import (
// width instead of the hand-rolled f128. SelectF128 is fuzz-checked to be
// bit-identical to this (FuzzSelectF128), which is what justifies trusting the
// hand-rolled integer arithmetic in f128.go. Mirrors binomialCDFWalkF128 exactly.
+//
+// Valid range: big.Float's exponent is an int32, so pmf0 = (1-p)^money underflows
+// to exactly 0 once it drops below ~2^(-2^31). That requires the mean money*p to
+// exceed ~1.5e9 (a committee of ~1.5 billion) -- far outside any reachable
+// sortition input, where the mean is a committee size (<= a few thousand). So the
+// oracle is exact for every reachable input: FuzzSelectF128 stays well inside it
+// (money %= 3001), and TestF128MatchesOracleLargeMoney exercises realistic large
+// money (up to ~2^51) where it is likewise exact. (A truly unbounded oracle would
+// need big.Rat, which is infeasible at large money -- (1-p)^money has a
+// total^money denominator -- so this range, covering all reachable inputs, is the
+// practical maximum.)
func selectBigOracle(money uint64, totalMoney uint64, expectedSize float64, vrfOutput Digest) uint64 {
binomialP := expectedSize / float64(totalMoney)
t := &big.Int{}
@@ -152,3 +163,56 @@ func TestF128AgreesWithCurrent(t *testing.T) {
t.Errorf("agreement %d/%d too low -- likely a bug, not knife-edge rounding", match, considered)
}
}
+
+// FuzzF128Ops validates the f128 arithmetic primitives directly against
+// math/big.Float, one operation at a time -- finer-grained than the end-to-end
+// FuzzSelectF128. Each fuzzed pair of f128 operands is exact-compared (via
+// toBigFloat, which is what gives that method a job: inspecting exact f128 values)
+// to the same operation in 128-bit big.Float. A rounding/normalization bug in
+// mul/add/divU shows here immediately, on operands the end-to-end walk never
+// produces. Run: go test -run x -fuzz FuzzF128Ops
+func FuzzF128Ops(f *testing.F) {
+ f.Add(uint64(1)<<63, uint64(0), 0, uint64(3)<<62, uint64(0), 0, uint64(7))
+ f.Add(uint64(0), uint64(0), 0, uint64(1)<<63, uint64(1), -5, uint64(1)) // zero operand
+ f.Fuzz(func(t *testing.T, ahi, alo uint64, aexp int, bhi, blo uint64, bexp int, u uint64) {
+ // norm128 turns arbitrary bits into a valid normalized (or zero) f128;
+ // bound the exponents so the op exponent arithmetic (a.exp+b.exp+128) is sane.
+ a := norm128(ahi, alo, aexp%4000-2000)
+ b := norm128(bhi, blo, bexp%4000-2000)
+ ab, bb := a.toBigFloat(), b.toBigFloat()
+ check := func(name string, got f128, want *big.Float) {
+ if got.toBigFloat().Cmp(want) != 0 {
+ t.Fatalf("%s: f128=%v big.Float=%v (a=%v b=%v u=%d)", name, got.toBigFloat(), want, ab, bb, u)
+ }
+ }
+ check("mul", a.mul(b), new(big.Float).SetPrec(f128MantBits).Mul(ab, bb))
+ check("add", a.add(b), new(big.Float).SetPrec(f128MantBits).Add(ab, bb))
+ if u != 0 {
+ check("divU", a.divU(u),
+ new(big.Float).SetPrec(f128MantBits).Quo(ab, new(big.Float).SetPrec(f128MantBits).SetUint64(u)))
+ }
+ })
+}
+
+// TestF128MatchesOracleLargeMoney extends the strict SelectF128 == oracle check
+// beyond FuzzSelectF128's money<=3000 cap to realistic LARGE money (up to ~2^51),
+// where the big.Float oracle is still exact (mean = money*p stays a committee
+// size). Each committee's mean is drawn in [0.05, size] so money stays <= total.
+func TestF128MatchesOracleLargeMoney(t *testing.T) {
+ rng := rand.New(rand.NewSource(2))
+ const total = uint64(2_000_000_000_000_000)
+ committees := []float64{20, 1500, 2990, 6000}
+ for i := 0; i < 3000; i++ {
+ size := committees[rng.Intn(len(committees))]
+ mean := 0.05 + rng.Float64()*size // mean <= size => money <= total
+ money := uint64(mean * float64(total) / size) // up to ~2^51
+ if money == 0 {
+ continue
+ }
+ var d Digest
+ rng.Read(d[:])
+ if got, want := SelectF128(money, total, size, d), selectBigOracle(money, total, size, d); got != want {
+ t.Fatalf("SelectF128=%d != oracle=%d (money=%d size=%g vrf=%x)", got, want, money, size, d)
+ }
+ }
+}
diff --git a/sortition.go b/sortition.go
index eb69926..5d9f29b 100644
--- a/sortition.go
+++ b/sortition.go
@@ -62,9 +62,20 @@ func Select(money uint64, totalMoney uint64, expectedSize float64, vrfOutput Dig
// it line-by-line with Select above: the two function bodies are IDENTICAL except
// the final call -- where Select invokes the C++ sortition_binomial_cdf_walk
// (Boost, hardware double), SelectF128 invokes binomialCDFWalkF128 (software
-// f128, see f128.go). Both perform the same binomial-CDF walk and return the same
-// selection count; SelectF128's result is additionally bit-reproducible on every
-// platform/toolchain (no libm, no FMA, no hardware floating point).
+// f128, see f128.go). Both perform the same binomial-CDF walk; SelectF128's result
+// is additionally bit-reproducible on every platform/toolchain (no libm, no FMA,
+// no hardware floating point).
+//
+// CONSENSUS / MIGRATION NOTE. SelectF128 is bit-reproducible but NOT bit-identical
+// to the Boost-double Select. They agree on the overwhelming majority of inputs
+// (see TestF128AgreesWithCurrent) but differ at knife-edge VRF outputs -- ratios
+// within ~2^-53 of a CDF boundary, including a VRF whose ratio rounds to exactly
+// 1.0, where the gap can exceed 1. Each difference is a different committee
+// selection, so swapping Select -> SelectF128 in production is a protocol-gated,
+// network-coordinated consensus change, never a drop-in: a node on SelectF128
+// while peers run Boost would fork at those inputs. At such edges SelectF128
+// returns the correctly-rounded (128-bit) count; the divergence is exactly the
+// libm/double last-bit non-determinism that f128 removes.
func SelectF128(money uint64, totalMoney uint64, expectedSize float64, vrfOutput Digest) uint64 {
binomialN := float64(money)
binomialP := expectedSize / float64(totalMoney)
From c4febea59f4e640d91d5f402e27af40106b5e6b5 Mon Sep 17 00:00:00 2001
From: cce <51567+cce@users.noreply.github.com>
Date: Tue, 21 Jul 2026 06:25:18 -0400
Subject: [PATCH 03/37] trim comments
---
f128.go | 3 +--
f128_test.go | 8 +++-----
2 files changed, 4 insertions(+), 7 deletions(-)
diff --git a/f128.go b/f128.go
index 60006a7..5e58617 100644
--- a/f128.go
+++ b/f128.go
@@ -36,8 +36,7 @@ import (
// TO EVEN (matching math/big.Float). Round-to-nearest is required, not merely
// nicer: a VRF near the maximum makes the ratio round to exactly 1.0, and only
// round-to-nearest lets the accumulated cdf reach 1.0 (truncation asymptotes just
-// below it and the walk runs to `money`). SelectF128 is fuzz-checked against a
-// math/big.Float reference (see f128_test.go), including that case.
+// below it and the walk runs to `money`).
type f128 struct {
hi, lo uint64
exp int
diff --git a/f128_test.go b/f128_test.go
index 1e53cc8..9243a9e 100644
--- a/f128_test.go
+++ b/f128_test.go
@@ -96,9 +96,7 @@ func bigIntPow(base *big.Float, e uint64, prec uint) *big.Float {
// FuzzSelectF128 differentially fuzzes the two independent deterministic
// implementations -- SelectF128 (hand-rolled f128 integer arithmetic) and
// selectBigOracle (math/big.Float) -- which must return the same count. A bug in
-// the f128 code would have to be mirrored in big.Float to escape this. Run:
-//
-// go test -run x -fuzz FuzzSelectF128
+// the f128 code would have to be mirrored in big.Float to escape this.
func FuzzSelectF128(f *testing.F) {
seedVRF := append(bytes.Repeat([]byte{0xff}, 7), make([]byte, 25)...) // ratio rounds to 1.0
// the two ratio->1.0 cases native fuzzing found while developing f128:
@@ -170,7 +168,7 @@ func TestF128AgreesWithCurrent(t *testing.T) {
// toBigFloat, which is what gives that method a job: inspecting exact f128 values)
// to the same operation in 128-bit big.Float. A rounding/normalization bug in
// mul/add/divU shows here immediately, on operands the end-to-end walk never
-// produces. Run: go test -run x -fuzz FuzzF128Ops
+// produces.
func FuzzF128Ops(f *testing.F) {
f.Add(uint64(1)<<63, uint64(0), 0, uint64(3)<<62, uint64(0), 0, uint64(7))
f.Add(uint64(0), uint64(0), 0, uint64(1)<<63, uint64(1), -5, uint64(1)) // zero operand
@@ -204,7 +202,7 @@ func TestF128MatchesOracleLargeMoney(t *testing.T) {
committees := []float64{20, 1500, 2990, 6000}
for i := 0; i < 3000; i++ {
size := committees[rng.Intn(len(committees))]
- mean := 0.05 + rng.Float64()*size // mean <= size => money <= total
+ mean := 0.05 + rng.Float64()*size // mean <= size => money <= total
money := uint64(mean * float64(total) / size) // up to ~2^51
if money == 0 {
continue
From 623a56736d459ae37a35c146c4aed5c6815e9513 Mon Sep 17 00:00:00 2001
From: cce <51567+cce@users.noreply.github.com>
Date: Tue, 21 Jul 2026 07:07:54 -0400
Subject: [PATCH 04/37] add direct 256-bit digest-to-f128 ratio conversion and
passes an f128 ratio into the CDF walk
---
f128.go | 94 ++++++++++++++++++++++++------------
f128_test.go | 133 +++++++++++++++++++++++++++++++++++++--------------
go.mod | 2 +-
sortition.go | 43 +++++------------
4 files changed, 176 insertions(+), 96 deletions(-)
diff --git a/f128.go b/f128.go
index 5e58617..4ead558 100644
--- a/f128.go
+++ b/f128.go
@@ -17,7 +17,7 @@
package sortition
import (
- "math"
+ "encoding/binary"
"math/big"
"math/bits"
)
@@ -33,10 +33,7 @@ import (
// The 128-bit mantissa is normalized so bit 127 (the MSB of hi) is set, or the
// value is zero (hi==lo==0). All sortition quantities (p, 1-p, ratio, pmf, cdf,
// factors) are >= 0, so there is no sign bit. Arithmetic ROUNDS TO NEAREST, TIES
-// TO EVEN (matching math/big.Float). Round-to-nearest is required, not merely
-// nicer: a VRF near the maximum makes the ratio round to exactly 1.0, and only
-// round-to-nearest lets the accumulated cdf reach 1.0 (truncation asymptotes just
-// below it and the walk runs to `money`).
+// TO EVEN (matching math/big.Float).
type f128 struct {
hi, lo uint64
exp int
@@ -117,6 +114,25 @@ func shl192(a2, a1, a0 uint64, n uint) (uint64, uint64, uint64) {
}
}
+// shl256 shifts a 256-bit value (a3:a2:a1:a0) left by n < 256.
+func shl256(a3, a2, a1, a0 uint64, n uint) (uint64, uint64, uint64, uint64) {
+ words := n / 64
+ shift := n % 64
+ in := [4]uint64{a3, a2, a1, a0}
+ var out [4]uint64
+ for i := 0; i < 4; i++ {
+ src := i + int(words)
+ if src >= len(in) {
+ break
+ }
+ out[i] = in[src] << shift
+ if shift != 0 && src+1 < len(in) {
+ out[i] |= in[src+1] >> (64 - shift)
+ }
+ }
+ return out[0], out[1], out[2], out[3]
+}
+
// norm128 normalizes a 128-bit mantissa (shifts MSB to bit 127). No bits are
// dropped, so no rounding is needed; used for exact conversions.
func norm128(hi, lo uint64, exp int) f128 {
@@ -180,21 +196,43 @@ func f128FromUint64(u uint64) f128 {
return f128{u << uint(s), 0, -(s + 64)}
}
-func f128FromFloat64(f float64) f128 {
- if f <= 0 {
+// f128FromDigestRatio returns digest/(2^256-1), rounded to nearest-even at
+// f128 precision, without reducing the digest to float64 first.
+func f128FromDigestRatio(d Digest) f128 {
+ w3 := binary.BigEndian.Uint64(d[0:8])
+ w2 := binary.BigEndian.Uint64(d[8:16])
+ w1 := binary.BigEndian.Uint64(d[16:24])
+ w0 := binary.BigEndian.Uint64(d[24:32])
+
+ var leading int
+ switch {
+ case w3 != 0:
+ leading = bits.LeadingZeros64(w3)
+ case w2 != 0:
+ leading = 64 + bits.LeadingZeros64(w2)
+ case w1 != 0:
+ leading = 128 + bits.LeadingZeros64(w1)
+ case w0 != 0:
+ leading = 192 + bits.LeadingZeros64(w0)
+ default:
return f128{}
}
- b := math.Float64bits(f)
- mant := b & (1<<52 - 1)
- exp := int((b >> 52) & 0x7ff)
- if exp == 0 { // subnormal: value = mant * 2^-1074
- return norm128(0, mant, -1074)
+
+ n3, n2, n1, n0 := shl256(w3, w2, w1, w0, uint(leading))
+ roundBit := n1&(uint64(1)<<63) != 0
+ sticky := n1&^(uint64(1)<<63) != 0 || n0 != 0
+
+ // Dividing by 2^256 places the binary point directly after the digest.
+ // The real denominator is one smaller, so the exact ratio is slightly
+ // larger. That correction is at most one discarded-bit unit and changes
+ // rounding only when the 2^256 quotient is exactly halfway.
+ if roundBit && !sticky {
+ sticky = true
}
- // normal: significand (mant|2^52) in [2^52,2^53); MSB (bit 52) -> bit 127.
- return f128{(mant | 1<<52) << 11, 0, exp - 1150}
+ return roundNE(n3, n2, roundBit, sticky, -128-leading)
}
-var f128bigMask = new(big.Int).SetUint64(math.MaxUint64)
+var f128bigMask = new(big.Int).SetUint64(^uint64(0))
// f128FromBigFloat converts a (one-time, setup) big.Float constant to f128,
// rounding to nearest even at 128 bits (so it matches a 128-bit big.Float).
@@ -386,30 +424,28 @@ func (b *binomialF128) cdf(j uint64) f128 {
}
// binomialCDFWalkF128 is the pure-Go, deterministic counterpart of the C++
-// sortition_binomial_cdf_walk in sortition.cpp. It has the SAME signature and the
-// SAME walk -- place the two side by side:
+// sortition_binomial_cdf_walk in sortition.cpp. It performs the same boundary
+// walk, using an f128 ratio and f128 CDF values:
//
// C++ sortition.cpp Go this function
// ------------------------------------------ -------------------------------------------
// uint64_t sortition_binomial_cdf_walk( func binomialCDFWalkF128(
-// double n, double p, double ratio, n, p, ratio float64, money uint64) uint64 {
+// double n, double p, double ratio, p float64, ratio f128, money uint64) uint64 {
// uint64_t money) {
// binomial_distribution dist(n, p); dist := newBinomialF128(p, money)
// for (uint64_t j = 0; j < money; j++) { for j := uint64(0); j < money; j++ {
// double boundary = cdf(dist, j); boundary := dist.cdf(j)
-// if (ratio <= boundary) { if rf.cmp(boundary) <= 0 {
+// if (ratio <= boundary) { if ratio.cmp(boundary) <= 0 {
// return j; return j
// } }
// } }
// return money; return money
// } }
//
-// The only difference is HOW the binomial CDF P(X<=j) is obtained: Boost computes
-// cdf(dist, j) = ibetac(j+1, n-j, p) afresh each step in hardware double, whereas
-// dist.cdf(j) returns the identical value as the running PMF sum in software f128
-// (see binomialF128), making the result bit-reproducible on every platform. (n is
-// unused: the trial count is the exact uint64 `money`; Boost needs n only as the
-// double argument used to construct dist.)
+// Boost computes cdf(dist, j) = ibetac(j+1, n-j, p) afresh each step in hardware
+// double, whereas dist.cdf(j) returns the same mathematical value as a running
+// PMF sum in software f128 (see binomialF128). The f128 path also receives the
+// digest ratio directly at f128 precision.
//
// Precondition: money is within the sortition domain -- at most the total online
// microalgo supply (~10^16 < 2^54). The f128 exponent is a plain int; for money
@@ -418,19 +454,17 @@ func (b *binomialF128) cdf(j uint64) f128 {
// supply (>~2^57) is outside the domain -- Boost's Select cannot evaluate it
// either -- and would eventually overflow the int exponent; behavior is undefined
// there.
-func binomialCDFWalkF128(n float64, p float64, ratio float64, money uint64) uint64 {
- _ = n
+func binomialCDFWalkF128(p float64, ratio f128, money uint64) uint64 {
dist := newBinomialF128(p, money)
if dist == nil { // p >= 1: cdf(j)==0 for j= 1
- if cratio <= 0 {
+ if ratio.Sign() <= 0 {
return 0
}
return money
}
pq := new(big.Float).SetPrec(prec).Quo(p, q)
- ratio := new(big.Float).SetPrec(prec).SetFloat64(cratio)
pmf := bigIntPow(q, money, prec) // (1-p)^money
cdf := new(big.Float).SetPrec(prec).Set(pmf)
if cdf.Cmp(ratio) >= 0 {
@@ -78,6 +74,16 @@ func selectBigOracle(money uint64, totalMoney uint64, expectedSize float64, vrfO
return money
}
+func digestRatioBig(d Digest, prec uint) *big.Float {
+ numerator := new(big.Int).SetBytes(d[:])
+ denominator := new(big.Int).Lsh(big.NewInt(1), DigestSize*8)
+ denominator.Sub(denominator, big.NewInt(1))
+ return new(big.Float).SetPrec(prec).Quo(
+ new(big.Float).SetInt(numerator),
+ new(big.Float).SetInt(denominator),
+ )
+}
+
func bigIntPow(base *big.Float, e uint64, prec uint) *big.Float {
result := new(big.Float).SetPrec(prec).SetInt64(1)
b := new(big.Float).SetPrec(prec).Set(base)
@@ -93,24 +99,21 @@ func bigIntPow(base *big.Float, e uint64, prec uint) *big.Float {
return result
}
-// FuzzSelectF128 differentially fuzzes the two independent deterministic
-// implementations -- SelectF128 (hand-rolled f128 integer arithmetic) and
-// selectBigOracle (math/big.Float) -- which must return the same count. A bug in
-// the f128 code would have to be mirrored in big.Float to escape this.
+// FuzzSelectF128 differentially fuzzes the hand-rolled f128 implementation
+// against the independent math/big.Float oracle.
func FuzzSelectF128(f *testing.F) {
- seedVRF := append(bytes.Repeat([]byte{0xff}, 7), make([]byte, 25)...) // ratio rounds to 1.0
- // the two ratio->1.0 cases native fuzzing found while developing f128:
- f.Add(uint64(1954), uint64(1999999999999964), 1500.0, seedVRF)
+ seedVRF := append(bytes.Repeat([]byte{0xff}, 7), make([]byte, 25)...)
+ f.Add(uint64(1954), uint64(1_999_999_999_999_964), 1500.0, seedVRF)
f.Add(uint64(1141), uint64(1000), 250.0, seedVRF)
f.Add(uint64(0), uint64(2_000_000_000_000_000), 20.0, make([]byte, 32))
- f.Add(uint64(1000), uint64(1000), 1000.0, make([]byte, 32)) // p == 1
+ f.Add(uint64(1000), uint64(1000), 1000.0, make([]byte, 32))
f.Fuzz(func(t *testing.T, money, total uint64, expected float64, vrf []byte) {
if total == 0 || math.IsNaN(expected) || math.IsInf(expected, 0) ||
expected < 0 || expected > float64(total) {
return
}
- money %= 3001 // bound the walk so each fuzz exec stays fast
+ money %= 3001
var d Digest
copy(d[:], vrf)
got := SelectF128(money, total, expected, d)
@@ -122,13 +125,9 @@ func FuzzSelectF128(f *testing.F) {
})
}
-// TestF128AgreesWithCurrent shows that SelectF128 (pure Go) returns the same
-// selection count as the current C++ Select (Boost, hardware double) on realistic
-// random inputs. They are designed to agree everywhere except at knife-edge VRF
-// outputs (ratios within ~2^-53 of a CDF boundary), where the current libm-double
-// value and the deterministic value can differ by 1 -- precisely the
-// last-bit non-determinism the f128 path is meant to remove. Random ratios
-// essentially never hit those, so agreement is ~100%.
+// TestF128AgreesWithCurrent checks broad agreement with the deployed
+// Boost-double implementation. Knife-edge differences remain expected because
+// SelectF128 uses an f128 digest ratio and CDF.
func TestF128AgreesWithCurrent(t *testing.T) {
rng := rand.New(rand.NewSource(1))
const total = uint64(2_000_000_000_000_000)
@@ -156,31 +155,95 @@ func TestF128AgreesWithCurrent(t *testing.T) {
t.Logf("knife-edge diff: money=%d exp=%g cpp=%d f128=%d", money, exp, cpp, f)
}
}
- t.Logf("SelectF128 vs C++ Select: %d/%d agree (%d differ, all knife edges)", match, considered, diffs)
- if match*1000 < considered*999 { // < 99.9% would indicate a real bug, not knife edges
- t.Errorf("agreement %d/%d too low -- likely a bug, not knife-edge rounding", match, considered)
+ t.Logf("SelectF128 vs C++ Select: %d/%d agree (%d differ)", match, considered, diffs)
+ if match*1000 < considered*999 {
+ t.Errorf("agreement %d/%d too low", match, considered)
+ }
+}
+
+func TestF128DigestRatioMatchesBigFloat(t *testing.T) {
+ cases := make([]Digest, 0, 1389)
+ cases = append(cases, Digest{})
+ setBit := func(d *Digest, bit int) {
+ d[len(d)-1-bit/8] |= byte(1) << uint(bit%8)
+ }
+
+ // Exercise every possible normalization shift.
+ for bit := 0; bit < DigestSize*8; bit++ {
+ var d Digest
+ setBit(&d, bit)
+ cases = append(cases, d)
+ }
+
+ // Exercise exact halfway tails at every shift where a tail remains. The
+ // denominator correction must make each of these round upward.
+ for leading := 0; leading < f128MantBits; leading++ {
+ var d Digest
+ setBit(&d, DigestSize*8-1-leading)
+ setBit(&d, f128MantBits-1-leading)
+ cases = append(cases, d)
+ }
+
+ var one Digest
+ one[len(one)-1] = 1
+ cases = append(cases, one)
+
+ var halfway Digest
+ halfway[0] = 0x80
+ halfway[16] = 0x80
+ cases = append(cases, halfway)
+
+ var maximum Digest
+ for i := range maximum {
+ maximum[i] = 0xff
+ }
+ cases = append(cases, maximum)
+
+ var nearMaximum Digest
+ for i := 0; i < 7; i++ {
+ nearMaximum[i] = 0xff
+ }
+ cases = append(cases, nearMaximum)
+
+ rng := rand.New(rand.NewSource(3))
+ for i := 0; i < 1000; i++ {
+ var d Digest
+ rng.Read(d[:])
+ cases = append(cases, d)
+ }
+
+ for _, d := range cases {
+ got := f128FromDigestRatio(d).toBigFloat()
+ want := digestRatioBig(d, f128MantBits)
+ if got.Cmp(want) != 0 {
+ t.Fatalf("digest ratio mismatch for %x: f128=%v big.Float=%v", d, got, want)
+ }
+ }
+}
+
+func TestSelectF128NearMaximumDigest(t *testing.T) {
+ var d Digest
+ for i := 0; i < 7; i++ {
+ d[i] = 0xff
+ }
+ got := SelectF128(1954, 1_999_999_999_999_964, 1500, d)
+ if got != 1 {
+ t.Fatalf("SelectF128=%d, want 1 for exact near-maximum digest ratio", got)
}
}
// FuzzF128Ops validates the f128 arithmetic primitives directly against
-// math/big.Float, one operation at a time -- finer-grained than the end-to-end
-// FuzzSelectF128. Each fuzzed pair of f128 operands is exact-compared (via
-// toBigFloat, which is what gives that method a job: inspecting exact f128 values)
-// to the same operation in 128-bit big.Float. A rounding/normalization bug in
-// mul/add/divU shows here immediately, on operands the end-to-end walk never
-// produces.
+// math/big.Float at the same mantissa width.
func FuzzF128Ops(f *testing.F) {
f.Add(uint64(1)<<63, uint64(0), 0, uint64(3)<<62, uint64(0), 0, uint64(7))
- f.Add(uint64(0), uint64(0), 0, uint64(1)<<63, uint64(1), -5, uint64(1)) // zero operand
+ f.Add(uint64(0), uint64(0), 0, uint64(1)<<63, uint64(1), -5, uint64(1))
f.Fuzz(func(t *testing.T, ahi, alo uint64, aexp int, bhi, blo uint64, bexp int, u uint64) {
- // norm128 turns arbitrary bits into a valid normalized (or zero) f128;
- // bound the exponents so the op exponent arithmetic (a.exp+b.exp+128) is sane.
a := norm128(ahi, alo, aexp%4000-2000)
b := norm128(bhi, blo, bexp%4000-2000)
ab, bb := a.toBigFloat(), b.toBigFloat()
check := func(name string, got f128, want *big.Float) {
if got.toBigFloat().Cmp(want) != 0 {
- t.Fatalf("%s: f128=%v big.Float=%v (a=%v b=%v u=%d)", name, got.toBigFloat(), want, ab, bb, u)
+ t.Fatalf("%s: f128=%v big.Float=%v (a=%v b=%v u=%d)", name, got.toBigFloat(), want, ab, bb, u)
}
}
check("mul", a.mul(b), new(big.Float).SetPrec(f128MantBits).Mul(ab, bb))
diff --git a/go.mod b/go.mod
index 96fec71..bf40810 100644
--- a/go.mod
+++ b/go.mod
@@ -1,3 +1,3 @@
module github.com/algorand/sortition
-go 1.17
+go 1.23
diff --git a/sortition.go b/sortition.go
index 5d9f29b..34ab997 100644
--- a/sortition.go
+++ b/sortition.go
@@ -58,39 +58,22 @@ func Select(money uint64, totalMoney uint64, expectedSize float64, vrfOutput Dig
return uint64(C.sortition_binomial_cdf_walk(C.double(binomialN), C.double(binomialP), C.double(cratio), C.uint64_t(money)))
}
-// SelectF128 is a pure-Go, cgo-free, deterministic equivalent of Select. Compare
-// it line-by-line with Select above: the two function bodies are IDENTICAL except
-// the final call -- where Select invokes the C++ sortition_binomial_cdf_walk
-// (Boost, hardware double), SelectF128 invokes binomialCDFWalkF128 (software
-// f128, see f128.go). Both perform the same binomial-CDF walk; SelectF128's result
-// is additionally bit-reproducible on every platform/toolchain (no libm, no FMA,
-// no hardware floating point).
+// SelectF128 is a deterministic sortition function. It evaluates both the VRF
+// ratio and binomial CDF at f128 precision using software integer arithmetic, so
+// its result is bit-reproducible across platforms.
//
-// CONSENSUS / MIGRATION NOTE. SelectF128 is bit-reproducible but NOT bit-identical
-// to the Boost-double Select. They agree on the overwhelming majority of inputs
-// (see TestF128AgreesWithCurrent) but differ at knife-edge VRF outputs -- ratios
-// within ~2^-53 of a CDF boundary, including a VRF whose ratio rounds to exactly
-// 1.0, where the gap can exceed 1. Each difference is a different committee
-// selection, so swapping Select -> SelectF128 in production is a protocol-gated,
-// network-coordinated consensus change, never a drop-in: a node on SelectF128
-// while peers run Boost would fork at those inputs. At such edges SelectF128
-// returns the correctly-rounded (128-bit) count; the divergence is exactly the
-// libm/double last-bit non-determinism that f128 removes.
+// CONSENSUS / MIGRATION NOTE. SelectF128 is not bit-identical to the deployed
+// Boost-double Select. They agree on the overwhelming majority of inputs but can
+// differ at knife-edge VRF outputs near a CDF boundary. SelectF128 also preserves
+// the digest ratio at f128 precision instead of first rounding it to float64;
+// this avoids the multi-step tail divergence when a near-maximum digest rounds
+// to 1.0 in float64. Generic CDF-boundary differences can still change a
+// selection by one, so replacing Select with SelectF128 remains a protocol-gated,
+// network-coordinated consensus change.
func SelectF128(money uint64, totalMoney uint64, expectedSize float64, vrfOutput Digest) uint64 {
- binomialN := float64(money)
binomialP := expectedSize / float64(totalMoney)
-
- t := &big.Int{}
- t.SetBytes(vrfOutput[:])
-
- h := big.Float{}
- h.SetPrec(precision)
- h.SetInt(t)
-
- ratio := big.Float{}
- cratio, _ := ratio.Quo(&h, maxFloat).Float64()
-
- return binomialCDFWalkF128(binomialN, binomialP, cratio, money)
+ ratio := f128FromDigestRatio(vrfOutput)
+ return binomialCDFWalkF128(binomialP, ratio, money)
}
func init() {
From 0b07a39a11ba3047653c82cbdb964847d7c32547 Mon Sep 17 00:00:00 2001
From: cce <51567+cce@users.noreply.github.com>
Date: Tue, 21 Jul 2026 07:14:02 -0400
Subject: [PATCH 05/37] add TestSelectF128RatioExactlyOne
---
f128.go | 6 +++++-
f128_test.go | 37 ++++++++++++++++++++++++++++++++++++-
sortition.go | 9 +++++++++
3 files changed, 50 insertions(+), 2 deletions(-)
diff --git a/f128.go b/f128.go
index 4ead558..fefc5b5 100644
--- a/f128.go
+++ b/f128.go
@@ -33,7 +33,11 @@ import (
// The 128-bit mantissa is normalized so bit 127 (the MSB of hi) is set, or the
// value is zero (hi==lo==0). All sortition quantities (p, 1-p, ratio, pmf, cdf,
// factors) are >= 0, so there is no sign bit. Arithmetic ROUNDS TO NEAREST, TIES
-// TO EVEN (matching math/big.Float).
+// TO EVEN (matching math/big.Float). Round-to-nearest is required, not merely
+// nicer: the ratio is exactly 1.0 for the all-0xff digest (and any digest with
+// >= 129 leading one bits rounds there), and only round-to-nearest lets the
+// accumulated cdf reach exactly 1.0 -- truncation asymptotes just below it and
+// the walk runs to `money` (see TestSelectF128RatioExactlyOne).
type f128 struct {
hi, lo uint64
exp int
diff --git a/f128_test.go b/f128_test.go
index 2a6f7a2..17783f1 100644
--- a/f128_test.go
+++ b/f128_test.go
@@ -107,13 +107,15 @@ func FuzzSelectF128(f *testing.F) {
f.Add(uint64(1141), uint64(1000), 250.0, seedVRF)
f.Add(uint64(0), uint64(2_000_000_000_000_000), 20.0, make([]byte, 32))
f.Add(uint64(1000), uint64(1000), 1000.0, make([]byte, 32))
+ // all-0xff digest: ratio is exactly 1.0, the cdf-reaches-1.0 regime
+ f.Add(uint64(1954), uint64(1_999_999_999_999_964), 1500.0, bytes.Repeat([]byte{0xff}, 32))
f.Fuzz(func(t *testing.T, money, total uint64, expected float64, vrf []byte) {
if total == 0 || math.IsNaN(expected) || math.IsInf(expected, 0) ||
expected < 0 || expected > float64(total) {
return
}
- money %= 3001
+ money %= 3001 // bound the walk so each fuzz exec stays fast
var d Digest
copy(d[:], vrf)
got := SelectF128(money, total, expected, d)
@@ -232,6 +234,39 @@ func TestSelectF128NearMaximumDigest(t *testing.T) {
}
}
+// TestSelectF128RatioExactlyOne pins the walk when the f128 ratio is exactly
+// 1.0: mathematically for the all-0xff digest, and by 128-bit rounding for any
+// digest with at least 129 leading one bits. The walk terminates early only
+// because round-to-nearest lets the accumulated cdf reach exactly 1.0 (see the
+// f128 doc comment); with truncation the cdf would stay below 1.0 forever and
+// the walk would run all the way to money.
+func TestSelectF128RatioExactlyOne(t *testing.T) {
+ one := f128FromUint64(1)
+
+ var maximum Digest
+ for i := range maximum {
+ maximum[i] = 0xff
+ }
+ var manyLeadingOnes Digest
+ for i := 0; i < 17; i++ { // 136 leading one bits
+ manyLeadingOnes[i] = 0xff
+ }
+
+ for _, d := range []Digest{maximum, manyLeadingOnes} {
+ if f128FromDigestRatio(d).cmp(one) != 0 {
+ t.Fatalf("digest %x: ratio is not exactly 1.0", d)
+ }
+ got := SelectF128(1954, 1_999_999_999_999_964, 1500, d)
+ want := selectBigOracle(1954, 1_999_999_999_999_964, 1500, d)
+ if got != want {
+ t.Fatalf("digest %x: SelectF128=%d != oracle=%d", d, got, want)
+ }
+ if got != 3 {
+ t.Fatalf("digest %x: SelectF128=%d, want 3 (cdf reaches exactly 1.0 in the near tail)", d, got)
+ }
+ }
+}
+
// FuzzF128Ops validates the f128 arithmetic primitives directly against
// math/big.Float at the same mantissa width.
func FuzzF128Ops(f *testing.F) {
diff --git a/sortition.go b/sortition.go
index 34ab997..40f9bef 100644
--- a/sortition.go
+++ b/sortition.go
@@ -70,6 +70,15 @@ func Select(money uint64, totalMoney uint64, expectedSize float64, vrfOutput Dig
// to 1.0 in float64. Generic CDF-boundary differences can still change a
// selection by one, so replacing Select with SelectF128 remains a protocol-gated,
// network-coordinated consensus change.
+//
+// One tail edge: the top ~2^-129 of digest space rounds to an f128 ratio of
+// exactly 1.0, which sits at or above every cdf(j) for j < money. There the walk
+// falls through and returns money (the whole trial count) -- the exact-math value,
+// since cdf(j) < 1 for all j < money and cdf(money) == 1 -- whereas Boost's double
+// cdf saturates to 1.0 early and returns a small count. So at those inputs the
+// divergence can be as large as money vs. a few; they are cryptographically
+// unreachable (a VRF hash in the top 2^-129), so this is a documented property,
+// not a case worth special-casing.
func SelectF128(money uint64, totalMoney uint64, expectedSize float64, vrfOutput Digest) uint64 {
binomialP := expectedSize / float64(totalMoney)
ratio := f128FromDigestRatio(vrfOutput)
From b646576dc9b6faea0b760e5df28c63c7c232c261 Mon Sep 17 00:00:00 2001
From: John Jannotti
Date: Thu, 9 Jul 2026 16:56:02 -0400
Subject: [PATCH 06/37] add f128 sub, compute 1-p natively in newBinomialF128
1-p is exact for any float64 p, so compute it with a native f128
subtraction instead of big.Float, dropping one f128FromBigFloat
conversion (~6 fewer allocs, ~17% off newBinomialF128). sub is done
exactly in a 192-bit field so it is correct under cancellation, and is
fuzz-checked bit-identical to the big.Float oracle.
---
f128.go | 92 ++++++++++++++++++++++++++++++++++++++++++++++++++++++---
1 file changed, 88 insertions(+), 4 deletions(-)
diff --git a/f128.go b/f128.go
index fefc5b5..a8b770f 100644
--- a/f128.go
+++ b/f128.go
@@ -192,6 +192,29 @@ func norm192(r2, r1, r0 uint64, exp int) f128 {
return roundNE(s2, s1, roundBit, sticky, exp+64-lz)
}
+// norm192s is norm192 with an incoming sticky bit: extra records that nonzero
+// bits were discarded below r0 (used by sub, where aligning the subtrahend can
+// shift bits past the 192-bit window). All such bits are far below the result's
+// round bit, so they only ever contribute to sticky.
+func norm192s(r2, r1, r0 uint64, exp int, extra bool) f128 {
+ if r2 == 0 && r1 == 0 && r0 == 0 {
+ return f128{}
+ }
+ var lz int
+ switch {
+ case r2 != 0:
+ lz = bits.LeadingZeros64(r2)
+ case r1 != 0:
+ lz = 64 + bits.LeadingZeros64(r1)
+ default:
+ lz = 128 + bits.LeadingZeros64(r0)
+ }
+ s2, s1, s0 := shl192(r2, r1, r0, uint(lz))
+ roundBit := s0&(1<<63) != 0
+ sticky := s0&^(uint64(1)<<63) != 0 || extra
+ return roundNE(s2, s1, roundBit, sticky, exp+64-lz)
+}
+
func f128FromUint64(u uint64) f128 {
if u == 0 {
return f128{}
@@ -329,6 +352,61 @@ func (a f128) add(b f128) f128 {
return roundNE(shi, slo, round, sticky, exp)
}
+// sub returns a-b, rounded to nearest even, for NON-NEGATIVE operands with
+// a >= b (f128 is unsigned; a < b returns zero). The subtraction is performed
+// exactly in a 192-bit field -- a occupies a.hi:a.lo:0, giving 64 guard bits
+// below a's ulp -- so it is correct even under catastrophic cancellation
+// (a ~= b). b is aligned into that field by an arithmetic right shift; any bits
+// pushed below bit 0 (only possible when b << a, i.e. no cancellation) are
+// folded into sticky. When such bits exist, one field-ulp is borrowed first so
+// the residual (field-ulp - discarded) rounds correctly as pure sticky.
+func (a f128) sub(b f128) f128 {
+ if b.isZero() {
+ return a
+ }
+ if a.cmp(b) <= 0 {
+ return f128{}
+ }
+ diff := uint(a.exp - b.exp)
+ var b2, b1, b0 uint64
+ var sticky bool
+ switch {
+ case diff == 0:
+ b2, b1, b0 = b.hi, b.lo, 0
+ case diff < 64:
+ b2 = b.hi >> diff
+ b1 = b.hi<<(64-diff) | b.lo>>diff
+ b0 = b.lo << (64 - diff)
+ case diff == 64:
+ b2, b1, b0 = 0, b.hi, b.lo
+ case diff < 128:
+ s := diff - 64
+ b1 = b.hi >> s
+ b0 = b.hi<<(64-s) | b.lo>>s
+ sticky = b.lo<<(64-s) != 0
+ case diff == 128:
+ b0 = b.hi
+ sticky = b.lo != 0
+ case diff < 192:
+ s := diff - 128
+ b0 = b.hi >> s
+ sticky = b.hi<<(64-s) != 0 || b.lo != 0
+ default:
+ sticky = true // b nonzero, entirely below the field
+ }
+ if sticky { // borrow one field-ulp so the discarded remainder is pure sticky
+ var brw uint64
+ b0, brw = bits.Add64(b0, 1, 0)
+ b1, brw = bits.Add64(b1, 0, brw)
+ b2 += brw
+ }
+ // a >= b, so the 192-bit subtraction A - B never borrows out of the top.
+ r0, brw := bits.Sub64(0, b0, 0)
+ r1, brw := bits.Sub64(a.lo, b1, brw)
+ r2, _ := bits.Sub64(a.hi, b2, brw)
+ return norm192s(r2, r1, r0, a.exp-64, sticky)
+}
+
func (a f128) cmp(b f128) int {
az, bz := a.isZero(), b.isZero()
switch {
@@ -407,13 +485,19 @@ type binomialF128 struct {
// p) and rounded to f128. Returns nil for the degenerate p >= 1 (all probability
// mass at j == money), which the caller handles.
func newBinomialF128(p float64, money uint64) *binomialF128 {
- pb := new(big.Float).SetPrec(f128MantBits).SetFloat64(p)
- qb := new(big.Float).SetPrec(f128MantBits).Sub(new(big.Float).SetPrec(f128MantBits).SetInt64(1), pb)
- if qb.Sign() <= 0 { // p >= 1
+ // 1-p is exact in f128 (p is a float64), so compute it natively rather than in
+ // big.Float; qf equals f128FromBigFloat(1-p) bit-for-bit (checked by the fuzz).
+ pf := f128FromFloat64(p)
+ if pf.cmp(f128FromUint64(1)) >= 0 { // p >= 1
return nil
}
+ qf := f128FromUint64(1).sub(pf) // 1-p
+ // pq = p/(1-p) still needs a full divide, which f128 lacks, so form it in
+ // big.Float and round to f128.
+ pb := new(big.Float).SetPrec(f128MantBits).SetFloat64(p)
+ qb := new(big.Float).SetPrec(f128MantBits).Sub(new(big.Float).SetPrec(f128MantBits).SetInt64(1), pb)
pq := f128FromBigFloat(new(big.Float).SetPrec(f128MantBits).Quo(pb, qb))
- pmf0 := f128FromBigFloat(qb).intPow(money) // (1-p)^money
+ pmf0 := qf.intPow(money) // (1-p)^money
return &binomialF128{money: money, pq: pq, pmf: pmf0, cum: pmf0, at: 0}
}
From 789f596f6b698b2524578c749ca038a547fe48e1 Mon Sep 17 00:00:00 2001
From: John Jannotti
Date: Thu, 9 Jul 2026 17:10:26 -0400
Subject: [PATCH 07/37] add f128 div, drop big.Float from newBinomialF128
entirely
Compute p/(1-p) with a native round-to-nearest-even f128 divide (128/128
via Knuth long division, used once per setup) instead of big.Float. With
the earlier native 1-p, newBinomialF128 is now heap-free apart from the
returned struct: 658ns/19allocs -> 195ns/1alloc, and the SelectF128 common
path drops 984ns/25allocs -> 535ns/7allocs (the remaining allocs are the
VRF->ratio big.Float work shared with Select).
div is fuzz-checked bit-identical to the big.Float oracle via SelectF128,
and TestDivVsBig cross-checks it directly against a 128-bit big.Float
divide on 5M broad random operands. Removes the now-dead f128FromBigFloat,
f128bigMask, and toBigFloat helpers and the math/big import.
---
f128.go | 162 ++++++++++++++++++++++++++++++++++++++-------------
f128_test.go | 37 ++++++++++--
2 files changed, 154 insertions(+), 45 deletions(-)
diff --git a/f128.go b/f128.go
index a8b770f..732a2da 100644
--- a/f128.go
+++ b/f128.go
@@ -18,7 +18,7 @@ package sortition
import (
"encoding/binary"
- "math/big"
+ "math"
"math/bits"
)
@@ -43,8 +43,8 @@ type f128 struct {
exp int
}
-// f128MantBits is the f128 mantissa width; the big.Float setup constants are
-// formed at this same precision so they round to f128 exactly.
+// f128MantBits is the f128 mantissa width. The big.Float oracle in the test
+// forms its constants at this same precision so they round to f128 exactly.
const f128MantBits = 128
func (a f128) isZero() bool { return a.hi == 0 && a.lo == 0 }
@@ -223,6 +223,20 @@ func f128FromUint64(u uint64) f128 {
return f128{u << uint(s), 0, -(s + 64)}
}
+func f128FromFloat64(f float64) f128 {
+ if f <= 0 {
+ return f128{}
+ }
+ b := math.Float64bits(f)
+ mant := b & (1<<52 - 1)
+ exp := int((b >> 52) & 0x7ff)
+ if exp == 0 { // subnormal: value = mant * 2^-1074
+ return norm128(0, mant, -1074)
+ }
+ // normal: significand (mant|2^52) in [2^52,2^53); MSB (bit 52) -> bit 127.
+ return f128{(mant | 1<<52) << 11, 0, exp - 1150}
+}
+
// f128FromDigestRatio returns digest/(2^256-1), rounded to nearest-even at
// f128 precision, without reducing the digest to float64 first.
func f128FromDigestRatio(d Digest) f128 {
@@ -259,24 +273,6 @@ func f128FromDigestRatio(d Digest) f128 {
return roundNE(n3, n2, roundBit, sticky, -128-leading)
}
-var f128bigMask = new(big.Int).SetUint64(^uint64(0))
-
-// f128FromBigFloat converts a (one-time, setup) big.Float constant to f128,
-// rounding to nearest even at 128 bits (so it matches a 128-bit big.Float).
-func f128FromBigFloat(x *big.Float) f128 {
- if x.Sign() <= 0 {
- return f128{}
- }
- r := new(big.Float).SetPrec(128).Set(x) // round to 128-bit mantissa, nearest-even
- m := new(big.Float).SetPrec(256)
- e := r.MantExp(m) // r = m * 2^e, m in [0.5,1), 128-bit
- m.SetMantExp(m, 128) // m * 2^128 = exact 128-bit integer
- bi, _ := m.Int(nil)
- lo := new(big.Int).And(bi, f128bigMask).Uint64()
- hi := new(big.Int).Rsh(bi, 64).Uint64()
- return norm128(hi, lo, e-128)
-}
-
// mul returns a*b rounded to nearest even.
func (a f128) mul(b f128) f128 {
if a.isZero() || b.isZero() {
@@ -323,6 +319,103 @@ func (a f128) divU(u uint64) f128 {
return norm192(q2, q1, q0, a.exp-64)
}
+// divStep is one digit step of Knuth's Algorithm D for a normalized (top bit
+// set) 128-bit divisor v1:v0: it divides the 192-bit value uHi:uMid:uLo by
+// v1:v0, where the running-remainder prefix uHi:uMid is already < v1:v0, and
+// returns the 64-bit quotient digit q and the new 128-bit remainder rHi:rLo.
+func divStep(uHi, uMid, uLo, v1, v0 uint64) (q, rHi, rLo uint64) {
+ // qhat = min((uHi:uMid)/v1, 2^64-1); Div64 requires uHi < v1, so cap when
+ // uHi == v1 (the running remainder guarantees uHi never exceeds v1).
+ var qhat, rhat uint64
+ refine := true
+ if uHi >= v1 {
+ qhat = ^uint64(0)
+ var c uint64
+ rhat, c = bits.Add64(uMid, v1, 0) // rhat = (v1:uMid) - qhat*v1 = uMid + v1
+ if c != 0 {
+ refine = false // rhat >= 2^64: the refine test is already false
+ }
+ } else {
+ qhat, rhat = bits.Div64(uHi, uMid, v1)
+ }
+ // Lower qhat (over-estimated by at most 2) until qhat*v0 <= rhat:uLo.
+ for refine {
+ hi, lo := bits.Mul64(qhat, v0)
+ if hi > rhat || (hi == rhat && lo > uLo) {
+ qhat--
+ var c uint64
+ rhat, c = bits.Add64(rhat, v1, 0)
+ if c != 0 {
+ break
+ }
+ continue
+ }
+ break
+ }
+ // u - qhat*(v1:v0), a 192-bit subtraction.
+ p1hi, p1lo := bits.Mul64(qhat, v1)
+ p0hi, p0lo := bits.Mul64(qhat, v0)
+ prodMid, c := bits.Add64(p1lo, p0hi, 0)
+ prodHi := p1hi + c
+ sLo, br := bits.Sub64(uLo, p0lo, 0)
+ sMid, br := bits.Sub64(uMid, prodMid, br)
+ _, br = bits.Sub64(uHi, prodHi, br)
+ q = qhat
+ if br != 0 { // qhat was 1 too large: add the divisor back
+ q--
+ sLo, c = bits.Add64(sLo, v0, 0)
+ sMid, _ = bits.Add64(sMid, v1, c)
+ }
+ return q, sMid, sLo
+}
+
+// div returns a/b rounded to nearest even, for NON-NEGATIVE operands (b != 0).
+// It divides the 256-bit a.hi:a.lo:0:0 by the normalized 128-bit b.hi:b.lo via
+// Knuth long division. Since both mantissas are in [2^127,2^128), the ratio is
+// in (0.5,2), so the 129-bit integer quotient Q is either already normalized
+// (Q < 2^128) or one bit wide (Q >= 2^128); the division remainder supplies the
+// bits below Q for round-to-nearest-even. Unlike divU (small integer divisor),
+// this handles a full f128 divisor; it is used once per setup, not in the walk.
+func (a f128) div(b f128) f128 {
+ if a.isZero() || b.isZero() {
+ return f128{}
+ }
+ v1, v0 := b.hi, b.lo
+ // Long-divide [a.hi, a.lo, 0, 0] by v1:v0, most-significant limb first.
+ var remHi, remLo uint64
+ var q1, q0 uint64 // the two low quotient digits; the top two are 0 and {0,1}
+ var q2 uint64
+ digit, remHi, remLo := divStep(remHi, remLo, a.hi, v1, v0) // = 0
+ _ = digit
+ q2, remHi, remLo = divStep(remHi, remLo, a.lo, v1, v0) // in {0,1}
+ q1, remHi, remLo = divStep(remHi, remLo, 0, v1, v0)
+ q0, remHi, remLo = divStep(remHi, remLo, 0, v1, v0)
+
+ expq := a.exp - b.exp - 128
+ if q2 != 0 { // Q in [2^128,2^129): mantissa = Q>>1, dropped low bit is round
+ mantHi := q2<<63 | q1>>1
+ mantLo := q1<<63 | q0>>1
+ round := q0&1 != 0
+ sticky := remHi != 0 || remLo != 0
+ return roundNE(mantHi, mantLo, round, sticky, expq+1)
+ }
+ // Q in [2^127,2^128): already normalized; round/sticky come from rem/b, i.e.
+ // round = (2*rem >= b), sticky = the leftover after that comparison.
+ dblLo := remLo << 1
+ dblHi := remHi<<1 | remLo>>63
+ carry := remHi >> 63
+ var round, sticky bool
+ if carry != 0 || dblHi > v1 || (dblHi == v1 && dblLo >= v0) {
+ round = true
+ sLo, br := bits.Sub64(dblLo, v0, 0)
+ sHi, _ := bits.Sub64(dblHi, v1, br)
+ sticky = sHi != 0 || sLo != 0
+ } else {
+ sticky = remHi != 0 || remLo != 0
+ }
+ return roundNE(q1, q0, round, sticky, expq)
+}
+
// add returns a+b (both non-negative), rounded to nearest even.
func (a f128) add(b f128) f128 {
if a.isZero() {
@@ -435,14 +528,6 @@ func (a f128) cmp(b f128) int {
return 0
}
-// toBigFloat returns the exact value as a big.Float (debug/inspection only).
-func (a f128) toBigFloat() *big.Float {
- hiF := new(big.Float).SetPrec(256).SetUint64(a.hi)
- hiF.SetMantExp(hiF, 64)
- m := new(big.Float).SetPrec(256).Add(hiF, new(big.Float).SetPrec(256).SetUint64(a.lo))
- return m.SetMantExp(m, a.exp)
-}
-
// intPow returns base^e by exponentiation by squaring (integer exponent).
func (base f128) intPow(e uint64) f128 {
result := f128FromUint64(1)
@@ -481,23 +566,18 @@ type binomialF128 struct {
// newBinomialF128 constructs the Binomial(money, p) CDF evaluator -- the analogue
// of constructing binomial_distribution(n=money, p). The PMF-recurrence
-// constants 1-p and p/(1-p) are formed once in big.Float (exact for the float64
-// p) and rounded to f128. Returns nil for the degenerate p >= 1 (all probability
-// mass at j == money), which the caller handles.
+// constants 1-p and p/(1-p) are formed once, entirely in f128 (no big.Float, no
+// heap): 1-p is exact for the float64 p, and p/(1-p) is a round-to-nearest-even
+// f128 divide. Returns nil for the degenerate p >= 1 (all probability mass at
+// j == money), which the caller handles.
func newBinomialF128(p float64, money uint64) *binomialF128 {
- // 1-p is exact in f128 (p is a float64), so compute it natively rather than in
- // big.Float; qf equals f128FromBigFloat(1-p) bit-for-bit (checked by the fuzz).
pf := f128FromFloat64(p)
if pf.cmp(f128FromUint64(1)) >= 0 { // p >= 1
return nil
}
- qf := f128FromUint64(1).sub(pf) // 1-p
- // pq = p/(1-p) still needs a full divide, which f128 lacks, so form it in
- // big.Float and round to f128.
- pb := new(big.Float).SetPrec(f128MantBits).SetFloat64(p)
- qb := new(big.Float).SetPrec(f128MantBits).Sub(new(big.Float).SetPrec(f128MantBits).SetInt64(1), pb)
- pq := f128FromBigFloat(new(big.Float).SetPrec(f128MantBits).Quo(pb, qb))
- pmf0 := qf.intPow(money) // (1-p)^money
+ qf := f128FromUint64(1).sub(pf) // 1-p (exact)
+ pq := pf.div(qf) // p/(1-p)
+ pmf0 := qf.intPow(money) // (1-p)^money
return &binomialF128{money: money, pq: pq, pmf: pmf0, cum: pmf0, at: 0}
}
diff --git a/f128_test.go b/f128_test.go
index 17783f1..9317d69 100644
--- a/f128_test.go
+++ b/f128_test.go
@@ -215,7 +215,7 @@ func TestF128DigestRatioMatchesBigFloat(t *testing.T) {
}
for _, d := range cases {
- got := f128FromDigestRatio(d).toBigFloat()
+ got := f128ToBig(f128FromDigestRatio(d))
want := digestRatioBig(d, f128MantBits)
if got.Cmp(want) != 0 {
t.Fatalf("digest ratio mismatch for %x: f128=%v big.Float=%v", d, got, want)
@@ -275,10 +275,11 @@ func FuzzF128Ops(f *testing.F) {
f.Fuzz(func(t *testing.T, ahi, alo uint64, aexp int, bhi, blo uint64, bexp int, u uint64) {
a := norm128(ahi, alo, aexp%4000-2000)
b := norm128(bhi, blo, bexp%4000-2000)
- ab, bb := a.toBigFloat(), b.toBigFloat()
+ ab, bb := f128ToBig(a), f128ToBig(b)
check := func(name string, got f128, want *big.Float) {
- if got.toBigFloat().Cmp(want) != 0 {
- t.Fatalf("%s: f128=%v big.Float=%v (a=%v b=%v u=%d)", name, got.toBigFloat(), want, ab, bb, u)
+ gotBig := f128ToBig(got)
+ if gotBig.Cmp(want) != 0 {
+ t.Fatalf("%s: f128=%v big.Float=%v (a=%v b=%v u=%d)", name, gotBig, want, ab, bb, u)
}
}
check("mul", a.mul(b), new(big.Float).SetPrec(f128MantBits).Mul(ab, bb))
@@ -312,3 +313,31 @@ func TestF128MatchesOracleLargeMoney(t *testing.T) {
}
}
}
+
+// f128ToBig returns the exact value of an f128 as a big.Float (test helper).
+func f128ToBig(x f128) *big.Float {
+ hi := new(big.Float).SetPrec(300).SetUint64(x.hi)
+ hi.SetMantExp(hi, 64)
+ m := new(big.Float).SetPrec(300).Add(hi, new(big.Float).SetPrec(300).SetUint64(x.lo))
+ return m.SetMantExp(m, x.exp)
+}
+
+// TestDivVsBig checks f128.div against a 128-bit round-nearest-even big.Float
+// divide on broad random operands. The SelectF128 fuzz only exercises div with
+// the p/(1-p) shape, so this independently validates the hand-rolled Knuth
+// long division across the full input space (spanning exponents and the
+// Q<2^128 vs Q>=2^128 normalization cases).
+func TestDivVsBig(t *testing.T) {
+ rng := rand.New(rand.NewSource(2))
+ randF128 := func() f128 {
+ return f128{rng.Uint64() | 1<<63, rng.Uint64(), rng.Intn(4000) - 2000} // normalized
+ }
+ for i := 0; i < 5_000_000; i++ {
+ a, b := randF128(), randF128()
+ got := f128ToBig(a.div(b))
+ want := new(big.Float).SetPrec(f128MantBits).Quo(f128ToBig(a), f128ToBig(b))
+ if got.Cmp(want) != 0 {
+ t.Fatalf("div mismatch: a=%+v b=%+v got=%s want=%s", a, b, got.Text('p', 0), want.Text('p', 0))
+ }
+ }
+}
From a6645efe919fdda8c7c1555a01151c8cbb196406 Mon Sep 17 00:00:00 2001
From: cce <51567+cce@users.noreply.github.com>
Date: Tue, 21 Jul 2026 07:29:07 -0400
Subject: [PATCH 08/37] fuzz sub and div in FuzzF128Ops, add
BenchmarkSelectF128
sub was previously exercised only through the (1, p) operand shape in newBinomialF128; fuzz it across the full operand space, with seeds for the sticky/borrow path (exponent diff >= 65) and near-equal cancellation. Fuzz div alongside TestDivVsBig for ongoing corpus-driven coverage.
Also correct the newBinomialF128 comment: 1-p is correctly rounded rather than exact for p below ~2^-76 (it still matches the 128-bit big.Float oracle bit-for-bit, which is the property the fuzz checks). BenchmarkSelectF128 mirrors BenchmarkSortition for direct comparison with the cgo/Boost path.
---
f128.go | 10 ++++++----
f128_test.go | 26 ++++++++++++++++++++++++++
2 files changed, 32 insertions(+), 4 deletions(-)
diff --git a/f128.go b/f128.go
index 732a2da..b5735a9 100644
--- a/f128.go
+++ b/f128.go
@@ -567,15 +567,17 @@ type binomialF128 struct {
// newBinomialF128 constructs the Binomial(money, p) CDF evaluator -- the analogue
// of constructing binomial_distribution(n=money, p). The PMF-recurrence
// constants 1-p and p/(1-p) are formed once, entirely in f128 (no big.Float, no
-// heap): 1-p is exact for the float64 p, and p/(1-p) is a round-to-nearest-even
-// f128 divide. Returns nil for the degenerate p >= 1 (all probability mass at
-// j == money), which the caller handles.
+// heap): both are round-to-nearest-even f128 operations, bit-identical to the
+// 128-bit big.Float oracle (1-p is additionally exact for any p >= ~2^-76,
+// which covers every realistic sortition probability). Returns nil for the
+// degenerate p >= 1 (all probability mass at j == money), which the caller
+// handles.
func newBinomialF128(p float64, money uint64) *binomialF128 {
pf := f128FromFloat64(p)
if pf.cmp(f128FromUint64(1)) >= 0 { // p >= 1
return nil
}
- qf := f128FromUint64(1).sub(pf) // 1-p (exact)
+ qf := f128FromUint64(1).sub(pf) // 1-p
pq := pf.div(qf) // p/(1-p)
pmf0 := qf.intPow(money) // (1-p)^money
return &binomialF128{money: money, pq: pq, pmf: pmf0, cum: pmf0, at: 0}
diff --git a/f128_test.go b/f128_test.go
index 9317d69..5dddf98 100644
--- a/f128_test.go
+++ b/f128_test.go
@@ -272,6 +272,9 @@ func TestSelectF128RatioExactlyOne(t *testing.T) {
func FuzzF128Ops(f *testing.F) {
f.Add(uint64(1)<<63, uint64(0), 0, uint64(3)<<62, uint64(0), 0, uint64(7))
f.Add(uint64(0), uint64(0), 0, uint64(1)<<63, uint64(1), -5, uint64(1))
+ // sub's sticky/borrow path (exponent diff >= 65) and near-equal cancellation:
+ f.Add(uint64(1)<<63, uint64(0), 65, uint64(1)<<63, uint64(1), 0, uint64(1))
+ f.Add(uint64(1)<<63, uint64(0), 0, uint64(1)<<63, uint64(1), 0, uint64(3))
f.Fuzz(func(t *testing.T, ahi, alo uint64, aexp int, bhi, blo uint64, bexp int, u uint64) {
a := norm128(ahi, alo, aexp%4000-2000)
b := norm128(bhi, blo, bexp%4000-2000)
@@ -288,6 +291,15 @@ func FuzzF128Ops(f *testing.F) {
check("divU", a.divU(u),
new(big.Float).SetPrec(f128MantBits).Quo(ab, new(big.Float).SetPrec(f128MantBits).SetUint64(u)))
}
+ // sub is defined for a >= b (f128 is unsigned), so order the operands.
+ if a.cmp(b) >= 0 {
+ check("sub", a.sub(b), new(big.Float).SetPrec(f128MantBits).Sub(ab, bb))
+ } else {
+ check("sub", b.sub(a), new(big.Float).SetPrec(f128MantBits).Sub(bb, ab))
+ }
+ if !b.isZero() {
+ check("div", a.div(b), new(big.Float).SetPrec(f128MantBits).Quo(ab, bb))
+ }
})
}
@@ -314,6 +326,20 @@ func TestF128MatchesOracleLargeMoney(t *testing.T) {
}
}
+// BenchmarkSelectF128 mirrors BenchmarkSortition (same parameters) so the pure-Go
+// deterministic path can be compared directly against the cgo/Boost Select.
+func BenchmarkSelectF128(b *testing.B) {
+ b.StopTimer()
+ keys := make([]Digest, b.N)
+ for i := 0; i < b.N; i++ {
+ rand.Read(keys[i][:])
+ }
+ b.StartTimer()
+ for i := 0; i < b.N; i++ {
+ SelectF128(1000000, 1000000000000, 2500, keys[i])
+ }
+}
+
// f128ToBig returns the exact value of an f128 as a big.Float (test helper).
func f128ToBig(x f128) *big.Float {
hi := new(big.Float).SetPrec(300).SetUint64(x.hi)
From da28510a80d50dbfc6a050538189d9734cc7635b Mon Sep 17 00:00:00 2001
From: cce <51567+cce@users.noreply.github.com>
Date: Tue, 21 Jul 2026 07:51:22 -0400
Subject: [PATCH 09/37] take expectedSize as uint64 in SelectF128
The committee size is a uint64 in the protocol (CommitteeSize(proto)); the float64 parameter only mirrored the C++ double signature and forced casts at every caller. Passing the exact integers lets each PMF-recurrence constant be a SINGLE round-to-nearest-even f128 divide of exact values -- q = (total-expected)/total and pq = expected/(total-expected) -- instead of stacking roundings through an intermediate float64 p (float64(totalMoney) is itself inexact above 2^53). Invalid probabilities (NaN/Inf/negative/fractional) become unrepresentable and p >= 1 is an exact integer comparison, so the differential fuzz no longer needs any input filtering.
f128FromFloat64 loses its only caller and is removed; sub and its norm192s helper likewise lose theirs (1-p is now a direct divide of exact integers, one rounding instead of two) and are removed along with their fuzz checks. The oracle mirrors the integer-quotient constants. Boost agreement is unchanged (200000/200000); the fuzz explored the new signature for 8.1M execs clean. Setup now does two divs instead of convert+sub+div: ~170ns/op (was ~159), still 1 alloc.
---
f128.go | 139 ++++++++++-----------------------------------------
f128_test.go | 64 +++++++++++-------------
sortition.go | 19 +++++--
3 files changed, 68 insertions(+), 154 deletions(-)
diff --git a/f128.go b/f128.go
index b5735a9..81990d4 100644
--- a/f128.go
+++ b/f128.go
@@ -18,7 +18,6 @@ package sortition
import (
"encoding/binary"
- "math"
"math/bits"
)
@@ -192,29 +191,6 @@ func norm192(r2, r1, r0 uint64, exp int) f128 {
return roundNE(s2, s1, roundBit, sticky, exp+64-lz)
}
-// norm192s is norm192 with an incoming sticky bit: extra records that nonzero
-// bits were discarded below r0 (used by sub, where aligning the subtrahend can
-// shift bits past the 192-bit window). All such bits are far below the result's
-// round bit, so they only ever contribute to sticky.
-func norm192s(r2, r1, r0 uint64, exp int, extra bool) f128 {
- if r2 == 0 && r1 == 0 && r0 == 0 {
- return f128{}
- }
- var lz int
- switch {
- case r2 != 0:
- lz = bits.LeadingZeros64(r2)
- case r1 != 0:
- lz = 64 + bits.LeadingZeros64(r1)
- default:
- lz = 128 + bits.LeadingZeros64(r0)
- }
- s2, s1, s0 := shl192(r2, r1, r0, uint(lz))
- roundBit := s0&(1<<63) != 0
- sticky := s0&^(uint64(1)<<63) != 0 || extra
- return roundNE(s2, s1, roundBit, sticky, exp+64-lz)
-}
-
func f128FromUint64(u uint64) f128 {
if u == 0 {
return f128{}
@@ -223,20 +199,6 @@ func f128FromUint64(u uint64) f128 {
return f128{u << uint(s), 0, -(s + 64)}
}
-func f128FromFloat64(f float64) f128 {
- if f <= 0 {
- return f128{}
- }
- b := math.Float64bits(f)
- mant := b & (1<<52 - 1)
- exp := int((b >> 52) & 0x7ff)
- if exp == 0 { // subnormal: value = mant * 2^-1074
- return norm128(0, mant, -1074)
- }
- // normal: significand (mant|2^52) in [2^52,2^53); MSB (bit 52) -> bit 127.
- return f128{(mant | 1<<52) << 11, 0, exp - 1150}
-}
-
// f128FromDigestRatio returns digest/(2^256-1), rounded to nearest-even at
// f128 precision, without reducing the digest to float64 first.
func f128FromDigestRatio(d Digest) f128 {
@@ -445,61 +407,6 @@ func (a f128) add(b f128) f128 {
return roundNE(shi, slo, round, sticky, exp)
}
-// sub returns a-b, rounded to nearest even, for NON-NEGATIVE operands with
-// a >= b (f128 is unsigned; a < b returns zero). The subtraction is performed
-// exactly in a 192-bit field -- a occupies a.hi:a.lo:0, giving 64 guard bits
-// below a's ulp -- so it is correct even under catastrophic cancellation
-// (a ~= b). b is aligned into that field by an arithmetic right shift; any bits
-// pushed below bit 0 (only possible when b << a, i.e. no cancellation) are
-// folded into sticky. When such bits exist, one field-ulp is borrowed first so
-// the residual (field-ulp - discarded) rounds correctly as pure sticky.
-func (a f128) sub(b f128) f128 {
- if b.isZero() {
- return a
- }
- if a.cmp(b) <= 0 {
- return f128{}
- }
- diff := uint(a.exp - b.exp)
- var b2, b1, b0 uint64
- var sticky bool
- switch {
- case diff == 0:
- b2, b1, b0 = b.hi, b.lo, 0
- case diff < 64:
- b2 = b.hi >> diff
- b1 = b.hi<<(64-diff) | b.lo>>diff
- b0 = b.lo << (64 - diff)
- case diff == 64:
- b2, b1, b0 = 0, b.hi, b.lo
- case diff < 128:
- s := diff - 64
- b1 = b.hi >> s
- b0 = b.hi<<(64-s) | b.lo>>s
- sticky = b.lo<<(64-s) != 0
- case diff == 128:
- b0 = b.hi
- sticky = b.lo != 0
- case diff < 192:
- s := diff - 128
- b0 = b.hi >> s
- sticky = b.hi<<(64-s) != 0 || b.lo != 0
- default:
- sticky = true // b nonzero, entirely below the field
- }
- if sticky { // borrow one field-ulp so the discarded remainder is pure sticky
- var brw uint64
- b0, brw = bits.Add64(b0, 1, 0)
- b1, brw = bits.Add64(b1, 0, brw)
- b2 += brw
- }
- // a >= b, so the 192-bit subtraction A - B never borrows out of the top.
- r0, brw := bits.Sub64(0, b0, 0)
- r1, brw := bits.Sub64(a.lo, b1, brw)
- r2, _ := bits.Sub64(a.hi, b2, brw)
- return norm192s(r2, r1, r0, a.exp-64, sticky)
-}
-
func (a f128) cmp(b f128) int {
az, bz := a.isZero(), b.isZero()
switch {
@@ -564,22 +471,27 @@ type binomialF128 struct {
at uint64 // index that pmf/cum currently hold
}
-// newBinomialF128 constructs the Binomial(money, p) CDF evaluator -- the analogue
-// of constructing binomial_distribution(n=money, p). The PMF-recurrence
-// constants 1-p and p/(1-p) are formed once, entirely in f128 (no big.Float, no
-// heap): both are round-to-nearest-even f128 operations, bit-identical to the
-// 128-bit big.Float oracle (1-p is additionally exact for any p >= ~2^-76,
-// which covers every realistic sortition probability). Returns nil for the
-// degenerate p >= 1 (all probability mass at j == money), which the caller
-// handles.
-func newBinomialF128(p float64, money uint64) *binomialF128 {
- pf := f128FromFloat64(p)
- if pf.cmp(f128FromUint64(1)) >= 0 { // p >= 1
+// newBinomialF128 constructs the CDF evaluator for Binomial(money trials,
+// p = expectedSize/totalMoney) -- the analogue of constructing
+// binomial_distribution(n=money, p). Taking p as its exact integer
+// numerator and denominator lets each PMF-recurrence constant be a SINGLE
+// round-to-nearest-even f128 divide of exact values:
+//
+// 1-p = (totalMoney-expectedSize) / totalMoney
+// p/(1-p) = expectedSize / (totalMoney-expectedSize)
+//
+// rather than stacking roundings through an intermediate float64 p (whose
+// float64(totalMoney) conversion is itself inexact above 2^53). Returns nil for
+// the degenerate p >= 1 -- expectedSize >= totalMoney, an exact integer
+// comparison; all probability mass at j == money -- which the caller handles.
+// totalMoney == 0 also lands on the nil path.
+func newBinomialF128(expectedSize, totalMoney, money uint64) *binomialF128 {
+ if expectedSize >= totalMoney { // p >= 1
return nil
}
- qf := f128FromUint64(1).sub(pf) // 1-p
- pq := pf.div(qf) // p/(1-p)
- pmf0 := qf.intPow(money) // (1-p)^money
+ qf := f128FromUint64(totalMoney - expectedSize).div(f128FromUint64(totalMoney)) // 1-p
+ pq := f128FromUint64(expectedSize).div(f128FromUint64(totalMoney - expectedSize)) // p/(1-p)
+ pmf0 := qf.intPow(money) // (1-p)^money
return &binomialF128{money: money, pq: pq, pmf: pmf0, cum: pmf0, at: 0}
}
@@ -600,9 +512,9 @@ func (b *binomialF128) cdf(j uint64) f128 {
// C++ sortition.cpp Go this function
// ------------------------------------------ -------------------------------------------
// uint64_t sortition_binomial_cdf_walk( func binomialCDFWalkF128(
-// double n, double p, double ratio, p float64, ratio f128, money uint64) uint64 {
-// uint64_t money) {
-// binomial_distribution dist(n, p); dist := newBinomialF128(p, money)
+// double n, double p, double ratio, expectedSize, totalMoney uint64,
+// uint64_t money) { ratio f128, money uint64) uint64 {
+// binomial_distribution dist(n, p); dist := newBinomialF128(expectedSize, totalMoney, money)
// for (uint64_t j = 0; j < money; j++) { for j := uint64(0); j < money; j++ {
// double boundary = cdf(dist, j); boundary := dist.cdf(j)
// if (ratio <= boundary) { if ratio.cmp(boundary) <= 0 {
@@ -615,7 +527,8 @@ func (b *binomialF128) cdf(j uint64) f128 {
// Boost computes cdf(dist, j) = ibetac(j+1, n-j, p) afresh each step in hardware
// double, whereas dist.cdf(j) returns the same mathematical value as a running
// PMF sum in software f128 (see binomialF128). The f128 path also receives the
-// digest ratio directly at f128 precision.
+// digest ratio directly at f128 precision, and the success probability as its
+// exact integer numerator and denominator rather than a float64 quotient.
//
// Precondition: money is within the sortition domain -- at most the total online
// microalgo supply (~10^16 < 2^54). The f128 exponent is a plain int; for money
@@ -624,8 +537,8 @@ func (b *binomialF128) cdf(j uint64) f128 {
// supply (>~2^57) is outside the domain -- Boost's Select cannot evaluate it
// either -- and would eventually overflow the int exponent; behavior is undefined
// there.
-func binomialCDFWalkF128(p float64, ratio f128, money uint64) uint64 {
- dist := newBinomialF128(p, money)
+func binomialCDFWalkF128(expectedSize, totalMoney uint64, ratio f128, money uint64) uint64 {
+ dist := newBinomialF128(expectedSize, totalMoney, money)
if dist == nil { // p >= 1: cdf(j)==0 for j= 1
+ if expectedSize >= totalMoney { // p >= 1
if ratio.Sign() <= 0 {
return 0
}
return money
}
- pq := new(big.Float).SetPrec(prec).Quo(p, q)
+ q := new(big.Float).SetPrec(prec).Quo(
+ new(big.Float).SetPrec(prec).SetUint64(totalMoney-expectedSize),
+ new(big.Float).SetPrec(prec).SetUint64(totalMoney))
+ pq := new(big.Float).SetPrec(prec).Quo(
+ new(big.Float).SetPrec(prec).SetUint64(expectedSize),
+ new(big.Float).SetPrec(prec).SetUint64(totalMoney-expectedSize))
pmf := bigIntPow(q, money, prec) // (1-p)^money
cdf := new(big.Float).SetPrec(prec).Set(pmf)
if cdf.Cmp(ratio) >= 0 {
@@ -103,25 +103,26 @@ func bigIntPow(base *big.Float, e uint64, prec uint) *big.Float {
// against the independent math/big.Float oracle.
func FuzzSelectF128(f *testing.F) {
seedVRF := append(bytes.Repeat([]byte{0xff}, 7), make([]byte, 25)...)
- f.Add(uint64(1954), uint64(1_999_999_999_999_964), 1500.0, seedVRF)
- f.Add(uint64(1141), uint64(1000), 250.0, seedVRF)
- f.Add(uint64(0), uint64(2_000_000_000_000_000), 20.0, make([]byte, 32))
- f.Add(uint64(1000), uint64(1000), 1000.0, make([]byte, 32))
+ f.Add(uint64(1954), uint64(1_999_999_999_999_964), uint64(1500), seedVRF)
+ f.Add(uint64(1141), uint64(1000), uint64(250), seedVRF)
+ f.Add(uint64(0), uint64(2_000_000_000_000_000), uint64(20), make([]byte, 32))
+ f.Add(uint64(1000), uint64(1000), uint64(1000), make([]byte, 32))
+ f.Add(uint64(100), uint64(1000), uint64(2000), make([]byte, 32)) // expectedSize > totalMoney
// all-0xff digest: ratio is exactly 1.0, the cdf-reaches-1.0 regime
- f.Add(uint64(1954), uint64(1_999_999_999_999_964), 1500.0, bytes.Repeat([]byte{0xff}, 32))
+ f.Add(uint64(1954), uint64(1_999_999_999_999_964), uint64(1500), bytes.Repeat([]byte{0xff}, 32))
- f.Fuzz(func(t *testing.T, money, total uint64, expected float64, vrf []byte) {
- if total == 0 || math.IsNaN(expected) || math.IsInf(expected, 0) ||
- expected < 0 || expected > float64(total) {
- return
- }
+ // The uint64 expectedSize needs no input filtering: NaN/Inf/negative/
+ // fractional sizes are unrepresentable, and expectedSize >= totalMoney
+ // (including totalMoney == 0) takes the exact-integer p >= 1 path in both
+ // implementations.
+ f.Fuzz(func(t *testing.T, money, total, expected uint64, vrf []byte) {
money %= 3001 // bound the walk so each fuzz exec stays fast
var d Digest
copy(d[:], vrf)
got := SelectF128(money, total, expected, d)
want := selectBigOracle(money, total, expected, d)
if got != want {
- t.Fatalf("SelectF128=%d != big.Float oracle=%d (money=%d total=%d expected=%g vrf=%x)",
+ t.Fatalf("SelectF128=%d != big.Float oracle=%d (money=%d total=%d expected=%d vrf=%x)",
got, want, money, total, expected, d)
}
})
@@ -133,20 +134,20 @@ func FuzzSelectF128(f *testing.F) {
func TestF128AgreesWithCurrent(t *testing.T) {
rng := rand.New(rand.NewSource(1))
const total = uint64(2_000_000_000_000_000)
- committees := []float64{20, 1500, 2990, 6000}
+ committees := []uint64{20, 1500, 2990, 6000}
const n = 200000
match, considered, diffs := 0, 0, 0
for i := 0; i < n; i++ {
exp := committees[rng.Intn(len(committees))]
mean := 0.05 + rng.Float64()*30
- money := uint64(mean * float64(total) / exp)
+ money := uint64(mean * float64(total) / float64(exp))
if money == 0 {
continue
}
var d Digest
rng.Read(d[:])
considered++
- cpp := Select(money, total, exp, d)
+ cpp := Select(money, total, float64(exp), d)
f := SelectF128(money, total, exp, d)
if cpp == f {
match++
@@ -154,7 +155,7 @@ func TestF128AgreesWithCurrent(t *testing.T) {
}
diffs++
if diffs <= 10 {
- t.Logf("knife-edge diff: money=%d exp=%g cpp=%d f128=%d", money, exp, cpp, f)
+ t.Logf("knife-edge diff: money=%d exp=%d cpp=%d f128=%d", money, exp, cpp, f)
}
}
t.Logf("SelectF128 vs C++ Select: %d/%d agree (%d differ)", match, considered, diffs)
@@ -272,9 +273,6 @@ func TestSelectF128RatioExactlyOne(t *testing.T) {
func FuzzF128Ops(f *testing.F) {
f.Add(uint64(1)<<63, uint64(0), 0, uint64(3)<<62, uint64(0), 0, uint64(7))
f.Add(uint64(0), uint64(0), 0, uint64(1)<<63, uint64(1), -5, uint64(1))
- // sub's sticky/borrow path (exponent diff >= 65) and near-equal cancellation:
- f.Add(uint64(1)<<63, uint64(0), 65, uint64(1)<<63, uint64(1), 0, uint64(1))
- f.Add(uint64(1)<<63, uint64(0), 0, uint64(1)<<63, uint64(1), 0, uint64(3))
f.Fuzz(func(t *testing.T, ahi, alo uint64, aexp int, bhi, blo uint64, bexp int, u uint64) {
a := norm128(ahi, alo, aexp%4000-2000)
b := norm128(bhi, blo, bexp%4000-2000)
@@ -291,12 +289,6 @@ func FuzzF128Ops(f *testing.F) {
check("divU", a.divU(u),
new(big.Float).SetPrec(f128MantBits).Quo(ab, new(big.Float).SetPrec(f128MantBits).SetUint64(u)))
}
- // sub is defined for a >= b (f128 is unsigned), so order the operands.
- if a.cmp(b) >= 0 {
- check("sub", a.sub(b), new(big.Float).SetPrec(f128MantBits).Sub(ab, bb))
- } else {
- check("sub", b.sub(a), new(big.Float).SetPrec(f128MantBits).Sub(bb, ab))
- }
if !b.isZero() {
check("div", a.div(b), new(big.Float).SetPrec(f128MantBits).Quo(ab, bb))
}
@@ -310,18 +302,18 @@ func FuzzF128Ops(f *testing.F) {
func TestF128MatchesOracleLargeMoney(t *testing.T) {
rng := rand.New(rand.NewSource(2))
const total = uint64(2_000_000_000_000_000)
- committees := []float64{20, 1500, 2990, 6000}
+ committees := []uint64{20, 1500, 2990, 6000}
for i := 0; i < 3000; i++ {
size := committees[rng.Intn(len(committees))]
- mean := 0.05 + rng.Float64()*size // mean <= size => money <= total
- money := uint64(mean * float64(total) / size) // up to ~2^51
+ mean := 0.05 + rng.Float64()*float64(size) // mean <= size => money <= total
+ money := uint64(mean * float64(total) / float64(size)) // up to ~2^51
if money == 0 {
continue
}
var d Digest
rng.Read(d[:])
if got, want := SelectF128(money, total, size, d), selectBigOracle(money, total, size, d); got != want {
- t.Fatalf("SelectF128=%d != oracle=%d (money=%d size=%g vrf=%x)", got, want, money, size, d)
+ t.Fatalf("SelectF128=%d != oracle=%d (money=%d size=%d vrf=%x)", got, want, money, size, d)
}
}
}
diff --git a/sortition.go b/sortition.go
index 40f9bef..8e1f774 100644
--- a/sortition.go
+++ b/sortition.go
@@ -62,13 +62,23 @@ func Select(money uint64, totalMoney uint64, expectedSize float64, vrfOutput Dig
// ratio and binomial CDF at f128 precision using software integer arithmetic, so
// its result is bit-reproducible across platforms.
//
+// Unlike Select, SelectF128 takes the committee size as the exact uint64 it is
+// in the protocol rather than a float64. The distribution constants are then
+// single correctly-rounded f128 divides of exact integers -- float64(totalMoney)
+// is inexact above 2^53, and a float64 p = expectedSize/totalMoney would stack
+// further roundings -- and invalid probabilities (NaN, Inf, negative,
+// fractional) are unrepresentable. p >= 1, i.e. expectedSize >= totalMoney, is
+// an exact integer comparison, handled as all probability mass at money.
+//
// CONSENSUS / MIGRATION NOTE. SelectF128 is not bit-identical to the deployed
// Boost-double Select. They agree on the overwhelming majority of inputs but can
// differ at knife-edge VRF outputs near a CDF boundary. SelectF128 also preserves
// the digest ratio at f128 precision instead of first rounding it to float64;
// this avoids the multi-step tail divergence when a near-maximum digest rounds
-// to 1.0 in float64. Generic CDF-boundary differences can still change a
-// selection by one, so replacing Select with SelectF128 remains a protocol-gated,
+// to 1.0 in float64. The success probability is likewise formed at f128
+// precision from the integer expectedSize/totalMoney instead of a float64
+// quotient. Generic CDF-boundary differences can still change a selection by
+// one, so replacing Select with SelectF128 remains a protocol-gated,
// network-coordinated consensus change.
//
// One tail edge: the top ~2^-129 of digest space rounds to an f128 ratio of
@@ -79,10 +89,9 @@ func Select(money uint64, totalMoney uint64, expectedSize float64, vrfOutput Dig
// divergence can be as large as money vs. a few; they are cryptographically
// unreachable (a VRF hash in the top 2^-129), so this is a documented property,
// not a case worth special-casing.
-func SelectF128(money uint64, totalMoney uint64, expectedSize float64, vrfOutput Digest) uint64 {
- binomialP := expectedSize / float64(totalMoney)
+func SelectF128(money uint64, totalMoney uint64, expectedSize uint64, vrfOutput Digest) uint64 {
ratio := f128FromDigestRatio(vrfOutput)
- return binomialCDFWalkF128(binomialP, ratio, money)
+ return binomialCDFWalkF128(expectedSize, totalMoney, ratio, money)
}
func init() {
From 9902d6def33a46cc4e3db54fc4598359e4d7d7c0 Mon Sep 17 00:00:00 2001
From: cce <51567+cce@users.noreply.github.com>
Date: Tue, 21 Jul 2026 08:07:13 -0400
Subject: [PATCH 10/37] update comment
---
sortition.go | 13 ++++++-------
1 file changed, 6 insertions(+), 7 deletions(-)
diff --git a/sortition.go b/sortition.go
index 8e1f774..501414b 100644
--- a/sortition.go
+++ b/sortition.go
@@ -82,13 +82,12 @@ func Select(money uint64, totalMoney uint64, expectedSize float64, vrfOutput Dig
// network-coordinated consensus change.
//
// One tail edge: the top ~2^-129 of digest space rounds to an f128 ratio of
-// exactly 1.0, which sits at or above every cdf(j) for j < money. There the walk
-// falls through and returns money (the whole trial count) -- the exact-math value,
-// since cdf(j) < 1 for all j < money and cdf(money) == 1 -- whereas Boost's double
-// cdf saturates to 1.0 early and returns a small count. So at those inputs the
-// divergence can be as large as money vs. a few; they are cryptographically
-// unreachable (a VRF hash in the top 2^-129), so this is a documented property,
-// not a case worth special-casing.
+// exactly 1.0. Although the exact CDF remains below 1 for every j < money, the
+// accumulated f128 CDF can itself round to 1.0; the walk returns the first j
+// where that happens (3 in TestSelectF128RatioExactlyOne). Boost's double CDF
+// can saturate at a different j, so the divergence at this edge can exceed one.
+// Such inputs are cryptographically unreachable in practice (a VRF hash in the
+// top 2^-129), so this is a documented property, not a case worth special-casing.
func SelectF128(money uint64, totalMoney uint64, expectedSize uint64, vrfOutput Digest) uint64 {
ratio := f128FromDigestRatio(vrfOutput)
return binomialCDFWalkF128(expectedSize, totalMoney, ratio, money)
From 0fdbb9de7efd338de8bdf49d43cf7c5df97fb083 Mon Sep 17 00:00:00 2001
From: cce <51567+cce@users.noreply.github.com>
Date: Tue, 21 Jul 2026 08:32:01 -0400
Subject: [PATCH 11/37] correct ratio==1.0 docs: both walk outcomes occur; pin
both branches
At ratio exactly 1.0 (only the all-0xff digest IS exactly 1.0; the rest of the top 2^-129 digest interval rounds up to it), the accumulated f128 CDF reaches exactly 1.0 only for some distributions, decided at ulp granularity: money=1954/exp=1500 stops at j=3 with total=1_999_999_999_999_964 but falls through with total=2_000_000_000_000_000, 36 more. Other distributions never get within half an ulp of 1.0 (p=1/2 with money=100 leaves cdf(99) = 1 - 2^-100, 2^28 ulps short) and the walk runs to money -- which, with the rounded threshold fixed at 1.0, is also the exact-CDF count, since the exact CDF is < 1 for every j < money. The previous text presented the early stop as the behavior and money as hypothetical; this had it backwards.
Reframe the f128 round-to-nearest rationale accordingly: RNE is required because every result must be the correctly-rounded 128-bit value to stay bit-identical to the 128-bit big.Float oracle; the ratio==1.0 edge is a consequence, not the requirement. Boost's double CDF -- evaluated independently per j via ibetac rather than accumulated -- can also saturate to 1.0 on its far coarser grid, potentially at a different (typically earlier) j, so old-vs-new divergence at this edge can be as large as money vs. a few.
TestSelectF128RatioExactlyOne now pins both branches and both boundaries: the hair-trigger total pair (j=3 vs money), the provably-stuck money=100/p=1/2 case, and the exact tie money=129/p=1/2 where true cdf(128) = 1 - 2^-129 sits precisely on the rounding midpoint and ties-to-even rounds it up to 1.0 (stops at j=128). The rounds-to-1.0 digest is constructed with exactly 129 leading one bits, the minimal such digest.
---
f128.go | 12 +++++++-----
f128_test.go | 54 +++++++++++++++++++++++++++++++++++++---------------
sortition.go | 21 ++++++++++++++------
3 files changed, 61 insertions(+), 26 deletions(-)
diff --git a/f128.go b/f128.go
index 81990d4..4846777 100644
--- a/f128.go
+++ b/f128.go
@@ -32,11 +32,13 @@ import (
// The 128-bit mantissa is normalized so bit 127 (the MSB of hi) is set, or the
// value is zero (hi==lo==0). All sortition quantities (p, 1-p, ratio, pmf, cdf,
// factors) are >= 0, so there is no sign bit. Arithmetic ROUNDS TO NEAREST, TIES
-// TO EVEN (matching math/big.Float). Round-to-nearest is required, not merely
-// nicer: the ratio is exactly 1.0 for the all-0xff digest (and any digest with
-// >= 129 leading one bits rounds there), and only round-to-nearest lets the
-// accumulated cdf reach exactly 1.0 -- truncation asymptotes just below it and
-// the walk runs to `money` (see TestSelectF128RatioExactlyOne).
+// TO EVEN (matching math/big.Float), so every result is the correctly-rounded
+// 128-bit value -- this is what makes SelectF128 match the big.Float oracle.
+// It also shapes the ratio == 1.0 edge (all-0xff digest, or any digest with
+// >= 129 leading one bits): for some distributions the accumulated cdf rounds
+// up to exactly 1.0 at an early j and the walk stops there; in others it stays
+// below 1.0 for all j < money and the walk runs to money (see
+// TestSelectF128RatioExactlyOne and the SelectF128 doc comment).
type f128 struct {
hi, lo uint64
exp int
diff --git a/f128_test.go b/f128_test.go
index bb00a1e..6e26e60 100644
--- a/f128_test.go
+++ b/f128_test.go
@@ -237,10 +237,22 @@ func TestSelectF128NearMaximumDigest(t *testing.T) {
// TestSelectF128RatioExactlyOne pins the walk when the f128 ratio is exactly
// 1.0: mathematically for the all-0xff digest, and by 128-bit rounding for any
-// digest with at least 129 leading one bits. The walk terminates early only
-// because round-to-nearest lets the accumulated cdf reach exactly 1.0 (see the
-// f128 doc comment); with truncation the cdf would stay below 1.0 forever and
-// the walk would run all the way to money.
+// digest with at least 129 leading one bits. With the f128-rounded threshold
+// fixed at 1.0, money is the exact-CDF count; the walk returns an earlier j
+// only when the accumulated f128 CDF happens to round up to exactly 1.0 (see
+// the SelectF128 doc comment). Each case pins one branch:
+//
+// - money=1954 with total=1_999_999_999_999_964: stops early at j=3, while
+// the same distribution with total=2_000_000_000_000_000 (36 more) falls
+// through to money. A hair-trigger pair pinned together: if a rounding
+// change flips either, the cdf trajectory moved by an ulp -- the walk did
+// not break.
+// - money=100, p=1/2: provably falls through to money -- cdf(99) is
+// 1 - 2^-100, which sits 2^28 ulps below 1.0, a gap no rounding can
+// bridge.
+// - money=129, p=1/2: the exact boundary -- true cdf(128) = 1 - 2^-129 is
+// precisely the rounding midpoint, and ties-to-even rounds it up to
+// exactly 1.0, stopping at j=128.
func TestSelectF128RatioExactlyOne(t *testing.T) {
one := f128FromUint64(1)
@@ -248,22 +260,34 @@ func TestSelectF128RatioExactlyOne(t *testing.T) {
for i := range maximum {
maximum[i] = 0xff
}
- var manyLeadingOnes Digest
- for i := 0; i < 17; i++ { // 136 leading one bits
- manyLeadingOnes[i] = 0xff
+ // exactly 129 leading one bits: the minimal digest that rounds to 1.0
+ var minLeadingOnes Digest
+ for i := 0; i < 16; i++ {
+ minLeadingOnes[i] = 0xff
}
+ minLeadingOnes[16] = 0x80
- for _, d := range []Digest{maximum, manyLeadingOnes} {
+ cases := []struct {
+ money, total, expected uint64
+ want uint64
+ }{
+ {1954, 1_999_999_999_999_964, 1500, 3},
+ {1954, 2_000_000_000_000_000, 1500, 1954},
+ {100, 200, 100, 100},
+ {129, 258, 129, 128},
+ }
+ for _, d := range []Digest{maximum, minLeadingOnes} {
if f128FromDigestRatio(d).cmp(one) != 0 {
t.Fatalf("digest %x: ratio is not exactly 1.0", d)
}
- got := SelectF128(1954, 1_999_999_999_999_964, 1500, d)
- want := selectBigOracle(1954, 1_999_999_999_999_964, 1500, d)
- if got != want {
- t.Fatalf("digest %x: SelectF128=%d != oracle=%d", d, got, want)
- }
- if got != 3 {
- t.Fatalf("digest %x: SelectF128=%d, want 3 (cdf reaches exactly 1.0 in the near tail)", d, got)
+ for _, c := range cases {
+ got := SelectF128(c.money, c.total, c.expected, d)
+ if oracle := selectBigOracle(c.money, c.total, c.expected, d); got != oracle {
+ t.Fatalf("digest %x money=%d: SelectF128=%d != oracle=%d", d, c.money, got, oracle)
+ }
+ if got != c.want {
+ t.Fatalf("digest %x money=%d: SelectF128=%d, want %d", d, c.money, got, c.want)
+ }
}
}
}
diff --git a/sortition.go b/sortition.go
index 501414b..7dac0a2 100644
--- a/sortition.go
+++ b/sortition.go
@@ -82,12 +82,21 @@ func Select(money uint64, totalMoney uint64, expectedSize float64, vrfOutput Dig
// network-coordinated consensus change.
//
// One tail edge: the top ~2^-129 of digest space rounds to an f128 ratio of
-// exactly 1.0. Although the exact CDF remains below 1 for every j < money, the
-// accumulated f128 CDF can itself round to 1.0; the walk returns the first j
-// where that happens (3 in TestSelectF128RatioExactlyOne). Boost's double CDF
-// can saturate at a different j, so the divergence at this edge can exceed one.
-// Such inputs are cryptographically unreachable in practice (a VRF hash in the
-// top 2^-129), so this is a documented property, not a case worth special-casing.
+// exactly 1.0 (only the all-0xff digest IS exactly 1.0; the rest of the
+// interval rounds up to it). With the threshold fixed at 1.0, the exact CDF is
+// < 1 for every j < money and the walk never evaluates cdf(money) == 1, so the
+// exact-CDF count is money -- and the walk returns money unless the accumulated
+// f128 CDF happens to round up to exactly 1.0 at an earlier j (a rounding
+// artifact), in which case it returns that j. Both outcomes occur, decided
+// per-distribution at ulp granularity: the money=1954 case in
+// TestSelectF128RatioExactlyOne stops at j=3, while the same distribution with
+// total=2_000_000_000_000_000 falls through to money. Both match the 128-bit
+// big.Float oracle. Boost's double CDF -- evaluated independently per j via
+// ibetac rather than accumulated -- can also saturate to 1.0 on its far coarser
+// grid, potentially at a different (typically earlier) j, so the divergence
+// here can be as large as money vs. a few. Such inputs are cryptographically
+// unreachable (a VRF hash in the top 2^-129), so this is a documented property,
+// not a case worth special-casing.
func SelectF128(money uint64, totalMoney uint64, expectedSize uint64, vrfOutput Digest) uint64 {
ratio := f128FromDigestRatio(vrfOutput)
return binomialCDFWalkF128(expectedSize, totalMoney, ratio, money)
From eec84b81a4612e242828d48752609a07de4055fc Mon Sep 17 00:00:00 2001
From: cce <51567+cce@users.noreply.github.com>
Date: Tue, 21 Jul 2026 09:18:41 -0400
Subject: [PATCH 12/37] clarify tail-edge divergence wording in SelectF128 doc
The compressed 'as large as money vs. a few' read as a typo (money is the parameter, not a quantity of currency). Spell it out: at a near-maximum digest one implementation may stop within a few steps of the binomial tail while the other returns the full trial count money.
---
sortition.go | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/sortition.go b/sortition.go
index 7dac0a2..a929824 100644
--- a/sortition.go
+++ b/sortition.go
@@ -93,10 +93,12 @@ func Select(money uint64, totalMoney uint64, expectedSize float64, vrfOutput Dig
// total=2_000_000_000_000_000 falls through to money. Both match the 128-bit
// big.Float oracle. Boost's double CDF -- evaluated independently per j via
// ibetac rather than accumulated -- can also saturate to 1.0 on its far coarser
-// grid, potentially at a different (typically earlier) j, so the divergence
-// here can be as large as money vs. a few. Such inputs are cryptographically
-// unreachable (a VRF hash in the top 2^-129), so this is a documented property,
-// not a case worth special-casing.
+// grid, potentially at a different (typically earlier) j. At these near-maximum
+// digests the two implementations can therefore return wildly different counts:
+// one may stop within a few steps of the binomial tail while the other returns
+// the full trial count money. Such inputs are cryptographically unreachable (a
+// VRF hash in the top 2^-129), so this is a documented property, not a case
+// worth special-casing.
func SelectF128(money uint64, totalMoney uint64, expectedSize uint64, vrfOutput Digest) uint64 {
ratio := f128FromDigestRatio(vrfOutput)
return binomialCDFWalkF128(expectedSize, totalMoney, ratio, money)
From 6b0359c01b154181a23e02c6d2a965f8e169d30d Mon Sep 17 00:00:00 2001
From: cce <51567+cce@users.noreply.github.com>
Date: Tue, 21 Jul 2026 10:33:56 -0400
Subject: [PATCH 13/37] short-circuit the walk when the accumulated CDF freezes
pmf(0) = (1-p)^money amplifies the 2^-129 rounding of 1-p by up to the trial count, and pmf(0) scales every PMF term, so the accumulated CDF settles at a plateau that can sit ~money*2^-129 below 1 (~2^-78 at supply-sized money). A digest ratio between the plateau and 1.0 (e.g. 2^256-1-2^176, ratio ~1-2^-80, with money == total == 2e15) sits above every boundary without rounding to 1.0, and the walk previously ground through all 2e15 no-op iterations -- hours of CPU on the consensus path -- before returning money.
Detect the freeze instead: once an add cannot move cum while pmf strictly shrank, no later term can move it either (the rounded step factor is < 1 and only decreases; round-to-nearest is monotone), so the walk returns money immediately -- the identical result the plain walk reaches, in O(binomial tail) time. FuzzSelectF128 continuously validates the short-circuit against the oracle's full walk, and TestSelectF128FrozenTailReturnsMoney pins the supply-sized case for both the 2^-80 digest and ratio exactly 1.0.
The count in the stuck sliver stays DEFINED as money: reaching it requires a VRF hash within ~2^-78 of the maximum, which cannot be ground for, so the 192-bit-pmf(0) variant that would return the exact binomial-tail crossing there (parked on sortition-f128-pmf192) is not worth its added audit surface under current consensus parameters. Documented in the SelectF128 tail-edge note.
---
f128.go | 34 +++++++++++++++++++++++++++++-----
f128_test.go | 33 +++++++++++++++++++++++++++++++++
sortition.go | 15 +++++++++++++++
3 files changed, 77 insertions(+), 5 deletions(-)
diff --git a/f128.go b/f128.go
index 4846777..4bd1335 100644
--- a/f128.go
+++ b/f128.go
@@ -471,6 +471,8 @@ type binomialF128 struct {
pmf f128 // pmf(at): the current term
cum f128 // cdf(at) = P(X <= at)
at uint64 // index that pmf/cum currently hold
+
+ frozen bool // cum can never change again: cdf(k) == cum for every k >= at
}
// newBinomialF128 constructs the CDF evaluator for Binomial(money trials,
@@ -483,10 +485,14 @@ type binomialF128 struct {
// p/(1-p) = expectedSize / (totalMoney-expectedSize)
//
// rather than stacking roundings through an intermediate float64 p (whose
-// float64(totalMoney) conversion is itself inexact above 2^53). Returns nil for
-// the degenerate p >= 1 -- expectedSize >= totalMoney, an exact integer
-// comparison; all probability mass at j == money -- which the caller handles.
-// totalMoney == 0 also lands on the nil path.
+// float64(totalMoney) conversion is itself inexact above 2^53). Note that
+// intPow then amplifies qf's single rounding by up to the trial count, so
+// pmf(0) carries ~money*2^-129 of relative error; the SelectF128 tail-edge
+// note documents the resulting CDF plateau and how the walk's freeze
+// detection handles it. Returns nil for the degenerate p >= 1 --
+// expectedSize >= totalMoney, an exact integer comparison; all probability
+// mass at j == money -- which the caller handles. totalMoney == 0 also lands
+// on the nil path.
func newBinomialF128(expectedSize, totalMoney, money uint64) *binomialF128 {
if expectedSize >= totalMoney { // p >= 1
return nil
@@ -498,11 +504,21 @@ func newBinomialF128(expectedSize, totalMoney, money uint64) *binomialF128 {
}
func (b *binomialF128) cdf(j uint64) f128 {
- for b.at < j {
+ for b.at < j && !b.frozen {
b.at++
// pmf(at) = pmf(at-1) * (money-at+1)/at * p/(1-p)
+ pmfPrev := b.pmf
b.pmf = f128FromUint64(b.money - b.at + 1).divU(b.at).mul(b.pq).mul(b.pmf)
+ cumPrev := b.cum
b.cum = b.cum.add(b.pmf)
+ // cum is frozen once an add is a no-op while pmf strictly shrank: a
+ // shrinking pmf proves the rounded step factor is < 1, and the factor
+ // only decreases with at (round-to-nearest is monotone), so every later
+ // pmf is <= this one; and if adding THIS pmf could not move cum, no
+ // later, smaller pmf can either. From here cdf(k) == cum for all k.
+ if b.cum.cmp(cumPrev) == 0 && b.pmf.cmp(pmfPrev) < 0 {
+ b.frozen = true
+ }
}
return b.cum
}
@@ -552,6 +568,14 @@ func binomialCDFWalkF128(expectedSize, totalMoney uint64, ratio f128, money uint
if ratio.cmp(boundary) <= 0 {
return j
}
+ if dist.frozen {
+ // The boundary can never increase again, so no remaining j can be
+ // selected: return the result the full walk would reach, without
+ // stepping through the up-to-money no-op iterations (for a
+ // near-maximum ratio above the CDF's plateau that walk could
+ // otherwise take hours at supply-sized money).
+ return money
+ }
}
return money
}
diff --git a/f128_test.go b/f128_test.go
index 6e26e60..8cd0fb4 100644
--- a/f128_test.go
+++ b/f128_test.go
@@ -383,3 +383,36 @@ func TestDivVsBig(t *testing.T) {
}
}
}
+
+// TestSelectF128FrozenTailReturnsMoney pins the walk's stagnation
+// short-circuit and the documented stuck-band count at supply-sized money.
+// pmf(0)'s trial-count-amplified rounding leaves the accumulated CDF at a
+// plateau ~2^-78 below 1 for money == totalMoney == 2e15, so this digest
+// (2^256-1-2^176, ratio ~1-2^-80) sits above every boundary: the plain walk
+// would grind through all 2e15 no-op iterations (hours) before returning
+// money. The short-circuit must return the same money immediately -- this
+// test completing at all is the liveness assertion. The oracle is not
+// consulted here because it has no short-circuit and would walk the full 2e15
+// steps; FuzzSelectF128 continuously validates short-circuit == full walk at
+// fuzzable money, where the oracle does complete, as do the fall-through
+// cases in TestSelectF128RatioExactlyOne.
+func TestSelectF128FrozenTailReturnsMoney(t *testing.T) {
+ const supply = uint64(2_000_000_000_000_000)
+ var d Digest
+ for i := range d {
+ d[i] = 0xff
+ }
+ d[9] = 0xfe // clear bit 176: digest 2^256-1-2^176, ratio ~= 1 - 2^-80
+ if got := SelectF128(supply, supply, 1500, d); got != supply {
+ t.Fatalf("SelectF128=%d, want money=%d for a ratio above the CDF plateau", got, supply)
+ }
+
+ // The all-0xff digest (ratio exactly 1.0) is also above this
+ // distribution's plateau and must take the same frozen path.
+ for i := range d {
+ d[i] = 0xff
+ }
+ if got := SelectF128(supply, supply, 1500, d); got != supply {
+ t.Fatalf("SelectF128=%d, want money=%d for ratio exactly 1.0 at supply-sized money", got, supply)
+ }
+}
diff --git a/sortition.go b/sortition.go
index a929824..a650375 100644
--- a/sortition.go
+++ b/sortition.go
@@ -99,6 +99,21 @@ func Select(money uint64, totalMoney uint64, expectedSize float64, vrfOutput Dig
// the full trial count money. Such inputs are cryptographically unreachable (a
// VRF hash in the top 2^-129), so this is a documented property, not a case
// worth special-casing.
+//
+// The same holds in a wider sliver just below 1.0. pmf(0) = (1-p)^money
+// amplifies the 2^-129 rounding of 1-p by up to the trial count, and pmf(0)
+// scales every PMF term, so the accumulated CDF settles at a plateau that can
+// sit as much as ~money*2^-129 below 1 (~2^-78 when money is the whole
+// supply). A digest ratio between that plateau and 1.0 sits above every
+// boundary without rounding to 1.0; the walk detects the frozen CDF and
+// immediately returns money, the same result the plain walk would reach after
+// up to money no-op iterations (hours at supply-sized money). Reaching the
+// sliver requires a VRF hash within ~2^-78 of the maximum, which cannot be
+// ground for -- the output is fixed by the registered key and seed -- so the
+// count there is DEFINED as money rather than the exact binomial-tail
+// crossing. (Computing pmf(0) with 64 guard bits would shrink the sliver to
+// ~2^-116 and make it return the tail crossing; that variant lives on the
+// sortition-f128-pmf192 branch if the trade is ever wanted.)
func SelectF128(money uint64, totalMoney uint64, expectedSize uint64, vrfOutput Digest) uint64 {
ratio := f128FromDigestRatio(vrfOutput)
return binomialCDFWalkF128(expectedSize, totalMoney, ratio, money)
From e59c909441f3c52bc371ed3d9c0af55b56a1209e Mon Sep 17 00:00:00 2001
From: cce <51567+cce@users.noreply.github.com>
Date: Tue, 21 Jul 2026 11:03:54 -0400
Subject: [PATCH 14/37] document frozen-tail risk under consensus bounds
Replace the unreachable-tail wording with the accepted probabilistic bound and its no-grinding assumption. Pin current go-algorand committee behavior at the base minimum, payout eligibility landmarks, mainnet supply ceiling, and original report while showing that Boost terminates at finite double-CDF boundaries.
---
f128_test.go | 87 ++++++++++++++++++++++++++++++++++++++++------------
sortition.go | 38 ++++++++++++++---------
2 files changed, 92 insertions(+), 33 deletions(-)
diff --git a/f128_test.go b/f128_test.go
index 8cd0fb4..abc2e38 100644
--- a/f128_test.go
+++ b/f128_test.go
@@ -384,27 +384,76 @@ func TestDivVsBig(t *testing.T) {
}
}
-// TestSelectF128FrozenTailReturnsMoney pins the walk's stagnation
-// short-circuit and the documented stuck-band count at supply-sized money.
-// pmf(0)'s trial-count-amplified rounding leaves the accumulated CDF at a
-// plateau ~2^-78 below 1 for money == totalMoney == 2e15, so this digest
-// (2^256-1-2^176, ratio ~1-2^-80) sits above every boundary: the plain walk
-// would grind through all 2e15 no-op iterations (hours) before returning
-// money. The short-circuit must return the same money immediately -- this
-// test completing at all is the liveness assertion. The oracle is not
-// consulted here because it has no short-circuit and would walk the full 2e15
-// steps; FuzzSelectF128 continuously validates short-circuit == full walk at
-// fuzzable money, where the oracle does complete, as do the fall-through
-// cases in TestSelectF128RatioExactlyOne.
-func TestSelectF128FrozenTailReturnsMoney(t *testing.T) {
- const supply = uint64(2_000_000_000_000_000)
+// maxDigestMinusPowerOfTwo returns the digest integer 2^256-1-2^bit.
+func maxDigestMinusPowerOfTwo(bit uint) Digest {
var d Digest
for i := range d {
d[i] = 0xff
}
- d[9] = 0xfe // clear bit 176: digest 2^256-1-2^176, ratio ~= 1 - 2^-80
- if got := SelectF128(supply, supply, 1500, d); got != supply {
- t.Fatalf("SelectF128=%d, want money=%d for a ratio above the CDF plateau", got, supply)
+ d[len(d)-1-int(bit/8)] &^= byte(1) << (bit % 8)
+ return d
+}
+
+// TestSelectF128CurrentConsensusFrozenTail pins the accepted frozen-tail
+// behavior at values admitted by current go-algorand consensus parameters.
+// Consensus v41 inherits NumProposers=20, NextCommitteeSize=5000, and
+// MinBalance=100,000 microalgos. Its payout-eligibility interval is 30,000
+// through 70,000,000 Algos, and the mainnet genesis supply is 10,000,000,000
+// Algos. The payout maximum is not a voting-stake cap--online accounts above
+// it can still take part in consensus--so the cases cover a proposer plus the
+// base account minimum, both payout landmarks, and the supply ceiling.
+//
+// In every case q=(1-p) rounds downward. Raising q to money scales every PMF
+// term down enough that the accumulated f128 CDF freezes below the chosen
+// digest ratio. SelectF128 defines this interval to return money; completion
+// is also the liveness assertion, since the unshortened walk would perform up
+// to money no-op iterations. The deployed Boost walk does not share the
+// plateau: the digest rounds to binary64 1.0, and its independently evaluated
+// CDF reaches 1.0 at the finite values pinned in boostWant.
+func TestSelectF128CurrentConsensusFrozenTail(t *testing.T) {
+ const (
+ mainnetSupply = uint64(10_000_000_000_000_000)
+ )
+ tests := []struct {
+ name string
+ money uint64
+ total uint64
+ expected uint64
+ clearBit uint
+ boostWant uint64
+ }{
+ {"proposer committee", 1_999_999_999_999_999, 1_999_999_999_999_999, 20, 175, 67},
+ {"base minimum balance", 100_000, mainnetSupply, 5_000, 141, 2},
+ {"payout minimum balance", 30_000_000_000, mainnetSupply, 5_000, 159, 6},
+ {"payout maximum balance", 70_000_000_000_000, mainnetSupply, 5_000, 170, 94},
+ {"mainnet supply ceiling", mainnetSupply, mainnetSupply, 5_000, 178, 5_598},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ d := maxDigestMinusPowerOfTwo(test.clearBit)
+ if got := SelectF128(test.money, test.total, test.expected, d); got != test.money {
+ t.Fatalf("SelectF128=%d, want money=%d for a ratio above the CDF plateau", got, test.money)
+ }
+ if got := Select(test.money, test.total, float64(test.expected), d); got != test.boostWant {
+ t.Fatalf("Boost Select=%d, want finite tail count %d", got, test.boostWant)
+ }
+ })
+ }
+}
+
+// TestSelectF128FrozenTailReportedCase retains the original supply-sized
+// reproducer: pmf(0)'s trial-count-amplified rounding leaves the accumulated
+// CDF around 2^-78 below 1 when money == totalMoney == 2e15 and committee size
+// is the current certification size 1500. The ratio 1-2^-80 is above that
+// plateau, while Boost terminates at its binary64 tail boundary.
+func TestSelectF128FrozenTailReportedCase(t *testing.T) {
+ const onlineStake = uint64(2_000_000_000_000_000)
+ d := maxDigestMinusPowerOfTwo(176) // ratio ~= 1 - 2^-80
+ if got := SelectF128(onlineStake, onlineStake, 1500, d); got != onlineStake {
+ t.Fatalf("SelectF128=%d, want money=%d for a ratio above the CDF plateau", got, onlineStake)
+ }
+ if got := Select(onlineStake, onlineStake, 1500, d); got != 1832 {
+ t.Fatalf("Boost Select=%d, want finite tail count 1832", got)
}
// The all-0xff digest (ratio exactly 1.0) is also above this
@@ -412,7 +461,7 @@ func TestSelectF128FrozenTailReturnsMoney(t *testing.T) {
for i := range d {
d[i] = 0xff
}
- if got := SelectF128(supply, supply, 1500, d); got != supply {
- t.Fatalf("SelectF128=%d, want money=%d for ratio exactly 1.0 at supply-sized money", got, supply)
+ if got := SelectF128(onlineStake, onlineStake, 1500, d); got != onlineStake {
+ t.Fatalf("SelectF128=%d, want money=%d for ratio exactly 1.0", got, onlineStake)
}
}
diff --git a/sortition.go b/sortition.go
index a650375..e382716 100644
--- a/sortition.go
+++ b/sortition.go
@@ -96,24 +96,34 @@ func Select(money uint64, totalMoney uint64, expectedSize float64, vrfOutput Dig
// grid, potentially at a different (typically earlier) j. At these near-maximum
// digests the two implementations can therefore return wildly different counts:
// one may stop within a few steps of the binomial tail while the other returns
-// the full trial count money. Such inputs are cryptographically unreachable (a
-// VRF hash in the top 2^-129), so this is a documented property, not a case
-// worth special-casing.
+// the full trial count money. A uniform VRF output lands in this interval with
+// probability about 2^-129 per credential. The event is possible, but the
+// consensus threat model treats it as negligible and assumes the registered
+// key and unpredictable seed prevent an adversary from targeting it. This is
+// therefore a documented statistical edge rather than a case special-cased by
+// the implementation.
//
// The same holds in a wider sliver just below 1.0. pmf(0) = (1-p)^money
// amplifies the 2^-129 rounding of 1-p by up to the trial count, and pmf(0)
// scales every PMF term, so the accumulated CDF settles at a plateau that can
-// sit as much as ~money*2^-129 below 1 (~2^-78 when money is the whole
-// supply). A digest ratio between that plateau and 1.0 sits above every
-// boundary without rounding to 1.0; the walk detects the frozen CDF and
-// immediately returns money, the same result the plain walk would reach after
-// up to money no-op iterations (hours at supply-sized money). Reaching the
-// sliver requires a VRF hash within ~2^-78 of the maximum, which cannot be
-// ground for -- the output is fixed by the registered key and seed -- so the
-// count there is DEFINED as money rather than the exact binomial-tail
-// crossing. (Computing pmf(0) with 64 guard bits would shrink the sliver to
-// ~2^-116 and make it return the tail crossing; that variant lives on the
-// sortition-f128-pmf192 branch if the trade is ever wanted.)
+// sit as much as ~money*2^-129 below 1 (~2^-78 at 2e15 microalgos of stake,
+// and ~2^-76 at the 10^16-microalgo mainnet supply ceiling). A digest ratio
+// between that plateau and 1.0 sits above every boundary without rounding to
+// 1.0; the walk detects the frozen CDF and immediately returns money, the same
+// result the plain walk would reach after up to money no-op iterations.
+//
+// These outputs are possible under current go-algorand committee and balance
+// bounds; TestSelectF128CurrentConsensusFrozenTail pins examples from the base
+// account minimum through the mainnet supply ceiling. For an account with
+// stake m, the affected interval is approximately m*2^-129, and summing that
+// first-order bound over all online accounts gives approximately
+// totalMoney*2^-129 per committee selection, independent of how stake is
+// split. The consensus rationale for accepting the edge is probabilistic, not
+// impossibility: registered keys and an unpredictable seed are assumed to
+// prevent targeting the interval. Within it the count is DEFINED as money
+// rather than the exact binomial-tail crossing. Computing pmf(0) with guard
+// bits could narrow the interval, at the cost of additional consensus-critical
+// arithmetic and audit surface.
func SelectF128(money uint64, totalMoney uint64, expectedSize uint64, vrfOutput Digest) uint64 {
ratio := f128FromDigestRatio(vrfOutput)
return binomialCDFWalkF128(expectedSize, totalMoney, ratio, money)
From 0861bcf33be863c0cbc5bc4b72b004a21ce3b952 Mon Sep 17 00:00:00 2001
From: cce <51567+cce@users.noreply.github.com>
Date: Tue, 21 Jul 2026 11:51:31 -0400
Subject: [PATCH 15/37] make the f128 exponent explicitly int64
The exponent was a platform-sized int, which is 32 bits on 386/arm. No reachable input wraps it -- consensus parameters keep every exponent in the low thousands, and even the unfiltered fuzz domain stays near 2*10^5 -- but that made the cross-GOARCH bit-identity guarantee depend on magnitudes staying small rather than on the type. Also restructure add's alignment check to compare exponents in int64 BEFORE converting to a uint shift count: uint is likewise 32-bit on those platforms, and the naive type swap would leave a truncation that could alias a huge exponent difference to a small shift.
---
f128.go | 25 +++++++++++++++----------
f128_test.go | 8 ++++----
2 files changed, 19 insertions(+), 14 deletions(-)
diff --git a/f128.go b/f128.go
index 4bd1335..cf32812 100644
--- a/f128.go
+++ b/f128.go
@@ -41,7 +41,10 @@ import (
// TestSelectF128RatioExactlyOne and the SelectF128 doc comment).
type f128 struct {
hi, lo uint64
- exp int
+ // exp is explicitly 64-bit: int is 32 bits on 386/arm, and a
+ // platform-sized exponent would make cross-GOARCH bit-identity depend on
+ // exponents staying small rather than on the type.
+ exp int64
}
// f128MantBits is the f128 mantissa width. The big.Float oracle in the test
@@ -140,7 +143,7 @@ func shl256(a3, a2, a1, a0 uint64, n uint) (uint64, uint64, uint64, uint64) {
// norm128 normalizes a 128-bit mantissa (shifts MSB to bit 127). No bits are
// dropped, so no rounding is needed; used for exact conversions.
-func norm128(hi, lo uint64, exp int) f128 {
+func norm128(hi, lo uint64, exp int64) f128 {
if hi == 0 && lo == 0 {
return f128{}
}
@@ -152,7 +155,7 @@ func norm128(hi, lo uint64, exp int) f128 {
}
if s != 0 {
hi, lo = shl128(hi, lo, uint(s))
- exp -= s
+ exp -= int64(s)
}
return f128{hi, lo, exp}
}
@@ -160,7 +163,7 @@ func norm128(hi, lo uint64, exp int) f128 {
// roundNE rounds the normalized 128-bit mantissa hi:lo to nearest, ties to even,
// given the round bit and sticky of the discarded tail, and renormalizes on
// carry-out. hi:lo must already be normalized (bit 127 set).
-func roundNE(hi, lo uint64, roundBit, sticky bool, exp int) f128 {
+func roundNE(hi, lo uint64, roundBit, sticky bool, exp int64) f128 {
if roundBit && (sticky || lo&1 != 0) {
var c uint64
lo, c = bits.Add64(lo, 1, 0)
@@ -174,7 +177,7 @@ func roundNE(hi, lo uint64, roundBit, sticky bool, exp int) f128 {
// norm192 rounds a 192-bit value (r2:r1:r0) * 2^exp to an f128 (top 128 bits,
// round to nearest even using the remaining bits).
-func norm192(r2, r1, r0 uint64, exp int) f128 {
+func norm192(r2, r1, r0 uint64, exp int64) f128 {
if r2 == 0 && r1 == 0 && r0 == 0 {
return f128{}
}
@@ -190,7 +193,7 @@ func norm192(r2, r1, r0 uint64, exp int) f128 {
s2, s1, s0 := shl192(r2, r1, r0, uint(lz))
roundBit := s0&(1<<63) != 0
sticky := s0&^(uint64(1)<<63) != 0
- return roundNE(s2, s1, roundBit, sticky, exp+64-lz)
+ return roundNE(s2, s1, roundBit, sticky, exp+64-int64(lz))
}
func f128FromUint64(u uint64) f128 {
@@ -198,7 +201,7 @@ func f128FromUint64(u uint64) f128 {
return f128{}
}
s := bits.LeadingZeros64(u)
- return f128{u << uint(s), 0, -(s + 64)}
+ return f128{u << uint(s), 0, -int64(s) - 64}
}
// f128FromDigestRatio returns digest/(2^256-1), rounded to nearest-even at
@@ -234,7 +237,7 @@ func f128FromDigestRatio(d Digest) f128 {
if roundBit && !sticky {
sticky = true
}
- return roundNE(n3, n2, roundBit, sticky, -128-leading)
+ return roundNE(n3, n2, roundBit, sticky, -128-int64(leading))
}
// mul returns a*b rounded to nearest even.
@@ -391,10 +394,12 @@ func (a f128) add(b f128) f128 {
if a.exp < b.exp {
a, b = b, a
}
- diff := uint(a.exp - b.exp)
- if diff > 128 {
+ // Compare in int64 before converting to a shift count: uint is 32-bit on
+ // 386/arm, where a large exponent difference would otherwise truncate.
+ if a.exp-b.exp > 128 {
return a // b is below the round bit
}
+ diff := uint(a.exp - b.exp)
bhi, blo, round, sticky := shr128gs(b.hi, b.lo, diff)
slo, c := bits.Add64(a.lo, blo, 0)
shi, c2 := bits.Add64(a.hi, bhi, c)
diff --git a/f128_test.go b/f128_test.go
index abc2e38..7e3839d 100644
--- a/f128_test.go
+++ b/f128_test.go
@@ -298,8 +298,8 @@ func FuzzF128Ops(f *testing.F) {
f.Add(uint64(1)<<63, uint64(0), 0, uint64(3)<<62, uint64(0), 0, uint64(7))
f.Add(uint64(0), uint64(0), 0, uint64(1)<<63, uint64(1), -5, uint64(1))
f.Fuzz(func(t *testing.T, ahi, alo uint64, aexp int, bhi, blo uint64, bexp int, u uint64) {
- a := norm128(ahi, alo, aexp%4000-2000)
- b := norm128(bhi, blo, bexp%4000-2000)
+ a := norm128(ahi, alo, int64(aexp%4000-2000))
+ b := norm128(bhi, blo, int64(bexp%4000-2000))
ab, bb := f128ToBig(a), f128ToBig(b)
check := func(name string, got f128, want *big.Float) {
gotBig := f128ToBig(got)
@@ -361,7 +361,7 @@ func f128ToBig(x f128) *big.Float {
hi := new(big.Float).SetPrec(300).SetUint64(x.hi)
hi.SetMantExp(hi, 64)
m := new(big.Float).SetPrec(300).Add(hi, new(big.Float).SetPrec(300).SetUint64(x.lo))
- return m.SetMantExp(m, x.exp)
+ return m.SetMantExp(m, int(x.exp))
}
// TestDivVsBig checks f128.div against a 128-bit round-nearest-even big.Float
@@ -372,7 +372,7 @@ func f128ToBig(x f128) *big.Float {
func TestDivVsBig(t *testing.T) {
rng := rand.New(rand.NewSource(2))
randF128 := func() f128 {
- return f128{rng.Uint64() | 1<<63, rng.Uint64(), rng.Intn(4000) - 2000} // normalized
+ return f128{rng.Uint64() | 1<<63, rng.Uint64(), int64(rng.Intn(4000) - 2000)} // normalized
}
for i := 0; i < 5_000_000; i++ {
a, b := randF128(), randF128()
From acafe69071c05054d2bfa12e42770302d4f14039 Mon Sep 17 00:00:00 2001
From: cce <51567+cce@users.noreply.github.com>
Date: Tue, 21 Jul 2026 11:51:58 -0400
Subject: [PATCH 16/37] fix divU rounding for shallow quotients (divisors above
~2^62)
divU folded the division remainder's sticky marker into the last quotient digit. For deep quotients that digit sits safely below the round bit, but once the divisor exceeds ~2^62 the quotient has at most 129 significant bits and the marker lands at the round bit (corrupting ties) or inside the mantissa (off by an ulp about half the time). The sortition walk cannot reach the broken region -- its divisor is the step index, bounded by money, ~2^53 at the supply -- but the round-to-nearest-even contract claimed by the doc was false there. Compute a 256-bit quotient instead: two full fraction digits keep the round bit inside computed digits for every divisor, and the remainder only ever contributes to sticky. norm192 and shl192 lose their last caller and are removed.
This survived ~30M FuzzF128Ops execs because go's mutator explores integers locally around corpus values and the seeds had u=7 and u=1: planted next to a u~2^63 seed, the fuzzer failed on the seed itself. Add two large-divisor seeds and TestDivUVsBig, a 2M-case differential test against big.Float with log-spread divisor magnitudes.
---
f128.go | 83 ++++++++++++++++++----------------------------------
f128_test.go | 30 +++++++++++++++++++
2 files changed, 59 insertions(+), 54 deletions(-)
diff --git a/f128.go b/f128.go
index cf32812..121a1b8 100644
--- a/f128.go
+++ b/f128.go
@@ -104,24 +104,6 @@ func shr128gs(hi, lo uint64, n uint) (rhi, rlo uint64, round, sticky bool) {
return rhi, rlo, round, sticky
}
-// shl192 shifts a 192-bit value (a2:a1:a0) left by n < 192.
-func shl192(a2, a1, a0 uint64, n uint) (uint64, uint64, uint64) {
- switch {
- case n == 0:
- return a2, a1, a0
- case n < 64:
- return a2<>(64-n), a1<>(64-n), a0 << n
- case n < 128:
- n -= 64
- if n == 0 {
- return a1, a0, 0
- }
- return a1<>(64-n), a0 << n, 0
- default:
- return a0 << (n - 128), 0, 0
- }
-}
-
// shl256 shifts a 256-bit value (a3:a2:a1:a0) left by n < 256.
func shl256(a3, a2, a1, a0 uint64, n uint) (uint64, uint64, uint64, uint64) {
words := n / 64
@@ -175,27 +157,6 @@ func roundNE(hi, lo uint64, roundBit, sticky bool, exp int64) f128 {
return f128{hi, lo, exp}
}
-// norm192 rounds a 192-bit value (r2:r1:r0) * 2^exp to an f128 (top 128 bits,
-// round to nearest even using the remaining bits).
-func norm192(r2, r1, r0 uint64, exp int64) f128 {
- if r2 == 0 && r1 == 0 && r0 == 0 {
- return f128{}
- }
- var lz int
- switch {
- case r2 != 0:
- lz = bits.LeadingZeros64(r2)
- case r1 != 0:
- lz = 64 + bits.LeadingZeros64(r1)
- default:
- lz = 128 + bits.LeadingZeros64(r0)
- }
- s2, s1, s0 := shl192(r2, r1, r0, uint(lz))
- roundBit := s0&(1<<63) != 0
- sticky := s0&^(uint64(1)<<63) != 0
- return roundNE(s2, s1, roundBit, sticky, exp+64-int64(lz))
-}
-
func f128FromUint64(u uint64) f128 {
if u == 0 {
return f128{}
@@ -270,20 +231,36 @@ func (a f128) mul(b f128) f128 {
return roundNE(hi, lo, roundBit, sticky, a.exp+b.exp+127)
}
-// divU returns a/u for a small unsigned integer u (the per-step denominator j),
-// rounded to nearest even.
+// divU returns a/u for an unsigned integer u (the walk's per-step denominator
+// j), rounded to nearest even.
func (a f128) divU(u uint64) f128 {
if a.isZero() || u == 0 {
return f128{}
}
- // (M/u)*2^64 = q2:q1:q0 (integer part q2:q1, next 64 frac bits q0).
- q2, r := bits.Div64(0, a.hi, u)
- q1, r := bits.Div64(r, a.lo, u)
+ // 256-bit quotient (M/u)*2^128 = q3:q2:q1:q0 by long division. Two full
+ // fraction digits below the mantissa keep the round bit inside the
+ // computed digits even for the shallowest quotient (u > a.hi, where the
+ // quotient has only 128 significant bits), so the remainder only ever
+ // contributes to sticky, strictly below the round bit. (A 192-bit
+ // quotient with the remainder OR'd into its last digit is NOT enough:
+ // for u > ~2^62 that marker lands in the mantissa or at the round bit
+ // and breaks round-to-nearest-even about half the time.)
+ q3, r := bits.Div64(0, a.hi, u)
+ q2, r := bits.Div64(r, a.lo, u)
+ q1, r := bits.Div64(r, 0, u)
q0, rem := bits.Div64(r, 0, u)
- if rem != 0 {
- q0 |= 1 // mark sticky for the division remainder
+ // M >= 2^127 and u < 2^64 make the quotient >= 2^191, so at most 64
+ // leading zeros: the mantissa always comes from o3:o2 below.
+ var lz int
+ if q3 != 0 {
+ lz = bits.LeadingZeros64(q3)
+ } else {
+ lz = 64
}
- return norm192(q2, q1, q0, a.exp-64)
+ o3, o2, o1, o0 := shl256(q3, q2, q1, q0, uint(lz))
+ round := o1&(uint64(1)<<63) != 0
+ sticky := o1&^(uint64(1)<<63) != 0 || o0 != 0 || rem != 0
+ return roundNE(o3, o2, round, sticky, a.exp-int64(lz))
}
// divStep is one digit step of Knuth's Algorithm D for a normalized (top bit
@@ -553,13 +530,11 @@ func (b *binomialF128) cdf(j uint64) f128 {
// digest ratio directly at f128 precision, and the success probability as its
// exact integer numerator and denominator rather than a float64 quotient.
//
-// Precondition: money is within the sortition domain -- at most the total online
-// microalgo supply (~10^16 < 2^54). The f128 exponent is a plain int; for money
-// in that range the exponent of (1-p)^money cannot overflow it (its magnitude is
-// bounded by roughly the mean money*p, a committee size). money far beyond the
-// supply (>~2^57) is outside the domain -- Boost's Select cannot evaluate it
-// either -- and would eventually overflow the int exponent; behavior is undefined
-// there.
+// Precondition: money < SelectF128MaxMoney (2^57). Below that bound every
+// exponent in the walk fits int64 even at the most extreme representable
+// probability: 1-p is at least 2^-64, so |exp| <= 64*(money-1) < 2^63. The
+// bound leaves ~8 bits of headroom over the ~10^16 microalgo supply; behavior
+// beyond it is undefined (Boost's Select cannot evaluate such money either).
func binomialCDFWalkF128(expectedSize, totalMoney uint64, ratio f128, money uint64) uint64 {
dist := newBinomialF128(expectedSize, totalMoney, money)
if dist == nil { // p >= 1: cdf(j)==0 for j ~2^62).
+ f.Add(uint64(1)<<63|12345, uint64(67890), 3, uint64(1)<<63, uint64(1), 0, uint64(1)<<63|54321)
+ f.Add(uint64(1)<<63|999, uint64(777), -9, uint64(1)<<63, uint64(1), 0, uint64(5)<<61|33)
f.Fuzz(func(t *testing.T, ahi, alo uint64, aexp int, bhi, blo uint64, bexp int, u uint64) {
a := norm128(ahi, alo, int64(aexp%4000-2000))
b := norm128(bhi, blo, int64(bexp%4000-2000))
@@ -394,6 +399,31 @@ func maxDigestMinusPowerOfTwo(bit uint) Digest {
return d
}
+// TestDivUVsBig checks divU against a 128-bit round-nearest-even big.Float
+// divide across divisor magnitudes. The log-spread divisor matters: the
+// shallow-quotient region (u > ~2^62, where the quotient has at most 129
+// significant bits) is unreachable from the sortition walk, whose divisor is
+// the step index bounded by money, but the round-to-nearest-even contract
+// covers it, and a previous divU broke there by folding the remainder's
+// sticky marker into a digit that landed in or at the rounding position.
+func TestDivUVsBig(t *testing.T) {
+ rng := rand.New(rand.NewSource(5))
+ for i := 0; i < 2_000_000; i++ {
+ a := f128{rng.Uint64() | 1<<63, rng.Uint64(), int64(rng.Intn(4000) - 2000)}
+ u := rng.Uint64() >> uint(rng.Intn(64)) // log-spread magnitudes
+ if u == 0 {
+ continue
+ }
+ got := f128ToBig(a.divU(u))
+ want := new(big.Float).SetPrec(f128MantBits).Quo(
+ f128ToBig(a), new(big.Float).SetPrec(f128MantBits).SetUint64(u))
+ if got.Cmp(want) != 0 {
+ t.Fatalf("divU mismatch: a={%#x,%#x,%d} u=%d got=%s want=%s",
+ a.hi, a.lo, a.exp, u, got.Text('p', 0), want.Text('p', 0))
+ }
+ }
+}
+
// TestSelectF128CurrentConsensusFrozenTail pins the accepted frozen-tail
// behavior at values admitted by current go-algorand consensus parameters.
// Consensus v41 inherits NumProposers=20, NextCommitteeSize=5000, and
From ea6538c53cf7e292143ab6f6b2b766cfed197a66 Mon Sep 17 00:00:00 2001
From: cce <51567+cce@users.noreply.github.com>
Date: Tue, 21 Jul 2026 11:52:24 -0400
Subject: [PATCH 17/37] add pgregory.net/rapid property tests beside the go
fuzz targets
rapid generates fresh values across the full input space every run, with explicit magnitude-band guidance layered on its boundary bias, complementing go fuzzing's corpus mutation, which dwells near seed values: the divU shallow-quotient misrounding survived ~30M fuzz execs but rapid found and minimized it within its first hundred generated cases (a={2^63+1,0}, u=2^64-1). TestRapidF128Ops mirrors FuzzF128Ops over the primitives; TestRapidSelectF128VsOracle mirrors FuzzSelectF128 end to end, drawing digests both uniformly and from the near-maximum regime. Both run 100 cases per ordinary go test invocation; raise with -rapid.checks for long runs.
---
f128_rapid_test.go | 112 +++++++++++++++++++++++++++++++++++++++++++++
go.mod | 2 +
go.sum | 2 +
3 files changed, 116 insertions(+)
create mode 100644 f128_rapid_test.go
create mode 100644 go.sum
diff --git a/f128_rapid_test.go b/f128_rapid_test.go
new file mode 100644
index 0000000..b4a51ce
--- /dev/null
+++ b/f128_rapid_test.go
@@ -0,0 +1,112 @@
+// Copyright (C) 2019-2023 Algorand, Inc.
+// This file is part of go-algorand
+//
+// go-algorand is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// go-algorand is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with go-algorand. If not, see .
+
+package sortition
+
+import (
+ "bytes"
+ "math/big"
+ "testing"
+
+ "pgregory.net/rapid"
+)
+
+// These rapid property tests complement the go fuzz targets rather than
+// replacing them. Go's fuzzer mutates locally around its corpus, so integer
+// arguments dwell near seed values -- divU's shallow-quotient misrounding
+// (u > ~2^62) survived ~30M fuzz execs with u=1 and u=7 seeds, while rapid
+// found it within its first hundred generated cases. The generators below add
+// explicit magnitude-band guidance on top of rapid's own boundary bias so
+// every regime is sampled every run. Raise the case count in a long run with:
+//
+// go test -run TestRapid -rapid.checks=100000
+//
+// A failure writes a minimized reproducer under testdata/rapid/.
+
+// bandedUint64 draws a uint64 spread across magnitude bands: the sortition
+// walk's divisor domain, the beyond-domain deep-quotient region, and the two
+// shallow-quotient bands where a divU rounding bug previously hid.
+func bandedUint64(t *rapid.T, label string) uint64 {
+ return rapid.OneOf(
+ rapid.Uint64Range(0, 1<<57), // walk domain (u = step index)
+ rapid.Uint64Range(1<<57, 1<<62), // deep quotients beyond the domain
+ rapid.Uint64Range(1<<62, 1<<63), // quotient round bit in the last digit
+ rapid.Uint64Range(1<<63, ^uint64(0)), // 128-bit shallow quotients
+ ).Draw(t, label)
+}
+
+// TestRapidF128Ops property-tests every f128 primitive against 128-bit
+// big.Float, mirroring FuzzF128Ops.
+func TestRapidF128Ops(t *testing.T) {
+ rapid.Check(t, func(t *rapid.T) {
+ a := norm128(rapid.Uint64().Draw(t, "ahi"), rapid.Uint64().Draw(t, "alo"),
+ rapid.Int64Range(-2000, 2000).Draw(t, "aexp"))
+ b := norm128(rapid.Uint64().Draw(t, "bhi"), rapid.Uint64().Draw(t, "blo"),
+ rapid.Int64Range(-2000, 2000).Draw(t, "bexp"))
+ u := bandedUint64(t, "u")
+ ab, bb := f128ToBig(a), f128ToBig(b)
+ check := func(name string, got f128, want *big.Float) {
+ if g := f128ToBig(got); g.Cmp(want) != 0 {
+ t.Fatalf("%s: f128=%v big.Float=%v (a=%v b=%v u=%d)", name, g, want, ab, bb, u)
+ }
+ }
+ check("mul", a.mul(b), new(big.Float).SetPrec(f128MantBits).Mul(ab, bb))
+ check("add", a.add(b), new(big.Float).SetPrec(f128MantBits).Add(ab, bb))
+ if u != 0 {
+ check("divU", a.divU(u),
+ new(big.Float).SetPrec(f128MantBits).Quo(ab, new(big.Float).SetPrec(f128MantBits).SetUint64(u)))
+ }
+ if !b.isZero() {
+ check("div", a.div(b), new(big.Float).SetPrec(f128MantBits).Quo(ab, bb))
+ }
+ })
+}
+
+// TestRapidSelectF128VsOracle property-tests the full walk against the
+// big.Float oracle, mirroring FuzzSelectF128. Digests are drawn both uniformly
+// and from the near-maximum regime (mostly-0xff), where the ratio ~1 tail
+// edges live.
+func TestRapidSelectF128VsOracle(t *testing.T) {
+ nearMaxDigest := rapid.Custom(func(t *rapid.T) []byte {
+ b := bytes.Repeat([]byte{0xff}, DigestSize)
+ i := rapid.IntRange(0, DigestSize-1).Draw(t, "hole")
+ b[i] = rapid.Byte().Draw(t, "holeval")
+ return b
+ })
+ rapid.Check(t, func(t *rapid.T) {
+ money := rapid.Uint64Range(0, 3000).Draw(t, "money") // bound the walk, as in FuzzSelectF128
+ total := rapid.OneOf(
+ rapid.Uint64Range(0, 1_000_000),
+ rapid.Uint64Range(1_000_000, 10_000_000_000_000_000), // through the supply ceiling
+ rapid.Uint64Range(10_000_000_000_000_000, ^uint64(0)),
+ ).Draw(t, "total")
+ expected := rapid.OneOf(
+ rapid.Uint64Range(0, 10_000), // committee sizes
+ rapid.Uint64Range(10_000, ^uint64(0)), // through and beyond p >= 1
+ ).Draw(t, "expected")
+ var d Digest
+ copy(d[:], rapid.OneOf(
+ rapid.SliceOfN(rapid.Byte(), DigestSize, DigestSize),
+ nearMaxDigest,
+ ).Draw(t, "vrf"))
+ got := SelectF128(money, total, expected, d)
+ want := selectBigOracle(money, total, expected, d)
+ if got != want {
+ t.Fatalf("SelectF128=%d != oracle=%d (money=%d total=%d expected=%d vrf=%x)",
+ got, want, money, total, expected, d)
+ }
+ })
+}
diff --git a/go.mod b/go.mod
index bf40810..ad11e8a 100644
--- a/go.mod
+++ b/go.mod
@@ -1,3 +1,5 @@
module github.com/algorand/sortition
go 1.23
+
+require pgregory.net/rapid v1.3.0
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..2ecce80
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,2 @@
+pgregory.net/rapid v1.3.0 h1:vBvO0VSqti75J1jjYqpgPNBLKMd1+gxa9fYo7vk/Exc=
+pgregory.net/rapid v1.3.0/go.mod h1:dPlE4OBBxgXPqkP79flB6sJL1dx5azpI7HQ9MY9Z7uk=
From 228fc166dc040e6b0f70c678962f4908966943bd Mon Sep 17 00:00:00 2001
From: cce <51567+cce@users.noreply.github.com>
Date: Tue, 21 Jul 2026 11:52:47 -0400
Subject: [PATCH 18/37] export SelectF128MaxMoney as the walk's domain bound
The money < 2^57 validity bound was prose describing undefined behavior, protected only by economic invariants living in go-algorand. Export it as a constant instead of enforcing it at runtime: below the bound every exponent in the walk fits int64 even at the most extreme representable probability (1-p >= 2^-64 gives |exp| <= 64*(money-1) < 2^63), and it keeps ~8 bits of headroom over the 10^16 microalgo supply. Consumers assert their supply invariants against the constant in their own test suites -- go-algorand will read it and fail if vFuture/vcurrent consensus parameters could exceed it -- so a future economics change fails loudly in a test rather than silently misrounding consensus. TestSelectF128MaxMoneyHeadroom is the local tripwire.
---
sortition.go | 13 ++++++++++++-
sortition_test.go | 12 ++++++++++++
2 files changed, 24 insertions(+), 1 deletion(-)
diff --git a/sortition.go b/sortition.go
index e382716..3552fb4 100644
--- a/sortition.go
+++ b/sortition.go
@@ -58,9 +58,20 @@ func Select(money uint64, totalMoney uint64, expectedSize float64, vrfOutput Dig
return uint64(C.sortition_binomial_cdf_walk(C.double(binomialN), C.double(binomialP), C.double(cratio), C.uint64_t(money)))
}
+// SelectF128MaxMoney bounds SelectF128's money argument. Below it, every
+// exponent in the f128 walk fits int64 even at the most extreme representable
+// probability (1-p is at least 2^-64 for any expectedSize < totalMoney, so
+// |exp| <= 64*(money-1) < 2^63). The bound leaves ~8 bits of headroom over
+// Algorand's 10^16 microalgo supply; behavior above it is undefined, and
+// SelectF128 does not check it at runtime. Consumers should assert their
+// supply invariants against this constant in a test, so a future economics
+// change fails loudly there instead of silently misrounding consensus.
+const SelectF128MaxMoney = uint64(1) << 57
+
// SelectF128 is a deterministic sortition function. It evaluates both the VRF
// ratio and binomial CDF at f128 precision using software integer arithmetic, so
-// its result is bit-reproducible across platforms.
+// its result is bit-reproducible across platforms. money must be below
+// SelectF128MaxMoney.
//
// Unlike Select, SelectF128 takes the committee size as the exact uint64 it is
// in the protocol rather than a float64. The distribution constants are then
diff --git a/sortition_test.go b/sortition_test.go
index 8b6fc07..982e596 100644
--- a/sortition_test.go
+++ b/sortition_test.go
@@ -58,3 +58,15 @@ func TestSortitionBasic(t *testing.T) {
t.Errorf("wanted %d selections but got %d, d=%d, maxd=%d", expected, hitcount, d, maxd)
}
}
+
+// TestSelectF128MaxMoneyHeadroom is a tripwire on the exported domain bound:
+// it must comfortably cover the 10^16 microalgo supply. Consumers are expected
+// to run the mirror-image check (their maximum stake against
+// SelectF128MaxMoney) in their own test suites.
+func TestSelectF128MaxMoneyHeadroom(t *testing.T) {
+ const mainnetSupply = uint64(10_000_000_000_000_000)
+ if SelectF128MaxMoney < 8*mainnetSupply {
+ t.Fatalf("SelectF128MaxMoney=%d leaves less than 3 bits of headroom over the %d supply",
+ SelectF128MaxMoney, mainnetSupply)
+ }
+}
From 924c168b65b044f220be1e59195b9407fb99abde Mon Sep 17 00:00:00 2001
From: cce <51567+cce@users.noreply.github.com>
Date: Tue, 21 Jul 2026 12:14:46 -0400
Subject: [PATCH 19/37] lower SelectF128MaxMoney to 2^56 with a corrected
exponent-safety proof
The previous proof forgot that the stored f128 exponent includes the mantissa normalization offset: v = M*2^exp with M in [2^127,2^128) means exp is about log2(v)-127, so (1-p)^money at the extreme representable probability (1-p just above 2^-64) needs a stored exponent near -64*money-127, not -64*(money-1). At money = 2^57-1, admitted by the old bound, that is about -2^63-63 and wraps int64: empirically, newBinomialF128(2^64-2, 2^64-1, 2^57-1) produces pmf(0) with exponent +9223372036854775745. Intermediate a.exp+b.exp sums inside mul reach about -64*money-256 and wrap slightly earlier still.
At 2^56 every stored exponent and intermediate stays within ~2^62+2^8, a factor-of-two margin. The doc also overstated the headroom: the bound sits ~7x (about 2.8 bits) above the 10^16 microalgo supply, not 8 bits; the tripwire test now requires 2 bits and states the actual figure, and the rapid divisor band reads the constant instead of a literal.
---
f128.go | 11 ++++++-----
f128_rapid_test.go | 8 ++++----
sortition.go | 26 +++++++++++++++++---------
sortition_test.go | 11 ++++++-----
4 files changed, 33 insertions(+), 23 deletions(-)
diff --git a/f128.go b/f128.go
index 121a1b8..09aeb28 100644
--- a/f128.go
+++ b/f128.go
@@ -530,11 +530,12 @@ func (b *binomialF128) cdf(j uint64) f128 {
// digest ratio directly at f128 precision, and the success probability as its
// exact integer numerator and denominator rather than a float64 quotient.
//
-// Precondition: money < SelectF128MaxMoney (2^57). Below that bound every
-// exponent in the walk fits int64 even at the most extreme representable
-// probability: 1-p is at least 2^-64, so |exp| <= 64*(money-1) < 2^63. The
-// bound leaves ~8 bits of headroom over the ~10^16 microalgo supply; behavior
-// beyond it is undefined (Boost's Select cannot evaluate such money either).
+// Precondition: money < SelectF128MaxMoney (2^56). Below that bound no int64
+// exponent arithmetic in the walk can wrap, even at the most extreme
+// representable probability -- see the constant's doc for the accounting,
+// which must include the mantissa normalization offset (stored exp is
+// log2(v) - 127) and mul's intermediate exponent sums. Behavior beyond the
+// bound is undefined (Boost's Select cannot evaluate such money either).
func binomialCDFWalkF128(expectedSize, totalMoney uint64, ratio f128, money uint64) uint64 {
dist := newBinomialF128(expectedSize, totalMoney, money)
if dist == nil { // p >= 1: cdf(j)==0 for j
Date: Tue, 21 Jul 2026 12:16:08 -0400
Subject: [PATCH 20/37] seed TestSortitionBasic deterministically
Since Go 1.20 the global math/rand is randomly seeded, and the test's 2% tolerance is only ~2 sigma at N=1000: measured 21 failures in 500 runs. A fixed seed keeps the sanity check stable in CI. This flake predates the SelectF128 work (the test exercises the cgo Select only) and explains a one-off suite failure observed while validating unrelated changes.
---
sortition_test.go | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/sortition_test.go b/sortition_test.go
index 998e00f..6093881 100644
--- a/sortition_test.go
+++ b/sortition_test.go
@@ -34,6 +34,10 @@ func BenchmarkSortition(b *testing.B) {
}
func TestSortitionBasic(t *testing.T) {
+ // The 2% tolerance below is only ~2 sigma for N=1000, so a randomly
+ // seeded rng flakes about 4% of the time (21/500 in one measurement);
+ // seed deterministically to keep the sanity check stable.
+ rng := rand.New(rand.NewSource(42))
hitcount := uint64(0)
const N = 1000
const expectedSize = 20
@@ -41,7 +45,7 @@ func TestSortitionBasic(t *testing.T) {
const totalMoney = 200
for i := 0; i < N; i++ {
var vrfOutput Digest
- rand.Read(vrfOutput[:])
+ rng.Read(vrfOutput[:])
selected := Select(myMoney, totalMoney, expectedSize, vrfOutput)
hitcount += selected
}
From c190cfa7f10d1435e9bd76c9c7f4989ec7b72893 Mon Sep 17 00:00:00 2001
From: cce <51567+cce@users.noreply.github.com>
Date: Tue, 21 Jul 2026 12:28:49 -0400
Subject: [PATCH 21/37] seed the fuzz blind spots, band the rapid generators,
add a monotonicity property
Go's mutator explores integers locally around corpus values, so every input regime needs a seed planted in it. Coverage of f128.go from the seed corpus alone had 31 uncovered blocks; this brings it to 6, all provably-unreachable defensive arms. New FuzzF128Ops seeds: constructed exact-tie vectors for mul (both tie directions), an add tie at exponent gap 128 into an all-ones mantissa (exercising carry-out renormalization), gap 129 (addend fully below the round bit), constructed divStep qhat-cap vectors (plain and rhat-carry), a mined refine-decrement-overflow vector, denormalized mantissas, and a zero operand. New FuzzSelectF128 seeds: an exact-halfway digest (the denominator-correction branch, ~2^-128 density), small digests reaching each normalization word, fall-through-to-money via both the full walk (p=1/2) and the freeze short-circuit (tiny p), p just below 1 (deep exponents), extreme total/expected magnitudes, and totalMoney == 0. All tie/carry/divStep vectors were constructed or mined by an instrumented search and are verified against big.Float by the fuzz bodies themselves on every go test run.
The one uncovered branch worth a comment instead of a seed: divStep's add-back is believed unreachable -- the refine loop runs to fixpoint with an exact 128-bit test (equivalent to U - qhat*V >= 0), unlike Knuth's Algorithm D, which bounds the D3 adjustment and needs D6; a 3M-case instrumented search never fired it. Documented at the branch.
The rapid tests gain structure-banded mantissa generation (uniform/sparse/dense, raising tie and carry densities from ~2^-64), banded exponent gaps around add's 64/127/128/129 alignment boundaries, oracle-independent mul/add commutativity checks, and TestRapidSelectF128DigestMonotonic: the selection count is non-decreasing in the digest, an exact property of the walk that needs no oracle. That narrows, but does not close, the differential tests' structural blind spot: a defect mirrored into the oracle now cannot violate ordering unnoticed, but an order-preserving one (like the pmf(0) plateau, where the stuck band returns the maximal count) would still pass; that class is covered by the dedicated frozen-tail pins instead.
CI runs the rapid tests at -rapid.checks=20000 in a separate workflow step: deep enough to sample every generator band reliably (the divU shallow-quotient bug reproduced within ~30 banded cases), ~1.3s on a laptop.
---
.github/workflows/test.yml | 7 +++
f128.go | 6 +++
f128_rapid_test.go | 93 +++++++++++++++++++++++++++++++++++---
f128_test.go | 43 ++++++++++++++++++
4 files changed, 143 insertions(+), 6 deletions(-)
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index f278759..0027270 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -20,3 +20,10 @@ jobs:
- name: Test
run: go test -v ./...
+
+ # Ordinary `go test` runs the rapid property tests at their default 100
+ # cases; run them again at a depth that reliably samples every generator
+ # band (the divU shallow-quotient bug reproduced within ~30 banded cases,
+ # so 20000 leaves ample margin while staying under a few seconds).
+ - name: Rapid property tests
+ run: go test -v -run TestRapid -rapid.checks=20000 ./...
diff --git a/f128.go b/f128.go
index 09aeb28..c5f1d3d 100644
--- a/f128.go
+++ b/f128.go
@@ -305,6 +305,12 @@ func divStep(uHi, uMid, uLo, v1, v0 uint64) (q, rHi, rLo uint64) {
sMid, br := bits.Sub64(uMid, prodMid, br)
_, br = bits.Sub64(uHi, prodHi, br)
q = qhat
+ // Defense in depth: unlike Knuth's Algorithm D, which bounds the D3
+ // adjustment at two rounds and relies on this D6 add-back, the refine loop
+ // above runs to fixpoint with an exact 128-bit test (qhat*v0 <= rhat:uLo
+ // is equivalent to U - qhat*V >= 0 given rhat's bookkeeping), so qhat
+ // should already be exact and br always 0. Kept in case that analysis is
+ // wrong; an instrumented 3M-case search never fired it.
if br != 0 { // qhat was 1 too large: add the divisor back
q--
sLo, c = bits.Add64(sLo, v0, 0)
diff --git a/f128_rapid_test.go b/f128_rapid_test.go
index bc71311..137e616 100644
--- a/f128_rapid_test.go
+++ b/f128_rapid_test.go
@@ -30,7 +30,9 @@ import (
// (u > ~2^62) survived ~30M fuzz execs with u=1 and u=7 seeds, while rapid
// found it within its first hundred generated cases. The generators below add
// explicit magnitude-band guidance on top of rapid's own boundary bias so
-// every regime is sampled every run. Raise the case count in a long run with:
+// every regime is sampled every run. Ordinary go test runs 100 cases per
+// test; CI runs them again at -rapid.checks=20000, and a long run can go
+// deeper:
//
// go test -run TestRapid -rapid.checks=100000
//
@@ -48,14 +50,59 @@ func bandedUint64(t *rapid.T, label string) uint64 {
).Draw(t, label)
}
+// bandedMantissa draws mantissa words by structure, not just value: uniform
+// bits, sparse (a few set bits), or dense (all ones with a few holes). Sparse
+// and dense mantissas raise the density of exact ties and of carry-out
+// renormalization, which sit near ~2^-64 measure under uniform draws.
+func bandedMantissa(t *rapid.T, label string) (uint64, uint64) {
+ switch rapid.IntRange(0, 2).Draw(t, label+"Kind") {
+ case 0:
+ return rapid.Uint64().Draw(t, label+"Hi"), rapid.Uint64().Draw(t, label+"Lo")
+ case 1: // sparse
+ var hi, lo uint64
+ for range rapid.IntRange(1, 3).Draw(t, label+"Bits") {
+ b := rapid.IntRange(0, 127).Draw(t, label+"Bit")
+ if b >= 64 {
+ hi |= 1 << (b - 64)
+ } else {
+ lo |= 1 << b
+ }
+ }
+ return hi, lo
+ default: // dense
+ hi, lo := ^uint64(0), ^uint64(0)
+ for range rapid.IntRange(0, 3).Draw(t, label+"Holes") {
+ b := rapid.IntRange(0, 127).Draw(t, label+"Hole")
+ if b >= 64 {
+ hi &^= 1 << (b - 64)
+ } else {
+ lo &^= 1 << b
+ }
+ }
+ return hi, lo
+ }
+}
+
// TestRapidF128Ops property-tests every f128 primitive against 128-bit
-// big.Float, mirroring FuzzF128Ops.
+// big.Float, mirroring FuzzF128Ops, plus the oracle-independent commutativity
+// of mul and add (asymmetric partial-product assembly cannot hide from these
+// even if a bug were somehow mirrored into the reference computation).
func TestRapidF128Ops(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
- a := norm128(rapid.Uint64().Draw(t, "ahi"), rapid.Uint64().Draw(t, "alo"),
- rapid.Int64Range(-2000, 2000).Draw(t, "aexp"))
- b := norm128(rapid.Uint64().Draw(t, "bhi"), rapid.Uint64().Draw(t, "blo"),
- rapid.Int64Range(-2000, 2000).Draw(t, "bexp"))
+ ahi, alo := bandedMantissa(t, "a")
+ aexp := rapid.Int64Range(-2000, 2000).Draw(t, "aexp")
+ a := norm128(ahi, alo, aexp)
+ // Band the exponent gap: add's alignment boundaries sit at gaps of
+ // exactly 64, 127, 128, and 129, which a uniform pair of exponents
+ // rarely produces.
+ gap := rapid.OneOf(
+ rapid.Int64Range(-4, 4),
+ rapid.Int64Range(-68, -60), rapid.Int64Range(60, 68),
+ rapid.Int64Range(-132, -124), rapid.Int64Range(124, 132),
+ rapid.Int64Range(-2000, 2000),
+ ).Draw(t, "gap")
+ bhi, blo := bandedMantissa(t, "b")
+ b := norm128(bhi, blo, aexp-gap)
u := bandedUint64(t, "u")
ab, bb := f128ToBig(a), f128ToBig(b)
check := func(name string, got f128, want *big.Float) {
@@ -72,6 +119,12 @@ func TestRapidF128Ops(t *testing.T) {
if !b.isZero() {
check("div", a.div(b), new(big.Float).SetPrec(f128MantBits).Quo(ab, bb))
}
+ if x, y := a.mul(b), b.mul(a); x != y {
+ t.Fatalf("mul not commutative: %+v vs %+v (a=%v b=%v)", x, y, ab, bb)
+ }
+ if x, y := a.add(b), b.add(a); x != y {
+ t.Fatalf("add not commutative: %+v vs %+v (a=%v b=%v)", x, y, ab, bb)
+ }
})
}
@@ -110,3 +163,31 @@ func TestRapidSelectF128VsOracle(t *testing.T) {
}
})
}
+
+// TestRapidSelectF128DigestMonotonic checks an ORACLE-INDEPENDENT property:
+// for fixed (money, total, expected), the selection count is non-decreasing in
+// the digest. This holds exactly -- the digest-to-ratio conversion is monotone
+// (round-to-nearest of a monotone quotient, and the halfway correction only
+// ever rounds up), and a larger ratio can only cross the same CDF boundaries
+// later or freeze to money. The differential tests share one structural blind
+// spot: a defect mirrored into the big.Float oracle (as the pmf(0) plateau
+// was) is invisible to them; a property test against mathematics is not.
+func TestRapidSelectF128DigestMonotonic(t *testing.T) {
+ rapid.Check(t, func(t *rapid.T) {
+ money := rapid.Uint64Range(0, 3000).Draw(t, "money")
+ total := rapid.Uint64Range(0, 10_000_000_000_000_000).Draw(t, "total")
+ expected := rapid.Uint64Range(0, 10_000).Draw(t, "expected")
+ var d1, d2 Digest
+ copy(d1[:], rapid.SliceOfN(rapid.Byte(), DigestSize, DigestSize).Draw(t, "vrf"))
+ // OR-ing random bits into d1 yields d2 >= d1 as a 256-bit integer.
+ d2 = d1
+ for _, i := range rapid.SliceOfN(rapid.IntRange(0, DigestSize-1), 1, 8).Draw(t, "orBytes") {
+ d2[i] |= rapid.Byte().Draw(t, "orVal")
+ }
+ low, high := SelectF128(money, total, expected, d1), SelectF128(money, total, expected, d2)
+ if low > high {
+ t.Fatalf("monotonicity violated: SelectF128(d1)=%d > SelectF128(d2)=%d with d1 <= d2 (money=%d total=%d expected=%d d1=%x d2=%x)",
+ low, high, money, total, expected, d1, d2)
+ }
+ })
+}
diff --git a/f128_test.go b/f128_test.go
index 3f690f1..df192ac 100644
--- a/f128_test.go
+++ b/f128_test.go
@@ -110,6 +110,34 @@ func FuzzSelectF128(f *testing.F) {
f.Add(uint64(100), uint64(1000), uint64(2000), make([]byte, 32)) // expectedSize > totalMoney
// all-0xff digest: ratio is exactly 1.0, the cdf-reaches-1.0 regime
f.Add(uint64(1954), uint64(1_999_999_999_999_964), uint64(1500), bytes.Repeat([]byte{0xff}, 32))
+ // fall-through to money by full walk (p=1/2: the pmf never drops below
+ // cum's half-ulp, so the CDF never freezes) and by the freeze short-circuit
+ // (tiny p: pmf underflows within a few steps); the second must equal the
+ // oracle's unshortened walk
+ f.Add(uint64(100), uint64(200), uint64(100), bytes.Repeat([]byte{0xff}, 32))
+ f.Add(uint64(1954), uint64(1_999_999_999_999_960), uint64(1500), bytes.Repeat([]byte{0xff}, 32))
+ // expectedSize > totalMoney with a nonzero digest: the degenerate path's money return
+ f.Add(uint64(100), uint64(1000), uint64(2000), bytes.Repeat([]byte{0xff}, 32))
+ // digest exactly halfway between ratio ulps: the denominator-correction
+ // round-up branch in f128FromDigestRatio, ~2^-128 density under mutation
+ halfway := make([]byte, DigestSize)
+ halfway[0], halfway[16] = 0x80, 0x80
+ f.Add(uint64(2000), uint64(4000), uint64(2000), halfway)
+ // tiny digest: the low-word normalization branch of the ratio conversion
+ tiny := make([]byte, DigestSize)
+ tiny[DigestSize-1] = 1
+ f.Add(uint64(1500), uint64(3000), uint64(1500), tiny)
+ mid := make([]byte, DigestSize)
+ mid[13] = 0x40 // leading zeros into the digest's third word
+ f.Add(uint64(1500), uint64(3000), uint64(1500), mid)
+ mid2 := make([]byte, DigestSize)
+ mid2[20] = 0x10 // leading zeros into the digest's second word
+ f.Add(uint64(1500), uint64(3000), uint64(1500), mid2)
+ // p just below 1: (1-p)^money exercises deep exponents; and extremes of
+ // total/expected magnitude the mutator will not reach from mid-range seeds
+ f.Add(uint64(2500), uint64(10_000_000_000_000_000), uint64(9_999_999_999_999_999), make([]byte, 32))
+ f.Add(uint64(3000), ^uint64(0), uint64(1), make([]byte, 32))
+ f.Add(uint64(7), uint64(0), uint64(0), make([]byte, 32)) // totalMoney == 0: degenerate nil path, zero ratio
// The uint64 expectedSize needs no input filtering: NaN/Inf/negative/
// fractional sizes are unrepresentable, and expectedSize >= totalMoney
@@ -302,6 +330,21 @@ func FuzzF128Ops(f *testing.F) {
// and missed divU's shallow-quotient misrounding (u > ~2^62).
f.Add(uint64(1)<<63|12345, uint64(67890), 3, uint64(1)<<63, uint64(1), 0, uint64(1)<<63|54321)
f.Add(uint64(1)<<63|999, uint64(777), -9, uint64(1)<<63, uint64(1), 0, uint64(5)<<61|33)
+ // For the same reason, each regime below gets its own seed. Exponents are
+ // encoded as E+2000 (the body maps aexp%4000-2000). Exact ties, carry-out
+ // renormalization, and the rare divStep branches have ~2^-64 density under
+ // uniform inputs; these vectors were constructed or mined by instrumented
+ // search and verified against big.Float:
+ f.Add(uint64(1)<<63, uint64(1), 2000, uint64(3)<<62, uint64(0), 2000, uint64(2)) // mul tail exactly half, odd mantissa: tie rounds up
+ f.Add(uint64(1)<<63, uint64(3), 2000, uint64(3)<<62, uint64(0), 2000, uint64(2)) // mul tie, even mantissa: ties to even
+ f.Add(^uint64(0), ^uint64(0), 2128, uint64(1)<<63, uint64(0), 2000, uint64(3)) // add tie at exp gap 128 into all-ones: carry renormalizes
+ f.Add(uint64(1)<<63, uint64(1), 2129, uint64(1)<<63, uint64(0), 2000, uint64(3)) // add exp gap 129: addend entirely below the round bit
+ f.Add(uint64(1)<<63, uint64(5), 2000, uint64(1)<<63, uint64(100), 2000, uint64(6)) // div: step remainder high word reaches v1 (qhat cap)
+ f.Add(uint64(1)<<63, uint64(1)<<63, 2000, uint64(1)<<63, uint64(1)<<63|2, 2000, uint64(6)) // div: qhat cap where rhat carries out
+ f.Add(uint64(0xff2432b605ae124e), uint64(0x7e0bcdcc481c2dbd), 2000,
+ uint64(0xa7c5c2e99ad4c9a0), uint64(0xd065b805fe1d2cf5), 2000, uint64(9)) // div: refine decrement hits the rhat overflow break
+ f.Add(uint64(0), uint64(12345), 2000, uint64(1), uint64(0), 2000, uint64(2)) // denormalized mantissas: norm128/shl128 paths
+ f.Add(uint64(1)<<63, uint64(0), 2000, uint64(0), uint64(0), 2000, uint64(5)) // zero second operand
f.Fuzz(func(t *testing.T, ahi, alo uint64, aexp int, bhi, blo uint64, bexp int, u uint64) {
a := norm128(ahi, alo, int64(aexp%4000-2000))
b := norm128(bhi, blo, int64(bexp%4000-2000))
From 4008e65c5c773af91ed617fe9d31ee8c2d8dec6b Mon Sep 17 00:00:00 2001
From: cce <51567+cce@users.noreply.github.com>
Date: Tue, 21 Jul 2026 12:35:03 -0400
Subject: [PATCH 22/37] pin the defensive primitive arms with direct unit tests
Full-suite coverage showed shl128's n==0 and n>=128 arms, shr128's n>=128 arm, f128FromUint64(0), and cmp's zero-operand arms as unreachable: norm128 only shifts by 1..127 and CDF boundaries are never zero, so neither the walk nor any differential harness can exercise them. They are total functions with defined answers, so TestF128PrimitiveEdges asserts them by direct call, turning untested defensive code into tested semantics. f128.go coverage is now 100% minus one branch: divStep's add-back, which is unreachable under any inputs (the fixpoint-refinement argument does not even need divStep's preconditions) and stays documented rather than covered. Package coverage: 98.1%.
---
f128_test.go | 35 +++++++++++++++++++++++++++++++++++
1 file changed, 35 insertions(+)
diff --git a/f128_test.go b/f128_test.go
index df192ac..2caa6e6 100644
--- a/f128_test.go
+++ b/f128_test.go
@@ -156,6 +156,41 @@ func FuzzSelectF128(f *testing.F) {
})
}
+// TestF128PrimitiveEdges pins the defensive arms of the shift and comparison
+// primitives that no production caller reaches (norm128 only shifts by
+// 1..127, and CDF boundaries are never zero, so neither the walk nor the
+// differential harnesses can cover them). They are total functions with
+// defined answers; assert them directly. The one remaining uncoverable branch
+// is divStep's add-back, which is unreachable under any inputs (see the
+// comment there).
+func TestF128PrimitiveEdges(t *testing.T) {
+ if hi, lo := shl128(5, 7, 0); hi != 5 || lo != 7 {
+ t.Fatalf("shl128 by 0: got %d,%d", hi, lo)
+ }
+ if hi, lo := shl128(5, 7, 128); hi != 0 || lo != 0 {
+ t.Fatalf("shl128 by 128: got %d,%d", hi, lo)
+ }
+ if hi, lo := shr128(5, 7, 200); hi != 0 || lo != 0 {
+ t.Fatalf("shr128 by 200: got %d,%d", hi, lo)
+ }
+ if !f128FromUint64(0).isZero() {
+ t.Fatal("f128FromUint64(0) is not zero")
+ }
+ one := f128FromUint64(1)
+ if c := (f128{}).cmp(f128{}); c != 0 {
+ t.Fatalf("cmp(0,0) = %d, want 0", c)
+ }
+ if c := one.cmp(f128{}); c != 1 {
+ t.Fatalf("cmp(1,0) = %d, want 1", c)
+ }
+ if c := (f128{}).cmp(one); c != -1 {
+ t.Fatalf("cmp(0,1) = %d, want -1", c)
+ }
+ if c := one.cmp(one); c != 0 {
+ t.Fatalf("cmp(1,1) = %d, want 0", c)
+ }
+}
+
// TestF128AgreesWithCurrent checks broad agreement with the deployed
// Boost-double implementation. Knife-edge differences remain expected because
// SelectF128 uses an f128 digest ratio and CDF.
From 118d53fe6e2baa5b7608db08009bfe5e0140fc98 Mon Sep 17 00:00:00 2001
From: cce <51567+cce@users.noreply.github.com>
Date: Tue, 21 Jul 2026 13:17:51 -0400
Subject: [PATCH 23/37] add 512-bit tolerance oracle, knife-edge tests, and a
mutation campaign
Three assurance layers beyond differential-vs-oracle testing, which is structurally blind to defects mirrored into the oracle (see docs/2026-07-21-sortition-f128-testing-blind-spots.md in the go-algorand docs area). TestSelectF128NearExactMath compares the walk against a 512-bit reference with the near-exact digest ratio and requires agreement within one boundary everywhere outside the defined frozen sliver, including supply-scale spot cases that nothing previously checked against any reference; the spots carry a mustAssert flag so a carve-out change can never silently skip them (an early version of the sliver bound misused big.Float SetMantExp, which multiplies rather than sets the exponent, and the spots passed vacuously until the flag caught it). TestSelectF128BoundaryStraddle constructs digests one ratio-ulp below, at, and above oracle CDF boundaries and requires bit-level agreement plus monotone transitions exactly where knife-edge bugs live. TestRapidSelectF128ScaleInvariance requires bit-identical results under power-of-two scaling of totalMoney and expectedSize, an exact oracle-free metamorphic property.
mutation_check.go (go run mutation_check.go) applies 33 curated single-site mutants to f128.go and requires the fast suite to kill each: 28 killed, 5 survive as proven-equivalent with the proof recorded in the table (among them: div cannot produce exact ties, the halfway correction makes n0's sticky redundant, and the refine loop's strictness is immaterial on exact digits). The campaign found two real gaps, both fixed here: no test broke a mul hi-branch tie through p0 alone (new constructed seeds for mul, add gap-128, divU remainder-only sticky, and a halfway-plus-low-bit digest family), and norm128 -- now used only as the harnesses' input constructor -- could corrupt values invisibly because the reference was derived downstream of it (the fuzz body now checks it against the raw words).
---
f128_exact_test.go | 207 +++++++++++++++++++++++++++++++++++++++++++++
f128_rapid_test.go | 24 ++++++
f128_test.go | 28 ++++++
mutation_check.go | 140 ++++++++++++++++++++++++++++++
4 files changed, 399 insertions(+)
create mode 100644 f128_exact_test.go
create mode 100644 mutation_check.go
diff --git a/f128_exact_test.go b/f128_exact_test.go
new file mode 100644
index 0000000..52d5878
--- /dev/null
+++ b/f128_exact_test.go
@@ -0,0 +1,207 @@
+// Copyright (C) 2019-2023 Algorand, Inc.
+// This file is part of go-algorand
+//
+// go-algorand is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// go-algorand is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with go-algorand. If not, see .
+
+package sortition
+
+import (
+ "math/big"
+ "math/rand"
+ "testing"
+)
+
+// The differential tests compare SelectF128 against an oracle that computes
+// at the SAME 128-bit precision, so a defect mirrored into the oracle (as the
+// pmf(0) plateau was) is invisible to them. The tests in this file compare
+// against a 512-bit reference instead: the 128-bit walk must land within one
+// boundary of the near-exact answer everywhere outside the documented
+// frozen-tail sliver, and must agree with the oracle bit-for-bit at
+// deliberately constructed knife-edge digests.
+
+// selectHighPrec runs the binomial-CDF walk at 512-bit precision with the
+// near-exact digest ratio. It is a reference for tolerance checking, not a
+// bit-identical mirror of SelectF128.
+func selectHighPrec(money, totalMoney, expectedSize uint64, vrfOutput Digest) uint64 {
+ const prec = 512
+ ratio := digestRatioBig(vrfOutput, prec)
+ if expectedSize >= totalMoney { // p >= 1
+ if ratio.Sign() <= 0 {
+ return 0
+ }
+ return money
+ }
+ q := new(big.Float).SetPrec(prec).Quo(
+ new(big.Float).SetPrec(prec).SetUint64(totalMoney-expectedSize),
+ new(big.Float).SetPrec(prec).SetUint64(totalMoney))
+ pq := new(big.Float).SetPrec(prec).Quo(
+ new(big.Float).SetPrec(prec).SetUint64(expectedSize),
+ new(big.Float).SetPrec(prec).SetUint64(totalMoney-expectedSize))
+ pmf := bigIntPow(q, money, prec)
+ cdf := new(big.Float).SetPrec(prec).Set(pmf)
+ if cdf.Cmp(ratio) >= 0 {
+ return 0
+ }
+ for j := uint64(1); j < money; j++ {
+ factor := new(big.Float).SetPrec(prec).Quo(
+ new(big.Float).SetPrec(prec).SetUint64(money-j+1),
+ new(big.Float).SetPrec(prec).SetUint64(j))
+ pmf = new(big.Float).SetPrec(prec).Mul(pmf, new(big.Float).SetPrec(prec).Mul(factor, pq))
+ cdf = new(big.Float).SetPrec(prec).Add(cdf, pmf)
+ if cdf.Cmp(ratio) >= 0 {
+ return j
+ }
+ }
+ return money
+}
+
+// inFrozenSliver reports whether the digest ratio is within the carved-out
+// near-1.0 region where the 128-bit walk's answer is DEFINED as money (see
+// the SelectF128 doc comment) and tolerance against exact math does not
+// apply. The bound (money+2)*2^-122 covers the plateau's ~money*2^-129 with
+// two orders of margin, including the boundary-crowding zone just above it.
+func inFrozenSliver(money uint64, ratio *big.Float) bool {
+ gap := new(big.Float).SetPrec(512).Sub(new(big.Float).SetPrec(512).SetInt64(1), ratio)
+ bound := new(big.Float).SetMantExp(new(big.Float).SetPrec(64).SetUint64(money+2), -122)
+ return gap.Cmp(bound) < 0
+}
+
+// TestSelectF128NearExactMath asserts |SelectF128 - selectHighPrec| <= 1 for
+// every input outside the frozen sliver. A mirrored magnitude error like the
+// pmf(0) plateau cannot hide from this: with the pre-freeze-fix code the
+// supply-scale spots below would have diverged by ~2e15 (or hung without the
+// short-circuit). Nothing else on this branch compares supply-scale results
+// against any reference outside the sliver.
+func TestSelectF128NearExactMath(t *testing.T) {
+ rng := rand.New(rand.NewSource(6))
+ // mustAssert guards against the comparison being skipped by the sliver
+ // carve-out: the deliberately chosen spot cases must actually assert (an
+ // earlier version of the carve-out bound was accidentally so wide that
+ // the supply-scale spots passed vacuously).
+ check := func(money, total, expected uint64, d Digest, mustAssert bool) {
+ t.Helper()
+ if inFrozenSliver(money, digestRatioBig(d, 512)) {
+ if mustAssert {
+ t.Fatalf("spot case unexpectedly in the frozen sliver (money=%d vrf=%x)", money, d)
+ }
+ return
+ }
+ j128 := SelectF128(money, total, expected, d)
+ jhp := selectHighPrec(money, total, expected, d)
+ diff := j128 - jhp
+ if jhp > j128 {
+ diff = jhp - j128
+ }
+ if diff > 1 {
+ t.Fatalf("SelectF128=%d vs 512-bit reference=%d (money=%d total=%d expected=%d vrf=%x)",
+ j128, jhp, money, total, expected, d)
+ }
+ }
+
+ for i := 0; i < 400; i++ {
+ money := rng.Uint64() % 2000
+ total := rng.Uint64() >> uint(rng.Intn(40))
+ if total == 0 {
+ total = 1
+ }
+ expected := rng.Uint64() >> uint(rng.Intn(50))
+ var d Digest
+ if i%3 == 0 {
+ rng.Read(d[:])
+ } else {
+ // near-maximum digests stress the tail, where boundaries crowd
+ for j := range d {
+ d[j] = 0xff
+ }
+ d[rng.Intn(14)] = byte(rng.Uint64())
+ }
+ check(money, total, expected, d, false)
+ }
+
+ // supply-scale spots, chosen outside the sliver (gaps 2^-56..2^-66 vs a
+ // sliver bound near 2^-68 at the supply ceiling)
+ const online = uint64(2_000_000_000_000_000)
+ const supply = uint64(10_000_000_000_000_000)
+ check(online, online, 1500, maxDigestMinusPowerOfTwo(196), true) // ratio ~1-2^-60
+ check(online, online, 6000, maxDigestMinusPowerOfTwo(200), true) // ratio ~1-2^-56
+ check(supply, supply, 5000, maxDigestMinusPowerOfTwo(190), true) // ratio ~1-2^-66
+ check(supply/3, supply, 2990, maxDigestMinusPowerOfTwo(198), true) // ratio ~1-2^-58
+}
+
+// oracleCDFAt returns the oracle's cdf(j), mirroring selectBigOracle's
+// 128-bit recurrence exactly.
+func oracleCDFAt(money, totalMoney, expectedSize, j uint64) *big.Float {
+ const prec = f128MantBits
+ q := new(big.Float).SetPrec(prec).Quo(
+ new(big.Float).SetPrec(prec).SetUint64(totalMoney-expectedSize),
+ new(big.Float).SetPrec(prec).SetUint64(totalMoney))
+ pq := new(big.Float).SetPrec(prec).Quo(
+ new(big.Float).SetPrec(prec).SetUint64(expectedSize),
+ new(big.Float).SetPrec(prec).SetUint64(totalMoney-expectedSize))
+ pmf := bigIntPow(q, money, prec)
+ cdf := new(big.Float).SetPrec(prec).Set(pmf)
+ for i := uint64(1); i <= j; i++ {
+ factor := new(big.Float).SetPrec(prec).Quo(
+ new(big.Float).SetPrec(prec).SetUint64(money-i+1),
+ new(big.Float).SetPrec(prec).SetUint64(i))
+ step := new(big.Float).SetPrec(prec).Mul(factor, pq)
+ pmf = new(big.Float).SetPrec(prec).Mul(pmf, step)
+ cdf = new(big.Float).SetPrec(prec).Add(cdf, pmf)
+ }
+ return cdf
+}
+
+// TestSelectF128BoundaryStraddle constructs digests one ratio-ulp below, at,
+// and above oracle CDF boundaries -- the knife edges where rounding bugs
+// live, which uniform sampling hits with probability ~2^-128 -- and asserts
+// bit-level agreement with the oracle at each, plus monotone results across
+// the constructed digests.
+func TestSelectF128BoundaryStraddle(t *testing.T) {
+ rng := rand.New(rand.NewSource(8))
+ den := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1))
+ denF := new(big.Float).SetPrec(600).SetInt(den)
+ ulp := new(big.Int).Lsh(big.NewInt(1), 128) // one 128-bit ratio ulp in digest units
+
+ for iter := 0; iter < 150; iter++ {
+ money := 2 + rng.Uint64()%250
+ total := 2 + rng.Uint64()>>uint(rng.Intn(40))
+ expected := rng.Uint64() % total // p < 1
+ boundaries := []uint64{0, money / 2, money - 1, rng.Uint64() % money}
+ for _, j := range boundaries {
+ c := oracleCDFAt(money, total, expected, j)
+ tt, _ := new(big.Float).SetPrec(600).Mul(c, denF).Int(nil)
+ prev := uint64(0)
+ first := true
+ for k := int64(-2); k <= 2; k++ {
+ ti := new(big.Int).Add(tt, new(big.Int).Mul(big.NewInt(k), ulp))
+ if ti.Sign() < 0 || ti.Cmp(den) > 0 {
+ continue
+ }
+ var d Digest
+ ti.FillBytes(d[:])
+ got := SelectF128(money, total, expected, d)
+ want := selectBigOracle(money, total, expected, d)
+ if got != want {
+ t.Fatalf("straddle mismatch at boundary j=%d offset %d ulp: SelectF128=%d oracle=%d (money=%d total=%d expected=%d vrf=%x)",
+ j, k, got, want, money, total, expected, d)
+ }
+ if !first && got < prev {
+ t.Fatalf("non-monotone across straddle at boundary j=%d: %d then %d (money=%d total=%d expected=%d)",
+ j, prev, got, money, total, expected)
+ }
+ prev, first = got, false
+ }
+ }
+ }
+}
diff --git a/f128_rapid_test.go b/f128_rapid_test.go
index 137e616..8db3fbf 100644
--- a/f128_rapid_test.go
+++ b/f128_rapid_test.go
@@ -164,6 +164,30 @@ func TestRapidSelectF128VsOracle(t *testing.T) {
})
}
+// TestRapidSelectF128ScaleInvariance checks an ORACLE-INDEPENDENT exact
+// property: scaling totalMoney and expectedSize by a common power of two
+// leaves every rational in the walk identical -- the same quotients round to
+// the same 128-bit values -- so the result must be BIT-IDENTICAL. Unlike
+// tolerance-based metamorphic properties (monotonicity in p or money), this
+// cannot flake at knife edges, and it catches numerator/denominator
+// mishandling without consulting the oracle.
+func TestRapidSelectF128ScaleInvariance(t *testing.T) {
+ rapid.Check(t, func(t *rapid.T) {
+ money := rapid.Uint64Range(0, 3000).Draw(t, "money")
+ total := rapid.Uint64Range(0, 1<<40).Draw(t, "total")
+ expected := rapid.Uint64Range(0, 1<<40).Draw(t, "expected")
+ k := rapid.IntRange(1, 23).Draw(t, "k")
+ var d Digest
+ copy(d[:], rapid.SliceOfN(rapid.Byte(), DigestSize, DigestSize).Draw(t, "vrf"))
+ base := SelectF128(money, total, expected, d)
+ scaled := SelectF128(money, total<>(n-1))&1 != 0", new: "round = (lo>>n)&1 != 0"},
+ {name: "gs-sticky-guard", old: "if n >= 2 {", new: "if n >= 3 {"},
+ {name: "gs-128-drop-lo", old: "sticky = (hi&^(uint64(1)<<63) != 0) || lo != 0", new: "sticky = hi&^(uint64(1)<<63) != 0"},
+ {name: "add-gap-boundary", old: "if a.exp-b.exp > 128 {", new: "if a.exp-b.exp > 127 {"},
+ {name: "add-carry-drop-sticky", old: "sticky = sticky || round", new: "sticky = round"},
+ {name: "add-carry-drop-round", old: "round = slo&1 != 0", new: "round = false"},
+ {name: "divU-drop-rem-sticky", old: "sticky := o1&^(uint64(1)<<63) != 0 || o0 != 0 || rem != 0", new: "sticky := o1&^(uint64(1)<<63) != 0 || o0 != 0 || rem > rem"},
+ {name: "divU-roundbit", old: "round := o1&(uint64(1)<<63) != 0", new: "round := o1&(uint64(1)<<62) != 0"},
+ {name: "divU-exp", old: "roundNE(o3, o2, round, sticky, a.exp-int64(lz))", new: "roundNE(o3, o2, round, sticky, a.exp-int64(lz)-1)"},
+ {name: "ratio-drop-halfway", old: "if roundBit && !sticky {", new: "if roundBit && sticky {"},
+ {name: "ratio-exp", old: "roundNE(n3, n2, roundBit, sticky, -128-int64(leading))", new: "roundNE(n3, n2, roundBit, sticky, -129-int64(leading))"},
+ {
+ name: "ratio-sticky-drop-n0", old: "sticky := n1&^(uint64(1)<<63) != 0 || n0 != 0", new: "sticky := n1&^(uint64(1)<<63) != 0 || n0 > n0",
+ equivalent: "the halfway denominator-correction turns any roundBit-without-sticky into sticky, so n0's contribution is recreated exactly when it could matter, and sticky is irrelevant when roundBit is clear",
+ },
+ {name: "cdf-recurrence-off-by-one", old: "f128FromUint64(b.money - b.at + 1)", new: "f128FromUint64(b.money - b.at)"},
+ {name: "walk-strict-compare", old: "P(X <= j)\n\t\tif ratio.cmp(boundary) <= 0 {", new: "P(X <= j)\n\t\tif ratio.cmp(boundary) < 0 {"},
+ {name: "freeze-fire-on-change", old: "if b.cum.cmp(cumPrev) == 0 && b.pmf.cmp(pmfPrev) < 0 {", new: "if b.cum.cmp(cumPrev) != 0 && b.pmf.cmp(pmfPrev) < 0 {"},
+ {
+ name: "freeze-nonstrict-pmf", old: "b.pmf.cmp(pmfPrev) < 0 {", new: "b.pmf.cmp(pmfPrev) <= 0 {",
+ equivalent: "an equal pmf whose add was a no-op still freezes cum forever: the step factor is non-increasing, so later pmfs stay <= this one and later adds stay no-ops",
+ },
+ {name: "divstep-cap-boundary", old: "if uHi >= v1 {", new: "if uHi > v1 {"},
+ {
+ name: "divstep-refine-nonstrict", old: "if hi > rhat || (hi == rhat && lo > uLo) {", new: "if hi > rhat || (hi == rhat && lo >= uLo) {",
+ equivalent: "the extra decrement fires only on exact quotient digits, whose zero-padded tail makes the remaining walk produce capped all-ones digits ending with remainder exactly V, and the final round-up carries the representation back to Q; the tie-sensitive case would need an odd exact 129-bit quotient, impossible since the dividend's 2^128 factor cannot come from M_b",
+ },
+ {name: "div-mantissa-shift", old: "mantHi := q2<<63 | q1>>1", new: "mantHi := q2<<62 | q1>>1"},
+ {name: "div-roundbit-q2", old: "round := q0&1 != 0", new: "round := false"},
+ {
+ name: "div-tie-nonstrict", old: "if carry != 0 || dblHi > v1 || (dblHi == v1 && dblLo >= v0) {", new: "if carry != 0 || dblHi > v1 || (dblHi == v1 && dblLo > v0) {",
+ equivalent: "exact ties are impossible in div: 2*rem == M_b requires 2^129 to divide M_b*(2q+1), and M_b < 2^128 with 2q+1 odd cannot supply it, so the >= vs > distinction is unreachable",
+ },
+ {name: "norm128-direction", old: "exp -= int64(s)", new: "exp += int64(s)"},
+ {name: "fromuint64-exp", old: "return f128{u << uint(s), 0, -int64(s) - 64}", new: "return f128{u << uint(s), 0, -int64(s) - 63}"},
+ {name: "shl256-fill-shift", old: "out[i] |= in[src+1] >> (64 - shift)", new: "out[i] |= in[src+1] >> (63 - shift)"},
+ {name: "cmp-lo-invert", old: "if a.lo < b.lo {", new: "if a.lo > b.lo {"},
+}
+
+func main() {
+ const target = "f128.go"
+ orig, err := os.ReadFile(target)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ restore := func() { os.WriteFile(target, orig, 0o644) }
+ defer restore()
+
+ killArgs := []string{
+ "test", "-count=1",
+ "-run", "TestF128Digest|TestF128Primitive|TestDivUVsBig|TestDivVsBig|TestSelectF128|TestRapid|Fuzz",
+ "-rapid.checks=2000",
+ }
+
+ src := string(orig)
+ killed, expectedSurvivors := 0, 0
+ var unexpected []string
+ for _, m := range mutants {
+ if c := strings.Count(src, m.old); c != 1 {
+ restore()
+ fmt.Fprintf(os.Stderr, "mutant %s: target string occurs %d times, need exactly 1\n", m.name, c)
+ os.Exit(1)
+ }
+ if err := os.WriteFile(target, []byte(strings.Replace(src, m.old, m.new, 1)), 0o644); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ out, testErr := exec.Command("go", killArgs...).CombinedOutput()
+ restore()
+ if strings.Contains(string(out), "[build failed]") {
+ fmt.Fprintf(os.Stderr, "mutant %s: does not compile, fix the table\n%s\n", m.name, out)
+ os.Exit(1)
+ }
+ switch {
+ case testErr != nil && m.equivalent == "":
+ killed++
+ fmt.Printf("KILLED %s\n", m.name)
+ case testErr != nil && m.equivalent != "":
+ fmt.Printf("UNEXPECTED KILL of equivalent mutant %s: the equivalence proof is wrong or the code changed\n", m.name)
+ unexpected = append(unexpected, m.name+" (equivalent mutant was killed)")
+ case testErr == nil && m.equivalent != "":
+ expectedSurvivors++
+ fmt.Printf("SURVIVED %s (expected: %s)\n", m.name, m.equivalent)
+ default:
+ fmt.Printf("SURVIVED %s <-- TEST GAP\n", m.name)
+ unexpected = append(unexpected, m.name)
+ }
+ }
+ fmt.Printf("\n%d mutants: %d killed, %d expected-equivalent survivors, %d UNEXPECTED\n",
+ len(mutants), killed, expectedSurvivors, len(unexpected))
+ if len(unexpected) > 0 {
+ for _, n := range unexpected {
+ fmt.Printf(" unexpected: %s\n", n)
+ }
+ os.Exit(1)
+ }
+}
From 24e95015ef55c429a7e804db0ddce957909c6bfe Mon Sep 17 00:00:00 2001
From: cce <51567+cce@users.noreply.github.com>
Date: Tue, 21 Jul 2026 13:28:45 -0400
Subject: [PATCH 24/37] add an independent-formula exact oracle and a
distribution test
The differential oracle shares the PMF recurrence with the implementation, so a shared algebra error would pass every differential test. selectExactRat computes the walk against the exact binomial CDF from first principles: stdlib big.Int.Binomial coefficients and pure integer cross-multiplication (t * T^money <= cdfNum(j) * (2^256-1)) -- no recurrence, no rounding, no floating point. TestSelectF128VsExactRat requires agreement within one boundary for money <= 50, over random inputs, near-maximum digests, and digests constructed to sit exactly on true CDF boundaries.
TestSelectF128Distribution mirrors TestSortitionBasic (which covers only the cgo path) for SelectF128 at toy and realistic stake scales with a fixed seed: summed selection weight must track N * money * p within 2%. Oracle-free and formula-free, so a gross semantic error fails it even if perfectly mirrored everywhere else. Both tests join the mutation campaign's kill suite via the TestSelectF128 pattern; the campaign still reports 33 mutants, 28 killed, 5 proven-equivalent, 0 unexpected.
---
f128_exact_test.go | 128 +++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 128 insertions(+)
diff --git a/f128_exact_test.go b/f128_exact_test.go
index 52d5878..f1145fc 100644
--- a/f128_exact_test.go
+++ b/f128_exact_test.go
@@ -139,6 +139,134 @@ func TestSelectF128NearExactMath(t *testing.T) {
check(supply/3, supply, 2990, maxDigestMinusPowerOfTwo(198), true) // ratio ~1-2^-58
}
+// exactCDFNum returns the numerator of the EXACT binomial CDF at j over the
+// denominator totalMoney^money: sum_{i<=j} C(money,i) * E^i * (T-E)^(money-i),
+// built from first principles with stdlib binomial coefficients. It shares no
+// formula with the walk or the differential oracle, which both use the PMF
+// recurrence pmf(j) = pmf(j-1)*(money-j+1)/j * pq -- a shared algebra error
+// there would pass every differential test but not this.
+func exactCDFNum(money, totalMoney, expectedSize, j uint64) *big.Int {
+ e := new(big.Int).SetUint64(expectedSize)
+ q := new(big.Int).SetUint64(totalMoney - expectedSize)
+ sum := new(big.Int)
+ for i := uint64(0); i <= j; i++ {
+ term := new(big.Int).Binomial(int64(money), int64(i))
+ term.Mul(term, new(big.Int).Exp(e, new(big.Int).SetUint64(i), nil))
+ term.Mul(term, new(big.Int).Exp(q, new(big.Int).SetUint64(money-i), nil))
+ sum.Add(sum, term)
+ }
+ return sum
+}
+
+// selectExactRat runs the walk against the exact CDF with pure integer
+// arithmetic: ratio <= cdf(j) becomes t * T^money <= cdfNum(j) * (2^256-1).
+// No rounding anywhere. Cost grows as T^money-sized integers, so callers keep
+// money small.
+func selectExactRat(money, totalMoney, expectedSize uint64, vrfOutput Digest) uint64 {
+ tDig := new(big.Int).SetBytes(vrfOutput[:])
+ if expectedSize >= totalMoney { // p >= 1
+ if tDig.Sign() == 0 {
+ return 0
+ }
+ return money
+ }
+ den256 := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1))
+ lhs := new(big.Int).Mul(tDig,
+ new(big.Int).Exp(new(big.Int).SetUint64(totalMoney), new(big.Int).SetUint64(money), nil))
+ for j := uint64(0); j < money; j++ {
+ rhs := new(big.Int).Mul(exactCDFNum(money, totalMoney, expectedSize, j), den256)
+ if lhs.Cmp(rhs) <= 0 {
+ return j
+ }
+ }
+ return money
+}
+
+// TestSelectF128VsExactRat compares the 128-bit walk against exact
+// mathematics for small money, including digests constructed to sit exactly
+// on true CDF boundaries. Agreement must be within one boundary outside the
+// frozen sliver; a formula error shared by the implementation and the
+// differential oracle cannot hide here.
+func TestSelectF128VsExactRat(t *testing.T) {
+ rng := rand.New(rand.NewSource(9))
+ den256 := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1))
+ check := func(money, total, expected uint64, d Digest) {
+ t.Helper()
+ if inFrozenSliver(money, digestRatioBig(d, 512)) {
+ return
+ }
+ got := SelectF128(money, total, expected, d)
+ want := selectExactRat(money, total, expected, d)
+ diff := got - want
+ if want > got {
+ diff = want - got
+ }
+ if diff > 1 {
+ t.Fatalf("SelectF128=%d vs exact=%d (money=%d total=%d expected=%d vrf=%x)",
+ got, want, money, total, expected, d)
+ }
+ }
+
+ for i := 0; i < 300; i++ {
+ money := rng.Uint64() % 51
+ total := 1 + rng.Uint64()>>uint(rng.Intn(40))
+ expected := rng.Uint64() >> uint(rng.Intn(50))
+ var d Digest
+ switch {
+ case i%3 == 0:
+ rng.Read(d[:])
+ case i%3 == 1: // near-maximum digests
+ for j := range d {
+ d[j] = 0xff
+ }
+ d[rng.Intn(10)] = byte(rng.Uint64())
+ default: // a digest exactly on (or one off) a true CDF boundary
+ if money == 0 || expected >= total {
+ rng.Read(d[:])
+ break
+ }
+ j := rng.Uint64() % money
+ tt := new(big.Int).Mul(exactCDFNum(money, total, expected, j), den256)
+ tt.Div(tt, new(big.Int).Exp(new(big.Int).SetUint64(total), new(big.Int).SetUint64(money), nil))
+ tt.Add(tt, big.NewInt(int64(rng.Intn(3)-1)))
+ if tt.Sign() < 0 || tt.Cmp(den256) > 0 {
+ rng.Read(d[:])
+ break
+ }
+ tt.FillBytes(d[:])
+ }
+ check(money, total, expected, d)
+ }
+}
+
+// TestSelectF128Distribution mirrors TestSortitionBasic for the pure-Go path:
+// summed selection weight over many digests must track N * money * p. This is
+// oracle-free AND formula-free -- a gross semantic error (wrong probability,
+// shifted distribution) fails it even if perfectly mirrored everywhere else.
+func TestSelectF128Distribution(t *testing.T) {
+ rng := rand.New(rand.NewSource(4))
+ cases := []struct {
+ name string
+ money, total, expected, rounds uint64
+ }{
+ {"half stake", 100, 200, 20, 1000},
+ {"realistic stake", 1_000_000_000_000_000, 2_000_000_000_000_000, 1500, 200},
+ }
+ for _, c := range cases {
+ var hits uint64
+ for i := uint64(0); i < c.rounds; i++ {
+ var d Digest
+ rng.Read(d[:])
+ hits += SelectF128(c.money, c.total, c.expected, d)
+ }
+ want := float64(c.rounds) * float64(c.expected) * float64(c.money) / float64(c.total)
+ if diff := float64(hits) - want; diff < -0.02*want || diff > 0.02*want {
+ t.Errorf("%s: %d selections over %d rounds, want %.0f +/- 2%%",
+ c.name, hits, c.rounds, want)
+ }
+ }
+}
+
// oracleCDFAt returns the oracle's cdf(j), mirroring selectBigOracle's
// 128-bit recurrence exactly.
func oracleCDFAt(money, totalMoney, expectedSize, j uint64) *big.Float {
From 8552735b3d04f000ca297ccede53380ff6855483 Mon Sep 17 00:00:00 2001
From: cce <51567+cce@users.noreply.github.com>
Date: Tue, 21 Jul 2026 13:29:20 -0400
Subject: [PATCH 25/37] mutation_check: suppress rapid failfiles from killed
mutants
Killed mutants fail the rapid tests by design, and each failure wrote a minimized reproducer under testdata/rapid that would then rerun as a stale regression case against the restored code. Pass -rapid.nofailfile in the kill suite.
---
mutation_check.go | 3 +++
1 file changed, 3 insertions(+)
diff --git a/mutation_check.go b/mutation_check.go
index eeb815d..b98af9a 100644
--- a/mutation_check.go
+++ b/mutation_check.go
@@ -93,6 +93,9 @@ func main() {
"test", "-count=1",
"-run", "TestF128Digest|TestF128Primitive|TestDivUVsBig|TestDivVsBig|TestSelectF128|TestRapid|Fuzz",
"-rapid.checks=2000",
+ // killed mutants fail the rapid tests by design; don't litter
+ // testdata/rapid with failfiles for them
+ "-rapid.nofailfile",
}
src := string(orig)
From 43b0ab09eedf5d6ef2ed7703fd4078e86ec4b81e Mon Sep 17 00:00:00 2001
From: cce <51567+cce@users.noreply.github.com>
Date: Tue, 21 Jul 2026 13:36:28 -0400
Subject: [PATCH 26/37] run the mutation campaign in CI
A separate single-platform job runs go run mutation_check.go on every push: a weakened test lets a mutant survive and a semantic change to the arithmetic kills a proven-equivalent mutant or orphans a target string, so the kill-rate claim stays continuously verified instead of decaying into a stale number. The mutant table is deliberately coupled to the exact arithmetic lines it guards; editing those lines requires updating the table. Also drop an out-of-repo doc path from the tool header.
---
.github/workflows/test.yml | 38 ++++++++++++++++++++++++++++++++++++--
mutation_check.go | 2 --
2 files changed, 36 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 0027270..3d8b9f7 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -7,8 +7,23 @@ on:
pull_request:
jobs:
- build:
- runs-on: ubuntu-latest
+ test:
+ name: Tests (${{ matrix.os }})
+ runs-on: ${{ matrix.os }}
+ strategy:
+ matrix:
+ # Both architectures on each OS. TestGolden pins exact outputs, so a
+ # floating-point divergence in the cgo/Boost CDF between these targets
+ # shows up here as a failure rather than a silent consensus split.
+ os:
+ - ubuntu-latest # linux/amd64
+ - ubuntu-24.04-arm # linux/arm64
+ - macos-latest # darwin/arm64
+ - macos-15-intel # darwin/amd64
+ - windows-latest # windows/amd64
+ defaults:
+ run:
+ shell: bash
steps:
- uses: actions/checkout@v3
@@ -27,3 +42,22 @@ jobs:
# so 20000 leaves ample margin while staying under a few seconds).
- name: Rapid property tests
run: go test -v -run TestRapid -rapid.checks=20000 ./...
+
+ # Applies each curated single-site mutant of the f128 arithmetic and
+ # verifies the fast suite kills it (survivors marked equivalent carry
+ # written proofs and are expected). This keeps the kill-rate claim true
+ # over time: a weakened test lets a mutant survive, and a semantic change
+ # to the arithmetic makes an equivalent mutant die or a target string
+ # disappear -- both fail the job, deliberately coupling the mutant table to
+ # the consensus-critical lines it guards. Platform-independent, so one leg.
+ mutation:
+ name: Mutation campaign
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Set up Go
+ uses: actions/setup-go@v3
+
+ - name: Run mutation campaign
+ run: go run mutation_check.go
diff --git a/mutation_check.go b/mutation_check.go
index b98af9a..e40c8ed 100644
--- a/mutation_check.go
+++ b/mutation_check.go
@@ -11,8 +11,6 @@
// Run from the repo root:
//
// go run mutation_check.go
-//
-// See ~/ga/docs/2026-07-21-sortition-f128-testing-blind-spots.md, section T2.
package main
import (
From d5f8c1e293e5a7d4af54a75989921bc55ddd358e Mon Sep 17 00:00:00 2001
From: cce <51567+cce@users.noreply.github.com>
Date: Tue, 21 Jul 2026 13:40:34 -0400
Subject: [PATCH 27/37] scale the straddle step to the boundary's own ulp
TestSelectF128BoundaryStraddle stepped candidate digests by a fixed 2^128 digest units, which is one f128 ratio ulp only for boundaries in [0.5, 1); for small boundaries (a large-mean cdf(0) can sit at 2^-2000) the candidates landed many ulps away, diluting the straddle into ordinary sampling. Derive the step from the boundary's MantExp (2^(exp-128) in digest units, clamped to one digest unit when the ulp is finer than the digest grid), and assert the candidate window genuinely brackets the boundary whenever no candidate was clamped at the digest range's ends.
---
f128_exact_test.go | 28 ++++++++++++++++++++++++++--
1 file changed, 26 insertions(+), 2 deletions(-)
diff --git a/f128_exact_test.go b/f128_exact_test.go
index f1145fc..5061464 100644
--- a/f128_exact_test.go
+++ b/f128_exact_test.go
@@ -299,7 +299,6 @@ func TestSelectF128BoundaryStraddle(t *testing.T) {
rng := rand.New(rand.NewSource(8))
den := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1))
denF := new(big.Float).SetPrec(600).SetInt(den)
- ulp := new(big.Int).Lsh(big.NewInt(1), 128) // one 128-bit ratio ulp in digest units
for iter := 0; iter < 150; iter++ {
money := 2 + rng.Uint64()%250
@@ -308,12 +307,25 @@ func TestSelectF128BoundaryStraddle(t *testing.T) {
boundaries := []uint64{0, money / 2, money - 1, rng.Uint64() % money}
for _, j := range boundaries {
c := oracleCDFAt(money, total, expected, j)
+ // One f128 ulp at c's own magnitude, in digest units: the ulp is
+ // 2^(exp-128) for c in [2^(exp-1), 2^exp), so it scales with the
+ // boundary -- a fixed 2^128 step would be one ulp only for c in
+ // [0.5, 1) and many ulps for small boundaries like a large-mean
+ // cdf(0). Clamp to one digest unit when the ulp is finer than the
+ // digest grid.
+ step := new(big.Int).Rsh(den, uint(128-c.MantExp(nil)))
+ if step.Sign() == 0 {
+ step.SetInt64(1)
+ }
tt, _ := new(big.Float).SetPrec(600).Mul(c, denF).Int(nil)
prev := uint64(0)
first := true
+ clamped := false
+ var loRatio, hiRatio *big.Float
for k := int64(-2); k <= 2; k++ {
- ti := new(big.Int).Add(tt, new(big.Int).Mul(big.NewInt(k), ulp))
+ ti := new(big.Int).Add(tt, new(big.Int).Mul(big.NewInt(k), step))
if ti.Sign() < 0 || ti.Cmp(den) > 0 {
+ clamped = true
continue
}
var d Digest
@@ -329,6 +341,18 @@ func TestSelectF128BoundaryStraddle(t *testing.T) {
j, prev, got, money, total, expected)
}
prev, first = got, false
+ if loRatio == nil {
+ loRatio = digestRatioBig(d, f128MantBits)
+ }
+ hiRatio = digestRatioBig(d, f128MantBits)
+ }
+ // The candidates must genuinely bracket the boundary (this is
+ // what makes it a straddle rather than ordinary sampling); only
+ // checkable when no candidate was clamped away at 0 or the
+ // maximum digest.
+ if !clamped && (loRatio.Cmp(c) > 0 || hiRatio.Cmp(c) < 0) {
+ t.Fatalf("straddle window does not bracket boundary j=%d: [%v, %v] vs cdf=%v (money=%d total=%d expected=%d)",
+ j, loRatio, hiRatio, c, money, total, expected)
}
}
}
From 003ae1baab76b6e557ef3ebb2b81da9605423c80 Mon Sep 17 00:00:00 2001
From: cce <51567+cce@users.noreply.github.com>
Date: Tue, 21 Jul 2026 13:45:52 -0400
Subject: [PATCH 28/37] bump actions versions
---
.github/workflows/test.yml | 11 ++++-------
1 file changed, 4 insertions(+), 7 deletions(-)
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 3d8b9f7..34e97fa 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -21,14 +21,11 @@ jobs:
- macos-latest # darwin/arm64
- macos-15-intel # darwin/amd64
- windows-latest # windows/amd64
- defaults:
- run:
- shell: bash
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v7
- name: Set up Go
- uses: actions/setup-go@v3
+ uses: actions/setup-go@v7
- name: Build
run: go build -v ./...
@@ -54,10 +51,10 @@ jobs:
name: Mutation campaign
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v7
- name: Set up Go
- uses: actions/setup-go@v3
+ uses: actions/setup-go@v7
- name: Run mutation campaign
run: go run mutation_check.go
From f3f8709082a6e2ed340c2689c2560b40ea0af921 Mon Sep 17 00:00:00 2001
From: cce <51567+cce@users.noreply.github.com>
Date: Tue, 21 Jul 2026 13:48:34 -0400
Subject: [PATCH 29/37] go version for actions
---
.github/workflows/test.yml | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 34e97fa..4910ff1 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -26,6 +26,8 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v7
+ with:
+ go-version-file: go.mod
- name: Build
run: go build -v ./...
@@ -55,6 +57,8 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v7
+ with:
+ go-version-file: go.mod
- name: Run mutation campaign
run: go run mutation_check.go
From b7835fb30a340d592883d477c71f55ebb2e90416 Mon Sep 17 00:00:00 2001
From: cce <51567+cce@users.noreply.github.com>
Date: Tue, 21 Jul 2026 13:53:13 -0400
Subject: [PATCH 30/37] fix args in windows CI
---
.github/workflows/test.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 4910ff1..a063492 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -40,7 +40,7 @@ jobs:
# band (the divU shallow-quotient bug reproduced within ~30 banded cases,
# so 20000 leaves ample margin while staying under a few seconds).
- name: Rapid property tests
- run: go test -v -run TestRapid -rapid.checks=20000 ./...
+ run: go test -v -run TestRapid ./... -args -rapid.checks=20000
# Applies each curated single-site mutant of the f128 arithmetic and
# verifies the fast suite kills it (survivors marked equivalent carry
From 237ce643ecf9d8c91369e97ccc26038d11b6768b Mon Sep 17 00:00:00 2001
From: cce <51567+cce@users.noreply.github.com>
Date: Tue, 21 Jul 2026 14:31:05 -0400
Subject: [PATCH 31/37] test: add independent f128 and CDF oracles
Add exact-integer primitive checks, an exhaustive small-domain boundary grid, and offline Arb-certified large-money quantile vectors.
Include the new tests in the curated mutation campaign and pin the optional oracle dependency.
---
.gitignore | 1 +
cdf_exhaustive_test.go | 176 +++++++++++++++++
f128_arb_certificate_test.go | 148 +++++++++++++++
f128_integer_oracle_test.go | 282 ++++++++++++++++++++++++++++
mutation_check.go | 2 +-
testdata/f128_arb_certificates.json | 139 ++++++++++++++
tools/generate_arb_oracle.py | 188 +++++++++++++++++++
tools/requirements-oracle.txt | 1 +
8 files changed, 936 insertions(+), 1 deletion(-)
create mode 100644 .gitignore
create mode 100644 cdf_exhaustive_test.go
create mode 100644 f128_arb_certificate_test.go
create mode 100644 f128_integer_oracle_test.go
create mode 100644 testdata/f128_arb_certificates.json
create mode 100644 tools/generate_arb_oracle.py
create mode 100644 tools/requirements-oracle.txt
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1cf67b5
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+.venv-oracle/
diff --git a/cdf_exhaustive_test.go b/cdf_exhaustive_test.go
new file mode 100644
index 0000000..598e689
--- /dev/null
+++ b/cdf_exhaustive_test.go
@@ -0,0 +1,176 @@
+// Copyright (C) 2019-2023 Algorand, Inc.
+// This file is part of go-algorand
+//
+// go-algorand is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// go-algorand is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with go-algorand. If not, see .
+
+package sortition
+
+import (
+ "math/big"
+ "testing"
+)
+
+func exactSelectFromCDFNumerators(money uint64, cdfNums []*big.Int, cdfDen, digestDen, digest *big.Int) uint64 {
+ lhs := new(big.Int).Mul(digest, cdfDen)
+ for j, cdfNum := range cdfNums {
+ rhs := new(big.Int).Mul(cdfNum, digestDen)
+ if lhs.Cmp(rhs) <= 0 {
+ return uint64(j)
+ }
+ }
+ return money
+}
+
+func digestFromBigInt(t *testing.T, x, digestDen *big.Int) Digest {
+ t.Helper()
+ if x.Sign() < 0 || x.Cmp(digestDen) > 0 {
+ t.Fatalf("digest integer outside [0, 2^256-1]: %s", x)
+ }
+ var d Digest
+ x.FillBytes(d[:])
+ return d
+}
+
+func uint64Distance(a, b uint64) uint64 {
+ if a >= b {
+ return a - b
+ }
+ return b - a
+}
+
+// TestSelectF128ExhaustiveSmallDomain complements the sampled exact-rational
+// test with a complete finite grid. Every non-degenerate rational probability,
+// every CDF boundary, and the adjacent digest integers are constructed from
+// the first-principles binomial formula. The f128 walk is allowed to move one
+// boundary because its per-operation RNE semantics intentionally differ from
+// exact mathematics at knife edges.
+func TestSelectF128ExhaustiveSmallDomain(t *testing.T) {
+ digestDen := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1))
+ const maxMoney = uint64(16)
+ const maxTotal = uint64(32)
+
+ var parameters, boundaries, bracketed, candidates, asserted, skippedSliver uint64
+ for money := uint64(1); money <= maxMoney; money++ {
+ for total := uint64(2); total <= maxTotal; total++ {
+ cdfDen := new(big.Int).Exp(new(big.Int).SetUint64(total), new(big.Int).SetUint64(money), nil)
+ for expected := uint64(1); expected < total; expected++ {
+ parameters++
+ cdfNums := make([]*big.Int, money)
+ points := make(map[string]*big.Int, 3*money+2)
+ points["0"] = new(big.Int)
+ points[digestDen.Text(16)] = new(big.Int).Set(digestDen)
+
+ for j := uint64(0); j < money; j++ {
+ boundaries++
+ cdfNum := exactCDFNum(money, total, expected, j)
+ cdfNums[j] = cdfNum
+ scaled := new(big.Int).Mul(cdfNum, digestDen)
+ at, rem := new(big.Int), new(big.Int)
+ at.QuoRem(scaled, cdfDen, rem)
+
+ // at/digestDen <= CDF(j) < (at+1)/digestDen. This
+ // assertion makes every generated window prove that it
+ // really straddles the intended exact boundary.
+ atScaled := new(big.Int).Mul(new(big.Int).Set(at), cdfDen)
+ above := new(big.Int).Add(new(big.Int).Set(at), big.NewInt(1))
+ aboveScaled := new(big.Int).Mul(new(big.Int).Set(above), cdfDen)
+ if atScaled.Cmp(scaled) > 0 || aboveScaled.Cmp(scaled) <= 0 || at.Cmp(digestDen) >= 0 {
+ t.Fatalf("invalid exact boundary bracket (money=%d total=%d expected=%d j=%d at=%s remainder=%s)",
+ money, total, expected, j, at, rem)
+ }
+ bracketed++
+
+ if at.Sign() > 0 {
+ below := new(big.Int).Sub(new(big.Int).Set(at), big.NewInt(1))
+ points[below.Text(16)] = below
+ }
+ points[at.Text(16)] = new(big.Int).Set(at)
+ points[above.Text(16)] = above
+ }
+
+ for _, point := range points {
+ candidates++
+ d := digestFromBigInt(t, point, digestDen)
+ got := SelectF128(money, total, expected, d)
+ if bitExact := selectBigOracle(money, total, expected, d); got != bitExact {
+ t.Fatalf("SelectF128=%d vs 128-bit walk oracle=%d (money=%d total=%d expected=%d digest=%x)",
+ got, bitExact, money, total, expected, d)
+ }
+ if inFrozenSliver(money, digestRatioBig(d, 512)) {
+ skippedSliver++
+ continue
+ }
+ want := exactSelectFromCDFNumerators(money, cdfNums, cdfDen, digestDen, point)
+ asserted++
+ if uint64Distance(got, want) > 1 {
+ t.Fatalf("SelectF128=%d vs exact=%d (money=%d total=%d expected=%d digest=%x)",
+ got, want, money, total, expected, d)
+ }
+ }
+ }
+ }
+ }
+
+ if boundaries == 0 || bracketed != boundaries {
+ t.Fatalf("anti-vacuity: bracketed %d of %d exact boundaries", bracketed, boundaries)
+ }
+ if parameters < 7_000 || asserted < 100_000 || skippedSliver >= candidates/2 {
+ t.Fatalf("anti-vacuity: parameters=%d boundaries=%d candidates=%d asserted=%d skippedSliver=%d",
+ parameters, boundaries, candidates, asserted, skippedSliver)
+ }
+ t.Logf("exhaustive exact grid: parameters=%d boundaries=%d candidates=%d asserted=%d skippedSliver=%d",
+ parameters, boundaries, candidates, asserted, skippedSliver)
+}
+
+// TestSelectF128ExhaustiveSmallDegenerateDomain keeps p=0, p>=1,
+// totalMoney=0, and money=0 out of the regular-grid special cases above. It
+// exhausts them separately with endpoint digests so none is lost to a skip.
+func TestSelectF128ExhaustiveSmallDegenerateDomain(t *testing.T) {
+ var maximum Digest
+ for i := range maximum {
+ maximum[i] = 0xff
+ }
+ var one Digest
+ one[len(one)-1] = 1
+ digests := []Digest{{}, one, maximum}
+
+ for money := uint64(0); money <= 16; money++ {
+ for total := uint64(0); total <= 32; total++ {
+ expectedValues := map[uint64]struct{}{0: {}, total: {}}
+ if total != ^uint64(0) {
+ expectedValues[total+1] = struct{}{}
+ }
+ for expected := range expectedValues {
+ for _, d := range digests {
+ got := SelectF128(money, total, expected, d)
+ var want uint64
+ switch {
+ case money == 0:
+ want = 0
+ case expected < total: // only expected==0: all mass at zero
+ want = 0
+ case d == (Digest{}): // p>=1 and ratio==0 crosses cdf(0)==0
+ want = 0
+ default:
+ want = money
+ }
+ if got != want {
+ t.Fatalf("degenerate SelectF128=%d, want %d (money=%d total=%d expected=%d digest=%x)",
+ got, want, money, total, expected, d)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/f128_arb_certificate_test.go b/f128_arb_certificate_test.go
new file mode 100644
index 0000000..06db6ec
--- /dev/null
+++ b/f128_arb_certificate_test.go
@@ -0,0 +1,148 @@
+// Copyright (C) 2019-2023 Algorand, Inc.
+// This file is part of go-algorand
+//
+// go-algorand is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// go-algorand is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with go-algorand. If not, see .
+
+package sortition
+
+import (
+ "encoding/hex"
+ "encoding/json"
+ "math/big"
+ "os"
+ "testing"
+)
+
+type arbDyadicCertificate struct {
+ Mantissa string `json:"mantissa"`
+ Exponent int64 `json:"exponent"`
+}
+
+type arbCDFVector struct {
+ Label string `json:"label"`
+ Money uint64 `json:"money"`
+ TotalMoney uint64 `json:"total_money"`
+ ExpectedSize uint64 `json:"expected_size"`
+ Digest string `json:"digest"`
+ Want uint64 `json:"want"`
+ PrecisionBits uint64 `json:"precision_bits"`
+ CDFPreviousUpper arbDyadicCertificate `json:"cdf_previous_upper"`
+ CDFSelectedLower arbDyadicCertificate `json:"cdf_selected_lower"`
+}
+
+type arbCertificateFile struct {
+ Format uint64 `json:"format"`
+ Generator string `json:"generator"`
+ Semantics string `json:"semantics"`
+ Vectors []arbCDFVector `json:"vectors"`
+}
+
+func parseCertificateMantissa(t *testing.T, d arbDyadicCertificate) *big.Int {
+ t.Helper()
+ m, ok := new(big.Int).SetString(d.Mantissa, 10)
+ if !ok {
+ t.Fatalf("invalid certificate mantissa %q", d.Mantissa)
+ }
+ return m
+}
+
+// compareDyadicToRat compares mantissa*2^exponent with num/den using exact
+// integer arithmetic. The certificate endpoints themselves come from Arb;
+// this check makes the inequalities carried by the JSON independently
+// inspectable by the Go test.
+func compareDyadicToRat(t *testing.T, d arbDyadicCertificate, num, den *big.Int) int {
+ t.Helper()
+ m := parseCertificateMantissa(t, d)
+ left, right := new(big.Int).Set(m), new(big.Int).Set(num)
+ if d.Exponent >= 0 {
+ left.Lsh(left, uint(d.Exponent))
+ left.Mul(left, den)
+ } else {
+ left.Mul(left, den)
+ right.Lsh(right, uint(-d.Exponent))
+ }
+ return left.Cmp(right)
+}
+
+// TestSelectF128ArbCertificates consumes offline certificates generated by
+// python-flint/Arb's regularized incomplete beta implementation. Unlike all
+// in-process floating references, Arb neither initializes at q^money nor uses
+// SelectF128's forward PMF recurrence. Every vector first proves the exact
+// binomial quantile inequality, then pins SelectF128 to that certified count.
+func TestSelectF128ArbCertificates(t *testing.T) {
+ raw, err := os.ReadFile("testdata/f128_arb_certificates.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var certificates arbCertificateFile
+ if err := json.Unmarshal(raw, &certificates); err != nil {
+ t.Fatal(err)
+ }
+ if certificates.Format != 1 || certificates.Generator == "" || certificates.Semantics == "" || len(certificates.Vectors) < 10 {
+ t.Fatalf("unexpected Arb certificate corpus: format=%d generator=%q semantics=%q vectors=%d",
+ certificates.Format, certificates.Generator, certificates.Semantics, len(certificates.Vectors))
+ }
+
+ digestDen := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1))
+ labels := make(map[string]struct{}, len(certificates.Vectors))
+ for _, vector := range certificates.Vectors {
+ t.Run(vector.Label, func(t *testing.T) {
+ if _, exists := labels[vector.Label]; exists || vector.Label == "" {
+ t.Fatalf("empty or duplicate certificate label %q", vector.Label)
+ }
+ labels[vector.Label] = struct{}{}
+ if vector.Money >= SelectF128MaxMoney || vector.TotalMoney == 0 || vector.ExpectedSize == 0 ||
+ vector.ExpectedSize >= vector.TotalMoney || vector.Want > vector.Money || vector.PrecisionBits < 128 {
+ t.Fatalf("invalid certified parameters or result: money=%d total=%d expected=%d want=%d precision=%d",
+ vector.Money, vector.TotalMoney, vector.ExpectedSize, vector.Want, vector.PrecisionBits)
+ }
+ digestBytes, err := hex.DecodeString(vector.Digest)
+ if err != nil || len(digestBytes) != DigestSize {
+ t.Fatalf("invalid digest %q", vector.Digest)
+ }
+ var d Digest
+ copy(d[:], digestBytes)
+ digestInt := new(big.Int).SetBytes(d[:])
+ if digestInt.Sign() <= 0 {
+ t.Fatal("certificate corpus deliberately excludes the ratio==0 degenerate boundary")
+ }
+ zero, one := new(big.Int), big.NewInt(1)
+ for name, endpoint := range map[string]arbDyadicCertificate{
+ "CDF(want-1) upper": vector.CDFPreviousUpper,
+ "CDF(want) lower": vector.CDFSelectedLower,
+ } {
+ if compareDyadicToRat(t, endpoint, zero, one) < 0 ||
+ compareDyadicToRat(t, endpoint, one, one) > 0 {
+ t.Fatalf("%s endpoint is outside [0,1]", name)
+ }
+ }
+
+ // Arb supplied an upper enclosure for CDF(want-1) and a lower
+ // enclosure for CDF(want). Exact integer comparison with the
+ // digest ratio proves the selected quantile.
+ if compareDyadicToRat(t, vector.CDFPreviousUpper, digestInt, digestDen) >= 0 {
+ t.Fatal("certificate does not prove CDF(want-1) < ratio")
+ }
+ if compareDyadicToRat(t, vector.CDFSelectedLower, digestInt, digestDen) < 0 {
+ t.Fatal("certificate does not prove ratio <= CDF(want)")
+ }
+ if inFrozenSliver(vector.Money, digestRatioBig(d, 512)) {
+ t.Fatal("exact-math certificate unexpectedly lies in the separately defined frozen sliver")
+ }
+ if got := SelectF128(vector.Money, vector.TotalMoney, vector.ExpectedSize, d); got != vector.Want {
+ t.Fatalf("SelectF128=%d, Arb-certified exact binomial quantile=%d", got, vector.Want)
+ }
+ })
+ }
+}
diff --git a/f128_integer_oracle_test.go b/f128_integer_oracle_test.go
new file mode 100644
index 0000000..fc0b7ae
--- /dev/null
+++ b/f128_integer_oracle_test.go
@@ -0,0 +1,282 @@
+// Copyright (C) 2019-2023 Algorand, Inc.
+// This file is part of go-algorand
+//
+// go-algorand is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// go-algorand is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with go-algorand. If not, see .
+
+package sortition
+
+import (
+ "math/big"
+ "math/rand"
+ "testing"
+)
+
+var integerOracleOne = big.NewInt(1)
+
+// floorLog2Rat returns floor(log2(num/den)) for positive integers. It uses
+// integer comparison only; in particular, the exact-integer oracle below does
+// not ask big.Float to decide normalization or rounding.
+func floorLog2Rat(num, den *big.Int) int {
+ e := num.BitLen() - den.BitLen()
+ if e >= 0 {
+ if num.Cmp(new(big.Int).Lsh(new(big.Int).Set(den), uint(e))) < 0 {
+ e--
+ }
+ } else if new(big.Int).Lsh(new(big.Int).Set(num), uint(-e)).Cmp(den) < 0 {
+ e--
+ }
+ return e
+}
+
+// roundExactRatToF128 rounds (num/den)*2^exp2 to f128 using only big.Int
+// quotient/remainder arithmetic. num must be non-negative and den positive.
+func roundExactRatToF128(num, den *big.Int, exp2 int64) f128 {
+ if num.Sign() == 0 {
+ return f128{}
+ }
+ if num.Sign() < 0 || den.Sign() <= 0 {
+ panic("roundExactRatToF128 requires num >= 0 and den > 0")
+ }
+
+ e := floorLog2Rat(num, den)
+ n, d := new(big.Int).Set(num), new(big.Int).Set(den)
+ if shift := 127 - e; shift >= 0 {
+ n.Lsh(n, uint(shift))
+ } else {
+ d.Lsh(d, uint(-shift))
+ }
+
+ mant, rem := new(big.Int), new(big.Int)
+ mant.QuoRem(n, d, rem)
+ twiceRem := new(big.Int).Lsh(new(big.Int).Set(rem), 1)
+ if cmp := twiceRem.Cmp(d); cmp > 0 || (cmp == 0 && mant.Bit(0) != 0) {
+ mant.Add(mant, integerOracleOne)
+ }
+
+ outExp := exp2 + int64(e) - 127
+ if mant.BitLen() == 129 { // rounded 2^128: renormalize to 2^127
+ mant.Rsh(mant, 1)
+ outExp++
+ }
+ if mant.BitLen() != 128 {
+ panic("integer oracle produced a non-normalized mantissa")
+ }
+ lo := mant.Uint64()
+ hi := new(big.Int).Rsh(new(big.Int).Set(mant), 64).Uint64()
+ return f128{hi: hi, lo: lo, exp: outExp}
+}
+
+func f128MantissaInt(x f128) *big.Int {
+ m := new(big.Int).SetUint64(x.hi)
+ m.Lsh(m, 64)
+ return m.Add(m, new(big.Int).SetUint64(x.lo))
+}
+
+func exactMulF128(a, b f128) f128 {
+ if a.isZero() || b.isZero() {
+ return f128{}
+ }
+ n := new(big.Int).Mul(f128MantissaInt(a), f128MantissaInt(b))
+ return roundExactRatToF128(n, integerOracleOne, a.exp+b.exp)
+}
+
+func exactAddF128(a, b f128) f128 {
+ if a.isZero() {
+ return b
+ }
+ if b.isZero() {
+ return a
+ }
+ baseExp := a.exp
+ if b.exp < baseExp {
+ baseExp = b.exp
+ }
+ an := f128MantissaInt(a)
+ bn := f128MantissaInt(b)
+ an.Lsh(an, uint(a.exp-baseExp))
+ bn.Lsh(bn, uint(b.exp-baseExp))
+ return roundExactRatToF128(new(big.Int).Add(an, bn), integerOracleOne, baseExp)
+}
+
+func exactDivF128(a, b f128) f128 {
+ if a.isZero() || b.isZero() {
+ return f128{}
+ }
+ return roundExactRatToF128(f128MantissaInt(a), f128MantissaInt(b), a.exp-b.exp)
+}
+
+func exactDivUF128(a f128, u uint64) f128 {
+ if a.isZero() || u == 0 {
+ return f128{}
+ }
+ return roundExactRatToF128(f128MantissaInt(a), new(big.Int).SetUint64(u), a.exp)
+}
+
+func assertCanonicalF128(t *testing.T, name string, x f128) {
+ t.Helper()
+ if x.isZero() {
+ if x.exp != 0 {
+ t.Fatalf("%s: zero has nonzero exponent: %+v", name, x)
+ }
+ return
+ }
+ if x.hi&(uint64(1)<<63) == 0 {
+ t.Fatalf("%s: mantissa is not normalized: %+v", name, x)
+ }
+}
+
+func assertExactF128(t *testing.T, name string, got, want f128) {
+ t.Helper()
+ assertCanonicalF128(t, name, got)
+ if got != want {
+ t.Fatalf("%s: got %+v, exact-integer RNE oracle wants %+v", name, got, want)
+ }
+}
+
+// TestF128OpsExactIntegerOracle validates the public arithmetic contract with
+// an oracle structurally independent of math/big.Float. Exact values are
+// represented as integer ratios; normalization and ties-to-even are decided
+// from quotient and remainder.
+func TestF128OpsExactIntegerOracle(t *testing.T) {
+ rng := rand.New(rand.NewSource(10))
+ gaps := [...]int64{0, 1, -1, 63, -63, 64, -64, 65, -65, 127, -127, 128, -128, 129, -129, 511, -511, 2000, -2000}
+ for i := 0; i < 100_000; i++ {
+ aexp := int64(rng.Intn(4001) - 2000)
+ bexp := aexp - gaps[i%len(gaps)]
+ a := f128{hi: rng.Uint64() | 1<<63, lo: rng.Uint64(), exp: aexp}
+ b := f128{hi: rng.Uint64() | 1<<63, lo: rng.Uint64(), exp: bexp}
+ if i%997 == 0 {
+ a = f128{}
+ }
+ if i%991 == 0 {
+ b = f128{}
+ }
+
+ assertExactF128(t, "mul", a.mul(b), exactMulF128(a, b))
+ assertExactF128(t, "add", a.add(b), exactAddF128(a, b))
+ assertExactF128(t, "div", a.div(b), exactDivF128(a, b))
+
+ var u uint64
+ switch i % 8 {
+ case 0:
+ u = 0
+ case 1:
+ u = 1
+ case 2:
+ u = SelectF128MaxMoney - 1
+ case 3:
+ u = 1<<62 | rng.Uint64()&((1<<62)-1)
+ case 4:
+ u = 1<<63 | rng.Uint64()&((1<<63)-1)
+ case 5:
+ u = ^uint64(0)
+ default:
+ u = rng.Uint64()
+ }
+ assertExactF128(t, "divU", a.divU(u), exactDivUF128(a, u))
+ }
+}
+
+// TestF128ConversionsExactIntegerOracle independently checks the two exact
+// input conversions. The existing big.Float matrix remains useful; this test
+// makes its rounding oracle non-circular.
+func TestF128ConversionsExactIntegerOracle(t *testing.T) {
+ rng := rand.New(rand.NewSource(11))
+ den := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), integerOracleOne)
+ for i := 0; i < 20_000; i++ {
+ u := rng.Uint64()
+ assertExactF128(t, "fromUint64", f128FromUint64(u),
+ roundExactRatToF128(new(big.Int).SetUint64(u), integerOracleOne, 0))
+
+ var d Digest
+ _, _ = rng.Read(d[:])
+ if i == 0 {
+ d = Digest{}
+ } else if i == 1 {
+ for j := range d {
+ d[j] = 0xff
+ }
+ }
+ assertExactF128(t, "digestRatio", f128FromDigestRatio(d),
+ roundExactRatToF128(new(big.Int).SetBytes(d[:]), den, 0))
+ }
+}
+
+func words128(x *big.Int) (uint64, uint64) {
+ lo := x.Uint64()
+ hi := new(big.Int).Rsh(new(big.Int).Set(x), 64).Uint64()
+ return hi, lo
+}
+
+// TestDivStepExactCertificate checks the long-division digit directly rather
+// than only through the final rounded f128 quotient. Under divStep's prefix
+// precondition, the returned digit and remainder must be the Euclidean
+// quotient and remainder of the 192-by-128-bit division.
+func TestDivStepExactCertificate(t *testing.T) {
+ rng := rand.New(rand.NewSource(12))
+ check := func(v1, v0 uint64, prefix *big.Int, uLo uint64) {
+ t.Helper()
+ v := new(big.Int).SetUint64(v1)
+ v.Lsh(v, 64).Add(v, new(big.Int).SetUint64(v0))
+ if prefix.Sign() < 0 || prefix.Cmp(v) >= 0 {
+ t.Fatal("invalid divStep test prefix")
+ }
+ uHi, uMid := words128(prefix)
+ q, rHi, rLo := divStep(uHi, uMid, uLo, v1, v0)
+
+ u := new(big.Int).Lsh(new(big.Int).Set(prefix), 64)
+ u.Add(u, new(big.Int).SetUint64(uLo))
+ wantQ, wantR := new(big.Int), new(big.Int)
+ wantQ.QuoRem(u, v, wantR)
+ if wantQ.BitLen() > 64 || q != wantQ.Uint64() {
+ t.Fatalf("divStep quotient: got %#x, want %s", q, wantQ.Text(16))
+ }
+ wantRHi, wantRLo := words128(wantR)
+ if rHi != wantRHi || rLo != wantRLo {
+ t.Fatalf("divStep remainder: got %#x:%#x, want %#x:%#x", rHi, rLo, wantRHi, wantRLo)
+ }
+
+ // Check the certificate separately from comparison with QuoRem.
+ gotR := new(big.Int).SetUint64(rHi)
+ gotR.Lsh(gotR, 64).Add(gotR, new(big.Int).SetUint64(rLo))
+ reconstructed := new(big.Int).Mul(new(big.Int).SetUint64(q), v)
+ reconstructed.Add(reconstructed, gotR)
+ if reconstructed.Cmp(u) != 0 || gotR.Sign() < 0 || gotR.Cmp(v) >= 0 {
+ t.Fatalf("invalid division certificate: U=%x q=%x V=%x R=%x", u, q, v, gotR)
+ }
+ }
+
+ for i := 0; i < 200_000; i++ {
+ v1 := rng.Uint64() | 1<<63
+ v0 := rng.Uint64()
+ if i%16 == 0 {
+ v0 |= 1 // V-1 then has uHi == v1 and exercises the qhat cap
+ }
+ v := new(big.Int).SetUint64(v1)
+ v.Lsh(v, 64).Add(v, new(big.Int).SetUint64(v0))
+ prefix := new(big.Int).SetUint64(rng.Uint64())
+ prefix.Lsh(prefix, 64).Add(prefix, new(big.Int).SetUint64(rng.Uint64()))
+ prefix.Mod(prefix, v)
+ if i%16 == 0 {
+ prefix.Sub(v, integerOracleOne)
+ }
+ check(v1, v0, prefix, rng.Uint64())
+ }
+
+ // qhat cap with rhat carry: uHi == v1, uMid+v1 overflows, and uMid < v0.
+ v1, v0 := uint64(1)<<63, ^uint64(0)
+ prefix := new(big.Int).SetUint64(v1)
+ prefix.Lsh(prefix, 64).Add(prefix, new(big.Int).SetUint64(1<<63))
+ check(v1, v0, prefix, 0)
+}
diff --git a/mutation_check.go b/mutation_check.go
index e40c8ed..46e983a 100644
--- a/mutation_check.go
+++ b/mutation_check.go
@@ -89,7 +89,7 @@ func main() {
killArgs := []string{
"test", "-count=1",
- "-run", "TestF128Digest|TestF128Primitive|TestDivUVsBig|TestDivVsBig|TestSelectF128|TestRapid|Fuzz",
+ "-run", "TestF128Digest|TestF128Primitive|TestF128OpsExact|TestF128ConversionsExact|TestDivStepExact|TestDivUVsBig|TestDivVsBig|TestSelectF128|TestRapid|Fuzz",
"-rapid.checks=2000",
// killed mutants fail the rapid tests by design; don't litter
// testdata/rapid with failfiles for them
diff --git a/testdata/f128_arb_certificates.json b/testdata/f128_arb_certificates.json
new file mode 100644
index 0000000..dc5b170
--- /dev/null
+++ b/testdata/f128_arb_certificates.json
@@ -0,0 +1,139 @@
+{
+ "format": 1,
+ "generator": "python-flint 0.6.0 / Arb regularized incomplete beta",
+ "semantics": "exact binomial CDF; frozen-tail definition is tested separately",
+ "vectors": [
+ {
+ "cdf_previous_upper": {"exponent": -128, "mantissa": "156599776420698413224136027028177551535"},
+ "cdf_selected_lower": {"exponent": -128, "mantissa": "183682590500240050239238580403590659885"},
+ "digest": "8000000000000000000000000000000000000000000000000000000000000000",
+ "expected_size": 100,
+ "label": "toy symmetric median",
+ "money": 100,
+ "precision_bits": 128,
+ "total_money": 200,
+ "want": 50
+ },
+ {
+ "cdf_previous_upper": {"exponent": 0, "mantissa": "0"},
+ "cdf_selected_lower": {"exponent": -127, "mantissa": "132489513636925215833268058479265445677"},
+ "digest": "5555555555555555555555555555555555555555555555555555555555555555",
+ "expected_size": 1,
+ "label": "toy low probability",
+ "money": 250,
+ "precision_bits": 128,
+ "total_money": 1000,
+ "want": 0
+ },
+ {
+ "cdf_previous_upper": {"exponent": -129, "mantissa": "336977676150904036158535031540153095825"},
+ "cdf_selected_lower": {"exponent": -128, "mantissa": "173445286874191883749825300413014048653"},
+ "digest": "8000000000000000000000000000000000000000000000000000000000000000",
+ "expected_size": 1500,
+ "label": "realistic half stake median",
+ "money": 1000000000000000,
+ "precision_bits": 128,
+ "total_money": 2000000000000000,
+ "want": 750
+ },
+ {
+ "cdf_previous_upper": {"exponent": 0, "mantissa": "0"},
+ "cdf_selected_lower": {"exponent": -128, "mantissa": "340282349906820542765149466846391461685"},
+ "digest": "8000000000000000000000000000000000000000000000000000000000000000",
+ "expected_size": 5000,
+ "label": "base minimum ordinary ratio",
+ "money": 100000,
+ "precision_bits": 128,
+ "total_money": 10000000000000000,
+ "want": 0
+ },
+ {
+ "cdf_previous_upper": {"exponent": -120, "mantissa": "1329227995776608823028674855379832097"},
+ "cdf_selected_lower": {"exponent": -128, "mantissa": "340282366920933148852584800746384209155"},
+ "digest": "fffffffffeffffffffffffffffffffffffffffffffffffffffffffffffffffff",
+ "expected_size": 5000,
+ "label": "payout minimum upper tail",
+ "money": 30000000000,
+ "precision_bits": 128,
+ "total_money": 10000000000000000,
+ "want": 5
+ },
+ {
+ "cdf_previous_upper": {"exponent": -255, "mantissa": "57896044618658097653266946477002718428901149743768787149754063757417995575359"},
+ "cdf_selected_lower": {"exponent": -256, "mantissa": "115792089237316195329040965944463479586722667708360581904416723911341536385337"},
+ "digest": "ffffffffffffffefffffffffffffffffffffffffffffffffffffffffffffffff",
+ "expected_size": 1500,
+ "label": "online stake 1500 tail",
+ "money": 2000000000000000,
+ "precision_bits": 256,
+ "total_money": 2000000000000000,
+ "want": 1852
+ },
+ {
+ "cdf_previous_upper": {"exponent": -254, "mantissa": "28948022309329048449299151382105534347536068724501085061549015824078971801291"},
+ "cdf_selected_lower": {"exponent": -253, "mantissa": "14474011154664524245255387440208678493560896567140995207334418326168362551413"},
+ "digest": "fffffffffffffeffffffffffffffffffffffffffffffffffffffffffffffffff",
+ "expected_size": 6000,
+ "label": "online stake 6000 tail",
+ "money": 2000000000000000,
+ "precision_bits": 256,
+ "total_money": 2000000000000000,
+ "want": 6667
+ },
+ {
+ "cdf_previous_upper": {"exponent": -1023, "mantissa": "89884656743115795385242970878554523733564596984016408477625334557586105188621702535322718752296048392454972761686449997199852199991795019341736990637396753510447422984243425760068850563107190941993659593943613760903625081088191641240624303691268980002798728649789643649421203935109649491495961034724279304257"},
+ "cdf_selected_lower": {"exponent": -1023, "mantissa": "89884656743115795385388413133669867415371296882976122087520179112241806050593573000179506316104827266705484847876967722941298388590715560014325342305736617517667199330567254403677229988571370026180049092144739446189306956680122827434420356103071786941032054663434032334314616392702364816670890868039570748871"},
+ "digest": "ffffffffffffffffbfffffffffffffffffffffffffffffffffffffffffffffff",
+ "expected_size": 5000,
+ "label": "supply ceiling 5000 tail",
+ "money": 10000000000000000,
+ "precision_bits": 1024,
+ "total_money": 10000000000000000,
+ "want": 5667
+ },
+ {
+ "cdf_previous_upper": {"exponent": -128, "mantissa": "340282366920938462250232204093755176997"},
+ "cdf_selected_lower": {"exponent": -126, "mantissa": "85070591730234615630680912901280710237"},
+ "digest": "ffffffffffffffbfffffffffffffffffffffffffffffffffffffffffffffffff",
+ "expected_size": 2990,
+ "label": "one third supply 2990 tail",
+ "money": 3333333333333333,
+ "precision_bits": 128,
+ "total_money": 10000000000000000,
+ "want": 1281
+ },
+ {
+ "cdf_previous_upper": {"exponent": -512, "mantissa": "13407807929942495810394297316189667165625824202373296128999426718380328133165363386009866257047385263178159879922554263125227642700345201700995329471586391"},
+ "cdf_selected_lower": {"exponent": -512, "mantissa": "13407807929942597033636683745655549224439377075387229830050959142212430928372199307480576084781478841759129035867135163847950930971121237959356775927134835"},
+ "digest": "ffffffffffffbfffffffffffffffffffffffffffffffffffffffffffffffffff",
+ "expected_size": 1,
+ "label": "domain edge tiny probability",
+ "money": 72057594037927935,
+ "precision_bits": 512,
+ "total_money": 18446744073709551615,
+ "want": 5
+ },
+ {
+ "cdf_previous_upper": {"exponent": -129, "mantissa": "267764159092786813970757076638904795559"},
+ "cdf_selected_lower": {"exponent": 0, "mantissa": "1"},
+ "digest": "8000000000000000000000000000000000000000000000000000000000000000",
+ "expected_size": 6000,
+ "label": "probability near one",
+ "money": 3000,
+ "precision_bits": 128,
+ "total_money": 6001,
+ "want": 3000
+ },
+ {
+ "cdf_previous_upper": {"exponent": -129, "mantissa": "335325780441179917990356248262715260671"},
+ "cdf_selected_lower": {"exponent": -128, "mantissa": "172619476700348505274287757720384776963"},
+ "digest": "8000000000000000000000000000000000000000000000000000000000000000",
+ "expected_size": 9223372036854775807,
+ "label": "large balanced probability",
+ "money": 3000,
+ "precision_bits": 128,
+ "total_money": 18446744073709551615,
+ "want": 1500
+ }
+ ]
+}
diff --git a/tools/generate_arb_oracle.py b/tools/generate_arb_oracle.py
new file mode 100644
index 0000000..4b14f39
--- /dev/null
+++ b/tools/generate_arb_oracle.py
@@ -0,0 +1,188 @@
+#!/usr/bin/env python3
+"""Generate rigorous large-money binomial-CDF certificates with Arb.
+
+This is deliberately an offline oracle, not a dependency of `go test`.
+python-flint exposes Arb ball arithmetic; its regularized incomplete beta
+implementation is structurally independent of SelectF128's q^money-seeded
+forward PMF recurrence. Each emitted vector carries dyadic Arb endpoints that
+prove, using exact integer comparison,
+
+ CDF(j-1) < digest/(2^256-1) <= CDF(j).
+
+Setup and regenerate from the repository root:
+
+ python3 -m venv .venv-oracle
+ .venv-oracle/bin/pip install -r tools/requirements-oracle.txt
+ .venv-oracle/bin/python tools/generate_arb_oracle.py \
+ > testdata/f128_arb_certificates.json
+"""
+
+import argparse
+import json
+import sys
+import time
+from dataclasses import dataclass
+
+import flint
+from flint import arb, ctx, fmpq
+
+
+DIGEST_DEN = (1 << 256) - 1
+MAX_MONEY = 1 << 56
+PRECISIONS = (128, 256, 512, 1024, 2048, 4096)
+
+
+@dataclass(frozen=True)
+class Case:
+ label: str
+ money: int
+ total: int
+ expected: int
+ digest: int
+
+
+def max_minus_power(bit: int) -> int:
+ return DIGEST_DEN - (1 << bit)
+
+
+CASES = (
+ Case("toy symmetric median", 100, 200, 100, 1 << 255),
+ Case("toy low probability", 250, 1000, 1, DIGEST_DEN // 3),
+ Case("realistic half stake median", 1_000_000_000_000_000, 2_000_000_000_000_000, 1500, 1 << 255),
+ Case("base minimum ordinary ratio", 100_000, 10_000_000_000_000_000, 5000, 1 << 255),
+ Case("payout minimum upper tail", 30_000_000_000, 10_000_000_000_000_000, 5000, max_minus_power(216)),
+ Case("online stake 1500 tail", 2_000_000_000_000_000, 2_000_000_000_000_000, 1500, max_minus_power(196)),
+ Case("online stake 6000 tail", 2_000_000_000_000_000, 2_000_000_000_000_000, 6000, max_minus_power(200)),
+ Case("supply ceiling 5000 tail", 10_000_000_000_000_000, 10_000_000_000_000_000, 5000, max_minus_power(190)),
+ Case("one third supply 2990 tail", 10_000_000_000_000_000 // 3, 10_000_000_000_000_000, 2990, max_minus_power(198)),
+ Case("domain edge tiny probability", MAX_MONEY - 1, DIGEST_DEN >> 192, 1, max_minus_power(206)),
+ Case("probability near one", 3000, 6001, 6000, 1 << 255),
+ Case("large balanced probability", 3000, DIGEST_DEN >> 192, (DIGEST_DEN >> 192) // 2, 1 << 255),
+)
+
+
+def dyadic(x: arb) -> tuple[int, int]:
+ """Return the exact dyadic point x as (integer mantissa, exponent)."""
+ mantissa, exponent = x.man_exp()
+ return int(mantissa), int(exponent)
+
+
+def compare_rat_dyadic(num: int, den: int, point: tuple[int, int]) -> int:
+ """Compare num/den with mantissa*2^exponent using integer arithmetic."""
+ mantissa, exponent = point
+ if exponent >= 0:
+ left, right = num, (mantissa << exponent) * den
+ else:
+ left, right = num << (-exponent), mantissa * den
+ return (left > right) - (left < right)
+
+
+def cdf_ball(case: Case, j: int, precision: int) -> arb:
+ ctx.prec = precision
+ if j < 0:
+ return arb(0)
+ if j >= case.money:
+ return arb(1)
+ q = arb(fmpq(case.total - case.expected, case.total))
+ return q.beta_lower(case.money - j, j + 1, regularized=True)
+
+
+def classify(case: Case, j: int) -> bool:
+ """Return whether exact ratio <= CDF(j), raising precision as needed."""
+ for precision in PRECISIONS:
+ cdf = cdf_ball(case, j, precision)
+ lower, upper = dyadic(cdf.lower()), dyadic(cdf.upper())
+ if compare_rat_dyadic(case.digest, DIGEST_DEN, lower) <= 0:
+ return True
+ if compare_rat_dyadic(case.digest, DIGEST_DEN, upper) > 0:
+ return False
+ raise RuntimeError(f"could not separate ratio from CDF({j}) for {case.label}")
+
+
+def select(case: Case) -> int:
+ if not 0 < case.expected < case.total:
+ raise ValueError(f"certificate case must have 0 < expected < total: {case.label}")
+ if not 0 < case.digest <= DIGEST_DEN:
+ raise ValueError(f"certificate case must have 0 < digest <= max: {case.label}")
+ # Do not binary-search the entire [0,n] interval immediately. For the
+ # protocol-shaped tiny probabilities, asking incomplete beta for j near
+ # n/2 is vastly harder than evaluating it around the committee-scale
+ # quantile. Bracket exponentially from the nearer tail first.
+ if 2 * case.expected <= case.total:
+ if classify(case, 0):
+ return 0
+ lo, hi = 0, 1
+ while hi < case.money and not classify(case, hi):
+ lo, hi = hi, min(case.money, 2 * hi)
+ else:
+ if not classify(case, case.money - 1):
+ return case.money
+ hi, delta = case.money - 1, 2
+ lo = max(-1, case.money - delta)
+ while lo >= 0 and classify(case, lo):
+ hi, delta = lo, 2 * delta
+ lo = max(-1, case.money - delta)
+
+ while hi - lo > 1:
+ mid = lo + (hi - lo) // 2
+ if classify(case, mid):
+ hi = mid
+ else:
+ lo = mid
+ return hi
+
+
+def certify(case: Case, selected: int) -> dict:
+ for precision in PRECISIONS:
+ previous = cdf_ball(case, selected - 1, precision).upper()
+ current = cdf_ball(case, selected, precision).lower()
+ previous_dyadic, current_dyadic = dyadic(previous), dyadic(current)
+ if (
+ compare_rat_dyadic(case.digest, DIGEST_DEN, previous_dyadic) > 0
+ and compare_rat_dyadic(case.digest, DIGEST_DEN, current_dyadic) <= 0
+ ):
+ return {
+ "label": case.label,
+ "money": case.money,
+ "total_money": case.total,
+ "expected_size": case.expected,
+ "digest": f"{case.digest:064x}",
+ "want": selected,
+ "precision_bits": precision,
+ "cdf_previous_upper": {
+ "mantissa": str(previous_dyadic[0]),
+ "exponent": previous_dyadic[1],
+ },
+ "cdf_selected_lower": {
+ "mantissa": str(current_dyadic[0]),
+ "exponent": current_dyadic[1],
+ },
+ }
+ raise RuntimeError(f"could not certify selected boundary for {case.label}")
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--case", help="generate only the case whose label contains this text")
+ args = parser.parse_args()
+ cases = tuple(case for case in CASES if args.case is None or args.case in case.label)
+ if not cases:
+ parser.error("--case did not match any vector")
+
+ vectors = []
+ for case in cases:
+ started = time.monotonic()
+ print(f"certifying {case.label}...", file=sys.stderr, flush=True)
+ vectors.append(certify(case, select(case)))
+ print(f"certified {case.label} in {time.monotonic() - started:.3f}s", file=sys.stderr, flush=True)
+ output = {
+ "format": 1,
+ "generator": f"python-flint {flint.__version__} / Arb regularized incomplete beta",
+ "semantics": "exact binomial CDF; frozen-tail definition is tested separately",
+ "vectors": vectors,
+ }
+ print(json.dumps(output, indent=2, sort_keys=True))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/requirements-oracle.txt b/tools/requirements-oracle.txt
new file mode 100644
index 0000000..638065c
--- /dev/null
+++ b/tools/requirements-oracle.txt
@@ -0,0 +1 @@
+python-flint==0.6.0
From 288b27001cdf76c5696f8bc7042520ee792320c8 Mon Sep 17 00:00:00 2001
From: cce <51567+cce@users.noreply.github.com>
Date: Tue, 21 Jul 2026 15:23:55 -0400
Subject: [PATCH 32/37] test: harden f128 oracle and liveness coverage
Add exact, multiprecision, trajectory, metamorphic, and mutation checks for the f128 CDF walk.
Schedule certified Arb corpus verification and document the testing layers and developer commands in TESTING.md.
---
.github/workflows/oracle.yml | 28 +++
.github/workflows/test.yml | 6 +-
README.md | 3 +
TESTING.md | 95 ++++++++++
f128_exact_test.go | 57 +++++-
f128_rapid_test.go | 79 ++++++++
f128_test.go | 10 +-
f128_trajectory_test.go | 353 +++++++++++++++++++++++++++++++++++
mutation_check.go | 136 ++++++++++++--
oracle_hardening_test.go | 147 +++++++++++++++
tools/generate_arb_oracle.py | 39 +++-
11 files changed, 922 insertions(+), 31 deletions(-)
create mode 100644 .github/workflows/oracle.yml
create mode 100644 TESTING.md
create mode 100644 f128_trajectory_test.go
create mode 100644 oracle_hardening_test.go
diff --git a/.github/workflows/oracle.yml b/.github/workflows/oracle.yml
new file mode 100644
index 0000000..a08533e
--- /dev/null
+++ b/.github/workflows/oracle.yml
@@ -0,0 +1,28 @@
+name: Regenerate numerical oracle
+
+on:
+ schedule:
+ # The full supply-scale Arb corpus takes roughly two minutes. Running it
+ # nightly catches certificate/generator drift without burdening every PR.
+ - cron: "17 5 * * *"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ arb-certificates:
+ name: Verify Arb certificates
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-python@v6
+ with:
+ python-version: "3.11"
+ cache: pip
+ cache-dependency-path: tools/requirements-oracle.txt
+ - name: Install python-flint
+ run: python -m pip install -r tools/requirements-oracle.txt
+ - name: Regenerate and compare certified quantiles
+ run: python tools/generate_arb_oracle.py --check testdata/f128_arb_certificates.json
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index a063492..c5bd5e9 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -12,9 +12,9 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
- # Both architectures on each OS. TestGolden pins exact outputs, so a
- # floating-point divergence in the cgo/Boost CDF between these targets
- # shows up here as a failure rather than a silent consensus split.
+ # Both architectures on each OS. Exact-output tests pin both paths, so
+ # a floating-point divergence in the cgo/Boost CDF between these
+ # targets shows up as a failure rather than a silent consensus split.
os:
- ubuntu-latest # linux/amd64
- ubuntu-24.04-arm # linux/arm64
diff --git a/README.md b/README.md
index ada67b5..e8b1cfe 100644
--- a/README.md
+++ b/README.md
@@ -5,6 +5,9 @@ This package provides an implementation of cryptographic sortition used by [go-a
Please visit the [go-algorand README](https://github.com/algorand/go-algorand/blob/master/README.md) for more information on building and using this software.
+See [TESTING.md](TESTING.md) for the testing strategy, oracle layers, and
+developer runbook for the deterministic sortition implementation.
+
## License
Please see the [COPYING_FAQ](COPYING_FAQ) for details about how to apply our license.
diff --git a/TESTING.md b/TESTING.md
new file mode 100644
index 0000000..89351a3
--- /dev/null
+++ b/TESTING.md
@@ -0,0 +1,95 @@
+# Testing sortition
+
+The test suite combines bit-identical differential testing, independent exact
+mathematics, metamorphic properties, certified offline vectors, and mutation
+testing. No single oracle is treated as sufficient for the consensus-critical
+`SelectF128` path.
+
+## Common commands
+
+```sh
+# Complete ordinary suite
+go test -count=1 ./...
+
+# Property tests at the depth used by CI
+go test -run TestRapid -rapid.checks=20000 -count=1 ./...
+
+# Curated production and test-oracle mutation campaign
+go run mutation_check.go
+
+# Longer fuzzing runs
+go test -run xxx -fuzz FuzzF128Ops -fuzztime 10m
+go test -run xxx -fuzz FuzzSelectF128 -fuzztime 10m
+```
+
+## Assurance layers
+
+| Approach | Purpose | Location |
+|---|---|---|
+| Ordinary and compatibility tests | Basic sortition behavior, benchmarks, agreement with the deployed C++/Boost path, defined frozen-tail behavior, and current-scale regression cases | `sortition_test.go`, `f128_test.go` |
+| Bit-identical 128-bit oracle | Mirrors the production CDF walk with 128-bit `math/big.Float`; detects implementation defects in the hand-written integer arithmetic | `selectBigOracle` and related tests in `f128_test.go` |
+| Primitive differential fuzzing | Checks f128 arithmetic against `big.Float`, with constructed tie/carry seeds and magnitude-banded rapid generators | `FuzzF128Ops` in `f128_test.go`, `TestRapidF128Ops` in `f128_rapid_test.go` |
+| Exact-integer RNE oracle | Computes normalization and round-to-nearest-even solely with `big.Int` quotient/remainder arithmetic, avoiding shared reliance on `big.Float` | `f128_integer_oracle_test.go` |
+| Division certificates | Proves sampled `divStep` digits satisfy `U = q*V + R` and `0 <= R < V`, including quotient-estimate corner paths | `TestDivStepExactCertificate` in `f128_integer_oracle_test.go` |
+| Exact binomial formula | Builds the CDF from binomial coefficients and integer cross-products rather than the production PMF recurrence | `f128_exact_test.go`, `cdf_exhaustive_test.go` |
+| Exhaustive boundary grids | Enumerates small parameter domains and checks digest integers below, at, and above every exact CDF boundary, with anti-vacuity counters | `cdf_exhaustive_test.go`, `oracle_hardening_test.go` |
+| Higher-precision convergence | Requires the 512- and 1024-bit CDF walks to converge and compares them with exact small-domain results and certified large-money results | `selectAtPrecision` in `f128_exact_test.go`, tests in `oracle_hardening_test.go` |
+| Internal trajectory and liveness | Checks PMF/CDF error envelopes and monotonicity, audits the maximum-domain exponent path, proves bounded freeze permanence, and pins CDF-evaluation limits | `f128_trajectory_test.go` |
+| Metamorphic properties | Checks digest monotonicity, power-of-two and arbitrary common-factor probability scaling, primitive identities, and arithmetic order properties without a numeric oracle | `f128_rapid_test.go` |
+| Distribution sanity | Checks aggregate selection weight against the expected binomial mean without reusing the CDF formula | `TestSelectF128Distribution` in `f128_exact_test.go` |
+| Arb-certified quantiles | Uses Arb's regularized incomplete beta implementation to certify large-money quantile inequalities with rigorous dyadic endpoints | `tools/generate_arb_oracle.py`, `testdata/f128_arb_certificates.json`, `f128_arb_certificate_test.go` |
+| Mutation testing | Applies curated bugs to production code and in-process test oracles; every non-equivalent mutant must be killed | `mutation_check.go` |
+
+## Arb corpus
+
+The Arb generator is an offline tool and is not a dependency of ordinary
+`go test`. Set it up in an ignored virtual environment:
+
+```sh
+python3 -m venv .venv-oracle
+.venv-oracle/bin/pip install -r tools/requirements-oracle.txt
+```
+
+Regenerate the corpus or verify it without rewriting the checked-in file:
+
+```sh
+.venv-oracle/bin/python tools/generate_arb_oracle.py \
+ > testdata/f128_arb_certificates.json
+
+.venv-oracle/bin/python tools/generate_arb_oracle.py \
+ --check testdata/f128_arb_certificates.json
+```
+
+The Go certificate test exactly compares the stored dyadic endpoints with the
+digest ratio. Trust in the external tool is limited to Arb's assertion that
+those endpoints enclose the true incomplete-beta CDF.
+
+## Important test semantics
+
+- The near-one frozen-tail sliver is defined to return `money`. Exact-math
+ tolerance tests exclude that interval deliberately; dedicated tests pin its
+ behavior and liveness at current-scale values.
+- `money` must remain below `SelectF128MaxMoney`. The maximum-domain exponent
+ audit checks the worst representable `1-p` combination without performing a
+ supply-sized walk.
+- Large-money in-process oracles have a committee-scale step budget. A broken
+ reference must fail loudly instead of hanging while attempting trillions of
+ iterations.
+- Randomized tests use deterministic seeds. Measure-zero events such as exact
+ ties and inclusive boundaries are constructed explicitly rather than left
+ to sampling.
+- Tests with skip or clamp paths need anti-vacuity counts or mandatory spot
+ cases proving that meaningful assertions ran.
+
+## Continuous integration
+
+- `.github/workflows/test.yml` runs builds, ordinary tests, and 20,000-check
+ rapid properties across Linux amd64/arm64, macOS amd64/arm64, and Windows
+ amd64. It also runs the mutation campaign on Linux.
+- `.github/workflows/oracle.yml` regenerates and compares the complete Arb
+ corpus nightly and on manual dispatch.
+
+When arithmetic or recurrence semantics change, update the relevant
+independent oracle and exact/certified vectors deliberately, then rerun the
+full suite and mutation campaign. A changed knife-edge pin is evidence that
+the numerical trajectory changed and should be explained in the commit.
diff --git a/f128_exact_test.go b/f128_exact_test.go
index 5061464..4fd218f 100644
--- a/f128_exact_test.go
+++ b/f128_exact_test.go
@@ -30,11 +30,10 @@ import (
// frozen-tail sliver, and must agree with the oracle bit-for-bit at
// deliberately constructed knife-edge digests.
-// selectHighPrec runs the binomial-CDF walk at 512-bit precision with the
-// near-exact digest ratio. It is a reference for tolerance checking, not a
-// bit-identical mirror of SelectF128.
-func selectHighPrec(money, totalMoney, expectedSize uint64, vrfOutput Digest) uint64 {
- const prec = 512
+// selectAtPrecision runs the binomial-CDF walk at the requested big.Float
+// precision. Multiple precisions are cross-checked below so the 512-bit
+// reference is not trusted merely because it has a larger mantissa.
+func selectAtPrecision(money, totalMoney, expectedSize uint64, vrfOutput Digest, prec uint) uint64 {
ratio := digestRatioBig(vrfOutput, prec)
if expectedSize >= totalMoney { // p >= 1
if ratio.Sign() <= 0 {
@@ -53,7 +52,7 @@ func selectHighPrec(money, totalMoney, expectedSize uint64, vrfOutput Digest) ui
if cdf.Cmp(ratio) >= 0 {
return 0
}
- for j := uint64(1); j < money; j++ {
+ for j := uint64(1); j < money && j <= testOracleMaxCDFSteps; j++ {
factor := new(big.Float).SetPrec(prec).Quo(
new(big.Float).SetPrec(prec).SetUint64(money-j+1),
new(big.Float).SetPrec(prec).SetUint64(j))
@@ -63,9 +62,17 @@ func selectHighPrec(money, totalMoney, expectedSize uint64, vrfOutput Digest) ui
return j
}
}
+ if money > testOracleMaxCDFSteps {
+ panic("selectAtPrecision exceeded the test oracle step budget")
+ }
return money
}
+// selectHighPrec runs the tolerance oracle at its ordinary 512-bit precision.
+func selectHighPrec(money, totalMoney, expectedSize uint64, vrfOutput Digest) uint64 {
+ return selectAtPrecision(money, totalMoney, expectedSize, vrfOutput, 512)
+}
+
// inFrozenSliver reports whether the digest ratio is within the carved-out
// near-1.0 region where the 128-bit walk's answer is DEFINED as money (see
// the SelectF128 doc comment) and tolerance against exact math does not
@@ -239,6 +246,24 @@ func TestSelectF128VsExactRat(t *testing.T) {
}
}
+// TestSelectF128ExactRatInclusiveBoundary constructs a digest ratio exactly
+// equal to CDF(0): for Binomial(1, 1/3), CDF(0)=2/3 and 3 divides 2^256-1.
+// Sampling cannot distinguish <= from < at this measure-zero boundary.
+func TestSelectF128ExactRatInclusiveBoundary(t *testing.T) {
+ den := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1))
+ at := new(big.Int).Quo(new(big.Int).Mul(new(big.Int).Set(den), big.NewInt(2)), big.NewInt(3))
+ if new(big.Int).Mod(new(big.Int).Set(den), big.NewInt(3)).Sign() != 0 {
+ t.Fatal("test construction requires 3 to divide 2^256-1")
+ }
+ for offset, want := range map[int64]uint64{-1: 0, 0: 0, 1: 1} {
+ point := new(big.Int).Add(new(big.Int).Set(at), big.NewInt(offset))
+ d := digestFromBigInt(t, point, den)
+ if got := selectExactRat(1, 3, 1, d); got != want {
+ t.Fatalf("offset %d: exact selector=%d, want %d", offset, got, want)
+ }
+ }
+}
+
// TestSelectF128Distribution mirrors TestSortitionBasic for the pure-Go path:
// summed selection weight over many digests must track N * money * p. This is
// oracle-free AND formula-free -- a gross semantic error (wrong probability,
@@ -299,6 +324,7 @@ func TestSelectF128BoundaryStraddle(t *testing.T) {
rng := rand.New(rand.NewSource(8))
den := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1))
denF := new(big.Float).SetPrec(600).SetInt(den)
+ var windows, bracketed, clampedWindows, candidates uint64
for iter := 0; iter < 150; iter++ {
money := 2 + rng.Uint64()%250
@@ -306,6 +332,7 @@ func TestSelectF128BoundaryStraddle(t *testing.T) {
expected := rng.Uint64() % total // p < 1
boundaries := []uint64{0, money / 2, money - 1, rng.Uint64() % money}
for _, j := range boundaries {
+ windows++
c := oracleCDFAt(money, total, expected, j)
// One f128 ulp at c's own magnitude, in digest units: the ulp is
// 2^(exp-128) for c in [2^(exp-1), 2^exp), so it scales with the
@@ -317,6 +344,14 @@ func TestSelectF128BoundaryStraddle(t *testing.T) {
if step.Sign() == 0 {
step.SetInt64(1)
}
+ ulp := new(big.Float).SetMantExp(new(big.Float).SetPrec(600).SetInt64(1), c.MantExp(nil)-128)
+ wantStep, _ := new(big.Float).SetPrec(600).Mul(ulp, denF).Int(nil)
+ if wantStep.Sign() == 0 {
+ wantStep.SetInt64(1)
+ }
+ if step.Cmp(wantStep) != 0 {
+ t.Fatalf("straddle step %s is not one f128 ulp (%s) at boundary j=%d", step, wantStep, j)
+ }
tt, _ := new(big.Float).SetPrec(600).Mul(c, denF).Int(nil)
prev := uint64(0)
first := true
@@ -328,6 +363,7 @@ func TestSelectF128BoundaryStraddle(t *testing.T) {
clamped = true
continue
}
+ candidates++
var d Digest
ti.FillBytes(d[:])
got := SelectF128(money, total, expected, d)
@@ -354,6 +390,15 @@ func TestSelectF128BoundaryStraddle(t *testing.T) {
t.Fatalf("straddle window does not bracket boundary j=%d: [%v, %v] vs cdf=%v (money=%d total=%d expected=%d)",
j, loRatio, hiRatio, c, money, total, expected)
}
+ if clamped {
+ clampedWindows++
+ } else {
+ bracketed++
+ }
}
}
+ if windows != 600 || bracketed < 400 || candidates < 2_000 || bracketed+clampedWindows != windows {
+ t.Fatalf("anti-vacuity: windows=%d bracketed=%d clamped=%d candidates=%d",
+ windows, bracketed, clampedWindows, candidates)
+ }
}
diff --git a/f128_rapid_test.go b/f128_rapid_test.go
index 8db3fbf..cf19e2f 100644
--- a/f128_rapid_test.go
+++ b/f128_rapid_test.go
@@ -188,6 +188,85 @@ func TestRapidSelectF128ScaleInvariance(t *testing.T) {
})
}
+// TestRapidSelectF128CommonFactorInvariance strengthens the power-of-two
+// property above. An arbitrary common factor changes the integer mantissas
+// presented to div, but preserves (T-E)/T and E/(T-E) exactly; both the
+// initialized distribution and final selection must therefore be identical.
+func TestRapidSelectF128CommonFactorInvariance(t *testing.T) {
+ rapid.Check(t, func(t *rapid.T) {
+ money := rapid.Uint64Range(0, 3000).Draw(t, "money")
+ factor := rapid.Uint64Range(2, 1<<20).Draw(t, "factor")
+ maxBase := ^uint64(0) / factor
+ if maxBase > 1_000_000_000_000_000 {
+ maxBase = 1_000_000_000_000_000
+ }
+ total := rapid.Uint64Range(1, maxBase).Draw(t, "total")
+ expected := rapid.Uint64Range(0, total).Draw(t, "expected")
+ var d Digest
+ copy(d[:], rapid.SliceOfN(rapid.Byte(), DigestSize, DigestSize).Draw(t, "vrf"))
+
+ baseDist := newBinomialF128(expected, total, money)
+ scaledDist := newBinomialF128(expected*factor, total*factor, money)
+ if (baseDist == nil) != (scaledDist == nil) {
+ t.Fatalf("common factor changed degenerate classification: T=%d E=%d k=%d", total, expected, factor)
+ }
+ if baseDist != nil && (baseDist.pq != scaledDist.pq || baseDist.pmf != scaledDist.pmf || baseDist.cum != scaledDist.cum) {
+ t.Fatalf("common factor changed initialized distribution: T=%d E=%d k=%d", total, expected, factor)
+ }
+ base := SelectF128(money, total, expected, d)
+ scaled := SelectF128(money, total*factor, expected*factor, d)
+ if base != scaled {
+ t.Fatalf("common-factor variance: SelectF128=%d but factor %d gives %d (money=%d total=%d expected=%d vrf=%x)",
+ base, factor, scaled, money, total, expected, d)
+ }
+ })
+}
+
+// TestRapidF128Metamorphic checks algebraic and order contracts without any
+// floating-point oracle. These overlap the exact-integer differential test on
+// purpose: corruption of either oracle cannot make an identity or monotonicity
+// failure disappear.
+func TestRapidF128Metamorphic(t *testing.T) {
+ rapid.Check(t, func(t *rapid.T) {
+ makeValue := func(label string) f128 {
+ return f128{
+ hi: rapid.Uint64().Draw(t, label+"Hi") | 1<<63,
+ lo: rapid.Uint64().Draw(t, label+"Lo"),
+ exp: rapid.Int64Range(-2000, 2000).Draw(t, label+"Exp"),
+ }
+ }
+ a, b, c, d := makeValue("a"), makeValue("b"), makeValue("c"), makeValue("d")
+ zero, one := f128{}, f128FromUint64(1)
+ if a.add(zero) != a || a.mul(one) != a || a.divU(1) != a || a.div(one) != a {
+ t.Fatalf("primitive identity failed for a=%+v", a)
+ }
+ if got := a.div(a); got != one {
+ t.Fatalf("self division=%+v, want one=%+v", got, one)
+ }
+ shift := rapid.Int64Range(-1000, 1000).Draw(t, "powerShift")
+ power := one
+ power.exp += shift
+ wantShift := a
+ wantShift.exp += shift
+ if got := a.mul(power); got != wantShift {
+ t.Fatalf("power-of-two shift=%+v, want %+v (a=%+v shift=%d)", got, wantShift, a, shift)
+ }
+
+ if a.cmp(b) > 0 {
+ a, b = b, a
+ }
+ if a.add(c).cmp(b.add(c)) > 0 || a.mul(c).cmp(b.mul(c)) > 0 || a.div(c).cmp(b.div(c)) > 0 {
+ t.Fatalf("numerator monotonicity failed: a=%+v b=%+v c=%+v", a, b, c)
+ }
+ if c.cmp(d) > 0 {
+ c, d = d, c
+ }
+ if a.div(c).cmp(a.div(d)) < 0 {
+ t.Fatalf("division denominator antitonicity failed: a=%+v c=%+v d=%+v", a, c, d)
+ }
+ })
+}
+
// TestRapidSelectF128DigestMonotonic checks an ORACLE-INDEPENDENT property:
// for fixed (money, total, expected), the selection count is non-decreasing in
// the digest. This holds exactly -- the digest-to-ratio conversion is monotone
diff --git a/f128_test.go b/f128_test.go
index 67127e0..296ed48 100644
--- a/f128_test.go
+++ b/f128_test.go
@@ -23,6 +23,11 @@ import (
"testing"
)
+// Test oracles only exercise committee-scale quantiles at large money. A
+// broken oracle must fail instead of attempting a supply-sized fall-through;
+// small-money exhaustive and fuzz cases remain allowed to walk to money.
+const testOracleMaxCDFSteps = uint64(20_000)
+
// selectBigOracle is an independent, "obviously correct" reference for SelectF128:
// the SAME binomial-CDF walk, but every arithmetic step uses math/big.Float (Go's
// standard arbitrary-precision float, round-to-nearest-even) at the f128 mantissa
@@ -60,7 +65,7 @@ func selectBigOracle(money uint64, totalMoney uint64, expectedSize uint64, vrfOu
if cdf.Cmp(ratio) >= 0 {
return 0
}
- for j := uint64(1); j < money; j++ {
+ for j := uint64(1); j < money && j <= testOracleMaxCDFSteps; j++ {
factor := new(big.Float).SetPrec(prec).Quo(
new(big.Float).SetPrec(prec).SetUint64(money-j+1),
new(big.Float).SetPrec(prec).SetUint64(j))
@@ -71,6 +76,9 @@ func selectBigOracle(money uint64, totalMoney uint64, expectedSize uint64, vrfOu
return j
}
}
+ if money > testOracleMaxCDFSteps {
+ panic("selectBigOracle exceeded the test oracle step budget")
+ }
return money
}
diff --git a/f128_trajectory_test.go b/f128_trajectory_test.go
new file mode 100644
index 0000000..e05dd9b
--- /dev/null
+++ b/f128_trajectory_test.go
@@ -0,0 +1,353 @@
+// Copyright (C) 2019-2023 Algorand, Inc.
+// This file is part of go-algorand
+//
+// go-algorand is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// go-algorand is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with go-algorand. If not, see .
+
+package sortition
+
+import (
+ "math/big"
+ "testing"
+)
+
+// auditedMul performs the same multiplication used by intPow while proving
+// with unbounded integers that both possible normalization exponents fit in
+// int64. Checking both +127 and +128 is conservative and avoids duplicating
+// mul's partial-product branch decision in the test.
+func auditedMul(t *testing.T, a, b f128, minExp, maxExp *int64) f128 {
+ t.Helper()
+ if !a.isZero() && !b.isZero() {
+ for _, normalization := range []int64{127, 128} {
+ e := new(big.Int).SetInt64(a.exp)
+ e.Add(e, new(big.Int).SetInt64(b.exp))
+ e.Add(e, new(big.Int).SetInt64(normalization))
+ if !e.IsInt64() {
+ t.Fatalf("mul exponent overflows int64: %d + %d + %d = %s", a.exp, b.exp, normalization, e)
+ }
+ }
+ }
+ out := a.mul(b)
+ assertCanonicalF128(t, "audited mul", out)
+ if !out.isZero() {
+ if out.exp < *minExp {
+ *minExp = out.exp
+ }
+ if out.exp > *maxExp {
+ *maxExp = out.exp
+ }
+ }
+ return out
+}
+
+func auditedIntPow(t *testing.T, base f128, e uint64) (f128, int64, int64) {
+ t.Helper()
+ minExp, maxExp := base.exp, base.exp
+ result, b := f128FromUint64(1), base
+ for e > 0 {
+ if e&1 == 1 {
+ result = auditedMul(t, result, b, &minExp, &maxExp)
+ }
+ e >>= 1
+ if e > 0 {
+ b = auditedMul(t, b, b, &minExp, &maxExp)
+ }
+ }
+ return result, minExp, maxExp
+}
+
+// TestSelectF128MaxDomainExponentAudit exercises the documented worst case:
+// money is one below the exported limit and 1-p is the smallest positive
+// rational representable by the uint64 API. The test audits every exponent
+// addition in exponentiation-by-squaring with big.Int, so a future bound or
+// normalization change fails before platform-independent int64 arithmetic can
+// wrap.
+func TestSelectF128MaxDomainExponentAudit(t *testing.T) {
+ const total = ^uint64(0)
+ money := SelectF128MaxMoney - 1
+ expected := total - 1 // 1-p = 1/(2^64-1)
+ q := f128FromUint64(1).div(f128FromUint64(total))
+ pmf, minExp, maxExp := auditedIntPow(t, q, money)
+ dist := newBinomialF128(expected, total, money)
+ if dist == nil || pmf != dist.pmf {
+ t.Fatalf("audited pmf(0)=%+v, constructor produced %+v", pmf, dist)
+ }
+
+ // q is just above 2^-64. Across money < 2^56 its logarithmic correction
+ // is less than one, so floor(log2(q^money)) is exactly -64*money.
+ wantExp := -int64(money<<6) - 127
+ if pmf.exp != wantExp {
+ t.Fatalf("domain-edge pmf exponent=%d, want %d", pmf.exp, wantExp)
+ }
+ if minExp < wantExp || maxExp > 0 {
+ t.Fatalf("unexpected audited exponent range [%d,%d], final lower bound %d", minExp, maxExp, wantExp)
+ }
+ if got := SelectF128(money, total, expected, Digest{}); got != 0 {
+ t.Fatalf("domain-edge ratio zero selected %d, want 0", got)
+ }
+}
+
+// TestSelectF128DegenerateDistributionShortCircuit pins construction, not
+// only the eventual return value. If p==1 accidentally enters the ordinary
+// walk, its all-zero PMF produces the right answer for nonzero digests only
+// after money iterations, recreating the liveness class that freeze prevents.
+func TestSelectF128DegenerateDistributionShortCircuit(t *testing.T) {
+ const money = SelectF128MaxMoney - 1
+ for _, total := range []uint64{0, 1, 6000, ^uint64(0)} {
+ if dist := newBinomialF128(total, total, money); dist != nil {
+ t.Fatalf("p==1 constructed an ordinary distribution (total=%d): %+v", total, dist)
+ }
+ if total != ^uint64(0) {
+ if dist := newBinomialF128(total+1, total, money); dist != nil {
+ t.Fatalf("p>1 constructed an ordinary distribution (total=%d): %+v", total, dist)
+ }
+ }
+ }
+}
+
+type highPrecisionBinomial struct {
+ money uint64
+ prec uint
+ at uint64
+ pq *big.Float
+ pmf *big.Float
+ cum *big.Float
+}
+
+func newHighPrecisionBinomial(money, total, expected uint64, prec uint) *highPrecisionBinomial {
+ q := new(big.Float).SetPrec(prec).Quo(
+ new(big.Float).SetPrec(prec).SetUint64(total-expected),
+ new(big.Float).SetPrec(prec).SetUint64(total))
+ pq := new(big.Float).SetPrec(prec).Quo(
+ new(big.Float).SetPrec(prec).SetUint64(expected),
+ new(big.Float).SetPrec(prec).SetUint64(total-expected))
+ pmf := bigIntPow(q, money, prec)
+ return &highPrecisionBinomial{
+ money: money,
+ prec: prec,
+ pq: pq,
+ pmf: pmf,
+ cum: new(big.Float).SetPrec(prec).Set(pmf),
+ }
+}
+
+func (b *highPrecisionBinomial) advance() {
+ b.at++
+ factor := new(big.Float).SetPrec(b.prec).Quo(
+ new(big.Float).SetPrec(b.prec).SetUint64(b.money-b.at+1),
+ new(big.Float).SetPrec(b.prec).SetUint64(b.at))
+ step := new(big.Float).SetPrec(b.prec).Mul(factor, b.pq)
+ b.pmf = new(big.Float).SetPrec(b.prec).Mul(b.pmf, step)
+ b.cum = new(big.Float).SetPrec(b.prec).Add(b.cum, b.pmf)
+}
+
+func binomialMode(money, total, expected uint64) uint64 {
+ n := new(big.Int).SetUint64(money)
+ n.Add(n, big.NewInt(1))
+ n.Mul(n, new(big.Int).SetUint64(expected))
+ n.Quo(n, new(big.Int).SetUint64(total))
+ return n.Uint64()
+}
+
+func f128AsBig(x f128, prec uint) *big.Float {
+ return new(big.Float).SetPrec(prec).Set(f128ToBig(x))
+}
+
+func absFloatDifference(a, b *big.Float, prec uint) *big.Float {
+ d := new(big.Float).SetPrec(prec).Sub(a, b)
+ return d.Abs(d)
+}
+
+func trajectoryErrorBound(money, at uint64, prec uint) *big.Float {
+ // RNE unit roundoff is 2^-128. Sixty-four ulps per initialization trial
+ // and recurrence index is deliberately conservative: it covers q^money's
+ // amplified input rounding, four rounded recurrence operations per term,
+ // and CDF summation without turning this into an empirical pinned maximum.
+ units := new(big.Int).SetUint64(money)
+ units.Add(units, new(big.Int).SetUint64(32*(at+1)+128))
+ return new(big.Float).SetMantExp(new(big.Float).SetPrec(prec).SetInt(units), -122)
+}
+
+func assertTrajectoryError(t *testing.T, name string, at uint64, got f128, reference, bound *big.Float, relative bool) {
+ t.Helper()
+ const prec = uint(512)
+ diff := absFloatDifference(f128AsBig(got, prec), reference, prec)
+ if relative {
+ if reference.Sign() == 0 {
+ if diff.Sign() != 0 {
+ t.Fatalf("%s(%d): nonzero value against zero reference", name, at)
+ }
+ return
+ }
+ diff.Quo(diff, reference)
+ }
+ if diff.Cmp(bound) > 0 {
+ t.Fatalf("%s(%d) error %s exceeds bound %s", name, at, diff.Text('p', 8), bound.Text('p', 8))
+ }
+}
+
+// TestSelectF128TrajectoryErrorBudget compares every production PMF/CDF state
+// through and beyond the mode with a 512-bit recurrence. Exact/Arb tests cover
+// shared-formula risk; this test instead detects substantial internal drift
+// that happens not to move the sampled final quantile.
+func TestSelectF128TrajectoryErrorBudget(t *testing.T) {
+ tests := []struct {
+ name string
+ money, total, expected uint64
+ maxAt uint64
+ }{
+ {"toy balanced", 256, 512, 256, 255},
+ {"online 1500", 2_000_000_000_000_000, 2_000_000_000_000_000, 1500, 2500},
+ {"one third supply", 10_000_000_000_000_000 / 3, 10_000_000_000_000_000, 2990, 1800},
+ {"payout minimum", 30_000_000_000, 10_000_000_000_000_000, 5000, 120},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ const prec = uint(512)
+ got := newBinomialF128(test.expected, test.total, test.money)
+ want := newHighPrecisionBinomial(test.money, test.total, test.expected, prec)
+ mode := binomialMode(test.money, test.total, test.expected)
+ observed := uint64(0)
+ for at := uint64(0); at <= test.maxAt; at++ {
+ if at > 0 {
+ pmfPrev, cumPrev := got.pmf, got.cum
+ got.cdf(at)
+ want.advance()
+ if got.cum.cmp(cumPrev) < 0 {
+ t.Fatalf("CDF decreased at %d", at)
+ }
+ if at <= mode && got.pmf.cmp(pmfPrev) < 0 {
+ t.Fatalf("PMF decreased before mode %d at %d", mode, at)
+ }
+ if at > mode && got.pmf.cmp(pmfPrev) > 0 {
+ t.Fatalf("PMF increased after mode %d at %d", mode, at)
+ }
+ }
+ bound := trajectoryErrorBound(test.money, at, prec)
+ assertTrajectoryError(t, "pmf", at, got.pmf, want.pmf, bound, true)
+ assertTrajectoryError(t, "cdf", at, got.cum, want.cum, bound, false)
+ observed++
+ if got.frozen {
+ break
+ }
+ }
+ if observed <= mode {
+ t.Fatalf("trajectory stopped after %d states before crossing mode %d", observed, mode)
+ }
+ })
+ }
+}
+
+// TestSelectF128FreezePermanence ignores the production short-circuit after a
+// bounded walk freezes and explicitly advances the recurrence to money-1.
+// Every later PMF must remain non-increasing and every rounded CDF add must be
+// a no-op, proving on the exercised grid that the optimization returns the
+// same answer as the otherwise impractical unshortened walk.
+func TestSelectF128FreezePermanence(t *testing.T) {
+ tests := []struct {
+ money, total, expected uint64
+ }{
+ {256, 512, 256},
+ {512, 1000, 1},
+ {1000, 10_000, 20},
+ {3000, 2_000_000_000_000_000, 1500},
+ }
+ frozenCases, checkedTailSteps := 0, uint64(0)
+ for _, test := range tests {
+ b := newBinomialF128(test.expected, test.total, test.money)
+ for j := uint64(1); j < test.money && !b.frozen; j++ {
+ b.cdf(j)
+ }
+ if !b.frozen {
+ continue
+ }
+ frozenCases++
+ pmf, cum, at := b.pmf, b.cum, b.at
+ for at+1 < test.money {
+ at++
+ pmfNext := f128FromUint64(test.money - at + 1).divU(at).mul(b.pq).mul(pmf)
+ if pmfNext.cmp(pmf) > 0 {
+ t.Fatalf("PMF increased after freeze at %d (money=%d total=%d expected=%d)", at, test.money, test.total, test.expected)
+ }
+ if cumNext := cum.add(pmfNext); cumNext != cum {
+ t.Fatalf("CDF changed after freeze at %d (money=%d total=%d expected=%d)", at, test.money, test.total, test.expected)
+ }
+ pmf = pmfNext
+ checkedTailSteps++
+ }
+ }
+ if frozenCases < 3 || checkedTailSteps < 100 {
+ t.Fatalf("anti-vacuity: frozenCases=%d checkedTailSteps=%d", frozenCases, checkedTailSteps)
+ }
+}
+
+func selectF128WithStepCount(money, total, expected uint64, d Digest) (selected, evaluations uint64, froze bool) {
+ ratio := f128FromDigestRatio(d)
+ dist := newBinomialF128(expected, total, money)
+ if dist == nil {
+ if ratio.isZero() {
+ return 0, 0, false
+ }
+ return money, 0, false
+ }
+ for j := uint64(0); j < money; j++ {
+ evaluations++
+ boundary := dist.cdf(j)
+ if ratio.cmp(boundary) <= 0 {
+ return j, evaluations, false
+ }
+ if dist.frozen {
+ return money, evaluations, true
+ }
+ }
+ return money, evaluations, false
+}
+
+// TestSelectF128ConsensusStepBounds makes liveness deterministic by counting
+// CDF evaluations rather than timing them. The cases cover certified tails
+// outside the frozen sliver and defined money-returning cases inside it.
+func TestSelectF128ConsensusStepBounds(t *testing.T) {
+ const (
+ online = uint64(2_000_000_000_000_000)
+ supply = uint64(10_000_000_000_000_000)
+ )
+ tests := []struct {
+ name string
+ money, total, expected uint64
+ digest Digest
+ want uint64
+ wantFreeze bool
+ }{
+ {"online 1500 certified tail", online, online, 1500, maxDigestMinusPowerOfTwo(196), 1852, false},
+ {"online 6000 certified tail", online, online, 6000, maxDigestMinusPowerOfTwo(200), 6667, false},
+ {"supply 5000 certified tail", supply, supply, 5000, maxDigestMinusPowerOfTwo(190), 5667, false},
+ {"proposer frozen tail", online - 1, online - 1, 20, maxDigestMinusPowerOfTwo(175), online - 1, true},
+ {"base minimum frozen tail", 100_000, supply, 5000, maxDigestMinusPowerOfTwo(141), 100_000, true},
+ {"supply frozen tail", supply, supply, 5000, maxDigestMinusPowerOfTwo(178), supply, true},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ got, evaluations, froze := selectF128WithStepCount(test.money, test.total, test.expected, test.digest)
+ if got != test.want || got != SelectF128(test.money, test.total, test.expected, test.digest) {
+ t.Fatalf("instrumented selection=%d, want %d", got, test.want)
+ }
+ if froze != test.wantFreeze {
+ t.Fatalf("freeze=%v, want %v after %d evaluations", froze, test.wantFreeze, evaluations)
+ }
+ limit := test.expected + 4096
+ if evaluations == 0 || evaluations > limit {
+ t.Fatalf("walk used %d CDF evaluations, limit %d", evaluations, limit)
+ }
+ })
+ }
+}
diff --git a/mutation_check.go b/mutation_check.go
index 46e983a..667098b 100644
--- a/mutation_check.go
+++ b/mutation_check.go
@@ -1,12 +1,12 @@
//go:build ignore
-// mutation_check applies curated single-site mutants to f128.go and verifies
-// the fast test suite kills each one. Every mutant models a plausible
-// implementation bug (flipped rounding masks, dropped carries or sticky
-// terms, off-by-one boundaries, inverted ties); an UNEXPECTED survivor is a
-// test gap and must be answered with a new test. Mutants marked equivalent
-// encode a proof of why the mutation cannot change behavior; they are
-// expected to survive and exist to document that analysis.
+// mutation_check applies curated single-site mutants to f128.go and its test
+// oracles, then verifies the fast suite kills each one. Every mutant models a
+// plausible implementation or harness bug (flipped rounding masks, dropped
+// carries, wrong initialization, corrupt recurrence/boundary logic); an
+// UNEXPECTED survivor is a test gap and must be answered with a new test.
+// Mutants marked equivalent encode a proof of why the mutation cannot change
+// behavior; they are expected to survive and document that analysis.
//
// Run from the repo root:
//
@@ -22,6 +22,7 @@ import (
type mutant struct {
name string
+ target string // defaults to f128.go; test-oracle mutants name their file
old, new string
equivalent string // non-empty: why this mutant is expected to survive
}
@@ -53,7 +54,29 @@ var mutants = []mutant{
name: "ratio-sticky-drop-n0", old: "sticky := n1&^(uint64(1)<<63) != 0 || n0 != 0", new: "sticky := n1&^(uint64(1)<<63) != 0 || n0 > n0",
equivalent: "the halfway denominator-correction turns any roundBit-without-sticky into sticky, so n0's contribution is recreated exactly when it could matter, and sticky is irrelevant when roundBit is clear",
},
+ {name: "digest-little-endian", old: "w3 := binary.BigEndian.Uint64(d[0:8])", new: "w3 := binary.LittleEndian.Uint64(d[0:8])"},
+ {name: "intpow-invert-bit", old: "if e&1 == 1 {", new: "if e&1 == 0 {"},
+ {name: "intpow-wrong-square", old: "b = b.mul(b)", new: "b = b.mul(base)"},
+ {name: "distribution-degenerate-strict", old: "if expectedSize >= totalMoney { // p >= 1", new: "if expectedSize > totalMoney { // p >= 1"},
+ {
+ name: "distribution-q-numerator",
+ old: "qf := f128FromUint64(totalMoney - expectedSize).div(f128FromUint64(totalMoney)) // 1-p",
+ new: "qf := f128FromUint64(expectedSize).div(f128FromUint64(totalMoney)) // 1-p",
+ },
+ {
+ name: "distribution-pq-denominator",
+ old: "pq := f128FromUint64(expectedSize).div(f128FromUint64(totalMoney - expectedSize)) // p/(1-p)",
+ new: "pq := f128FromUint64(expectedSize).div(f128FromUint64(totalMoney)) // p/(1-p)",
+ },
+ {name: "distribution-pmf0-power", old: "pmf0 := qf.intPow(money)", new: "pmf0 := qf.intPow(money + 1)"},
+ {name: "distribution-cum-zero", old: "pmf: pmf0, cum: pmf0, at: 0", new: "pmf: pmf0, cum: f128{}, at: 0"},
+ {name: "cdf-skip-index", old: "b.at++", new: "b.at += 2"},
{name: "cdf-recurrence-off-by-one", old: "f128FromUint64(b.money - b.at + 1)", new: "f128FromUint64(b.money - b.at)"},
+ {
+ name: "walk-start-at-one",
+ old: "for j := uint64(0); j < money; j++ {\n\t\tboundary := dist.cdf(j)",
+ new: "for j := uint64(1); j < money; j++ {\n\t\tboundary := dist.cdf(j)",
+ },
{name: "walk-strict-compare", old: "P(X <= j)\n\t\tif ratio.cmp(boundary) <= 0 {", new: "P(X <= j)\n\t\tif ratio.cmp(boundary) < 0 {"},
{name: "freeze-fire-on-change", old: "if b.cum.cmp(cumPrev) == 0 && b.pmf.cmp(pmfPrev) < 0 {", new: "if b.cum.cmp(cumPrev) != 0 && b.pmf.cmp(pmfPrev) < 0 {"},
{
@@ -75,42 +98,115 @@ var mutants = []mutant{
{name: "fromuint64-exp", old: "return f128{u << uint(s), 0, -int64(s) - 64}", new: "return f128{u << uint(s), 0, -int64(s) - 63}"},
{name: "shl256-fill-shift", old: "out[i] |= in[src+1] >> (64 - shift)", new: "out[i] |= in[src+1] >> (63 - shift)"},
{name: "cmp-lo-invert", old: "if a.lo < b.lo {", new: "if a.lo > b.lo {"},
+
+ // Test the tests: corrupt each major in-process oracle independently. The
+ // exact-integer, exact-CDF, high-precision, Arb, metamorphic, and production
+ // layers should make these harness defects observable rather than allowing
+ // all references to agree with one another accidentally.
+ {
+ name: "big-oracle-pmf0-power",
+ target: "f128_test.go",
+ old: "pmf := bigIntPow(q, money, prec) // (1-p)^money",
+ new: "pmf := bigIntPow(q, money+1, prec) // (1-p)^money",
+ },
+ {
+ name: "big-oracle-recurrence-off-by-one",
+ target: "f128_test.go",
+ old: "new(big.Float).SetPrec(prec).SetUint64(money-j+1),",
+ new: "new(big.Float).SetPrec(prec).SetUint64(money-j),",
+ },
+ {
+ name: "digest-oracle-denominator",
+ target: "f128_test.go",
+ old: "denominator.Sub(denominator, big.NewInt(1))",
+ new: "denominator.Sub(denominator, big.NewInt(2))",
+ },
+ {
+ name: "highprec-oracle-recurrence-off-by-one",
+ target: "f128_exact_test.go",
+ old: "new(big.Float).SetPrec(prec).SetUint64(money-j+1),",
+ new: "new(big.Float).SetPrec(prec).SetUint64(money-j),",
+ },
+ {
+ name: "frozen-sliver-invert",
+ target: "f128_exact_test.go",
+ old: "return gap.Cmp(bound) < 0",
+ new: "return gap.Cmp(bound) > 0",
+ },
+ {
+ name: "exact-cdf-binomial-off-by-one",
+ target: "f128_exact_test.go",
+ old: "term := new(big.Int).Binomial(int64(money), int64(i))",
+ new: "term := new(big.Int).Binomial(int64(money-1), int64(i))",
+ },
+ {
+ name: "exact-select-strict-boundary",
+ target: "f128_exact_test.go",
+ old: "if lhs.Cmp(rhs) <= 0 {",
+ new: "if lhs.Cmp(rhs) < 0 {",
+ },
+ {
+ name: "straddle-wrong-ulp",
+ target: "f128_exact_test.go",
+ old: "step := new(big.Int).Rsh(den, uint(128-c.MantExp(nil)))",
+ new: "step := new(big.Int).Rsh(den, uint(127-c.MantExp(nil)))",
+ },
}
func main() {
- const target = "f128.go"
- orig, err := os.ReadFile(target)
- if err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
+ const defaultTarget = "f128.go"
+ originals := make(map[string][]byte)
+ for i := range mutants {
+ if mutants[i].target == "" {
+ mutants[i].target = defaultTarget
+ }
+ if _, ok := originals[mutants[i].target]; ok {
+ continue
+ }
+ orig, err := os.ReadFile(mutants[i].target)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ originals[mutants[i].target] = orig
+ }
+ restore := func(target string) {
+ if err := os.WriteFile(target, originals[target], 0o644); err != nil {
+ fmt.Fprintf(os.Stderr, "restore %s: %v\n", target, err)
+ os.Exit(1)
+ }
+ }
+ restoreAll := func() {
+ for target := range originals {
+ restore(target)
+ }
}
- restore := func() { os.WriteFile(target, orig, 0o644) }
- defer restore()
+ defer restoreAll()
killArgs := []string{
"test", "-count=1",
- "-run", "TestF128Digest|TestF128Primitive|TestF128OpsExact|TestF128ConversionsExact|TestDivStepExact|TestDivUVsBig|TestDivVsBig|TestSelectF128|TestRapid|Fuzz",
+ "-run", "TestF128Digest|TestF128Primitive|TestF128OpsExact|TestF128ConversionsExact|TestDivStepExact|TestDivUVsBig|TestDivVsBig|TestSelectF128|TestSelectHighPrecision|TestRapid|Fuzz",
"-rapid.checks=2000",
// killed mutants fail the rapid tests by design; don't litter
// testdata/rapid with failfiles for them
"-rapid.nofailfile",
}
- src := string(orig)
killed, expectedSurvivors := 0, 0
var unexpected []string
for _, m := range mutants {
+ src := string(originals[m.target])
if c := strings.Count(src, m.old); c != 1 {
- restore()
- fmt.Fprintf(os.Stderr, "mutant %s: target string occurs %d times, need exactly 1\n", m.name, c)
+ restoreAll()
+ fmt.Fprintf(os.Stderr, "mutant %s: target string occurs %d times in %s, need exactly 1\n", m.name, c, m.target)
os.Exit(1)
}
- if err := os.WriteFile(target, []byte(strings.Replace(src, m.old, m.new, 1)), 0o644); err != nil {
+ if err := os.WriteFile(m.target, []byte(strings.Replace(src, m.old, m.new, 1)), 0o644); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
out, testErr := exec.Command("go", killArgs...).CombinedOutput()
- restore()
+ restore(m.target)
if strings.Contains(string(out), "[build failed]") {
fmt.Fprintf(os.Stderr, "mutant %s: does not compile, fix the table\n%s\n", m.name, out)
os.Exit(1)
diff --git a/oracle_hardening_test.go b/oracle_hardening_test.go
new file mode 100644
index 0000000..5ff893c
--- /dev/null
+++ b/oracle_hardening_test.go
@@ -0,0 +1,147 @@
+// Copyright (C) 2019-2023 Algorand, Inc.
+// This file is part of go-algorand
+//
+// go-algorand is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// go-algorand is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with go-algorand. If not, see .
+
+package sortition
+
+import (
+ "encoding/json"
+ "math/big"
+ "os"
+ "testing"
+)
+
+// TestSelectHighPrecisionCertifiedVectors makes the ordinary big.Float
+// tolerance oracle earn its precision assumption: 256, 512, and 1024-bit
+// walks must converge to every independently Arb-certified large-money
+// quantile. This catches precision-sensitive or shared harness defects even
+// when the production result itself still matches the checked-in want value.
+func TestSelectHighPrecisionCertifiedVectors(t *testing.T) {
+ raw, err := os.ReadFile("testdata/f128_arb_certificates.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var certificates arbCertificateFile
+ if err := json.Unmarshal(raw, &certificates); err != nil {
+ t.Fatal(err)
+ }
+ if len(certificates.Vectors) < 10 {
+ t.Fatalf("too few certified vectors: %d", len(certificates.Vectors))
+ }
+ for _, vector := range certificates.Vectors {
+ t.Run(vector.Label, func(t *testing.T) {
+ var d Digest
+ decoded, ok := new(big.Int).SetString(vector.Digest, 16)
+ if !ok || decoded.Sign() < 0 || decoded.BitLen() > 256 {
+ t.Fatalf("invalid digest %q", vector.Digest)
+ }
+ decoded.FillBytes(d[:])
+ for _, prec := range []uint{256, 512, 1024} {
+ if got := selectAtPrecision(vector.Money, vector.TotalMoney, vector.ExpectedSize, d, prec); got != vector.Want {
+ t.Fatalf("%d-bit oracle selected %d, Arb certificate wants %d", prec, got, vector.Want)
+ }
+ }
+ })
+ }
+}
+
+func exactDigestBoundary(point, digestDen, cdfDen *big.Int, cdfNums []*big.Int) bool {
+ lhs := new(big.Int).Mul(point, cdfDen)
+ for _, cdfNum := range cdfNums {
+ if lhs.Cmp(new(big.Int).Mul(cdfNum, digestDen)) == 0 {
+ return true
+ }
+ }
+ return false
+}
+
+// TestSelectHighPrecisionExhaustiveSmallDomain independently checks the
+// high-precision harness over a complete finite parameter/boundary grid. At
+// non-equality points the 512/1024-bit walks must equal exact rational
+// mathematics; on exact boundaries their rounded recurrence may land one
+// inclusive boundary later, but the two high precisions must still converge.
+func TestSelectHighPrecisionExhaustiveSmallDomain(t *testing.T) {
+ digestDen := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1))
+ const maxMoney = uint64(10)
+ const maxTotal = uint64(20)
+ var parameters, candidates, asserted, exactBoundaries, coarseDifferences uint64
+
+ for money := uint64(1); money <= maxMoney; money++ {
+ for total := uint64(2); total <= maxTotal; total++ {
+ cdfDen := new(big.Int).Exp(new(big.Int).SetUint64(total), new(big.Int).SetUint64(money), nil)
+ for expected := uint64(1); expected < total; expected++ {
+ parameters++
+ cdfNums := make([]*big.Int, money)
+ points := map[string]*big.Int{
+ "0": new(big.Int),
+ digestDen.Text(16): new(big.Int).Set(digestDen),
+ }
+ for j := uint64(0); j < money; j++ {
+ cdfNum := exactCDFNum(money, total, expected, j)
+ cdfNums[j] = cdfNum
+ scaled := new(big.Int).Mul(cdfNum, digestDen)
+ at := new(big.Int).Quo(scaled, cdfDen)
+ if at.Sign() > 0 {
+ below := new(big.Int).Sub(new(big.Int).Set(at), big.NewInt(1))
+ points[below.Text(16)] = below
+ }
+ points[at.Text(16)] = new(big.Int).Set(at)
+ if at.Cmp(digestDen) < 0 {
+ above := new(big.Int).Add(new(big.Int).Set(at), big.NewInt(1))
+ points[above.Text(16)] = above
+ }
+ }
+
+ for _, point := range points {
+ candidates++
+ d := digestFromBigInt(t, point, digestDen)
+ if inFrozenSliver(money, digestRatioBig(d, 512)) {
+ continue
+ }
+ asserted++
+ want := exactSelectFromCDFNumerators(money, cdfNums, cdfDen, digestDen, point)
+ got256 := selectAtPrecision(money, total, expected, d, 256)
+ got512 := selectAtPrecision(money, total, expected, d, 512)
+ got1024 := selectAtPrecision(money, total, expected, d, 1024)
+ if got512 != got1024 {
+ t.Fatalf("oracle failed to converge: 512=%d 1024=%d (money=%d total=%d expected=%d digest=%x)",
+ got512, got1024, money, total, expected, d)
+ }
+ if got256 != got512 {
+ coarseDifferences++
+ if uint64Distance(got256, got512) > 1 {
+ t.Fatalf("256-bit oracle differs by more than one boundary: 256=%d 512=%d", got256, got512)
+ }
+ }
+ if exactDigestBoundary(point, digestDen, cdfDen, cdfNums) {
+ exactBoundaries++
+ if uint64Distance(got1024, want) > 1 {
+ t.Fatalf("1024-bit oracle=%d vs exact boundary=%d", got1024, want)
+ }
+ } else if got1024 != want {
+ t.Fatalf("1024-bit oracle=%d vs exact=%d (money=%d total=%d expected=%d digest=%x)",
+ got1024, want, money, total, expected, d)
+ }
+ }
+ }
+ }
+ }
+ if parameters < 1_000 || asserted < 20_000 || exactBoundaries == 0 || coarseDifferences < 1_000 {
+ t.Fatalf("anti-vacuity: parameters=%d candidates=%d asserted=%d exactBoundaries=%d coarseDifferences=%d",
+ parameters, candidates, asserted, exactBoundaries, coarseDifferences)
+ }
+ t.Logf("oracle grid: parameters=%d candidates=%d asserted=%d exactBoundaries=%d 256vs512=%d",
+ parameters, candidates, asserted, exactBoundaries, coarseDifferences)
+}
diff --git a/tools/generate_arb_oracle.py b/tools/generate_arb_oracle.py
index 4b14f39..44f6384 100644
--- a/tools/generate_arb_oracle.py
+++ b/tools/generate_arb_oracle.py
@@ -15,9 +15,15 @@
.venv-oracle/bin/pip install -r tools/requirements-oracle.txt
.venv-oracle/bin/python tools/generate_arb_oracle.py \
> testdata/f128_arb_certificates.json
+
+Verify the checked-in corpus without rewriting it:
+
+ .venv-oracle/bin/python tools/generate_arb_oracle.py \
+ --check testdata/f128_arb_certificates.json
"""
import argparse
+import difflib
import json
import sys
import time
@@ -164,7 +170,14 @@ def certify(case: Case, selected: int) -> dict:
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--case", help="generate only the case whose label contains this text")
+ parser.add_argument(
+ "--check",
+ metavar="PATH",
+ help="regenerate the full corpus and fail if its parsed JSON differs from PATH",
+ )
args = parser.parse_args()
+ if args.case is not None and args.check is not None:
+ parser.error("--case and --check cannot be combined")
cases = tuple(case for case in CASES if args.case is None or args.case in case.label)
if not cases:
parser.error("--case did not match any vector")
@@ -181,7 +194,31 @@ def main() -> None:
"semantics": "exact binomial CDF; frozen-tail definition is tested separately",
"vectors": vectors,
}
- print(json.dumps(output, indent=2, sort_keys=True))
+ rendered = json.dumps(output, indent=2, sort_keys=True)
+ if args.check is None:
+ print(rendered)
+ return
+
+ try:
+ with open(args.check, encoding="utf-8") as certificate_file:
+ committed = json.load(certificate_file)
+ except (OSError, json.JSONDecodeError) as error:
+ raise SystemExit(f"cannot read certificate corpus {args.check}: {error}") from error
+ if committed != output:
+ committed_rendered = json.dumps(committed, indent=2, sort_keys=True)
+ print(
+ "".join(
+ difflib.unified_diff(
+ committed_rendered.splitlines(keepends=True),
+ rendered.splitlines(keepends=True),
+ fromfile=args.check,
+ tofile="fresh Arb generation",
+ )
+ ),
+ file=sys.stderr,
+ )
+ raise SystemExit("Arb certificate corpus is stale or non-reproducible")
+ print(f"verified {len(vectors)} Arb certificates against {args.check}", file=sys.stderr)
if __name__ == "__main__":
From 357a49cfd4931d70346a2f3ff9ffc7a118e50bcd Mon Sep 17 00:00:00 2001
From: cce <51567+cce@users.noreply.github.com>
Date: Wed, 22 Jul 2026 11:57:38 -0400
Subject: [PATCH 33/37] fix CI and update copyright
---
.github/workflows/test.yml | 1 +
README.md | 2 +-
cdf_exhaustive_test.go | 2 +-
f128.go | 2 +-
f128_arb_certificate_test.go | 2 +-
f128_exact_test.go | 2 +-
f128_integer_oracle_test.go | 2 +-
f128_rapid_test.go | 2 +-
f128_test.go | 2 +-
f128_trajectory_test.go | 2 +-
mutation_check.go | 16 ++++++++++++++++
oracle_hardening_test.go | 2 +-
sortition.go | 2 +-
sortition_test.go | 2 +-
14 files changed, 29 insertions(+), 12 deletions(-)
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index c5bd5e9..691829a 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -40,6 +40,7 @@ jobs:
# band (the divU shallow-quotient bug reproduced within ~30 banded cases,
# so 20000 leaves ample margin while staying under a few seconds).
- name: Rapid property tests
+ shell: bash
run: go test -v -run TestRapid ./... -args -rapid.checks=20000
# Applies each curated single-site mutant of the f128 arithmetic and
diff --git a/README.md b/README.md
index e8b1cfe..01a5b44 100644
--- a/README.md
+++ b/README.md
@@ -12,4 +12,4 @@ developer runbook for the deterministic sortition implementation.
Please see the [COPYING_FAQ](COPYING_FAQ) for details about how to apply our license.
-Copyright (C) 2019-2023, Algorand Inc.
+Copyright (C) 2019-2026, Algorand Foundation Ltd.
diff --git a/cdf_exhaustive_test.go b/cdf_exhaustive_test.go
index 598e689..fcac795 100644
--- a/cdf_exhaustive_test.go
+++ b/cdf_exhaustive_test.go
@@ -1,4 +1,4 @@
-// Copyright (C) 2019-2023 Algorand, Inc.
+// Copyright (C) 2019-2026 Algorand Foundation Ltd.
// This file is part of go-algorand
//
// go-algorand is free software: you can redistribute it and/or modify
diff --git a/f128.go b/f128.go
index c5f1d3d..8d87a1e 100644
--- a/f128.go
+++ b/f128.go
@@ -1,4 +1,4 @@
-// Copyright (C) 2019-2023 Algorand, Inc.
+// Copyright (C) 2019-2026 Algorand Foundation Ltd.
// This file is part of go-algorand
//
// go-algorand is free software: you can redistribute it and/or modify
diff --git a/f128_arb_certificate_test.go b/f128_arb_certificate_test.go
index 06db6ec..52f55ff 100644
--- a/f128_arb_certificate_test.go
+++ b/f128_arb_certificate_test.go
@@ -1,4 +1,4 @@
-// Copyright (C) 2019-2023 Algorand, Inc.
+// Copyright (C) 2019-2026 Algorand Foundation Ltd.
// This file is part of go-algorand
//
// go-algorand is free software: you can redistribute it and/or modify
diff --git a/f128_exact_test.go b/f128_exact_test.go
index 4fd218f..d8fa376 100644
--- a/f128_exact_test.go
+++ b/f128_exact_test.go
@@ -1,4 +1,4 @@
-// Copyright (C) 2019-2023 Algorand, Inc.
+// Copyright (C) 2019-2026 Algorand Foundation Ltd.
// This file is part of go-algorand
//
// go-algorand is free software: you can redistribute it and/or modify
diff --git a/f128_integer_oracle_test.go b/f128_integer_oracle_test.go
index fc0b7ae..6f42ec2 100644
--- a/f128_integer_oracle_test.go
+++ b/f128_integer_oracle_test.go
@@ -1,4 +1,4 @@
-// Copyright (C) 2019-2023 Algorand, Inc.
+// Copyright (C) 2019-2026 Algorand Foundation Ltd.
// This file is part of go-algorand
//
// go-algorand is free software: you can redistribute it and/or modify
diff --git a/f128_rapid_test.go b/f128_rapid_test.go
index cf19e2f..4652d29 100644
--- a/f128_rapid_test.go
+++ b/f128_rapid_test.go
@@ -1,4 +1,4 @@
-// Copyright (C) 2019-2023 Algorand, Inc.
+// Copyright (C) 2019-2026 Algorand Foundation Ltd.
// This file is part of go-algorand
//
// go-algorand is free software: you can redistribute it and/or modify
diff --git a/f128_test.go b/f128_test.go
index 296ed48..2b00ee2 100644
--- a/f128_test.go
+++ b/f128_test.go
@@ -1,4 +1,4 @@
-// Copyright (C) 2019-2023 Algorand, Inc.
+// Copyright (C) 2019-2026 Algorand Foundation Ltd.
// This file is part of go-algorand
//
// go-algorand is free software: you can redistribute it and/or modify
diff --git a/f128_trajectory_test.go b/f128_trajectory_test.go
index e05dd9b..019bf91 100644
--- a/f128_trajectory_test.go
+++ b/f128_trajectory_test.go
@@ -1,4 +1,4 @@
-// Copyright (C) 2019-2023 Algorand, Inc.
+// Copyright (C) 2019-2026 Algorand Foundation Ltd.
// This file is part of go-algorand
//
// go-algorand is free software: you can redistribute it and/or modify
diff --git a/mutation_check.go b/mutation_check.go
index 667098b..4c9f22c 100644
--- a/mutation_check.go
+++ b/mutation_check.go
@@ -1,3 +1,19 @@
+// Copyright (C) 2019-2026 Algorand Foundation Ltd.
+// This file is part of go-algorand
+//
+// go-algorand is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// go-algorand is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with go-algorand. If not, see .
+
//go:build ignore
// mutation_check applies curated single-site mutants to f128.go and its test
diff --git a/oracle_hardening_test.go b/oracle_hardening_test.go
index 5ff893c..b36572c 100644
--- a/oracle_hardening_test.go
+++ b/oracle_hardening_test.go
@@ -1,4 +1,4 @@
-// Copyright (C) 2019-2023 Algorand, Inc.
+// Copyright (C) 2019-2026 Algorand Foundation Ltd.
// This file is part of go-algorand
//
// go-algorand is free software: you can redistribute it and/or modify
diff --git a/sortition.go b/sortition.go
index 7799c1c..77dd5cd 100644
--- a/sortition.go
+++ b/sortition.go
@@ -1,4 +1,4 @@
-// Copyright (C) 2019-2023 Algorand, Inc.
+// Copyright (C) 2019-2026 Algorand Foundation Ltd.
// This file is part of go-algorand
//
// go-algorand is free software: you can redistribute it and/or modify
diff --git a/sortition_test.go b/sortition_test.go
index 6093881..9550312 100644
--- a/sortition_test.go
+++ b/sortition_test.go
@@ -1,4 +1,4 @@
-// Copyright (C) 2019-2023 Algorand, Inc.
+// Copyright (C) 2019-2026 Algorand Foundation Ltd.
// This file is part of go-algorand
//
// go-algorand is free software: you can redistribute it and/or modify
From 5c069200af9530d8c04fa3a703dd9c5e9aeabbb8 Mon Sep 17 00:00:00 2001
From: cce <51567+cce@users.noreply.github.com>
Date: Wed, 22 Jul 2026 12:17:20 -0400
Subject: [PATCH 34/37] CI: use original job name
---
.github/workflows/test.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 691829a..c15d22b 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -7,7 +7,7 @@ on:
pull_request:
jobs:
- test:
+ build:
name: Tests (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
From 193a7f939712e0527e77210b43c4fdac1a760707 Mon Sep 17 00:00:00 2001
From: cce <51567+cce@users.noreply.github.com>
Date: Wed, 22 Jul 2026 12:35:18 -0400
Subject: [PATCH 35/37] ci: add native linux/386 leg to exercise 32-bit int
Go's int is 32-bit on GOARCH=386, the platform width that motivates the explicitly-int64 f128 exponent. x86-64 runs i386 user code natively, so this validates cross-GOARCH bit-identity with no QEMU; gcc/g++-multilib supplies the 32-bit toolchain for the cgo/Boost build (Go adds -m32 automatically).
---
.github/workflows/test.yml | 34 ++++++++++++++++++++++++++++++++++
1 file changed, 34 insertions(+)
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index c15d22b..7e0eb11 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -43,6 +43,40 @@ jobs:
shell: bash
run: go test -v -run TestRapid ./... -args -rapid.checks=20000
+ # Cross-checks the "bit-identical on every platform" claim on a 32-bit-int
+ # target, where Go's int is 32 bits -- the reason f128.exp is explicitly
+ # int64. x86-64 runs i386 user code natively, so this needs no emulation;
+ # only a 32-bit C/C++ toolchain for the cgo/Boost build, which the Go
+ # toolchain invokes with -m32 automatically under GOARCH=386. armv7 (the
+ # Raspberry Pi target) has the same 32-bit int but would need slow QEMU, so
+ # 386 buys the same integer width for free.
+ test-386:
+ name: Tests (linux/386, native 32-bit)
+ runs-on: ubuntu-latest
+ env:
+ GOARCH: "386"
+ CGO_ENABLED: "1"
+ steps:
+ - uses: actions/checkout@v7
+
+ - name: Set up Go
+ uses: actions/setup-go@v7
+ with:
+ go-version-file: go.mod
+
+ - name: Install 32-bit C/C++ toolchain
+ run: sudo apt-get update && sudo apt-get install -y gcc-multilib g++-multilib
+
+ - name: Build
+ run: go build -v ./...
+
+ - name: Test
+ run: go test -v ./...
+
+ - name: Rapid property tests
+ shell: bash
+ run: go test -v -run TestRapid ./... -args -rapid.checks=20000
+
# Applies each curated single-site mutant of the f128 arithmetic and
# verifies the fast suite kills it (survivors marked equivalent carry
# written proofs and are expected). This keeps the kill-rate claim true
From 51f9204a69cdeb1d753fe739a6e86828ab00055e Mon Sep 17 00:00:00 2001
From: cce <51567+cce@users.noreply.github.com>
Date: Wed, 22 Jul 2026 12:40:49 -0400
Subject: [PATCH 36/37] ci: add emulated linux/arm/v7 leg (Raspberry Pi target)
armv7 (GOARM=7) has the same 32-bit int as 386 but on real ARM hardware like the Raspberry Pi. It has no native path on the amd64 runner, so it runs under QEMU via a foreign-arch container; the stock golang image carries gcc/g++ and Boost is vendored, so no extra install is needed. Emulated and thus the slowest leg, so it runs only the default rapid depth.
---
.github/workflows/test.yml | 22 ++++++++++++++++++++++
1 file changed, 22 insertions(+)
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 7e0eb11..ad51d57 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -77,6 +77,28 @@ jobs:
shell: bash
run: go test -v -run TestRapid ./... -args -rapid.checks=20000
+ # Same 32-bit-int check as the 386 leg, but on the real Raspberry Pi target
+ # (armv7, GOARM=7). ARM has no i386-style native path on the amd64 runner, so
+ # it runs under QEMU in a foreign-arch container; the stock golang image
+ # carries gcc/g++ and the repo vendors Boost, so nothing extra is installed.
+ # Emulated, hence the slowest leg -- it runs the default rapid depth only,
+ # leaving the 20000-case sweep to the native legs.
+ test-armv7:
+ name: Tests (linux/arm/v7, emulated)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ - name: Test under QEMU (armv7 -- Raspberry Pi, 32-bit int)
+ run: |
+ docker run --rm --platform linux/arm/v7 \
+ -v "$PWD":/src -w /src -e CGO_ENABLED=1 \
+ golang:1.23 \
+ go test -v ./...
+
# Applies each curated single-site mutant of the f128 arithmetic and
# verifies the fast suite kills it (survivors marked equivalent carry
# written proofs and are expected). This keeps the kill-rate claim true
From 26c0c59b7012579ab3d79e89f359e021bdd43d7c Mon Sep 17 00:00:00 2001
From: cce <51567+cce@users.noreply.github.com>
Date: Wed, 22 Jul 2026 12:48:25 -0400
Subject: [PATCH 37/37] CI: add cancel-in-progress: true
---
.github/workflows/test.yml | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index ad51d57..7d48bca 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -6,6 +6,10 @@ on:
- main
pull_request:
+concurrency:
+ group: test-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
jobs:
build:
name: Tests (${{ matrix.os }})