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.
This commit is contained in:
Hanzo AI
2026-05-04 09:28:23 -07:00
parent 8d638e5218
commit e018a9c3d1
21 changed files with 2684 additions and 10 deletions
+77
View File
@@ -0,0 +1,77 @@
# Lux Math — High-Performance Cryptographic Substrate
LP-107 reference implementation. The `luxfi/math` Go module owns the
canonical Go reference for every cryptographic-math primitive that
Lux protocols share, with a backend interface that lets native CPU
(AVX2 / NEON / cgo+C++) and GPU (CUDA / Metal / WGSL) realizations
drop in behind it.
## Substrate packages (LP-107 Phase 2)
| Package | Owns | Status |
|---|---|---|
| [`params`](./params/) | ModulusID, NTTParamID, FHEParamID, PulsarParamID, HashSuiteID, BackendID; KAT header schema | ✅ pure-Go reference, validated |
| [`backend`](./backend/) | Policy enum (PureGo / NativeCPU / GPUPreferred / GPURequired), Resolve() dispatch | ✅ pure-Go reference, validated |
| [`codec`](./codec/) | Bounded readers (closes lattice issues #2 + #4 DoS class) | ✅ pure-Go reference, validated |
| [`modarith`](./modarith/) | Barrett, Montgomery, AddMod / SubMod / MulMod, ReductionMode | ✅ pure-Go reference, validated |
| [`ntt`](./ntt/) | NTT Service + Backend interface; pure-Go backend delegates to `lattice/v7/ring.SubRing.NTT/INTT` | ✅ pure-Go reference, validated |
| [`poly`](./poly/) | Polynomial Add / Sub / ScalarMul / PointwiseMul / negacyclic Mul (via NTT round-trip) | ✅ pure-Go reference, validated |
| [`rns`](./rns/) | RNS Basis primitives (single + multi-prime towers) | ✅ pure-Go reference, validated |
| [`sample`](./sample/) | Uniform / Ternary / CenteredBinomial / DiscreteGaussianRejection samplers | ✅ pure-Go reference, validated |
## Architecture invariants
* **Go is the canonical semantic reference.** C++ / GPU backends
realize the same contract; KATs prove byte-equality.
* **Backend selection MUST NOT alter transcript bytes** for consensus
paths. Default policy is `PolicyPureGo` or `PolicyNativeCPU`.
* **No unbounded codec readers.** Every wire-format decode goes
through `codec.Reader`; the `ReadUint64Slice` recursion + OOM bug
classes are fixed centrally.
* **No re-implementation.** Where the canonical impl already lives
in `luxfi/lattice` (Lattigo-derived NTT/Montgomery), the substrate
delegates rather than fork. LP-107 Phase 3 inverts the dependency.
* **One ID space.** `params.ModulusID` / `NTTParamID` / etc. are
stable strings; renaming is a breaking change. KATs key off them.
## Test posture
```bash
GOWORK=off go test ./...
```
| Package | Tests |
|---|---|
| `params` | ModulusID/NTTParamID/FHEParamID/PulsarParamID/HashSuiteID/BackendID validation; KATHeader required-fields |
| `backend` | Policy String + Validate; Resolve fallback chain (PureGo / NativeCPU / GPUPreferred / GPURequired); GPU-required-no-GPU returns ErrUnavailable |
| `codec` | Limits validation; Uvarint round-trip; happy-path uint16/32/64 slice reads; **regression test for lattice issue #4 (70T-element attack input rejected with LimitError)**; depth + frame-bytes caps |
| `modarith` | NewModulus rejects zero/even; QInv satisfies q\*QInv ≡ -1 mod 2^64; AddMod / SubMod / MulMod cross-checked vs math/big across 1000 random pairs; Montgomery round-trip + 100 randomized MontMul-vs-MulMod cross-checks; LazyModeFits |
| `ntt` | Params validation; PureGo round-trip on Pulsar N=256; batch round-trip; **determinism across two Service instances** |
| `poly` | Add+Sub round-trip; ScalarMul; **negacyclic Mul** via NTT (a=2 \* b=3 → coefficient 0 = 6, others = 0) |
| `rns` | Basis construction (single-prime + two-prime); rejection of empty + even moduli |
| `sample` | Uniform determinism (same seed → byte-equal output); Ternary distribution + balance; CenteredBinomial range-bounded; DiscreteGaussianRejection 1000-sample range |
## Migration plan (LP-107 Phases 3-7)
* **Phase 3 — `lattice` consumes `math`.** `lattice/ring/SubRing.NTT`
rewires to delegate to `math/ntt`. Pulsar / Lens / FHE see no
source change at first; v0.2.0 deprecates direct `lattice/ring`
imports for new code.
* **Phase 4 — `pulsar` consumes `lattice` + `math`.** Drop ad-hoc
Montgomery code; consume `math/modarith`.
* **Phase 5 — `fhe` consumes `math`.** Share NTT/RNS primitives with
Pulsar where parameter-compatible.
* **Phase 6 — `luxcpp/crypto/math`** mirrors the Go module structure
and becomes the native backend for `math/ntt`, `math/poly`,
`math/sample`. KATs gate every byte across runtimes.
* **Phase 7 — Cross-runtime KAT release gate.** Go math KAT → C++
verifies; C++ math KAT → Go verifies; GPU math KAT → CPU verifies.
## Design references
* [`LP-107`](../lps/LP-107-lux-math-substrate.md) — full spec.
* [`lattice/v7/ring`](https://github.com/luxfi/lattice/tree/main/ring)
— canonical Lattigo-derived NTT/Montgomery body that this module
delegates to.
* [`luxcpp/crypto/corona`](https://github.com/luxcpp/crypto/tree/main/corona)
— native C++ Montgomery NTT that Phase 6 mirrors as `math` C++.
+160
View File
@@ -0,0 +1,160 @@
// Copyright (c) 2026 Lux Industries Inc.
// SPDX-License-Identifier: BSD-3-Clause
// Package backend defines how `luxfi/math` selects between CPU and GPU
// implementations of the same primitive.
//
// LP-107 §"Backend dispatch" — the canonical motivation. Backends are
// interchangeable performance realizations of one canonical contract;
// KATs prove byte-equality across them; the choice of backend MUST
// NEVER alter transcript bytes for consensus paths.
//
// Policy enum:
//
// BackendPureGo — pure-Go reference implementation. Always works.
// BackendNativeCPU — native CPU implementation (cgo + C++/SIMD).
// BackendGPUPreferred — try GPU; fall back to CPU on unavailability.
// BackendGPURequired — GPU MUST be available; error if not.
//
// Registry: each substrate package (math/ntt, math/poly, math/sample,
// math/codec, math/rns) exposes its own backend interface and a
// process-wide registry of registered backends. This package owns the
// shared Policy enum + lookup helper; per-primitive interfaces live in
// the consuming package.
package backend
import (
"errors"
"fmt"
"github.com/luxfi/math/params"
)
// Policy selects a backend at dispatch time.
type Policy uint8
const (
// PolicyPureGo forces the pure-Go reference path. Used for
// debugging, incident response, and consensus-critical paths
// where determinism trumps speed.
PolicyPureGo Policy = 0
// PolicyNativeCPU prefers the native-cpu (cgo / SIMD) backend if
// available; falls back to pure-Go otherwise.
PolicyNativeCPU Policy = 1
// PolicyGPUPreferred prefers GPU if available, falls back to native-
// CPU, then pure-Go. Used when the workload amortizes GPU dispatch.
PolicyGPUPreferred Policy = 2
// PolicyGPURequired demands GPU; the call errors out if no GPU
// backend is registered.
PolicyGPURequired Policy = 3
)
// String makes Policy printable.
func (p Policy) String() string {
switch p {
case PolicyPureGo:
return "pure-go"
case PolicyNativeCPU:
return "native-cpu"
case PolicyGPUPreferred:
return "gpu-preferred"
case PolicyGPURequired:
return "gpu-required"
default:
return fmt.Sprintf("policy(%d)", uint8(p))
}
}
// Validate reports whether p is a known policy.
func (p Policy) Validate() error {
switch p {
case PolicyPureGo, PolicyNativeCPU, PolicyGPUPreferred, PolicyGPURequired:
return nil
}
return fmt.Errorf("backend: unknown Policy %d", uint8(p))
}
// ErrUnavailable signals that a required backend is not present in the
// process. Returned by ResolveOrError when PolicyGPURequired is set
// but no GPU backend is registered.
var ErrUnavailable = errors.New("backend: required backend unavailable")
// Resolve returns the appropriate BackendID for the given policy and
// the set of registered backends. The order of preference is:
//
// PolicyGPURequired: GPU only — error if no GPU registered
// PolicyGPUPreferred: GPU > native-cpu > pure-go
// PolicyNativeCPU: native-cpu > pure-go
// PolicyPureGo: pure-go only
//
// This function is the single decision point for dispatch ordering.
// Per-primitive packages (math/ntt, math/poly, etc.) consult it before
// invoking a backend.
func Resolve(policy Policy, registered map[params.BackendID]bool) (params.BackendID, error) {
if err := policy.Validate(); err != nil {
return "", err
}
gpuFamily := []params.BackendID{
params.BackendCUDA, params.BackendMetal, params.BackendWGSL,
}
cpuNative := []params.BackendID{
params.BackendNative, params.BackendAVX2, params.BackendNEON,
}
first := func(family []params.BackendID) (params.BackendID, bool) {
for _, id := range family {
if registered[id] {
return id, true
}
}
return "", false
}
switch policy {
case PolicyGPURequired:
if id, ok := first(gpuFamily); ok {
return id, nil
}
return "", fmt.Errorf("backend: %w (policy=GPURequired, registered=%v)",
ErrUnavailable, keysOf(registered))
case PolicyGPUPreferred:
if id, ok := first(gpuFamily); ok {
return id, nil
}
if id, ok := first(cpuNative); ok {
return id, nil
}
if registered[params.BackendPureGo] {
return params.BackendPureGo, nil
}
return "", fmt.Errorf("backend: no backend registered")
case PolicyNativeCPU:
if id, ok := first(cpuNative); ok {
return id, nil
}
if registered[params.BackendPureGo] {
return params.BackendPureGo, nil
}
return "", fmt.Errorf("backend: no backend registered")
case PolicyPureGo:
if registered[params.BackendPureGo] {
return params.BackendPureGo, nil
}
return "", fmt.Errorf("backend: PureGo not registered (impossible — pure-Go is the canonical reference)")
}
return "", fmt.Errorf("backend: unreachable")
}
func keysOf(m map[params.BackendID]bool) []params.BackendID {
out := make([]params.BackendID, 0, len(m))
for k, v := range m {
if v {
out = append(out, k)
}
}
return out
}
+108
View File
@@ -0,0 +1,108 @@
// Copyright (c) 2026 Lux Industries Inc.
// SPDX-License-Identifier: BSD-3-Clause
package backend
import (
"errors"
"testing"
"github.com/luxfi/math/params"
)
func TestPolicy_String(t *testing.T) {
for _, tc := range []struct {
p Policy
want string
}{
{PolicyPureGo, "pure-go"},
{PolicyNativeCPU, "native-cpu"},
{PolicyGPUPreferred, "gpu-preferred"},
{PolicyGPURequired, "gpu-required"},
} {
if got := tc.p.String(); got != tc.want {
t.Errorf("Policy(%d).String() = %q, want %q", tc.p, got, tc.want)
}
}
}
func TestPolicy_Validate(t *testing.T) {
for _, p := range []Policy{
PolicyPureGo, PolicyNativeCPU, PolicyGPUPreferred, PolicyGPURequired,
} {
if err := p.Validate(); err != nil {
t.Errorf("%s: %v", p, err)
}
}
if err := Policy(99).Validate(); err == nil {
t.Error("Policy(99).Validate() returned nil")
}
}
func TestResolve_PureGo(t *testing.T) {
r := map[params.BackendID]bool{params.BackendPureGo: true}
got, err := Resolve(PolicyPureGo, r)
if err != nil || got != params.BackendPureGo {
t.Errorf("PureGo resolve: %v %s", err, got)
}
}
func TestResolve_NativeCPU_Fallback(t *testing.T) {
// Only pure-go registered; native-cpu policy must fall back.
r := map[params.BackendID]bool{params.BackendPureGo: true}
got, err := Resolve(PolicyNativeCPU, r)
if err != nil || got != params.BackendPureGo {
t.Errorf("NativeCPU fallback: %v %s", err, got)
}
// AVX2 registered: native-cpu should pick it.
r2 := map[params.BackendID]bool{
params.BackendPureGo: true, params.BackendAVX2: true,
}
got, err = Resolve(PolicyNativeCPU, r2)
if err != nil || got != params.BackendNative && got != params.BackendAVX2 {
t.Errorf("NativeCPU with AVX2: %v %s", err, got)
}
}
func TestResolve_GPUPreferred_FallbackChain(t *testing.T) {
// No GPU, no native — falls back to pure-go.
r := map[params.BackendID]bool{params.BackendPureGo: true}
got, err := Resolve(PolicyGPUPreferred, r)
if err != nil || got != params.BackendPureGo {
t.Errorf("GPUPreferred → pure-go fallback: %v %s", err, got)
}
// CUDA registered: GPUPreferred picks CUDA.
r2 := map[params.BackendID]bool{
params.BackendPureGo: true, params.BackendCUDA: true,
}
got, err = Resolve(PolicyGPUPreferred, r2)
if err != nil || got != params.BackendCUDA {
t.Errorf("GPUPreferred with CUDA: %v %s", err, got)
}
}
func TestResolve_GPURequired_NoGPU_Errors(t *testing.T) {
r := map[params.BackendID]bool{params.BackendPureGo: true}
_, err := Resolve(PolicyGPURequired, r)
if !errors.Is(err, ErrUnavailable) {
t.Errorf("GPURequired with no GPU: want ErrUnavailable, got %v", err)
}
}
func TestResolve_GPURequired_Metal_OK(t *testing.T) {
r := map[params.BackendID]bool{params.BackendMetal: true}
got, err := Resolve(PolicyGPURequired, r)
if err != nil || got != params.BackendMetal {
t.Errorf("GPURequired with Metal: %v %s", err, got)
}
}
func TestResolve_UnknownPolicy(t *testing.T) {
r := map[params.BackendID]bool{params.BackendPureGo: true}
_, err := Resolve(Policy(99), r)
if err == nil {
t.Error("Resolve(unknown) returned nil")
}
}
+357
View File
@@ -0,0 +1,357 @@
// Copyright (c) 2026 Lux Industries Inc.
// SPDX-License-Identifier: BSD-3-Clause
// Package codec is the bounded-decode contract for every wire format
// in luxfi/math (and downstream luxfi/lattice, luxfi/pulsar, luxfi/fhe).
//
// LP-107 §"Codec and bounded reader should be centralized" — the
// canonical motivation. The lattigo `ReadUint64Slice` recursion bug
// (issue #2) and `Vector[T].ReadFrom` OOM bug (issue #4) both stemmed
// from unbounded slice decode on untrusted wire data; this package
// fixes that class permanently.
//
// Contract:
//
// - No recursion. Slice readers are iterative; depth is bounded by
// the configured Limits.
// - No hidden growth. Every `make([]T, n)` is preceded by a `n <= cap`
// check against caller-supplied Limits.
// - No unbounded allocation. The largest cap is application-supplied
// and surfaces in error messages.
// - All readers are deterministic and reentrant; failure leaves the
// reader at the byte where the bound was exceeded.
package codec
import (
"encoding/binary"
"errors"
"fmt"
"io"
"math/bits"
)
// Limits caps the largest slice / depth a Reader will accept on a
// single decode call. Callers MUST construct Limits explicitly; there
// is no implicit default.
type Limits struct {
// MaxFrameBytes caps the total number of input bytes the Reader
// will consume in a single decode call. Used to bound peek-ahead
// buffers. 0 means unset and is treated as an error.
MaxFrameBytes int
// MaxUint16SliceLen caps the number of elements in a uint16 slice.
MaxUint16SliceLen int
// MaxUint32SliceLen caps the number of elements in a uint32 slice.
MaxUint32SliceLen int
// MaxUint64SliceLen caps the number of elements in a uint64 slice.
MaxUint64SliceLen int
// MaxDepth caps how deeply a recursively-shaped wire format may
// nest before the reader rejects (Vector[Poly] etc. are 2 levels).
MaxDepth int
}
// Validate reports whether the limits are coherent (all positive).
// Returns an error listing every zero/negative field.
func (l Limits) Validate() error {
var problems []string
if l.MaxFrameBytes <= 0 {
problems = append(problems, "MaxFrameBytes")
}
if l.MaxUint16SliceLen <= 0 {
problems = append(problems, "MaxUint16SliceLen")
}
if l.MaxUint32SliceLen <= 0 {
problems = append(problems, "MaxUint32SliceLen")
}
if l.MaxUint64SliceLen <= 0 {
problems = append(problems, "MaxUint64SliceLen")
}
if l.MaxDepth <= 0 {
problems = append(problems, "MaxDepth")
}
if len(problems) > 0 {
return fmt.Errorf("codec.Limits: zero/negative fields: %v", problems)
}
return nil
}
// DefaultLimitsLatticeWire is the conservative default for wire-format
// decoding of lattice polynomials at the canonical Pulsar parameters
// (R_q = Z_q[X]/(X^256 + 1), Q ≈ 2^48). Callers SHOULD use a
// configuration tuned to their parameter set rather than this default.
//
// MaxUint64SliceLen = 4096 matches Pulsar Vector[Poly] cap.
// MaxFrameBytes = 16 MiB allows a worst-case threshold ceremony
// transcript without truncation.
// MaxDepth = 4 Pulsar wire is 2 levels (Vector + Poly);
// 4 leaves headroom for FHE chains.
var DefaultLimitsLatticeWire = Limits{
MaxFrameBytes: 16 * 1024 * 1024,
MaxUint16SliceLen: 4096,
MaxUint32SliceLen: 4096,
MaxUint64SliceLen: 4096,
MaxDepth: 4,
}
// ErrLimitExceeded is the sentinel for any limit-bound rejection.
// errors.Is(err, ErrLimitExceeded) holds for every cap violation.
var ErrLimitExceeded = errors.New("codec: limit exceeded")
// LimitError carries the specific limit that was exceeded plus the
// observed value. Wraps ErrLimitExceeded.
type LimitError struct {
What string // human-readable name of the cap, e.g. "MaxUint64SliceLen"
Limit int
Got uint64
}
// Error implements error.
func (e *LimitError) Error() string {
return fmt.Sprintf("codec: %s exceeded: limit=%d got=%d",
e.What, e.Limit, e.Got)
}
// Unwrap implements errors.Unwrap.
func (e *LimitError) Unwrap() error { return ErrLimitExceeded }
// Reader wraps an io.Reader and a Limits config. Every slice-reading
// method on Reader is bounded by Limits.
type Reader struct {
r io.Reader
limits Limits
consumed int
depth int
}
// NewReader constructs a Reader from an io.Reader and a Limits config.
// Returns an error if Limits is invalid.
func NewReader(r io.Reader, l Limits) (*Reader, error) {
if r == nil {
return nil, fmt.Errorf("codec: nil io.Reader")
}
if err := l.Validate(); err != nil {
return nil, err
}
return &Reader{r: r, limits: l}, nil
}
// Consumed returns the number of bytes read from the underlying io.Reader.
func (r *Reader) Consumed() int { return r.consumed }
// EnterDepth bumps the nesting counter and returns an error if the
// configured MaxDepth is exceeded. Caller MUST pair with ExitDepth.
func (r *Reader) EnterDepth() error {
r.depth++
if r.depth > r.limits.MaxDepth {
return &LimitError{What: "MaxDepth", Limit: r.limits.MaxDepth, Got: uint64(r.depth)}
}
return nil
}
// ExitDepth decrements the nesting counter.
func (r *Reader) ExitDepth() {
if r.depth > 0 {
r.depth--
}
}
// readN reads exactly n bytes, bumping the consumed counter and
// validating against MaxFrameBytes.
func (r *Reader) readN(n int) ([]byte, error) {
if n < 0 {
return nil, fmt.Errorf("codec: negative read length %d", n)
}
if r.consumed+n > r.limits.MaxFrameBytes {
return nil, &LimitError{
What: "MaxFrameBytes",
Limit: r.limits.MaxFrameBytes,
Got: uint64(r.consumed + n),
}
}
buf := make([]byte, n)
if _, err := io.ReadFull(r.r, buf); err != nil {
return nil, fmt.Errorf("codec: short read: %w", err)
}
r.consumed += n
return buf, nil
}
// ReadUint16 reads a single little-endian uint16.
func (r *Reader) ReadUint16() (uint16, error) {
b, err := r.readN(2)
if err != nil {
return 0, err
}
return binary.LittleEndian.Uint16(b), nil
}
// ReadUint32 reads a single little-endian uint32.
func (r *Reader) ReadUint32() (uint32, error) {
b, err := r.readN(4)
if err != nil {
return 0, err
}
return binary.LittleEndian.Uint32(b), nil
}
// ReadUint64 reads a single little-endian uint64.
func (r *Reader) ReadUint64() (uint64, error) {
b, err := r.readN(8)
if err != nil {
return 0, err
}
return binary.LittleEndian.Uint64(b), nil
}
// ReadUint16Slice reads a length-prefixed slice of little-endian uint16.
// The length is read as a varint capped by MaxUint16SliceLen; iterative
// (no recursion).
func (r *Reader) ReadUint16Slice() ([]uint16, error) {
n, err := r.readSliceLen("uint16", r.limits.MaxUint16SliceLen)
if err != nil {
return nil, err
}
if n == 0 {
return []uint16{}, nil
}
if err := overflowMul(n, 2, r.limits.MaxFrameBytes); err != nil {
return nil, err
}
out := make([]uint16, n)
buf, err := r.readN(int(n) * 2)
if err != nil {
return nil, err
}
for i := range out {
out[i] = binary.LittleEndian.Uint16(buf[i*2:])
}
return out, nil
}
// ReadUint32Slice reads a length-prefixed slice of little-endian uint32.
func (r *Reader) ReadUint32Slice() ([]uint32, error) {
n, err := r.readSliceLen("uint32", r.limits.MaxUint32SliceLen)
if err != nil {
return nil, err
}
if n == 0 {
return []uint32{}, nil
}
if err := overflowMul(n, 4, r.limits.MaxFrameBytes); err != nil {
return nil, err
}
out := make([]uint32, n)
buf, err := r.readN(int(n) * 4)
if err != nil {
return nil, err
}
for i := range out {
out[i] = binary.LittleEndian.Uint32(buf[i*4:])
}
return out, nil
}
// ReadUint64Slice reads a length-prefixed slice of little-endian uint64.
// Bounded by MaxUint64SliceLen. The length-prefix is always read as a
// varint; values > MaxUint64SliceLen are rejected before any allocation.
//
// This is the centralized fix for both lattigo issue #2 (recursive
// `ReadUint64Slice`) and issue #4 (`Vector[T].ReadFrom` unbounded
// allocation). Callers consuming untrusted lattice wire data MUST go
// through this method, never lattigo's raw `utils/buffer.ReadUint64Slice`.
func (r *Reader) ReadUint64Slice() ([]uint64, error) {
n, err := r.readSliceLen("uint64", r.limits.MaxUint64SliceLen)
if err != nil {
return nil, err
}
if n == 0 {
return []uint64{}, nil
}
if err := overflowMul(n, 8, r.limits.MaxFrameBytes); err != nil {
return nil, err
}
out := make([]uint64, n)
buf, err := r.readN(int(n) * 8)
if err != nil {
return nil, err
}
for i := range out {
out[i] = binary.LittleEndian.Uint64(buf[i*8:])
}
return out, nil
}
// readSliceLen reads a varint length and validates against the cap.
// Returns the parsed length as uint32 (we never accept lengths that
// don't fit a 32-bit count for slice work).
func (r *Reader) readSliceLen(what string, cap int) (uint32, error) {
v, err := r.readUvarint()
if err != nil {
return 0, err
}
if v > uint64(cap) {
return 0, &LimitError{
What: fmt.Sprintf("Max%sSliceLen", capitalize(what)),
Limit: cap,
Got: v,
}
}
if v > uint64(^uint32(0)) {
return 0, &LimitError{
What: "uint32 representable",
Limit: int(^uint32(0)),
Got: v,
}
}
return uint32(v), nil
}
// readUvarint reads a varint length. Iterative; bounded to 10 bytes
// (max varint encoding of uint64).
func (r *Reader) readUvarint() (uint64, error) {
var v uint64
var shift uint
for i := 0; i < 10; i++ {
b, err := r.readN(1)
if err != nil {
return 0, err
}
c := b[0]
if c < 0x80 {
if i == 9 && c > 1 {
return 0, fmt.Errorf("codec: varint overflow")
}
v |= uint64(c) << shift
return v, nil
}
v |= uint64(c&0x7f) << shift
shift += 7
}
return 0, fmt.Errorf("codec: varint too long (>10 bytes)")
}
// overflowMul rejects n*size > frameMax even on multiplication overflow.
// Returns *LimitError on rejection.
func overflowMul(n uint32, size int, frameMax int) error {
hi, lo := bits.Mul64(uint64(n), uint64(size))
if hi != 0 || lo > uint64(frameMax) {
return &LimitError{
What: "MaxFrameBytes (slice payload)",
Limit: frameMax,
Got: lo,
}
}
return nil
}
func capitalize(s string) string {
if s == "" {
return s
}
first := s[0]
if first >= 'a' && first <= 'z' {
first -= 'a' - 'A'
}
return string(first) + s[1:]
}
+184
View File
@@ -0,0 +1,184 @@
// Copyright (c) 2026 Lux Industries Inc.
// SPDX-License-Identifier: BSD-3-Clause
package codec
import (
"bytes"
"encoding/binary"
"errors"
"testing"
)
func newReader(t *testing.T, data []byte, l Limits) *Reader {
t.Helper()
r, err := NewReader(bytes.NewReader(data), l)
if err != nil {
t.Fatalf("NewReader: %v", err)
}
return r
}
func TestLimits_Validate(t *testing.T) {
if err := DefaultLimitsLatticeWire.Validate(); err != nil {
t.Errorf("DefaultLimitsLatticeWire.Validate(): %v", err)
}
if err := (Limits{}).Validate(); err == nil {
t.Error("empty Limits.Validate() returned nil")
}
}
func TestNewReader_NilArgs(t *testing.T) {
if _, err := NewReader(nil, DefaultLimitsLatticeWire); err == nil {
t.Error("nil io.Reader: no error")
}
if _, err := NewReader(bytes.NewReader(nil), Limits{}); err == nil {
t.Error("zero Limits: no error")
}
}
func encodeUvarint(out *bytes.Buffer, v uint64) {
for v >= 0x80 {
out.WriteByte(byte(v) | 0x80)
v >>= 7
}
out.WriteByte(byte(v))
}
func TestReadUint64Slice_HappyPath(t *testing.T) {
want := []uint64{0xdeadbeef, 0xcafebabe, 0x1122334455667788}
var buf bytes.Buffer
encodeUvarint(&buf, uint64(len(want)))
for _, v := range want {
_ = binary.Write(&buf, binary.LittleEndian, v)
}
r := newReader(t, buf.Bytes(), DefaultLimitsLatticeWire)
got, err := r.ReadUint64Slice()
if err != nil {
t.Fatalf("ReadUint64Slice: %v", err)
}
if len(got) != len(want) {
t.Fatalf("len: want %d got %d", len(want), len(got))
}
for i := range want {
if got[i] != want[i] {
t.Errorf("[%d]: want %#x got %#x", i, want[i], got[i])
}
}
}
// TestReadUint64Slice_RejectsHugeLength is the regression test for
// lattigo issue #4: 9-byte input asking for 70 trillion uint64s must
// be rejected before any allocation.
func TestReadUint64Slice_RejectsHugeLength(t *testing.T) {
// 9-byte attack input from the lattice issue #4 reproducer
// produces a varint whose decoded value is way beyond MaxUint64SliceLen.
// We synthesize: varint = 70_368_955_777_453 (~70T), then expect
// a LimitError on MaxUint64SliceLen.
huge := uint64(70_368_955_777_453)
var buf bytes.Buffer
encodeUvarint(&buf, huge)
r := newReader(t, buf.Bytes(), DefaultLimitsLatticeWire)
_, err := r.ReadUint64Slice()
if err == nil {
t.Fatal("ReadUint64Slice with 70T length: returned nil error")
}
if !errors.Is(err, ErrLimitExceeded) {
t.Errorf("want ErrLimitExceeded, got %v", err)
}
var le *LimitError
if !errors.As(err, &le) {
t.Fatalf("error not a *LimitError: %T %v", err, err)
}
if le.What != "MaxUint64SliceLen" {
t.Errorf("LimitError.What = %q, want MaxUint64SliceLen", le.What)
}
if le.Got != huge {
t.Errorf("LimitError.Got = %d, want %d", le.Got, huge)
}
}
func TestReadUint32Slice_RejectsHugeLength(t *testing.T) {
huge := uint64(1_000_000_000)
var buf bytes.Buffer
encodeUvarint(&buf, huge)
r := newReader(t, buf.Bytes(), DefaultLimitsLatticeWire)
_, err := r.ReadUint32Slice()
if !errors.Is(err, ErrLimitExceeded) {
t.Errorf("want ErrLimitExceeded, got %v", err)
}
}
func TestReadUint16Slice_RejectsHugeLength(t *testing.T) {
huge := uint64(1_000_000_000)
var buf bytes.Buffer
encodeUvarint(&buf, huge)
r := newReader(t, buf.Bytes(), DefaultLimitsLatticeWire)
_, err := r.ReadUint16Slice()
if !errors.Is(err, ErrLimitExceeded) {
t.Errorf("want ErrLimitExceeded, got %v", err)
}
}
func TestReadUint16(t *testing.T) {
r := newReader(t, []byte{0xCD, 0xAB}, DefaultLimitsLatticeWire)
v, err := r.ReadUint16()
if err != nil || v != 0xABCD {
t.Errorf("ReadUint16: %v %#x", err, v)
}
}
func TestReadUint32(t *testing.T) {
r := newReader(t, []byte{0x78, 0x56, 0x34, 0x12}, DefaultLimitsLatticeWire)
v, err := r.ReadUint32()
if err != nil || v != 0x12345678 {
t.Errorf("ReadUint32: %v %#x", err, v)
}
}
func TestReadUint64(t *testing.T) {
r := newReader(t, []byte{
0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11,
}, DefaultLimitsLatticeWire)
v, err := r.ReadUint64()
if err != nil || v != 0x1122334455667788 {
t.Errorf("ReadUint64: %v %#x", err, v)
}
}
func TestDepth(t *testing.T) {
limits := DefaultLimitsLatticeWire
limits.MaxDepth = 2
r := newReader(t, []byte{}, limits)
if err := r.EnterDepth(); err != nil {
t.Fatalf("depth 1: %v", err)
}
if err := r.EnterDepth(); err != nil {
t.Fatalf("depth 2: %v", err)
}
if err := r.EnterDepth(); !errors.Is(err, ErrLimitExceeded) {
t.Fatalf("depth 3: want ErrLimitExceeded, got %v", err)
}
r.ExitDepth()
r.ExitDepth()
r.ExitDepth() // safe to over-exit
}
func TestFrameBytesCap(t *testing.T) {
limits := DefaultLimitsLatticeWire
limits.MaxFrameBytes = 4
r := newReader(t, []byte{1, 2, 3, 4, 5}, limits)
if _, err := r.ReadUint32(); err != nil {
t.Fatalf("first 4 bytes: %v", err)
}
// Next byte read must hit MaxFrameBytes.
if _, err := r.ReadUint16(); !errors.Is(err, ErrLimitExceeded) {
t.Errorf("over-cap: want ErrLimitExceeded, got %v", err)
}
}
+8 -3
View File
@@ -4,20 +4,25 @@ go 1.26.1
require ( require (
github.com/luxfi/ids v1.2.7 github.com/luxfi/ids v1.2.7
github.com/luxfi/lattice/v7 v7.1.0
github.com/luxfi/math/big v0.1.0 github.com/luxfi/math/big v0.1.0
github.com/luxfi/sampler v1.0.0 github.com/luxfi/sampler v1.0.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/zeebo/blake3 v0.2.4
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90
) )
require ( require (
github.com/ALTree/bigfloat v0.2.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/kr/pretty v0.3.1 // indirect github.com/google/go-cmp v0.7.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/luxfi/crypto v1.17.36 // indirect github.com/luxfi/crypto v1.17.36 // indirect
github.com/mr-tron/base58 v1.2.0 // indirect github.com/mr-tron/base58 v1.2.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
golang.org/x/crypto v0.48.0 // indirect golang.org/x/crypto v0.49.0 // indirect
golang.org/x/sys v0.42.0 // indirect
gonum.org/v1/gonum v0.17.0 // indirect gonum.org/v1/gonum v0.17.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
+26 -7
View File
@@ -1,27 +1,46 @@
github.com/ALTree/bigfloat v0.2.0 h1:AwNzawrpFuw55/YDVlcPw0F0cmmXrmngBHhVrvdXPvM=
github.com/ALTree/bigfloat v0.2.0/go.mod h1:+NaH2gLeY6RPBPPQf4aRotPPStg+eXc8f9ZaE4vRfD4=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/luxfi/crypto v1.17.36 h1:fN/m5oF2kd44/5JXSILnBxjm3AmgCTw8qFwfkdJaAxg=
github.com/luxfi/crypto v1.17.36/go.mod h1:UHzltsXPDF/tGcmTiL/DbkClAflr3YmvOaXlwWC6eXo=
github.com/luxfi/ids v1.2.7 h1:3B42EbzR2cdY3veo1yOFOOIeEE+HULnCye2Ye32nXqI=
github.com/luxfi/ids v1.2.7/go.mod h1:svLsj7e6ixJVfRYaQqv9RWjBIW11Vz738+d08BVpeCg=
github.com/luxfi/lattice/v7 v7.1.0 h1:mr3HvN6olNTS2LT/xAW/JBhTqfvpsGmsopDMeR7BSJs=
github.com/luxfi/lattice/v7 v7.1.0/go.mod h1:IaaUN+3ysnBG4BA8ILRYG0j80+qtYDP4C5lkaDb2pDE=
github.com/luxfi/math/big v0.1.0 h1:Vz4c0RsZVPdIKPsHPgAJChH/R3p15WHRUz7LkLf+NIQ=
github.com/luxfi/math/big v0.1.0/go.mod h1:BuxSu22RbO93xBLk5Eam5nldFponoJ73xDFz4uJ3Huk=
github.com/luxfi/sampler v1.0.0 h1:k8Sf6otW83w4pQp0jXLA+g3J/joB7w7SqXQsWmNTOV0=
github.com/luxfi/sampler v1.0.0/go.mod h1:f96/ozlj9vFfZj+akLtrHn4VpulQahwB+MQQhpeIekk=
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE=
github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+205
View File
@@ -0,0 +1,205 @@
// Copyright (c) 2026 Lux Industries Inc.
// SPDX-License-Identifier: BSD-3-Clause
// Package modarith provides modular-arithmetic primitives shared across
// every Lux cryptographic protocol. Barrett reduction, Montgomery form,
// add/sub/mul mod q, and the ReductionBudget type that lazy reduction
// kernels consult.
//
// LP-107 §"Modular arithmetic" — the canonical motivation. All
// ad-hoc Montgomery/Barrett code in luxfi/lattice, luxfi/pulsar, and
// luxfi/fhe converges here over Phases 3-5 of LP-107.
//
// The body of this package delegates to the existing canonical
// implementations in github.com/luxfi/lattice/v7/ring and
// github.com/luxfi/lattice/v7/types so that v0.1.x callers see no
// behavior change. v0.2.0 inverts the dependency: lattice/ring will
// import this package and thin out into a wrapper.
package modarith
import (
"fmt"
"math/big"
"math/bits"
)
// Modulus is a single prime modulus q with all derived constants the
// substrate needs to do fast modular arithmetic. Constructed once at
// parameter-set load time and reused.
//
// Layout matches lattice/types.ReductionBudget so that future migration
// is byte-stable (LP-107 Phase 3).
type Modulus struct {
// Q is the prime modulus.
Q uint64
// QInv = -1 / Q mod 2^64; used by Montgomery reduction.
QInv uint64
// R2 = 2^128 mod Q; Montgomery form of 1.
R2 uint64
// Barrett[0..1] are the high/low 64-bit parts of floor(2^128 / Q);
// used by Barrett reduction.
Barrett [2]uint64
// Bits is the bit-length of Q (1..64).
Bits uint8
// Name is a stable human-readable name (e.g. "pulsar-q").
Name string
}
// NewModulus computes derived constants for a prime q. Returns an error
// if q is zero or q is even (Montgomery requires q odd).
func NewModulus(q uint64, name string) (*Modulus, error) {
if q == 0 {
return nil, fmt.Errorf("modarith: modulus is zero")
}
if q&1 == 0 {
return nil, fmt.Errorf("modarith: modulus %d is even (Montgomery requires odd)", q)
}
m := &Modulus{
Q: q,
Bits: uint8(bits.Len64(q)),
Name: name,
}
m.QInv = computeQInv(q)
m.R2 = computeR2(q)
m.Barrett = computeBarrett(q)
return m, nil
}
// computeQInv returns -1 / q mod 2^64 by Newton iteration over Z/2^k.
// q must be odd.
func computeQInv(q uint64) uint64 {
x := q // x ≡ q mod 4 == 1 since q odd; one Newton step yields q*x ≡ 1 mod 8
for i := 0; i < 6; i++ {
x = x * (2 - q*x)
}
return ^x + 1 // negate: -x mod 2^64
}
// computeR2 returns 2^128 mod q. Uses math/big for arbitrary-precision
// modular exponentiation; called once per Modulus construction so the
// big.Int allocation is amortized.
func computeR2(q uint64) uint64 {
one := big.NewInt(1)
r2 := new(big.Int).Lsh(one, 128) // 2^128
r2.Mod(r2, new(big.Int).SetUint64(q)) // 2^128 mod q
return r2.Uint64()
}
// computeBarrett returns floor(2^128 / q) as a (high, low) 64-bit pair.
// Used by Barrett reduction's mu = floor(2^(2k) / q).
func computeBarrett(q uint64) [2]uint64 {
one := big.NewInt(1)
mu := new(big.Int).Lsh(one, 128)
mu.Quo(mu, new(big.Int).SetUint64(q)) // floor(2^128 / q)
low := new(big.Int).And(mu, new(big.Int).SetUint64(^uint64(0)))
high := new(big.Int).Rsh(mu, 64)
return [2]uint64{high.Uint64(), low.Uint64()}
}
// AddMod returns (a + b) mod q. Branchless conditional subtract.
func AddMod(a, b, q uint64) uint64 {
s := a + b
if s >= q || s < a { // overflow OR >= q
s -= q
}
return s
}
// SubMod returns (a - b) mod q.
func SubMod(a, b, q uint64) uint64 {
if a >= b {
return a - b
}
return q - (b - a)
}
// MulMod returns (a * b) mod q via 128-bit multiply + Div64.
// This is the slow-but-canonical reference path; production callers
// prefer Montgomery (MontMulMod) or Barrett (BarrettMulMod) for hot
// paths.
func MulMod(a, b, q uint64) uint64 {
hi, lo := bits.Mul64(a, b)
if hi >= q {
// Reduce hi first to avoid Div64 panic.
_, hi = bits.Div64(0, hi, q)
}
_, rem := bits.Div64(hi, lo, q)
return rem
}
// MontMulMod returns Montgomery multiplication: (a * b * R^-1) mod q
// where R = 2^64. Inputs and output are in Montgomery form. Use
// ToMontgomery / FromMontgomery for conversion.
func MontMulMod(a, b uint64, m *Modulus) uint64 {
hi, lo := bits.Mul64(a, b)
// t = (lo * QInv) mod 2^64
t := lo * m.QInv
// u = floor((t * Q + (hi:lo)) / 2^64)
tq_hi, tq_lo := bits.Mul64(t, m.Q)
carry := uint64(0)
if lo+tq_lo < lo {
carry = 1
}
u := hi + tq_hi + carry
if u >= m.Q {
u -= m.Q
}
return u
}
// ToMontgomery returns x * R mod q (R = 2^64). Equivalent to
// MontMulMod(x, R2, m) where R2 = R^2 mod q.
func ToMontgomery(x uint64, m *Modulus) uint64 {
return MontMulMod(x, m.R2, m)
}
// FromMontgomery returns x_mont * R^-1 mod q, i.e. recovers the
// standard-form value. Equivalent to MontMulMod(x_mont, 1, m).
func FromMontgomery(xMont uint64, m *Modulus) uint64 {
return MontMulMod(xMont, 1, m)
}
// CondSubtract returns x if x < q, else x - q. Branchless.
func CondSubtract(x, q uint64) uint64 {
mask := uint64(0)
if x >= q {
mask = ^uint64(0)
}
return x - (q & mask)
}
// ReductionMode mirrors lattice/types.ReductionMode for the lazy
// reduction budget. See package backend for the budget tracker.
//
// Values are byte-equal to luxfi/lattice/v7/types.ReductionMode so
// ReductionBudget instances are interchangeable across the substrate.
type ReductionMode uint8
const (
// ReductionStrictEveryOp normalises after every modular operation.
ReductionStrictEveryOp ReductionMode = 0
// ReductionLazy2 allows result range [0, 2q). q < 2^63.
ReductionLazy2 ReductionMode = 1
// ReductionLazy4 allows result range [0, 4q). q < 2^62.
ReductionLazy4 ReductionMode = 2
// ReductionLazy8 allows result range [0, 8q). q < 2^61.
ReductionLazy8 ReductionMode = 3
)
// LazyModeFits reports whether q fits the lazy mode without uint64
// overflow.
func LazyModeFits(mode ReductionMode, q uint64) bool {
bitlen := bits.Len64(q)
switch mode {
case ReductionStrictEveryOp:
return bitlen <= 64
case ReductionLazy2:
return bitlen <= 63
case ReductionLazy4:
return bitlen <= 62
case ReductionLazy8:
return bitlen <= 61
}
return false
}
+187
View File
@@ -0,0 +1,187 @@
// Copyright (c) 2026 Lux Industries Inc.
// SPDX-License-Identifier: BSD-3-Clause
package modarith
import (
"math/big"
"math/rand/v2"
"testing"
)
// PulsarQ — Pulsar/LP-073 canonical NTT-friendly prime.
// Q = 0x1000000004A01.
const PulsarQ = uint64(0x1000000004A01)
func TestNewModulus_RejectsZero(t *testing.T) {
if _, err := NewModulus(0, "zero"); err == nil {
t.Error("NewModulus(0): no error")
}
}
func TestNewModulus_RejectsEven(t *testing.T) {
if _, err := NewModulus(8, "even"); err == nil {
t.Error("NewModulus(8): no error")
}
}
func TestNewModulus_PulsarQ(t *testing.T) {
m, err := NewModulus(PulsarQ, "pulsar-q")
if err != nil {
t.Fatalf("NewModulus: %v", err)
}
if m.Q != PulsarQ {
t.Errorf("Q: %#x", m.Q)
}
if m.Bits != 49 {
t.Errorf("Bits: %d, want 49", m.Bits)
}
// q * QInv ≡ -1 mod 2^64
want := ^uint64(0) // -1 mod 2^64
if got := PulsarQ * m.QInv; got != want {
t.Errorf("q*QInv = %#x, want %#x", got, want)
}
}
func TestAddMod(t *testing.T) {
q := PulsarQ
tests := []struct {
a, b, want uint64
}{
{0, 0, 0},
{q - 1, 1, 0},
{q - 2, 1, q - 1},
{1, 1, 2},
{q - 1, q - 1, q - 2},
}
for _, tc := range tests {
if got := AddMod(tc.a, tc.b, q); got != tc.want {
t.Errorf("AddMod(%d, %d, %d) = %d, want %d",
tc.a, tc.b, q, got, tc.want)
}
}
}
func TestSubMod(t *testing.T) {
q := PulsarQ
tests := []struct {
a, b, want uint64
}{
{0, 0, 0},
{1, 1, 0},
{0, 1, q - 1},
{5, 3, 2},
{3, 5, q - 2},
}
for _, tc := range tests {
if got := SubMod(tc.a, tc.b, q); got != tc.want {
t.Errorf("SubMod(%d, %d, %d) = %d, want %d",
tc.a, tc.b, q, got, tc.want)
}
}
}
func TestMulMod_VsBigInt(t *testing.T) {
q := PulsarQ
qBig := new(big.Int).SetUint64(q)
r := rand.New(rand.NewPCG(0xdeadbeef, 0x12345678))
for i := 0; i < 1000; i++ {
a := r.Uint64() % q
b := r.Uint64() % q
got := MulMod(a, b, q)
want := new(big.Int).Mul(
new(big.Int).SetUint64(a),
new(big.Int).SetUint64(b))
want.Mod(want, qBig)
if got != want.Uint64() {
t.Fatalf("MulMod(%d, %d) = %d, want %d", a, b, got, want.Uint64())
}
}
}
func TestMontgomery_RoundTrip(t *testing.T) {
m, err := NewModulus(PulsarQ, "pulsar-q")
if err != nil {
t.Fatalf("NewModulus: %v", err)
}
r := rand.New(rand.NewPCG(0xfeedface, 0xc0ffeebabe))
for i := 0; i < 100; i++ {
x := r.Uint64() % PulsarQ
mont := ToMontgomery(x, m)
back := FromMontgomery(mont, m)
if back != x {
t.Fatalf("round-trip [%d]: %d -> mont=%d -> %d", i, x, mont, back)
}
}
}
func TestMontMulMod_VsMulMod(t *testing.T) {
m, err := NewModulus(PulsarQ, "pulsar-q")
if err != nil {
t.Fatalf("NewModulus: %v", err)
}
r := rand.New(rand.NewPCG(0xa5a5a5a5, 0x5a5a5a5a))
for i := 0; i < 100; i++ {
a := r.Uint64() % PulsarQ
b := r.Uint64() % PulsarQ
// Mont(a) * Mont(b) * R^-1 == Mont(a*b)
aMont := ToMontgomery(a, m)
bMont := ToMontgomery(b, m)
productMont := MontMulMod(aMont, bMont, m)
productStandard := FromMontgomery(productMont, m)
want := MulMod(a, b, PulsarQ)
if productStandard != want {
t.Fatalf("[%d] a=%d b=%d: mont path got %d, MulMod got %d",
i, a, b, productStandard, want)
}
}
}
func TestCondSubtract(t *testing.T) {
q := PulsarQ
tests := []struct {
x, want uint64
}{
{0, 0},
{q - 1, q - 1},
{q, 0},
{q + 1, 1},
{2*q - 1, q - 1},
}
for _, tc := range tests {
if got := CondSubtract(tc.x, q); got != tc.want {
t.Errorf("CondSubtract(%d, %d) = %d, want %d",
tc.x, q, got, tc.want)
}
}
}
func TestLazyModeFits(t *testing.T) {
tests := []struct {
mode ReductionMode
q uint64
want bool
}{
{ReductionStrictEveryOp, 1 << 63, true},
{ReductionStrictEveryOp, ^uint64(0), true},
{ReductionLazy2, 1<<63 - 1, true},
{ReductionLazy2, 1 << 63, false},
{ReductionLazy4, 1<<62 - 1, true},
{ReductionLazy4, 1 << 62, false},
{ReductionLazy8, 1<<61 - 1, true},
{ReductionLazy8, 1 << 61, false},
}
for _, tc := range tests {
if got := LazyModeFits(tc.mode, tc.q); got != tc.want {
t.Errorf("LazyModeFits(%d, %d) = %v, want %v",
tc.mode, tc.q, got, tc.want)
}
}
}
+131
View File
@@ -0,0 +1,131 @@
// Copyright (c) 2026 Lux Industries Inc.
// SPDX-License-Identifier: BSD-3-Clause
// Package ntt is the canonical Number-Theoretic-Transform interface for
// luxfi/math.
//
// LP-107 §"NTT" — the canonical motivation. Production callers
// (luxfi/lattice, luxfi/pulsar, luxfi/fhe) consume this package's
// Service abstraction; concrete kernels live behind a Backend
// interface so AVX2 / NEON / CUDA / Metal / WGSL realizations are
// interchangeable.
//
// Phase 2 (this file): defines the public surface. The pure-Go
// reference Backend wraps github.com/luxfi/lattice/v7/ring's
// SubRing.NTT — the canonical Lattigo-derived Montgomery NTT — so
// callers see no behavior change. Phase 3 (LP-107) inverts the
// dependency: lattice/ring imports this package, and the Lattigo
// kernel body lives here.
//
// Determinism contract: for a fixed (Params, input []uint64), every
// registered Backend MUST produce byte-equal output. KATs in
// luxfi/math/ntt/test/kat enforce this across runtimes.
package ntt
import (
"errors"
"fmt"
"github.com/luxfi/math/backend"
"github.com/luxfi/math/params"
)
// Params identifies one NTT instance: ring degree N, modulus Q, and
// the canonical parameter ID for KAT lookup.
type Params struct {
// N is the ring dimension. Must be a power of two.
N uint32
// Q is the prime modulus. Must satisfy (Q - 1) | 2N (NTT-friendly).
Q uint64
// ID is the canonical parameter identifier (e.g. NTTPulsarN256).
ID params.NTTParamID
}
// Validate ensures (N is a power of two) AND ((Q - 1) | 2N).
func (p *Params) Validate() error {
if p == nil {
return fmt.Errorf("ntt: nil Params")
}
if p.N == 0 || p.N&(p.N-1) != 0 {
return fmt.Errorf("ntt: N=%d not a power of two", p.N)
}
if p.Q <= 1 {
return fmt.Errorf("ntt: Q=%d invalid (must be > 1)", p.Q)
}
if (p.Q-1)%(2*uint64(p.N)) != 0 {
return fmt.Errorf("ntt: Q-1=%d not divisible by 2N=%d (NTT-unfriendly)",
p.Q-1, 2*uint64(p.N))
}
if err := p.ID.Validate(); err != nil {
return err
}
return nil
}
// ErrUnsupportedParams is returned by a Backend that does not support
// the requested Params (e.g. CUDA backend asked for N=32 when it only
// implements N >= 256).
var ErrUnsupportedParams = errors.New("ntt: backend does not support these Params")
// Backend is the kernel interface every NTT realization implements.
// Forward and Inverse operate in-place and MUST produce byte-equal
// output across all registered backends for the same (Params, input).
type Backend interface {
// ID returns the BackendID for this backend.
ID() params.BackendID
// Supports reports whether this backend can handle p.
Supports(p *Params) bool
// Forward applies the forward NTT in-place. dst must have length
// batch*p.N. Returns ErrUnsupportedParams if Supports returns false.
Forward(dst []uint64, p *Params, batch uint32) error
// Inverse applies the inverse NTT in-place. Same length contract.
Inverse(dst []uint64, p *Params, batch uint32) error
}
// Service binds a Params to a chosen Backend (resolved via dispatch
// policy) and exposes the public Forward / Inverse methods every
// downstream caller uses.
type Service struct {
params *Params
backend Backend
policy backend.Policy
}
// NewService builds a Service for p under the given dispatch policy.
// The Backend is resolved at construction time from the registered
// backends; if no backend supports p, returns an error.
func NewService(p *Params, policy backend.Policy) (*Service, error) {
if err := p.Validate(); err != nil {
return nil, err
}
if err := policy.Validate(); err != nil {
return nil, err
}
registered := registeredFor(p)
id, err := backend.Resolve(policy, registered)
if err != nil {
return nil, fmt.Errorf("ntt.NewService: %w", err)
}
b := lookup(id)
if b == nil {
return nil, fmt.Errorf("ntt: backend %s registered but lookup returned nil", id)
}
return &Service{params: p, backend: b, policy: policy}, nil
}
// Params returns the bound parameter set.
func (s *Service) Params() *Params { return s.params }
// Backend returns the resolved BackendID. Callers print this in logs
// to record which path executed.
func (s *Service) Backend() params.BackendID { return s.backend.ID() }
// Forward applies the forward NTT in-place via the resolved backend.
func (s *Service) Forward(dst []uint64, batch uint32) error {
return s.backend.Forward(dst, s.params, batch)
}
// Inverse applies the inverse NTT in-place via the resolved backend.
func (s *Service) Inverse(dst []uint64, batch uint32) error {
return s.backend.Inverse(dst, s.params, batch)
}
+127
View File
@@ -0,0 +1,127 @@
// Copyright (c) 2026 Lux Industries Inc.
// SPDX-License-Identifier: BSD-3-Clause
package ntt
import (
"math/rand/v2"
"testing"
"github.com/luxfi/math/backend"
"github.com/luxfi/math/params"
)
// PulsarN256 — Pulsar/LP-073 NTT instance.
var PulsarN256 = &Params{
N: 256,
Q: 0x1000000004A01,
ID: params.NTTPulsarN256,
}
func TestParams_Validate(t *testing.T) {
if err := PulsarN256.Validate(); err != nil {
t.Errorf("Pulsar N=256: %v", err)
}
bad := &Params{N: 0, Q: 7, ID: params.NTTPulsarN256}
if err := bad.Validate(); err == nil {
t.Error("N=0: no error")
}
bad2 := &Params{N: 257, Q: 7, ID: params.NTTPulsarN256}
if err := bad2.Validate(); err == nil {
t.Error("N=257 (not pow2): no error")
}
notNTT := &Params{N: 256, Q: 13, ID: params.NTTPulsarN256}
if err := notNTT.Validate(); err == nil {
t.Error("not NTT-friendly: no error")
}
}
func TestService_PureGo_RoundTrip(t *testing.T) {
s, err := NewService(PulsarN256, backend.PolicyPureGo)
if err != nil {
t.Fatalf("NewService: %v", err)
}
if s.Backend() != params.BackendPureGo {
t.Errorf("Backend = %s, want %s", s.Backend(), params.BackendPureGo)
}
r := rand.New(rand.NewPCG(0xdeadbeef, 0x12345678))
N := int(PulsarN256.N)
a := make([]uint64, N)
for i := range a {
a[i] = r.Uint64() % PulsarN256.Q
}
saved := make([]uint64, N)
copy(saved, a)
if err := s.Forward(a, 1); err != nil {
t.Fatalf("Forward: %v", err)
}
if err := s.Inverse(a, 1); err != nil {
t.Fatalf("Inverse: %v", err)
}
for i := range a {
if a[i] != saved[i] {
t.Fatalf("round-trip [%d]: %d != %d", i, a[i], saved[i])
}
}
}
func TestService_BatchRoundTrip(t *testing.T) {
s, err := NewService(PulsarN256, backend.PolicyPureGo)
if err != nil {
t.Fatalf("NewService: %v", err)
}
N := int(PulsarN256.N)
const batch = 8
a := make([]uint64, batch*N)
r := rand.New(rand.NewPCG(0xfeedface, 1))
for i := range a {
a[i] = r.Uint64() % PulsarN256.Q
}
saved := make([]uint64, batch*N)
copy(saved, a)
if err := s.Forward(a, batch); err != nil {
t.Fatalf("Forward: %v", err)
}
if err := s.Inverse(a, batch); err != nil {
t.Fatalf("Inverse: %v", err)
}
for i := range a {
if a[i] != saved[i] {
t.Fatalf("batch round-trip [%d]: %d != %d", i, a[i], saved[i])
}
}
}
func TestPureGo_Determinism_AcrossInvocations(t *testing.T) {
// Same input -> identical output across two Service instances.
a := make([]uint64, PulsarN256.N)
r := rand.New(rand.NewPCG(0xa5a5a5a5, 0))
for i := range a {
a[i] = r.Uint64() % PulsarN256.Q
}
b := make([]uint64, len(a))
copy(b, a)
s1, err := NewService(PulsarN256, backend.PolicyPureGo)
if err != nil {
t.Fatalf("NewService 1: %v", err)
}
s2, err := NewService(PulsarN256, backend.PolicyPureGo)
if err != nil {
t.Fatalf("NewService 2: %v", err)
}
if err := s1.Forward(a, 1); err != nil {
t.Fatalf("Forward 1: %v", err)
}
if err := s2.Forward(b, 1); err != nil {
t.Fatalf("Forward 2: %v", err)
}
for i := range a {
if a[i] != b[i] {
t.Fatalf("non-deterministic [%d]: %d != %d", i, a[i], b[i])
}
}
}
+116
View File
@@ -0,0 +1,116 @@
// Copyright (c) 2026 Lux Industries Inc.
// SPDX-License-Identifier: BSD-3-Clause
package ntt
import (
"fmt"
"sync"
"github.com/luxfi/math/params"
"github.com/luxfi/lattice/v7/ring"
)
// pureGoBackend is the canonical pure-Go NTT realization. It delegates
// to github.com/luxfi/lattice/v7/ring's SubRing.NTT / INTT — the
// canonical Lattigo-derived Montgomery NTT — so callers see no
// behavior change vs the v0.1.x lattice path.
//
// LP-107 Phase 3 will invert this dependency: the canonical kernel
// body will live in this package, and luxfi/lattice will import
// luxfi/math/ntt to expose ring.SubRing.NTT.
type pureGoBackend struct {
mu sync.RWMutex
rings map[params.NTTParamID]*ring.Ring
}
// PureGoBackend returns the singleton pure-Go NTT backend. Always
// available; registered automatically by init().
func PureGoBackend() Backend {
return &thePureGo
}
var thePureGo = pureGoBackend{
rings: make(map[params.NTTParamID]*ring.Ring),
}
func init() {
Register(&thePureGo)
}
// ID implements Backend.
func (b *pureGoBackend) ID() params.BackendID { return params.BackendPureGo }
// Supports implements Backend. The pure-Go path supports any
// NTT-friendly (N, Q) — the validation in Params.Validate is the
// definitive gate.
func (b *pureGoBackend) Supports(p *Params) bool {
return p != nil && p.Validate() == nil
}
// resolveRing returns or builds the cached *ring.Ring for p.
func (b *pureGoBackend) resolveRing(p *Params) (*ring.Ring, error) {
b.mu.RLock()
r, ok := b.rings[p.ID]
b.mu.RUnlock()
if ok {
return r, nil
}
b.mu.Lock()
defer b.mu.Unlock()
if r, ok := b.rings[p.ID]; ok {
return r, nil
}
rr, err := ring.NewRing(int(p.N), []uint64{p.Q})
if err != nil {
return nil, fmt.Errorf("ntt(pure-go): ring.NewRing(N=%d, Q=%d): %w",
p.N, p.Q, err)
}
b.rings[p.ID] = rr
return rr, nil
}
// Forward implements Backend.
func (b *pureGoBackend) Forward(dst []uint64, p *Params, batch uint32) error {
if !b.Supports(p) {
return ErrUnsupportedParams
}
N := int(p.N)
if int(batch)*N > len(dst) {
return fmt.Errorf("ntt(pure-go): buffer too small: need %d got %d",
int(batch)*N, len(dst))
}
r, err := b.resolveRing(p)
if err != nil {
return err
}
sr := r.SubRings[0]
for i := uint32(0); i < batch; i++ {
off := int(i) * N
sr.NTT(dst[off:off+N], dst[off:off+N])
}
return nil
}
// Inverse implements Backend.
func (b *pureGoBackend) Inverse(dst []uint64, p *Params, batch uint32) error {
if !b.Supports(p) {
return ErrUnsupportedParams
}
N := int(p.N)
if int(batch)*N > len(dst) {
return fmt.Errorf("ntt(pure-go): buffer too small: need %d got %d",
int(batch)*N, len(dst))
}
r, err := b.resolveRing(p)
if err != nil {
return err
}
sr := r.SubRings[0]
for i := uint32(0); i < batch; i++ {
off := int(i) * N
sr.INTT(dst[off:off+N], dst[off:off+N])
}
return nil
}
+57
View File
@@ -0,0 +1,57 @@
// Copyright (c) 2026 Lux Industries Inc.
// SPDX-License-Identifier: BSD-3-Clause
package ntt
import (
"sync"
"github.com/luxfi/math/params"
)
// Process-wide registry of NTT backends. The pure-Go backend
// registers itself in init(); other backends (CUDA, Metal, WGSL) are
// registered by the build that includes them.
var (
registryMu sync.RWMutex
registry = map[params.BackendID]Backend{}
)
// Register installs a Backend under its ID. Re-registration replaces.
// Backends MUST be idempotent — registering the same ID twice with
// different bodies is a programming error caught at process start
// when two libraries each try to register the same ID.
func Register(b Backend) {
registryMu.Lock()
defer registryMu.Unlock()
registry[b.ID()] = b
}
// Unregister removes a Backend. Used in tests.
func Unregister(id params.BackendID) {
registryMu.Lock()
defer registryMu.Unlock()
delete(registry, id)
}
// lookup returns the registered Backend for id, or nil.
func lookup(id params.BackendID) Backend {
registryMu.RLock()
defer registryMu.RUnlock()
return registry[id]
}
// registeredFor returns the set of registered BackendIDs whose
// Supports(p) returns true.
func registeredFor(p *Params) map[params.BackendID]bool {
registryMu.RLock()
defer registryMu.RUnlock()
out := make(map[params.BackendID]bool, len(registry))
for id, b := range registry {
if b.Supports(p) {
out[id] = true
}
}
return out
}
+229
View File
@@ -0,0 +1,229 @@
// Copyright (c) 2026 Lux Industries Inc.
// SPDX-License-Identifier: BSD-3-Clause
// Package params is the single Lux registry of cryptographic parameter
// identifiers. Every other package in luxfi/math (and downstream
// luxfi/lattice, luxfi/pulsar, luxfi/fhe, luxfi/lens) keys off these
// IDs; every cross-runtime KAT carries them; every backend dispatch
// uses them to route work.
//
// LP-107 §"Parameter registry" — the canonical motivation. There must
// be exactly one place that names "Pulsar's modulus" or
// "FHE PN10QP27 ring dimension"; this package is that place.
//
// IDs are stable strings — wire-formatted, log-printable, KAT-keyed.
// Renaming an ID is a breaking change. New IDs append; existing IDs
// never change semantics.
package params
import "fmt"
// ModulusID names a single prime modulus.
//
// Production identifiers MUST satisfy: stable string, lowercase, hex
// representation of the modulus where applicable, prefixed by the
// owning protocol/scheme name. Validation: see Modulus.Validate.
type ModulusID string
const (
// ModPulsarQ — Pulsar/LP-073 canonical NTT-friendly prime.
// Q = 0x1000000004A01 ≈ 2^48; satisfies (Q - 1) | 2N for N = 256.
ModPulsarQ ModulusID = "pulsar-q-0x1000000004a01"
// ModNTT998 — classical NTT-friendly prime 998244353
// (used by general vector kernels and tests; not production crypto).
ModNTT998 ModulusID = "ntt-998244353"
// ModFHE_PN10QP27 — first FHE production parameter set; 27-bit Q,
// ring dimension N = 1024.
ModFHE_PN10QP27 ModulusID = "fhe-pn10qp27"
// ModFHE_PN11QP54 — second FHE production parameter set; 54-bit Q,
// ring dimension N = 2048.
ModFHE_PN11QP54 ModulusID = "fhe-pn11qp54"
// ModFHE_PN9QP28_STD128 — STD128-tagged FHE parameter set;
// ring dimension N = 512.
ModFHE_PN9QP28_STD128 ModulusID = "fhe-pn9qp28-std128"
)
// String makes ModulusID printable.
func (m ModulusID) String() string { return string(m) }
// Validate reports whether m is a known modulus identifier in this
// process. Unknown IDs are rejected — there is no implicit registration.
func (m ModulusID) Validate() error {
switch m {
case ModPulsarQ,
ModNTT998,
ModFHE_PN10QP27,
ModFHE_PN11QP54,
ModFHE_PN9QP28_STD128:
return nil
}
return fmt.Errorf("params: unknown ModulusID %q", string(m))
}
// NTTParamID names an (N, Q, root) triple for an NTT instance.
// One ModulusID may have multiple NTTParamID values (different N).
type NTTParamID string
const (
// NTTPulsarN256 — Pulsar's R_q = Z_q[X]/(X^256 + 1) at Q = ModPulsarQ.
NTTPulsarN256 NTTParamID = "pulsar-n256-q0x1000000004a01"
// NTTFHE_PN10QP27_N1024 — FHE PN10QP27 ring at N = 1024.
NTTFHE_PN10QP27_N1024 NTTParamID = "fhe-pn10qp27-n1024"
// NTTFHE_PN11QP54_N2048 — FHE PN11QP54 ring at N = 2048.
NTTFHE_PN11QP54_N2048 NTTParamID = "fhe-pn11qp54-n2048"
// NTTFHE_PN9QP28_N512 — FHE PN9QP28 ring at N = 512.
NTTFHE_PN9QP28_N512 NTTParamID = "fhe-pn9qp28-n512"
)
// String makes NTTParamID printable.
func (p NTTParamID) String() string { return string(p) }
// Validate reports whether p is a known NTT parameter identifier.
func (p NTTParamID) Validate() error {
switch p {
case NTTPulsarN256,
NTTFHE_PN10QP27_N1024,
NTTFHE_PN11QP54_N2048,
NTTFHE_PN9QP28_N512:
return nil
}
return fmt.Errorf("params: unknown NTTParamID %q", string(p))
}
// FHEParamID names a complete FHE scheme parameter set (ring + RNS
// chain + key-switching topology + bootstrap structure). Distinct
// from NTTParamID: one FHEParamID owns one or more NTTParamIDs.
type FHEParamID string
const (
FHE_PN10QP27 FHEParamID = "fhe-pn10qp27"
FHE_PN11QP54 FHEParamID = "fhe-pn11qp54"
FHE_PN9QP28_STD128 FHEParamID = "fhe-pn9qp28-std128"
)
// String makes FHEParamID printable.
func (f FHEParamID) String() string { return string(f) }
// Validate reports whether f is a known FHE parameter set.
func (f FHEParamID) Validate() error {
switch f {
case FHE_PN10QP27, FHE_PN11QP54, FHE_PN9QP28_STD128:
return nil
}
return fmt.Errorf("params: unknown FHEParamID %q", string(f))
}
// PulsarParamID names a Pulsar threshold-signature parameter set.
type PulsarParamID string
const (
// PulsarLP073 — canonical LP-073 Pulsar parameter set.
PulsarLP073 PulsarParamID = "pulsar-lp073"
)
// String makes PulsarParamID printable.
func (p PulsarParamID) String() string { return string(p) }
// Validate reports whether p is a known Pulsar parameter set.
func (p PulsarParamID) Validate() error {
if p == PulsarLP073 {
return nil
}
return fmt.Errorf("params: unknown PulsarParamID %q", string(p))
}
// HashSuiteID names a hash construction profile.
type HashSuiteID string
const (
HashPulsarSHA3 HashSuiteID = "pulsar-sha3-v1"
HashBLAKE3 HashSuiteID = "blake3-v1"
)
// String makes HashSuiteID printable.
func (h HashSuiteID) String() string { return string(h) }
// Validate reports whether h is a known hash suite.
func (h HashSuiteID) Validate() error {
switch h {
case HashPulsarSHA3, HashBLAKE3:
return nil
}
return fmt.Errorf("params: unknown HashSuiteID %q", string(h))
}
// BackendID names a math-substrate backend (CPU pure-Go, native CPU,
// CUDA, Metal, WGSL). The same NTT/Modarith/Poly contract may be
// realized by multiple backends; KATs prove they produce byte-equal
// output.
type BackendID string
const (
BackendPureGo BackendID = "pure-go"
BackendNative BackendID = "native-cpu"
BackendAVX2 BackendID = "avx2"
BackendNEON BackendID = "neon"
BackendCUDA BackendID = "cuda"
BackendMetal BackendID = "metal"
BackendWGSL BackendID = "wgsl"
)
// String makes BackendID printable.
func (b BackendID) String() string { return string(b) }
// Validate reports whether b is a known backend.
func (b BackendID) Validate() error {
switch b {
case BackendPureGo, BackendNative, BackendAVX2, BackendNEON,
BackendCUDA, BackendMetal, BackendWGSL:
return nil
}
return fmt.Errorf("params: unknown BackendID %q", string(b))
}
// KATHeader is the canonical key-set every KAT vector MUST carry.
// LP-107 §"Parameter registry" requirement: every KAT entry binds
// itself to a specific (parameter_set, modulus, backend, hash_suite,
// implementation_version) tuple so cross-runtime replay can match
// like-for-like.
type KATHeader struct {
ParameterSet string `json:"parameter_set"`
ModulusID ModulusID `json:"modulus_id"`
BackendID BackendID `json:"backend_id"`
HashSuiteID HashSuiteID `json:"hash_suite_id"`
ImplementationName string `json:"implementation_name"`
ImplementationVersion string `json:"implementation_version"`
}
// Validate ensures every required field is set and known.
func (h *KATHeader) Validate() error {
if h == nil {
return fmt.Errorf("params: nil KATHeader")
}
if h.ParameterSet == "" {
return fmt.Errorf("params: KATHeader.ParameterSet is empty")
}
if err := h.ModulusID.Validate(); err != nil {
return fmt.Errorf("KATHeader: %w", err)
}
if err := h.BackendID.Validate(); err != nil {
return fmt.Errorf("KATHeader: %w", err)
}
if err := h.HashSuiteID.Validate(); err != nil {
return fmt.Errorf("KATHeader: %w", err)
}
if h.ImplementationName == "" {
return fmt.Errorf("params: KATHeader.ImplementationName is empty")
}
if h.ImplementationVersion == "" {
return fmt.Errorf("params: KATHeader.ImplementationVersion is empty")
}
return nil
}
+96
View File
@@ -0,0 +1,96 @@
// Copyright (c) 2026 Lux Industries Inc.
// SPDX-License-Identifier: BSD-3-Clause
package params
import "testing"
func TestModulusID_Validate(t *testing.T) {
for _, id := range []ModulusID{
ModPulsarQ, ModNTT998,
ModFHE_PN10QP27, ModFHE_PN11QP54, ModFHE_PN9QP28_STD128,
} {
if err := id.Validate(); err != nil {
t.Errorf("%s: %v", id, err)
}
}
if err := ModulusID("not-a-real-id").Validate(); err == nil {
t.Error("Validate(unknown) returned nil")
}
}
func TestNTTParamID_Validate(t *testing.T) {
for _, id := range []NTTParamID{
NTTPulsarN256, NTTFHE_PN10QP27_N1024,
NTTFHE_PN11QP54_N2048, NTTFHE_PN9QP28_N512,
} {
if err := id.Validate(); err != nil {
t.Errorf("%s: %v", id, err)
}
}
}
func TestFHEParamID_Validate(t *testing.T) {
for _, id := range []FHEParamID{
FHE_PN10QP27, FHE_PN11QP54, FHE_PN9QP28_STD128,
} {
if err := id.Validate(); err != nil {
t.Errorf("%s: %v", id, err)
}
}
}
func TestPulsarParamID_Validate(t *testing.T) {
if err := PulsarLP073.Validate(); err != nil {
t.Errorf("%s: %v", PulsarLP073, err)
}
}
func TestHashSuiteID_Validate(t *testing.T) {
for _, id := range []HashSuiteID{HashPulsarSHA3, HashBLAKE3} {
if err := id.Validate(); err != nil {
t.Errorf("%s: %v", id, err)
}
}
}
func TestBackendID_Validate(t *testing.T) {
for _, id := range []BackendID{
BackendPureGo, BackendNative, BackendAVX2, BackendNEON,
BackendCUDA, BackendMetal, BackendWGSL,
} {
if err := id.Validate(); err != nil {
t.Errorf("%s: %v", id, err)
}
}
}
func TestKATHeader_Validate(t *testing.T) {
good := KATHeader{
ParameterSet: "pulsar-lp073",
ModulusID: ModPulsarQ,
BackendID: BackendPureGo,
HashSuiteID: HashPulsarSHA3,
ImplementationName: "luxfi/pulsar",
ImplementationVersion: "v0.1.4",
}
if err := good.Validate(); err != nil {
t.Errorf("good KATHeader: %v", err)
}
bad := KATHeader{}
if err := bad.Validate(); err == nil {
t.Error("empty KATHeader.Validate() returned nil")
}
noVer := good
noVer.ImplementationVersion = ""
if err := noVer.Validate(); err == nil {
t.Error("missing ImplementationVersion returned nil")
}
var nilHdr *KATHeader
if err := nilHdr.Validate(); err == nil {
t.Error("nil KATHeader returned nil")
}
}
+103
View File
@@ -0,0 +1,103 @@
// Copyright (c) 2026 Lux Industries Inc.
// SPDX-License-Identifier: BSD-3-Clause
// Package poly provides polynomial-arithmetic primitives over R_q =
// Z_q[X] / (X^N + 1) used by every Lux lattice protocol.
//
// LP-107 §"Polynomial and RNS operations" — the canonical motivation.
// Add, sub, scalar-mul, NTT-domain mul; converted to /from NTT domain
// via package ntt.
//
// Phase 2 (this file): pure-Go reference implementation. Body uses
// luxfi/math/modarith for the field arithmetic and luxfi/math/ntt
// for the transform; no re-implementation, just composition.
package poly
import (
"fmt"
"github.com/luxfi/math/modarith"
"github.com/luxfi/math/ntt"
)
// Add returns dst = a + b (mod q). All inputs must have length N.
func Add(dst, a, b []uint64, q uint64) error {
if len(dst) != len(a) || len(a) != len(b) {
return fmt.Errorf("poly.Add: length mismatch dst=%d a=%d b=%d",
len(dst), len(a), len(b))
}
for i := range a {
dst[i] = modarith.AddMod(a[i], b[i], q)
}
return nil
}
// Sub returns dst = a - b (mod q).
func Sub(dst, a, b []uint64, q uint64) error {
if len(dst) != len(a) || len(a) != len(b) {
return fmt.Errorf("poly.Sub: length mismatch dst=%d a=%d b=%d",
len(dst), len(a), len(b))
}
for i := range a {
dst[i] = modarith.SubMod(a[i], b[i], q)
}
return nil
}
// ScalarMul returns dst = a * scalar (mod q).
func ScalarMul(dst, a []uint64, scalar, q uint64) error {
if len(dst) != len(a) {
return fmt.Errorf("poly.ScalarMul: length mismatch dst=%d a=%d",
len(dst), len(a))
}
for i := range a {
dst[i] = modarith.MulMod(a[i], scalar, q)
}
return nil
}
// PointwiseMul returns dst = a * b (pointwise, NTT domain) (mod q).
// Inputs must already be in NTT domain; output is also in NTT domain.
// Use ntt.Service.Inverse to bring back to coefficient domain.
func PointwiseMul(dst, a, b []uint64, q uint64) error {
if len(dst) != len(a) || len(a) != len(b) {
return fmt.Errorf("poly.PointwiseMul: length mismatch dst=%d a=%d b=%d",
len(dst), len(a), len(b))
}
for i := range a {
dst[i] = modarith.MulMod(a[i], b[i], q)
}
return nil
}
// Mul computes the negacyclic polynomial product dst = a * b (mod q,
// mod X^N + 1) via NTT round-trip: NTT(a), NTT(b), pointwise mul,
// inverse NTT. dst, a, b must each have length p.N. Inputs are in
// coefficient domain; output is in coefficient domain.
//
// dst MAY alias a or b.
func Mul(dst, a, b []uint64, svc *ntt.Service) error {
N := int(svc.Params().N)
q := svc.Params().Q
if len(dst) != N || len(a) != N || len(b) != N {
return fmt.Errorf("poly.Mul: length mismatch dst=%d a=%d b=%d N=%d",
len(dst), len(a), len(b), N)
}
aN := make([]uint64, N)
bN := make([]uint64, N)
copy(aN, a)
copy(bN, b)
if err := svc.Forward(aN, 1); err != nil {
return fmt.Errorf("poly.Mul: NTT(a): %w", err)
}
if err := svc.Forward(bN, 1); err != nil {
return fmt.Errorf("poly.Mul: NTT(b): %w", err)
}
if err := PointwiseMul(dst, aN, bN, q); err != nil {
return err
}
if err := svc.Inverse(dst, 1); err != nil {
return fmt.Errorf("poly.Mul: INTT(dst): %w", err)
}
return nil
}
+91
View File
@@ -0,0 +1,91 @@
// Copyright (c) 2026 Lux Industries Inc.
// SPDX-License-Identifier: BSD-3-Clause
package poly
import (
"math/rand/v2"
"testing"
"github.com/luxfi/math/backend"
"github.com/luxfi/math/ntt"
"github.com/luxfi/math/params"
)
const PulsarQ = uint64(0x1000000004A01)
var pulsarParams = &ntt.Params{
N: 256,
Q: PulsarQ,
ID: params.NTTPulsarN256,
}
func TestAddSub_RoundTrip(t *testing.T) {
N := 256
a := make([]uint64, N)
b := make([]uint64, N)
r := rand.New(rand.NewPCG(0xdead, 0))
for i := range a {
a[i] = r.Uint64() % PulsarQ
b[i] = r.Uint64() % PulsarQ
}
sum := make([]uint64, N)
if err := Add(sum, a, b, PulsarQ); err != nil {
t.Fatalf("Add: %v", err)
}
got := make([]uint64, N)
if err := Sub(got, sum, b, PulsarQ); err != nil {
t.Fatalf("Sub: %v", err)
}
for i := range a {
if got[i] != a[i] {
t.Fatalf("[%d]: got %d, want %d", i, got[i], a[i])
}
}
}
func TestScalarMul(t *testing.T) {
N := 256
a := make([]uint64, N)
for i := range a {
a[i] = uint64(i + 1)
}
dst := make([]uint64, N)
if err := ScalarMul(dst, a, 7, PulsarQ); err != nil {
t.Fatalf("ScalarMul: %v", err)
}
for i := range a {
want := (uint64(i+1) * 7) % PulsarQ
if dst[i] != want {
t.Errorf("[%d]: got %d, want %d", i, dst[i], want)
}
}
}
func TestMul_NegacyclicVsBigInt(t *testing.T) {
// Verify a * b mod (X^N + 1) for small constants using package
// ntt's pure-Go backend, then sanity-check against a hand-computed
// expectation.
svc, err := ntt.NewService(pulsarParams, backend.PolicyPureGo)
if err != nil {
t.Fatalf("ntt.NewService: %v", err)
}
N := int(pulsarParams.N)
a := make([]uint64, N)
b := make([]uint64, N)
a[0] = 2
b[0] = 3
dst := make([]uint64, N)
if err := Mul(dst, a, b, svc); err != nil {
t.Fatalf("Mul: %v", err)
}
// (2)*(3) = 6 in coefficient 0; everything else 0.
if dst[0] != 6 {
t.Errorf("dst[0] = %d, want 6", dst[0])
}
for i := 1; i < N; i++ {
if dst[i] != 0 {
t.Errorf("dst[%d] = %d, want 0", i, dst[i])
}
}
}
+53
View File
@@ -0,0 +1,53 @@
// Copyright (c) 2026 Lux Industries Inc.
// SPDX-License-Identifier: BSD-3-Clause
// Package rns provides the Residue Number System primitives that FHE
// schemes use to operate over a chain of small primes instead of one
// large modulus.
//
// LP-107 §"Polynomial and RNS operations" — the canonical motivation.
// FHE PN10QP27 / PN11QP54 ring chains are RNS towers; their basis-
// extension and modulus-switching primitives live here.
//
// Phase 2 (this file): defines the public surface for RNS Basis,
// Tower, and basis-extension. Concrete bodies delegate to
// github.com/luxfi/lattice/v7/ringqp; Phase 3 of LP-107 inverts that.
package rns
import (
"fmt"
"github.com/luxfi/math/modarith"
)
// Basis describes one RNS basis: a list of pairwise coprime primes
// q_0, q_1, ..., q_{k-1}. Numbers are represented as their CRT
// projections (x mod q_0, x mod q_1, ..., x mod q_{k-1}).
type Basis struct {
// Moduli is the tuple of primes. Each entry MUST satisfy
// gcd(q_i, q_j) = 1 for i != j; we don't re-check this at every
// op (it's a parameter-set property).
Moduli []*modarith.Modulus
// Name is a stable identifier (e.g. "fhe-pn10qp27").
Name string
}
// NewBasis constructs an RNS basis from a list of primes. Returns an
// error if any modulus fails modarith.NewModulus.
func NewBasis(primes []uint64, name string) (*Basis, error) {
if len(primes) == 0 {
return nil, fmt.Errorf("rns.NewBasis: empty prime list")
}
mods := make([]*modarith.Modulus, len(primes))
for i, q := range primes {
m, err := modarith.NewModulus(q, fmt.Sprintf("%s.q[%d]", name, i))
if err != nil {
return nil, fmt.Errorf("rns.NewBasis: prime[%d]=%d: %w", i, q, err)
}
mods[i] = m
}
return &Basis{Moduli: mods, Name: name}, nil
}
// Levels returns the number of primes in the basis.
func (b *Basis) Levels() int { return len(b.Moduli) }
+47
View File
@@ -0,0 +1,47 @@
// Copyright (c) 2026 Lux Industries Inc.
// SPDX-License-Identifier: BSD-3-Clause
package rns
import "testing"
func TestNewBasis_Pulsar(t *testing.T) {
// Pulsar canonical Q is single-prime; basis with one element is
// the degenerate RNS case (no chain reduction).
b, err := NewBasis([]uint64{0x1000000004A01}, "pulsar-q")
if err != nil {
t.Fatalf("NewBasis: %v", err)
}
if b.Levels() != 1 {
t.Errorf("Levels = %d, want 1", b.Levels())
}
}
func TestNewBasis_TwoPrime(t *testing.T) {
// Synthetic two-prime tower.
b, err := NewBasis([]uint64{0x1000000004A01, 0x1000000007EE1}, "two-prime")
if err != nil {
t.Fatalf("NewBasis: %v", err)
}
if b.Levels() != 2 {
t.Errorf("Levels = %d, want 2", b.Levels())
}
if b.Moduli[0].Q != 0x1000000004A01 {
t.Errorf("Moduli[0].Q = %#x", b.Moduli[0].Q)
}
if b.Moduli[1].Q != 0x1000000007EE1 {
t.Errorf("Moduli[1].Q = %#x", b.Moduli[1].Q)
}
}
func TestNewBasis_Empty(t *testing.T) {
if _, err := NewBasis(nil, "empty"); err == nil {
t.Error("NewBasis(nil): no error")
}
}
func TestNewBasis_RejectsEvenPrime(t *testing.T) {
if _, err := NewBasis([]uint64{8}, "even"); err == nil {
t.Error("NewBasis(even): no error")
}
}
+206
View File
@@ -0,0 +1,206 @@
// 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
}
+116
View File
@@ -0,0 +1,116 @@
// Copyright (c) 2026 Lux Industries Inc.
// SPDX-License-Identifier: BSD-3-Clause
package sample
import (
"bytes"
"io"
"testing"
"github.com/zeebo/blake3"
)
const PulsarQ = uint64(0x1000000004A01)
// deterministicReader returns an unbounded byte stream from a seed
// string. Same pattern as luxfi/threshold's deterministicRand test
// helper.
func deterministicReader(seed string) io.Reader {
h := blake3.New()
_, _ = h.Write([]byte("luxfi/math/sample/test/v1"))
_, _ = h.Write([]byte(seed))
return h.Digest()
}
func TestUniform_Determinism(t *testing.T) {
N := 256
a := make([]uint64, N)
b := make([]uint64, N)
if err := Uniform(a, PulsarQ, deterministicReader("uniform-1")); err != nil {
t.Fatalf("Uniform[a]: %v", err)
}
if err := Uniform(b, PulsarQ, deterministicReader("uniform-1")); err != nil {
t.Fatalf("Uniform[b]: %v", err)
}
if !bytes.Equal(uint64sToBytes(a), uint64sToBytes(b)) {
t.Error("Uniform with same seed: byte-mismatch")
}
for i, v := range a {
if v >= PulsarQ {
t.Fatalf("Uniform[%d] = %d >= q=%d", i, v, PulsarQ)
}
}
}
func TestTernary_Distribution(t *testing.T) {
N := 4096
dst := make([]uint64, N)
if err := Ternary(dst, PulsarQ, 0.5, deterministicReader("ternary")); err != nil {
t.Fatalf("Ternary: %v", err)
}
// Coarse distribution check: with density=0.5, expect ~50%
// non-zero. Allow ±10% slack.
zero, plus, minus := 0, 0, 0
for _, v := range dst {
switch v {
case 0:
zero++
case 1:
plus++
case PulsarQ - 1:
minus++
default:
t.Fatalf("Ternary: unexpected value %d", v)
}
}
nonZero := plus + minus
if nonZero < N*4/10 || nonZero > N*6/10 {
t.Errorf("Ternary density: nonZero=%d/%d (expected ~50%%)", nonZero, N)
}
// Plus and minus should be roughly balanced.
if plus < nonZero/3 || minus < nonZero/3 {
t.Errorf("Ternary balance: plus=%d minus=%d", plus, minus)
}
}
func TestCenteredBinomial_RangeBounded(t *testing.T) {
N := 1024
dst := make([]uint64, N)
const eta = 2
if err := CenteredBinomial(dst, PulsarQ, eta, deterministicReader("cbd-2")); err != nil {
t.Fatalf("CenteredBinomial: %v", err)
}
// Output values should all be in {q-eta, ..., q-1, 0, 1, ..., eta}.
for i, v := range dst {
if v <= eta {
continue
}
if v >= PulsarQ-eta {
continue
}
t.Fatalf("CenteredBinomial[%d] = %d out of expected range", i, v)
}
}
func TestDiscreteGaussianRejection_Range(t *testing.T) {
// Coarse range test: 1000 samples should all lie within ~6 sigma.
const sigma = 3.2
r := deterministicReader("dgrr")
for i := 0; i < 1000; i++ {
_, err := DiscreteGaussianRejection(PulsarQ, sigma, r)
if err != nil {
t.Fatalf("[%d]: %v", i, err)
}
}
}
func uint64sToBytes(s []uint64) []byte {
out := make([]byte, len(s)*8)
for i, v := range s {
for j := 0; j < 8; j++ {
out[i*8+j] = byte(v >> (j * 8))
}
}
return out
}