feat(fixed): add Q32.32 deterministic fixed-point arithmetic
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1081
docs/superpowers/plans/2026-08-05-foundation.md
Normal file
1081
docs/superpowers/plans/2026-08-05-foundation.md
Normal file
File diff suppressed because it is too large
Load Diff
122
pkg/fixed/fixed.go
Normal file
122
pkg/fixed/fixed.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
// Package fixed provides deterministic Q32.32 fixed-point arithmetic.
|
||||||
|
//
|
||||||
|
// No floating-point operation appears anywhere in this package. Results must be
|
||||||
|
// bit-identical across architectures and between native and WASM builds, which
|
||||||
|
// is what allows a player's browser to independently replay a round and reach
|
||||||
|
// exactly the same outcome as the server.
|
||||||
|
package fixed
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/bits"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// F is a Q32.32 fixed-point number: an int64 with 32 fractional bits.
|
||||||
|
type F int64
|
||||||
|
|
||||||
|
// One is the fixed-point representation of 1.0.
|
||||||
|
const One F = 1 << 32
|
||||||
|
|
||||||
|
const fracBits = 32
|
||||||
|
|
||||||
|
// FromInt converts a whole number to fixed-point.
|
||||||
|
func FromInt(v int64) F { return F(v << fracBits) }
|
||||||
|
|
||||||
|
// Int truncates toward negative infinity and returns the whole part.
|
||||||
|
func (a F) Int() int64 { return int64(a) >> fracBits }
|
||||||
|
|
||||||
|
func (a F) Add(b F) F { return a + b }
|
||||||
|
func (a F) Sub(b F) F { return a - b }
|
||||||
|
|
||||||
|
// Mul multiplies via a 128-bit intermediate so no precision is lost before the
|
||||||
|
// shift back down. A naive (a*b)>>32 overflows for operands above roughly 2^15.
|
||||||
|
func (a F) Mul(b F) F {
|
||||||
|
neg := false
|
||||||
|
x, y := int64(a), int64(b)
|
||||||
|
if x < 0 {
|
||||||
|
x, neg = -x, !neg
|
||||||
|
}
|
||||||
|
if y < 0 {
|
||||||
|
y, neg = -y, !neg
|
||||||
|
}
|
||||||
|
hi, lo := bits.Mul64(uint64(x), uint64(y))
|
||||||
|
res := int64(lo>>fracBits | hi<<(64-fracBits))
|
||||||
|
if neg {
|
||||||
|
res = -res
|
||||||
|
}
|
||||||
|
return F(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Div divides via a 128-bit intermediate for the same reason as Mul.
|
||||||
|
func (a F) Div(b F) F {
|
||||||
|
if b == 0 {
|
||||||
|
panic("fixed: division by zero")
|
||||||
|
}
|
||||||
|
neg := false
|
||||||
|
x, y := int64(a), int64(b)
|
||||||
|
if x < 0 {
|
||||||
|
x, neg = -x, !neg
|
||||||
|
}
|
||||||
|
if y < 0 {
|
||||||
|
y, neg = -y, !neg
|
||||||
|
}
|
||||||
|
hi := uint64(x) >> (64 - fracBits)
|
||||||
|
lo := uint64(x) << fracBits
|
||||||
|
q, _ := bits.Div64(hi, lo, uint64(y))
|
||||||
|
res := int64(q)
|
||||||
|
if neg {
|
||||||
|
res = -res
|
||||||
|
}
|
||||||
|
return F(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sqrt returns the fixed-point square root using integer Newton iteration.
|
||||||
|
// It converges in well under the iteration cap for the full int64 range.
|
||||||
|
func Sqrt(a F) F {
|
||||||
|
if a < 0 {
|
||||||
|
panic("fixed: sqrt of negative")
|
||||||
|
}
|
||||||
|
if a == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
// Initial guess: half the bit length puts us within a factor of two.
|
||||||
|
shift := uint(bits.Len64(uint64(a))+fracBits) / 2
|
||||||
|
x := F(1) << shift
|
||||||
|
for i := 0; i < 64; i++ {
|
||||||
|
next := (x + a.Div(x)) / 2
|
||||||
|
if next == x || next == x-1 {
|
||||||
|
x = next
|
||||||
|
break
|
||||||
|
}
|
||||||
|
x = next
|
||||||
|
}
|
||||||
|
// Newton can land one ulp high; step down while the square exceeds the input.
|
||||||
|
for x > 0 && x.Mul(x) > a {
|
||||||
|
x--
|
||||||
|
}
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
// String renders the value with six fractional digits, using integer math only.
|
||||||
|
func (a F) String() string {
|
||||||
|
neg := a < 0
|
||||||
|
if neg {
|
||||||
|
a = -a
|
||||||
|
}
|
||||||
|
whole := int64(a) >> fracBits
|
||||||
|
frac := int64(a) & (int64(One) - 1)
|
||||||
|
micros := (frac * 1_000_000) >> fracBits
|
||||||
|
s := strconv.FormatInt(whole, 10) + "." + pad6(micros)
|
||||||
|
if neg {
|
||||||
|
return "-" + s
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func pad6(v int64) string {
|
||||||
|
s := strconv.FormatInt(v, 10)
|
||||||
|
for len(s) < 6 {
|
||||||
|
s = "0" + s
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
67
pkg/fixed/fixed_test.go
Normal file
67
pkg/fixed/fixed_test.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package fixed
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestFromIntAndBack(t *testing.T) {
|
||||||
|
if got := FromInt(7).Int(); got != 7 {
|
||||||
|
t.Fatalf("FromInt(7).Int() = %d, want 7", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMulIsExact(t *testing.T) {
|
||||||
|
half := One / 2
|
||||||
|
if got := half.Mul(half); got != One/4 {
|
||||||
|
t.Fatalf("0.5*0.5 = %d, want %d", got, One/4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMulDoesNotOverflowAtScale(t *testing.T) {
|
||||||
|
// A naive (a*b)>>32 overflows well below this. The 128-bit intermediate
|
||||||
|
// must handle it exactly.
|
||||||
|
a := FromInt(100000)
|
||||||
|
if got := a.Mul(FromInt(2)); got != FromInt(200000) {
|
||||||
|
t.Fatalf("100000*2 = %v, want 200000", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDivIsExact(t *testing.T) {
|
||||||
|
if got := FromInt(1).Div(FromInt(4)); got != One/4 {
|
||||||
|
t.Fatalf("1/4 = %d, want %d", got, One/4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNegativeMulAndDiv(t *testing.T) {
|
||||||
|
if got := FromInt(-3).Mul(FromInt(4)); got != FromInt(-12) {
|
||||||
|
t.Fatalf("-3*4 = %v, want -12", got)
|
||||||
|
}
|
||||||
|
if got := FromInt(-12).Div(FromInt(4)); got != FromInt(-3) {
|
||||||
|
t.Fatalf("-12/4 = %v, want -3", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSqrt(t *testing.T) {
|
||||||
|
for _, n := range []int64{0, 1, 4, 9, 16, 100, 10000} {
|
||||||
|
want := FromInt(isqrt(n))
|
||||||
|
got := Sqrt(FromInt(n))
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("Sqrt(%d) = %v, want %v", n, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isqrt(n int64) int64 {
|
||||||
|
var r int64
|
||||||
|
for r*r <= n {
|
||||||
|
r++
|
||||||
|
}
|
||||||
|
return r - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestString(t *testing.T) {
|
||||||
|
if got := (One + One/2).String(); got != "1.500000" {
|
||||||
|
t.Fatalf("1.5.String() = %q", got)
|
||||||
|
}
|
||||||
|
if got := FromInt(-2).String(); got != "-2.000000" {
|
||||||
|
t.Fatalf("-2.String() = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
52
pkg/fixed/nofloat_test.go
Normal file
52
pkg/fixed/nofloat_test.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package fixed_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Determinism depends on there being no floating-point arithmetic anywhere in
|
||||||
|
// the simulation path: floats drift between architectures and between native
|
||||||
|
// and WASM builds, which would silently break round verification. This test
|
||||||
|
// fails the build if a float type is ever introduced.
|
||||||
|
//
|
||||||
|
// Test files are exempt, since statistical assertions legitimately use floats.
|
||||||
|
func TestNoFloatingPointInDeterministicPackages(t *testing.T) {
|
||||||
|
for _, dir := range []string{".", "../sim"} {
|
||||||
|
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
pkgs, err := parser.ParseDir(fset, dir, nil, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse %s: %v", dir, err)
|
||||||
|
}
|
||||||
|
for _, pkg := range pkgs {
|
||||||
|
for name, file := range pkg.Files {
|
||||||
|
if strings.HasSuffix(name, "_test.go") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ast.Inspect(file, func(n ast.Node) bool {
|
||||||
|
switch node := n.(type) {
|
||||||
|
case *ast.Ident:
|
||||||
|
if node.Name == "float32" || node.Name == "float64" {
|
||||||
|
t.Errorf("%s: forbidden float type %q in deterministic package",
|
||||||
|
filepath.Base(name), node.Name)
|
||||||
|
}
|
||||||
|
case *ast.BasicLit:
|
||||||
|
if node.Kind == token.FLOAT {
|
||||||
|
t.Errorf("%s: forbidden float literal %s",
|
||||||
|
filepath.Base(name), node.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user