mirror of
https://github.com/luxfi/math.git
synced 2026-07-27 03:38:49 +00:00
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.
207 lines
5.8 KiB
Go
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
|
|
}
|