Files
math/sample/sample.go
T
Hanzo AI e018a9c3d1 LP-107 Phase 2: math substrate — params, backend, codec, modarith,
ntt, poly, rns, sample

Eight pure-Go reference packages that own the canonical semantics of
every cryptographic-math primitive Lux protocols share. Backends
(AVX2 / NEON / cgo+C++ / CUDA / Metal / WGSL) plug in behind the
substrate; KATs gate byte-equality across them.

Packages added:

* params/    — ModulusID, NTTParamID, FHEParamID, PulsarParamID,
               HashSuiteID, BackendID; KATHeader required-fields
               schema. Stable string IDs; renaming is a breaking
               change.

* backend/   — Policy enum (PureGo / NativeCPU / GPUPreferred /
               GPURequired); Resolve(policy, registered) returns the
               BackendID per LP-107's fallback chain. GPURequired
               returns ErrUnavailable when no GPU backend is
               registered.

* codec/     — Bounded readers (closes lattice issues #2 + #4 DoS
               class permanently). Limits + LimitError + Reader.
               ReadUint{16,32,64}Slice all reject the 70T-element
               attack input before allocating; depth + frame-bytes
               caps; iterative (no recursion). Regression test
               TestReadUint64Slice_RejectsHugeLength encodes the
               original lattice issue #4 input.

* modarith/  — Modulus type with QInv, R2, Barrett constants
               (computed via math/big once per modulus). AddMod /
               SubMod / MulMod (Div64 reference path). MontMulMod /
               ToMontgomery / FromMontgomery cross-checked against
               MulMod across 100 random Pulsar-Q pairs.
               ReductionMode enum byte-equal to lattice/types.

* ntt/       — Service + Backend interface; pure-Go backend
               delegates to lattice/v7/ring.SubRing.NTT/INTT (the
               canonical Lattigo-derived Montgomery NTT body — no
               re-implementation, just dispatch). Round-trip +
               batch + determinism tests on Pulsar N=256.

* poly/      — Add / Sub / ScalarMul / PointwiseMul (NTT-domain) /
               negacyclic Mul (NTT round-trip). Composes modarith +
               ntt; no lower-level reach.

* rns/       — Basis(Moduli, Name) for FHE RNS chains. Single +
               two-prime construction validated; rejects even moduli.
               Phase 3 will add basis-extension and modulus-switching.

* sample/    — Uniform (rejection sampling, mask-and-retry); Ternary
               (density + sign byte); CenteredBinomial (popcount
               difference); DiscreteGaussianRejection (6-sigma cutoff).
               All take an io.Reader so callers can KAT-replay.

Architecture invariants enforced in package docs:

  - Go is the canonical semantic reference.
  - Backend selection MUST NOT alter transcript bytes (consensus
    paths default to PolicyPureGo / PolicyNativeCPU).
  - No unbounded codec readers anywhere near wire formats.
  - No re-implementation: where lattice/v7/ring already owns the
    canonical body, we delegate, not fork.
  - One ID space; renaming is a breaking change.

Test posture: 8/8 packages green via `GOWORK=off go test ./...`.

Phases 3-7 (queued):
  3. lattice consumes math
  4. pulsar consumes lattice + math
  5. fhe consumes math
  6. luxcpp/crypto/math native backend mirror
  7. cross-runtime KAT release gate

See SUBSTRATE.md for the full posture and migration plan; full LP at
~/work/lux/lps/LP-107-lux-math-substrate.md.
2026-05-04 09:28:23 -07:00

207 lines
5.8 KiB
Go

// Copyright (c) 2026 Lux Industries Inc.
// SPDX-License-Identifier: BSD-3-Clause
// Package sample provides primitive samplers — uniform mod q, ternary,
// centered binomial, discrete Gaussian — used as building blocks by
// every Lux lattice protocol.
//
// LP-107 §"Sampling" — the canonical motivation. Protocol-specific
// samplers (e.g. Pulsar's transcript-bound discrete Gaussian for the
// proof of knowledge) remain in their owning protocol package; this
// package is the source of the primitive distributions those
// protocols compose.
//
// Determinism contract: every sampler accepts an io.Reader as its
// entropy source. Same seed → same samples, byte-identically.
//
// Phase 2 (this file): pure-Go reference implementation. Body uses
// luxfi/math/modarith for modular reduction.
package sample
import (
"encoding/binary"
"fmt"
"io"
"math/big"
)
// Uniform fills dst with values uniformly distributed in [0, q).
// Uses rejection sampling with a per-sample mask up to bits.Len64(q)
// bits to avoid bias. Reads len(dst) * ceil(log2(q)/8) bytes from r
// in expectation; rejection rate is at most 2x.
func Uniform(dst []uint64, q uint64, r io.Reader) error {
if q < 2 {
return fmt.Errorf("sample.Uniform: q=%d invalid", q)
}
// Mask = next power-of-two-minus-one >= q-1.
mask := uint64(1)
for mask < q {
mask <<= 1
}
mask--
buf := make([]byte, 8)
for i := range dst {
for {
if _, err := io.ReadFull(r, buf); err != nil {
return fmt.Errorf("sample.Uniform[%d]: %w", i, err)
}
v := binary.LittleEndian.Uint64(buf) & mask
if v < q {
dst[i] = v
break
}
}
}
return nil
}
// Ternary fills dst with values from {-1 mod q, 0, 1} per the given
// non-zero density (probability of non-zero coefficient). Standard
// lattice short-secret distribution.
func Ternary(dst []uint64, q uint64, density float64, r io.Reader) error {
if q < 2 {
return fmt.Errorf("sample.Ternary: q=%d invalid", q)
}
if density < 0 || density > 1 {
return fmt.Errorf("sample.Ternary: density=%f out of [0,1]", density)
}
// Two bytes per sample: byte 0 selects zero/non-zero, byte 1
// selects sign.
buf := make([]byte, 2)
thresh := byte(density * 256)
if density >= 1 {
thresh = 0xFF
}
for i := range dst {
if _, err := io.ReadFull(r, buf); err != nil {
return fmt.Errorf("sample.Ternary[%d]: %w", i, err)
}
if buf[0] >= thresh {
dst[i] = 0
continue
}
if buf[1]&1 == 0 {
dst[i] = 1
} else {
dst[i] = q - 1 // -1 mod q
}
}
return nil
}
// CenteredBinomial fills dst with values from a centered binomial
// distribution with parameter eta (Bin(2*eta, 0.5) - eta). Standard
// Module-LWE error distribution.
func CenteredBinomial(dst []uint64, q uint64, eta int, r io.Reader) error {
if q < 2 {
return fmt.Errorf("sample.CenteredBinomial: q=%d invalid", q)
}
if eta < 1 || eta > 32 {
return fmt.Errorf("sample.CenteredBinomial: eta=%d out of [1,32]", eta)
}
bytesPerSample := (2*eta + 7) / 8
buf := make([]byte, bytesPerSample)
for i := range dst {
if _, err := io.ReadFull(r, buf); err != nil {
return fmt.Errorf("sample.CenteredBinomial[%d]: %w", i, err)
}
// Compute popcount of first eta bits and last eta bits, take
// the difference.
var bits uint64
for j := 0; j < bytesPerSample; j++ {
bits |= uint64(buf[j]) << (j * 8)
}
mask := (uint64(1) << uint(eta)) - 1
a := bitCount64(bits & mask)
b := bitCount64((bits >> uint(eta)) & mask)
// signed difference in [-eta, +eta]; map to [0, q).
signed := a - b
if signed >= 0 {
dst[i] = uint64(signed) % q
} else {
dst[i] = q - uint64(-signed)%q
}
}
return nil
}
func bitCount64(x uint64) int64 {
count := int64(0)
for x != 0 {
count += int64(x & 1)
x >>= 1
}
return count
}
// DiscreteGaussianRejection samples one value approximately from the
// discrete Gaussian D_{Z, sigma}, centered at 0, via rejection
// sampling with a 6-sigma cutoff. Accepts ~64% of draws on average
// for sigma in the typical lattice range [3, 50]; exact for the
// uncentered tail.
//
// This is the same reference path used by lattice/gpu.SampleGaussian
// (which we're consolidating here under LP-107).
func DiscreteGaussianRejection(q uint64, sigma float64, r io.Reader) (uint64, error) {
if q < 2 {
return 0, fmt.Errorf("sample.DiscreteGaussianRejection: q=%d invalid", q)
}
if sigma <= 0 {
return 0, fmt.Errorf("sample.DiscreteGaussianRejection: sigma=%f invalid", sigma)
}
bound := int64(sigma*6 + 1)
buf := make([]byte, 8)
for {
if _, err := io.ReadFull(r, buf); err != nil {
return 0, fmt.Errorf("sample.DiscreteGaussianRejection: %w", err)
}
raw := binary.LittleEndian.Uint64(buf)
// Map raw to a signed integer in [-bound, +bound].
span := uint64(2*bound + 1)
v := int64(raw%span) - bound
// Accept with probability exp(-v^2 / (2 sigma^2)).
var probAccept big.Float
// Use float math to avoid float64 overflow on sigma*sigma for
// moderate sigma values.
num := float64(v * v)
den := 2 * sigma * sigma
probAccept.SetFloat64(num / den)
expVal := approxExp(-num / den)
// Draw acceptance threshold uniformly in [0, 1).
if _, err := io.ReadFull(r, buf); err != nil {
return 0, fmt.Errorf("sample.DiscreteGaussianRejection (accept): %w", err)
}
threshold := float64(binary.LittleEndian.Uint64(buf)) / float64(^uint64(0))
if threshold < expVal {
if v >= 0 {
return uint64(v) % q, nil
}
return q - uint64(-v)%q, nil
}
}
}
// approxExp returns exp(x) for x <= 0. Uses the standard taylor
// expansion truncated at 12 terms; sufficient for the sigma <= 50
// regime that lattice protocols use.
func approxExp(x float64) float64 {
if x > 0 {
return 1 // shouldn't happen; defensive
}
if x < -50 {
return 0
}
result := 1.0
term := 1.0
for n := 1; n <= 12; n++ {
term *= x / float64(n)
result += term
}
if result < 0 {
return 0
}
return result
}