From 94135372513b1dcb5dad5e4ef0d4d5f16b4d9983 Mon Sep 17 00:00:00 2001 From: drjones Date: Wed, 5 Aug 2026 02:19:06 +0000 Subject: [PATCH] feat(fixed): add Q32.32 deterministic fixed-point arithmetic Co-Authored-By: Claude Opus 5 --- .../plans/2026-08-05-foundation.md | 1081 +++++++++++++++++ go.mod | 3 + pkg/fixed/fixed.go | 122 ++ pkg/fixed/fixed_test.go | 67 + pkg/fixed/nofloat_test.go | 52 + 5 files changed, 1325 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-05-foundation.md create mode 100644 go.mod create mode 100644 pkg/fixed/fixed.go create mode 100644 pkg/fixed/fixed_test.go create mode 100644 pkg/fixed/nofloat_test.go diff --git a/docs/superpowers/plans/2026-08-05-foundation.md b/docs/superpowers/plans/2026-08-05-foundation.md new file mode 100644 index 0000000..fa40104 --- /dev/null +++ b/docs/superpowers/plans/2026-08-05-foundation.md @@ -0,0 +1,1081 @@ +# Quantum Arcade Foundation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the deterministic fixed-point physics core and the double-entry ledger — the two components every other part of Quantum Arcade depends on. + +**Architecture:** A single Go module. `pkg/fixed` provides deterministic Q32.32 fixed-point arithmetic with no floating point anywhere. `pkg/sim` builds a fixed-timestep simulation on top of it, seeded by an integer, producing bit-identical results natively and under WASM. `pkg/ledger` implements append-only double-entry accounting over PostgreSQL, where every transaction's postings sum to zero and balances can never go negative. + +**Tech Stack:** Go 1.26, PostgreSQL 16, pgx/v5, testify, Docker Compose. + +## Global Constraints + +- Go module path: `github.com/drjones/quantum-arcade` +- No floating-point types (`float32`, `float64`) anywhere in `pkg/fixed` or `pkg/sim`. Enforced by a test that greps the packages. +- All monetary amounts are `int64` millisatoshis. Never a float, never a string. +- Ledger tables are append-only: no `UPDATE`, no `DELETE`. Corrections are compensating entries. +- Every posting set within one transaction sums to exactly zero. +- Player balances may never go negative. +- Fixed-point format is Q32.32: `int64` with 32 fractional bits. `One = 1 << 32`. + +--- + +### Task 1: Fixed-point arithmetic primitives + +**Files:** +- Create: `pkg/fixed/fixed.go` +- Test: `pkg/fixed/fixed_test.go` + +**Interfaces:** +- Consumes: nothing +- Produces: `type F int64`; `const One F = 1 << 32`; `func FromInt(int64) F`; `func (F) Int() int64`; `func (F) Mul(F) F`; `func (F) Div(F) F`; `func (F) Add(F) F`; `func (F) Sub(F) F`; `func (F) String() string`; `func Sqrt(F) F` + +- [ ] **Step 1: Write the failing test** + +```go +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 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 TestSqrt(t *testing.T) { + if got := Sqrt(FromInt(4)); got != FromInt(2) { + t.Fatalf("Sqrt(4) = %v, want 2", got) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./pkg/fixed/ -run TestFromIntAndBack -v` +Expected: FAIL — undefined: FromInt + +- [ ] **Step 3: Write minimal implementation** + +```go +// 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. +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 zero 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 ~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. +func Sqrt(a F) F { + if a < 0 { + panic("fixed: sqrt of negative") + } + if a == 0 { + return 0 + } + // Initial guess: shift-based estimate of the integer square root. + x := F(1) << F(uint(bits.Len64(uint64(a))+fracBits)/2) + for i := 0; i < 40; i++ { + if x == 0 { + return 0 + } + next := (x + a.Div(x)) / 2 + if next == x { + break + } + x = next + } + return x +} + +// String renders the value with 6 fractional digits, without using floats. +func (a F) String() string { + neg := a < 0 + if neg { + a = -a + } + whole := int64(a) >> fracBits + frac := int64(a) & (int64(One) - 1) + // Scale the fraction to 6 decimal places using integer math only. + 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 +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./pkg/fixed/ -v` +Expected: PASS (all four tests) + +- [ ] **Step 5: Commit** + +```bash +git add pkg/fixed/ +git commit -m "feat(fixed): add Q32.32 deterministic fixed-point arithmetic" +``` + +--- + +### Task 2: No-float enforcement test + +**Files:** +- Create: `pkg/fixed/nofloat_test.go` + +**Interfaces:** +- Consumes: nothing +- Produces: nothing (a guard test) + +- [ ] **Step 1: Write the test** + +```go +package fixed_test + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" +) + +// Determinism depends on there being no floating-point arithmetic in the +// simulation path. This test fails the build if a float type is ever +// introduced into pkg/fixed or pkg/sim. +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 { + id, ok := n.(*ast.Ident) + if !ok { + return true + } + if id.Name == "float32" || id.Name == "float64" { + t.Errorf("%s: forbidden float type %q in deterministic package", + filepath.Base(name), id.Name) + } + return true + }) + } + } + } +} +``` + +- [ ] **Step 2: Run it** + +Run: `go test ./pkg/fixed/ -run TestNoFloatingPoint -v` +Expected: PASS (no floats present yet) + +- [ ] **Step 3: Commit** + +```bash +git add pkg/fixed/nofloat_test.go +git commit -m "test(fixed): forbid float types in deterministic packages" +``` + +--- + +### Task 3: Seeded deterministic RNG + +**Files:** +- Create: `pkg/sim/rng.go` +- Test: `pkg/sim/rng_test.go` + +**Interfaces:** +- Consumes: `fixed.F`, `fixed.One` +- Produces: `type RNG struct{...}`; `func NewRNG(seed [32]byte) *RNG`; `func (*RNG) Uint64() uint64`; `func (*RNG) Unit() fixed.F` (returns [0,1)) + +- [ ] **Step 1: Write the failing test** + +```go +package sim + +import "testing" + +func TestRNGIsDeterministic(t *testing.T) { + var seed [32]byte + copy(seed[:], "quantum-arcade-test-seed") + a, b := NewRNG(seed), NewRNG(seed) + for i := 0; i < 1000; i++ { + if x, y := a.Uint64(), b.Uint64(); x != y { + t.Fatalf("iteration %d: %d != %d", i, x, y) + } + } +} + +func TestUnitInRange(t *testing.T) { + var seed [32]byte + seed[0] = 9 + r := NewRNG(seed) + for i := 0; i < 10000; i++ { + u := r.Unit() + if u < 0 || u >= 1<<32 { + t.Fatalf("Unit() = %v out of [0,1)", u) + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./pkg/sim/ -run TestRNG -v` +Expected: FAIL — undefined: NewRNG + +- [ ] **Step 3: Write minimal implementation** + +```go +package sim + +import ( + "encoding/binary" + + "github.com/drjones/quantum-arcade/pkg/fixed" +) + +// RNG is a deterministic ChaCha-style counter PRNG seeded from 32 bytes. +// It uses only integer operations so it is reproducible across platforms. +type RNG struct { + state [4]uint64 + counter uint64 +} + +// NewRNG creates a reproducible generator from a 32-byte seed. +func NewRNG(seed [32]byte) *RNG { + r := &RNG{} + for i := 0; i < 4; i++ { + r.state[i] = binary.LittleEndian.Uint64(seed[i*8 : i*8+8]) + } + // Guard against an all-zero state, which would be a fixed point. + if r.state[0]|r.state[1]|r.state[2]|r.state[3] == 0 { + r.state[0] = 0x9E3779B97F4A7C15 + } + return r +} + +// Uint64 returns the next 64 bits using xoshiro256** mixing. +func (r *RNG) Uint64() uint64 { + s := &r.state + result := rotl(s[1]*5, 7) * 9 + t := s[1] << 17 + s[2] ^= s[0] + s[3] ^= s[1] + s[1] ^= s[2] + s[0] ^= s[3] + s[2] ^= t + s[3] = rotl(s[3], 45) + r.counter++ + return result +} + +func rotl(x uint64, k uint) uint64 { return (x << k) | (x >> (64 - k)) } + +// Unit returns a fixed-point value uniformly distributed over [0, 1). +func (r *RNG) Unit() fixed.F { + // Take the top 32 bits so the result occupies exactly the fractional part. + return fixed.F(r.Uint64() >> 32) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./pkg/sim/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add pkg/sim/rng.go pkg/sim/rng_test.go +git commit -m "feat(sim): add deterministic seeded RNG" +``` + +--- + +### Task 4: Crash curve and outcome derivation + +**Files:** +- Create: `pkg/sim/crash.go` +- Test: `pkg/sim/crash_test.go` + +**Interfaces:** +- Consumes: `fixed.F`, `RNG` +- Produces: `const HouseEdgeBP int64 = 200`; `func CrashPoint(seed [32]byte) fixed.F`; `func MultiplierAt(tick int) fixed.F`; `func TicksToMultiplier(m fixed.F) int` + +- [ ] **Step 1: Write the failing test** + +```go +package sim + +import "testing" + +func TestCrashPointNeverBelowOne(t *testing.T) { + for i := 0; i < 20000; i++ { + var seed [32]byte + seed[0], seed[1] = byte(i), byte(i>>8) + if cp := CrashPoint(seed); cp < 1<<32 { + t.Fatalf("seed %d: crash point %v below 1.0", i, cp) + } + } +} + +func TestCrashPointIsDeterministic(t *testing.T) { + var seed [32]byte + copy(seed[:], "repeatable") + first := CrashPoint(seed) + for i := 0; i < 100; i++ { + if got := CrashPoint(seed); got != first { + t.Fatalf("run %d: %v != %v", i, got, first) + } + } +} + +func TestHouseEdgeIsApproximatelyTwoPercent(t *testing.T) { + // With a 2% edge, cashing out at exactly 2.00x should win slightly + // under half the time. Sample enough seeds to check the distribution. + const n = 200000 + target := 2 * (1 << 32) + wins := 0 + for i := 0; i < n; i++ { + var seed [32]byte + seed[0], seed[1], seed[2] = byte(i), byte(i>>8), byte(i>>16) + if int64(CrashPoint(seed)) >= int64(target) { + wins++ + } + } + // Fair would be 50%; a 2% edge puts it near 49%. Allow a 1.5pt band. + pct := float64(wins) * 100 / n + if pct < 47.5 || pct > 50.5 { + t.Fatalf("win rate at 2.00x = %.2f%%, want ~49%%", pct) + } +} + +func TestMultiplierStartsAtOne(t *testing.T) { + if got := MultiplierAt(0); got != 1<<32 { + t.Fatalf("MultiplierAt(0) = %v, want 1.0", got) + } +} + +func TestMultiplierIsMonotonic(t *testing.T) { + prev := MultiplierAt(0) + for tick := 1; tick < 5000; tick++ { + cur := MultiplierAt(tick) + if cur < prev { + t.Fatalf("tick %d: multiplier decreased %v -> %v", tick, prev, cur) + } + prev = cur + } +} +``` + +Note: `crash_test.go` may use floats for statistical assertions; the no-float +guard in Task 2 skips `_test.go` files for exactly this reason. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./pkg/sim/ -run TestCrash -v` +Expected: FAIL — undefined: CrashPoint + +- [ ] **Step 3: Write minimal implementation** + +```go +package sim + +import "github.com/drjones/quantum-arcade/pkg/fixed" + +// HouseEdgeBP is the house edge in basis points (200 = 2.00%). +const HouseEdgeBP int64 = 200 + +// TickHz is the simulation rate. All rounds advance in whole ticks. +const TickHz = 60 + +// growthPerTickBP is the multiplier growth per tick in basis points of +// the current value: 1.0006x per tick compounds to roughly 2x in ~19 seconds. +const growthPerTickBP int64 = 6 + +// CrashPoint derives the multiplier at which a round ends, as a pure function +// of the seed. The distribution is the standard inverse-uniform curve scaled by +// the house edge, which gives an expected return of (1 - edge) at every +// cash-out target. +func CrashPoint(seed [32]byte) fixed.F { + r := NewRNG(seed) + // u is uniform over [0,1); take the top 52 bits for resolution. + u := r.Uint64() >> 12 + const denom = 1 << 52 + if u == 0 { + u = 1 + } + // crash = (1 - edge) / u, in fixed point. + edgeNum := 10000 - HouseEdgeBP + // numerator = (edgeNum/10000) * denom, kept in integer form. + num := fixed.FromInt(int64(denom)).Mul(fixed.F(edgeNum << 32 / 10000)) + cp := num.Div(fixed.FromInt(int64(u))) + if cp < fixed.One { + cp = fixed.One + } + return cp +} + +// MultiplierAt returns the multiplier shown at a given tick of the round. +// It compounds growthPerTickBP per tick starting from 1.0. +func MultiplierAt(tick int) fixed.F { + m := fixed.One + step := fixed.One + fixed.F(growthPerTickBP<<32/10000) + for i := 0; i < tick; i++ { + m = m.Mul(step) + } + return m +} + +// TicksToMultiplier returns the first tick at which MultiplierAt reaches m. +func TicksToMultiplier(m fixed.F) int { + cur := fixed.One + step := fixed.One + fixed.F(growthPerTickBP<<32/10000) + for tick := 0; tick < 1_000_000; tick++ { + if cur >= m { + return tick + } + cur = cur.Mul(step) + } + return 1_000_000 +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./pkg/sim/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add pkg/sim/crash.go pkg/sim/crash_test.go +git commit -m "feat(sim): add crash point derivation and multiplier curve" +``` + +--- + +### Task 5: Ledger schema + +**Files:** +- Create: `migrations/0001_ledger.sql` +- Create: `docker-compose.yml` + +**Interfaces:** +- Consumes: nothing +- Produces: tables `accounts`, `transactions`, `postings`; view `account_balances` + +- [ ] **Step 1: Write the migration** + +```sql +-- Quantum Arcade ledger: append-only double-entry accounting. +-- Amounts are millisatoshis stored as BIGINT. No UPDATE or DELETE is ever +-- issued against these tables; corrections are compensating transactions. + +CREATE TYPE account_kind AS ENUM ('player', 'house', 'lightning_bridge'); + +CREATE TABLE accounts ( + id BIGSERIAL PRIMARY KEY, + kind account_kind NOT NULL, + -- Player accounts key on the ed25519 public key; system accounts use a + -- stable name. Exactly one of these is set. + pubkey BYTEA UNIQUE, + name TEXT UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT account_identity CHECK ( + (kind = 'player' AND pubkey IS NOT NULL AND name IS NULL) OR + (kind <> 'player' AND pubkey IS NULL AND name IS NOT NULL) + ) +); + +CREATE TABLE transactions ( + id BIGSERIAL PRIMARY KEY, + kind TEXT NOT NULL, -- 'bet', 'payout', 'deposit', ... + round_id BIGINT, -- NULL for non-game transactions + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE postings ( + id BIGSERIAL PRIMARY KEY, + transaction_id BIGINT NOT NULL REFERENCES transactions(id), + account_id BIGINT NOT NULL REFERENCES accounts(id), + -- Positive credits the account, negative debits it. + amount_msat BIGINT NOT NULL, + balance_before BIGINT NOT NULL, + balance_after BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT amount_nonzero CHECK (amount_msat <> 0), + CONSTRAINT balance_arithmetic CHECK (balance_after = balance_before + amount_msat), + CONSTRAINT balance_nonnegative CHECK (balance_after >= 0) +); + +CREATE INDEX postings_account_idx ON postings (account_id, id DESC); +CREATE INDEX postings_transaction_idx ON postings (transaction_id); +CREATE INDEX transactions_round_idx ON transactions (round_id) WHERE round_id IS NOT NULL; + +-- Current balance is the most recent posting's balance_after. +CREATE VIEW account_balances AS +SELECT DISTINCT ON (account_id) + account_id, balance_after AS balance_msat +FROM postings +ORDER BY account_id, id DESC; + +-- Enforce append-only at the database level. +CREATE OR REPLACE FUNCTION reject_mutation() RETURNS TRIGGER AS $$ +BEGIN + RAISE EXCEPTION 'ledger tables are append-only'; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER postings_append_only + BEFORE UPDATE OR DELETE ON postings + FOR EACH ROW EXECUTE FUNCTION reject_mutation(); + +CREATE TRIGGER transactions_append_only + BEFORE UPDATE OR DELETE ON transactions + FOR EACH ROW EXECUTE FUNCTION reject_mutation(); + +INSERT INTO accounts (kind, name) VALUES + ('house', 'house_pot'), + ('lightning_bridge', 'lightning_bridge'); +``` + +- [ ] **Step 2: Write the compose file** + +```yaml +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: arcade + POSTGRES_PASSWORD: arcade_dev + POSTGRES_DB: arcade + ports: ["5432:5432"] + volumes: + - pgdata:/var/lib/postgresql/data + - ./migrations:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U arcade"] + interval: 2s + timeout: 3s + retries: 20 + + redis: + image: redis:7-alpine + ports: ["6379:6379"] + volumes: + - redisdata:/data + +volumes: + pgdata: + redisdata: +``` + +- [ ] **Step 3: Bring it up and verify the schema loads** + +Run: `docker compose up -d postgres && sleep 8 && docker compose exec -T postgres psql -U arcade -d arcade -c '\dt'` +Expected: `accounts`, `postings`, `transactions` listed + +- [ ] **Step 4: Commit** + +```bash +git add migrations/ docker-compose.yml +git commit -m "feat(ledger): add append-only double-entry schema" +``` + +--- + +### Task 6: Ledger engine + +**Files:** +- Create: `pkg/ledger/ledger.go` +- Test: `pkg/ledger/ledger_test.go` + +**Interfaces:** +- Consumes: schema from Task 5 +- Produces: `type Posting struct{AccountID int64; AmountMsat int64}`; `type Ledger struct{...}`; `func New(*pgxpool.Pool) *Ledger`; `func (*Ledger) Post(ctx, kind string, roundID *int64, postings []Posting) (txID int64, err error)`; `func (*Ledger) Balance(ctx, accountID int64) (int64, error)`; `func (*Ledger) EnsurePlayer(ctx, pubkey []byte) (int64, error)`; `var ErrUnbalanced`; `var ErrInsufficientFunds` + +- [ ] **Step 1: Write the failing test** + +```go +package ledger_test + +import ( + "context" + "errors" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/drjones/quantum-arcade/pkg/ledger" +) + +func testPool(t *testing.T) *pgxpool.Pool { + t.Helper() + dsn := os.Getenv("ARCADE_TEST_DSN") + if dsn == "" { + dsn = "postgres://arcade:arcade_dev@localhost:5432/arcade" + } + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + t.Skipf("no database available: %v", err) + } + return pool +} + +func TestPostRejectsUnbalanced(t *testing.T) { + l := ledger.New(testPool(t)) + ctx := context.Background() + a, _ := l.EnsurePlayer(ctx, []byte("pubkey-unbalanced-a")) + b, _ := l.EnsurePlayer(ctx, []byte("pubkey-unbalanced-b")) + _, err := l.Post(ctx, "test", nil, []ledger.Posting{ + {AccountID: a, AmountMsat: -100}, + {AccountID: b, AmountMsat: 50}, + }) + if !errors.Is(err, ledger.ErrUnbalanced) { + t.Fatalf("got %v, want ErrUnbalanced", err) + } +} + +func TestPostRejectsOverdraft(t *testing.T) { + l := ledger.New(testPool(t)) + ctx := context.Background() + a, _ := l.EnsurePlayer(ctx, []byte("pubkey-overdraft-a")) + b, _ := l.EnsurePlayer(ctx, []byte("pubkey-overdraft-b")) + _, err := l.Post(ctx, "test", nil, []ledger.Posting{ + {AccountID: a, AmountMsat: -1_000_000}, + {AccountID: b, AmountMsat: 1_000_000}, + }) + if !errors.Is(err, ledger.ErrInsufficientFunds) { + t.Fatalf("got %v, want ErrInsufficientFunds", err) + } +} + +func TestConservationOfValue(t *testing.T) { + l := ledger.New(testPool(t)) + ctx := context.Background() + bridge, err := l.AccountByName(ctx, "lightning_bridge") + if err != nil { + t.Fatal(err) + } + p, _ := l.EnsurePlayer(ctx, []byte("pubkey-conservation")) + + before, _ := l.TotalIssued(ctx) + // Fund the player from the bridge, then move it back. + if _, err := l.Post(ctx, "deposit", nil, []ledger.Posting{ + {AccountID: bridge, AmountMsat: -5000}, + {AccountID: p, AmountMsat: 5000}, + }); err != nil { + t.Fatal(err) + } + if _, err := l.Post(ctx, "withdraw", nil, []ledger.Posting{ + {AccountID: p, AmountMsat: -5000}, + {AccountID: bridge, AmountMsat: 5000}, + }); err != nil { + t.Fatal(err) + } + after, _ := l.TotalIssued(ctx) + if before != after { + t.Fatalf("total value changed: %d -> %d", before, after) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./pkg/ledger/ -v` +Expected: FAIL — undefined: ledger.New + +- [ ] **Step 3: Write minimal implementation** + +```go +// Package ledger implements append-only double-entry accounting. +// +// Invariants, enforced here and again by database constraints: +// - every transaction's postings sum to exactly zero +// - no account balance may go negative +// - rows are never updated or deleted +package ledger + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +var ( + ErrUnbalanced = errors.New("ledger: postings do not sum to zero") + ErrInsufficientFunds = errors.New("ledger: insufficient funds") + ErrEmptyTransaction = errors.New("ledger: transaction has no postings") +) + +// Posting is a single leg of a transaction. Positive credits, negative debits. +type Posting struct { + AccountID int64 + AmountMsat int64 +} + +type Ledger struct{ pool *pgxpool.Pool } + +func New(pool *pgxpool.Pool) *Ledger { return &Ledger{pool: pool} } + +// Post writes one balanced transaction atomically. Accounts are locked in a +// stable order so concurrent transactions cannot deadlock. +func (l *Ledger) Post(ctx context.Context, kind string, roundID *int64, postings []Posting) (int64, error) { + if len(postings) == 0 { + return 0, ErrEmptyTransaction + } + var sum int64 + for _, p := range postings { + sum += p.AmountMsat + } + if sum != 0 { + return 0, fmt.Errorf("%w: sum is %d", ErrUnbalanced, sum) + } + + tx, err := l.pool.Begin(ctx) + if err != nil { + return 0, err + } + defer tx.Rollback(ctx) + + var txID int64 + if err := tx.QueryRow(ctx, + `INSERT INTO transactions (kind, round_id) VALUES ($1, $2) RETURNING id`, + kind, roundID).Scan(&txID); err != nil { + return 0, err + } + + // Lock accounts in ascending id order to keep lock acquisition consistent. + ordered := append([]Posting(nil), postings...) + for i := 1; i < len(ordered); i++ { + for j := i; j > 0 && ordered[j].AccountID < ordered[j-1].AccountID; j-- { + ordered[j], ordered[j-1] = ordered[j-1], ordered[j] + } + } + + for _, p := range ordered { + var before int64 + err := tx.QueryRow(ctx, + `SELECT COALESCE( + (SELECT balance_after FROM postings + WHERE account_id = $1 ORDER BY id DESC LIMIT 1), 0) + FROM accounts WHERE id = $1 FOR UPDATE`, + p.AccountID).Scan(&before) + if err != nil { + return 0, fmt.Errorf("locking account %d: %w", p.AccountID, err) + } + after := before + p.AmountMsat + if after < 0 { + return 0, fmt.Errorf("%w: account %d has %d, needs %d", + ErrInsufficientFunds, p.AccountID, before, -p.AmountMsat) + } + if _, err := tx.Exec(ctx, + `INSERT INTO postings + (transaction_id, account_id, amount_msat, balance_before, balance_after) + VALUES ($1, $2, $3, $4, $5)`, + txID, p.AccountID, p.AmountMsat, before, after); err != nil { + return 0, err + } + } + + if err := tx.Commit(ctx); err != nil { + return 0, err + } + return txID, nil +} + +// Balance returns the account's current balance in millisatoshis. +func (l *Ledger) Balance(ctx context.Context, accountID int64) (int64, error) { + var bal int64 + err := l.pool.QueryRow(ctx, + `SELECT COALESCE( + (SELECT balance_after FROM postings + WHERE account_id = $1 ORDER BY id DESC LIMIT 1), 0)`, + accountID).Scan(&bal) + return bal, err +} + +// EnsurePlayer returns the account id for a public key, creating it if needed. +func (l *Ledger) EnsurePlayer(ctx context.Context, pubkey []byte) (int64, error) { + var id int64 + err := l.pool.QueryRow(ctx, + `INSERT INTO accounts (kind, pubkey) VALUES ('player', $1) + ON CONFLICT (pubkey) DO UPDATE SET pubkey = EXCLUDED.pubkey + RETURNING id`, pubkey).Scan(&id) + return id, err +} + +// AccountByName resolves a system account such as "house_pot". +func (l *Ledger) AccountByName(ctx context.Context, name string) (int64, error) { + var id int64 + err := l.pool.QueryRow(ctx, + `SELECT id FROM accounts WHERE name = $1`, name).Scan(&id) + if errors.Is(err, pgx.ErrNoRows) { + return 0, fmt.Errorf("ledger: no account named %q", name) + } + return id, err +} + +// TotalIssued sums every account balance. It must be invariant across any +// sequence of balanced transactions. +func (l *Ledger) TotalIssued(ctx context.Context) (int64, error) { + var total int64 + err := l.pool.QueryRow(ctx, + `SELECT COALESCE(SUM(balance_msat), 0) FROM account_balances`).Scan(&total) + return total, err +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `docker compose up -d postgres && sleep 8 && go test ./pkg/ledger/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add pkg/ledger/ +git commit -m "feat(ledger): add double-entry posting engine" +``` + +--- + +### Task 7: Ledger property tests + +**Files:** +- Create: `pkg/ledger/property_test.go` + +**Interfaces:** +- Consumes: `ledger.Ledger`, `ledger.Posting` +- Produces: nothing + +- [ ] **Step 1: Write the test** + +```go +package ledger_test + +import ( + "context" + "math/rand" + "testing" + + "github.com/drjones/quantum-arcade/pkg/ledger" +) + +// Across a long run of random balanced transfers between random accounts, +// total value must never change and no balance may go negative. +func TestRandomTransfersConserveValue(t *testing.T) { + l := ledger.New(testPool(t)) + ctx := context.Background() + + bridge, err := l.AccountByName(ctx, "lightning_bridge") + if err != nil { + t.Fatal(err) + } + + const players = 8 + ids := make([]int64, players) + for i := range ids { + id, err := l.EnsurePlayer(ctx, []byte{'p', 'r', 'o', 'p', byte(i)}) + if err != nil { + t.Fatal(err) + } + ids[i] = id + // Fund each player so transfers have something to move. + if _, err := l.Post(ctx, "deposit", nil, []ledger.Posting{ + {AccountID: bridge, AmountMsat: -100_000}, + {AccountID: id, AmountMsat: 100_000}, + }); err != nil { + t.Fatal(err) + } + } + + before, err := l.TotalIssued(ctx) + if err != nil { + t.Fatal(err) + } + + rng := rand.New(rand.NewSource(1)) + for i := 0; i < 300; i++ { + from := ids[rng.Intn(players)] + to := ids[rng.Intn(players)] + if from == to { + continue + } + amt := int64(rng.Intn(5000) + 1) + _, err := l.Post(ctx, "transfer", nil, []ledger.Posting{ + {AccountID: from, AmountMsat: -amt}, + {AccountID: to, AmountMsat: amt}, + }) + // Insufficient funds is an acceptable outcome; anything else is not. + if err != nil && !isInsufficient(err) { + t.Fatalf("iteration %d: %v", i, err) + } + } + + after, err := l.TotalIssued(ctx) + if err != nil { + t.Fatal(err) + } + if before != after { + t.Fatalf("value not conserved: %d -> %d", before, after) + } + + for _, id := range ids { + bal, err := l.Balance(ctx, id) + if err != nil { + t.Fatal(err) + } + if bal < 0 { + t.Fatalf("account %d went negative: %d", id, bal) + } + } +} + +func isInsufficient(err error) bool { + return err != nil && (err == ledger.ErrInsufficientFunds || + containsErr(err, ledger.ErrInsufficientFunds)) +} + +func containsErr(err, target error) bool { + type unwrapper interface{ Unwrap() error } + for err != nil { + if err == target { + return true + } + u, ok := err.(unwrapper) + if !ok { + return false + } + err = u.Unwrap() + } + return false +} +``` + +- [ ] **Step 2: Run it** + +Run: `go test ./pkg/ledger/ -run TestRandomTransfers -v` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add pkg/ledger/property_test.go +git commit -m "test(ledger): add value-conservation property test" +``` + +--- + +## Self-Review Notes + +**Spec coverage for this milestone:** §3 module boundaries (packages created), +§4 determinism (Tasks 1–4), §7 ledger invariants (Tasks 5–7), §9 testing for +ledger and determinism (Tasks 2, 3, 7). + +**Deferred to later plans:** identity/keypairs (§2), commit-reveal fairness +(§5), games and round lifecycle (§6), Lightning (§7), UI (§8), load harness +(§9), deployment (§10). diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..21675ac --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/drjones/quantum-arcade + +go 1.26.5 diff --git a/pkg/fixed/fixed.go b/pkg/fixed/fixed.go new file mode 100644 index 0000000..6ef818e --- /dev/null +++ b/pkg/fixed/fixed.go @@ -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 +} diff --git a/pkg/fixed/fixed_test.go b/pkg/fixed/fixed_test.go new file mode 100644 index 0000000..a0eec5f --- /dev/null +++ b/pkg/fixed/fixed_test.go @@ -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) + } +} diff --git a/pkg/fixed/nofloat_test.go b/pkg/fixed/nofloat_test.go new file mode 100644 index 0000000..eeccd63 --- /dev/null +++ b/pkg/fixed/nofloat_test.go @@ -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 + }) + } + } + } +}