pulsar-m: drop unnecessary Lux prefix from round identifiers

LuxRoundContext / LuxRoundSessionID / LuxRoundCommitteeRoot /
LuxRoundSigShare / LuxRoundQuorumPolicy / DefaultLuxRoundQuorumPolicy
→ RoundContext / RoundSessionID / RoundCommitteeRoot / RoundSigShare /
RoundQuorumPolicy / DefaultRoundQuorumPolicy.

Files: luxround.go luxround_math.go luxround_test.go → round.go
round_test.go (math helpers folded into round.go). The protocol name
"Lux round" stays in prose / spec / paper titles -- only the Go
identifiers drop the brand prefix. The cSHAKE customization tag
PULSAR-M-LUXROUND-COMMITTEE-V1 is wire-format-stable and stays.
This commit is contained in:
2026-05-11 13:42:08 -07:00
parent 52eea92a11
commit 604a32a47d
3 changed files with 56 additions and 67 deletions
-12
View File
@@ -1,12 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package pulsarm
import "math"
// logFloat / expFloat are the math.Log / math.Exp pair, pulled
// behind named indirection so luxround.go can stub them in unit
// tests if needed.
func logFloat(x float64) float64 { return math.Log(x) }
func expFloat(x float64) float64 { return math.Exp(x) }
@@ -3,7 +3,7 @@
package pulsarm
// luxround.go -- Wave-driven metastable threshold signing.
// round.go -- Wave-driven metastable threshold signing.
//
// For validator pools above the GF(257) Pulsar-M cap of n=256, the
// canonical Lux deployment does NOT run one large Pulsar-M ceremony.
@@ -27,13 +27,14 @@ package pulsarm
import (
"encoding/binary"
"math"
)
// LuxRoundContext is the per-Lux-round binding context. Each Wave
// Tick produces a fresh context whose bytes are committed into every
// RoundContext is the per-Lux-round binding context. Each Wave Tick
// produces a fresh context whose bytes are committed into every
// Pulsar-M transcript-hash so cross-round Round-1 commits cannot be
// replayed against a different Wave round.
type LuxRoundContext struct {
type RoundContext struct {
// Epoch is the validator-set epoch identifier (e.g. block height
// divided by the epoch length).
Epoch uint64
@@ -48,10 +49,10 @@ type LuxRoundContext struct {
CommitteeRoot [32]byte
}
// Encode returns the canonical wire encoding of a LuxRoundContext.
// The encoding is consumed by transcripts; changing the layout
// Encode returns the canonical wire encoding of a RoundContext. The
// encoding is consumed by transcripts; changing the layout
// invalidates every KAT pinned at this version.
func (c LuxRoundContext) Encode() []byte {
func (c RoundContext) Encode() []byte {
out := make([]byte, 0, 8+8+32+32)
var u [8]byte
binary.BigEndian.PutUint64(u[:], c.Epoch)
@@ -63,24 +64,24 @@ func (c LuxRoundContext) Encode() []byte {
return out
}
// LuxRoundSessionID derives a deterministic per-Lux-round Pulsar-M
// SessionID from a LuxRoundContext. The session-id binds the Pulsar-M
// RoundSessionID derives a deterministic per-Lux-round Pulsar-M
// SessionID from a RoundContext. The session-id binds the Pulsar-M
// per-round PRNG (PULSAR-M-SIGN-PRNG-V1 customisation tag in
// transcript.go) to the unique (epoch, round, item, committee) tuple,
// closing the CRIT-1 replay vector that del Pino-Niot's PRNGKeyForRound
// addresses in the small-committee path.
func LuxRoundSessionID(ctx LuxRoundContext) [16]byte {
func RoundSessionID(ctx RoundContext) [16]byte {
digest := cshake256(ctx.Encode(), 16, tagSignPRNG)
var out [16]byte
copy(out[:], digest)
return out
}
// LuxRoundCommitteeRoot returns the canonical 32-byte digest of a
// RoundCommitteeRoot returns the canonical 32-byte digest of a
// Wave-sampled K-committee (sorted ascending NodeID). Both the
// per-round Pulsar-M transcript and the Wave protocol's per-round
// commitments bind to this digest.
func LuxRoundCommitteeRoot(committee []NodeID) [32]byte {
func RoundCommitteeRoot(committee []NodeID) [32]byte {
sorted := make([]NodeID, len(committee))
copy(sorted, committee)
for i := 1; i < len(sorted); i++ {
@@ -96,16 +97,16 @@ func LuxRoundCommitteeRoot(committee []NodeID) [32]byte {
return transcriptHash32(tagDKGCommit, parts...)
}
// LuxRoundSigShare is the per-validator Pulsar-M signature
// contribution emitted in one Lux round. It rides alongside the
// validator's Wave preference vote on the Photon wire. The
// aggregator at the Quasar layer collects β rounds worth of these,
// passes them to LargeCombine (or the small-committee Combine), and
// emits one FIPS 204 ML-DSA signature that the P3Q rollup attests
// to as part of the final block certificate.
type LuxRoundSigShare struct {
// RoundSigShare is the per-validator Pulsar-M signature contribution
// emitted in one Lux round. It rides alongside the validator's Wave
// preference vote on the Photon wire. The aggregator at the Quasar
// layer collects β rounds worth of these, passes them to LargeCombine
// (or the small-committee Combine), and emits one FIPS 204 ML-DSA
// signature that the P3Q rollup attests to as part of the final
// block certificate.
type RoundSigShare struct {
// Context binds the share to its Lux round.
Context LuxRoundContext
Context RoundContext
// Round1 is the per-round Pulsar-M Round-1 commit message.
Round1 *Round1Message
// Round2 is the per-round Pulsar-M Round-2 reveal message.
@@ -113,8 +114,8 @@ type LuxRoundSigShare struct {
Round2 *Round2Message
}
// LuxRoundQuorumPolicy bundles the (alpha, beta) parameters Wave uses
// to drive Lux-round agreement. These are exposed here so the
// RoundQuorumPolicy bundles the (alpha, beta) parameters Wave uses to
// drive Lux-round agreement. These are exposed here so the
// consensus-layer Wave-driver can synchronise them with the Pulsar-M
// combiner's expectations.
//
@@ -123,18 +124,18 @@ type LuxRoundSigShare struct {
// number of consecutive successful Lux rounds before the Focus
// tracker fires "decided" -- at which point the aggregator combines
// β·alpha Pulsar-M shares into one FIPS 204 signature.
type LuxRoundQuorumPolicy struct {
type RoundQuorumPolicy struct {
K int
Alpha int
Beta int
}
// DefaultLuxRoundQuorumPolicy matches consensus/protocol/quasar's
// DefaultRoundQuorumPolicy matches consensus/protocol/quasar's
// grouped-threshold defaults (DefaultGroupSize=3, DefaultGroupThreshold=2)
// extended to a metastable K-sample of 21 (Wave's typical K) with a
// 15-of-21 alpha and 12-round beta. These are starting points; the
// Quasar config layer (config/pq_mode.go) is the source of truth.
var DefaultLuxRoundQuorumPolicy = LuxRoundQuorumPolicy{
var DefaultRoundQuorumPolicy = RoundQuorumPolicy{
K: 21,
Alpha: 15,
Beta: 12,
@@ -151,7 +152,7 @@ var DefaultLuxRoundQuorumPolicy = LuxRoundQuorumPolicy{
// pool: Pr[X >= alpha when X ~ Bin(K, rho)]. We return the natural
// logarithm to avoid floating-point precision loss; callers convert
// to log2 as needed.
func ApproxRoundSecurity(rho float64, policy LuxRoundQuorumPolicy) float64 {
func ApproxRoundSecurity(rho float64, policy RoundQuorumPolicy) float64 {
// Cumulative binomial Pr[X >= alpha; K, rho] via direct sum.
logFactorial := func(n int) float64 {
acc := 0.0
@@ -178,9 +179,9 @@ func ApproxRoundSecurity(rho float64, policy LuxRoundQuorumPolicy) float64 {
return sum
}
// logf / expf are stdlib wrappers; pulled out so the file imports a
// single math symbol (and so unit tests can stub them if needed).
// logf / expf are stdlib wrappers; pulled behind named indirection so
// unit tests can stub them if needed.
var (
logf = func(x float64) float64 { return logFloat(x) }
expf = func(x float64) float64 { return expFloat(x) }
logf = func(x float64) float64 { return math.Log(x) }
expf = func(x float64) float64 { return math.Exp(x) }
)
@@ -9,12 +9,12 @@ import (
"testing"
)
// TestLuxRoundContext_Encode_Deterministic verifies the wire encoding
// of a LuxRoundContext is deterministic and per-field unique. Changing
// TestRoundContext_Encode_Deterministic verifies the wire encoding
// of a RoundContext is deterministic and per-field unique. Changing
// any field changes the encoding -- catches accidental field reorder
// in future refactors.
func TestLuxRoundContext_Encode_Deterministic(t *testing.T) {
base := LuxRoundContext{
func TestRoundContext_Encode_Deterministic(t *testing.T) {
base := RoundContext{
Epoch: 1,
Round: 2,
Item: [32]byte{0xab},
@@ -26,11 +26,11 @@ func TestLuxRoundContext_Encode_Deterministic(t *testing.T) {
t.Fatalf("Encode is non-deterministic")
}
// Mutate each field; each mutation must change the encoding.
for i, mutate := range []func(*LuxRoundContext){
func(c *LuxRoundContext) { c.Epoch++ },
func(c *LuxRoundContext) { c.Round++ },
func(c *LuxRoundContext) { c.Item[0] = 0xff },
func(c *LuxRoundContext) { c.CommitteeRoot[0] = 0xff },
for i, mutate := range []func(*RoundContext){
func(c *RoundContext) { c.Epoch++ },
func(c *RoundContext) { c.Round++ },
func(c *RoundContext) { c.Item[0] = 0xff },
func(c *RoundContext) { c.CommitteeRoot[0] = 0xff },
} {
mutated := base
mutate(&mutated)
@@ -40,31 +40,31 @@ func TestLuxRoundContext_Encode_Deterministic(t *testing.T) {
}
}
// TestLuxRoundSessionID_PerRoundDistinct verifies that two consecutive
// TestRoundSessionID_PerRoundDistinct verifies that two consecutive
// Lux rounds get distinct session IDs even when item + committee
// match -- so the Pulsar-M PRNG is forced to re-seed every round
// (the CRIT-1 cross-round replay defense from del Pino-Niot).
func TestLuxRoundSessionID_PerRoundDistinct(t *testing.T) {
ctxR1 := LuxRoundContext{Epoch: 7, Round: 1, Item: [32]byte{0x11}, CommitteeRoot: [32]byte{0x22}}
func TestRoundSessionID_PerRoundDistinct(t *testing.T) {
ctxR1 := RoundContext{Epoch: 7, Round: 1, Item: [32]byte{0x11}, CommitteeRoot: [32]byte{0x22}}
ctxR2 := ctxR1
ctxR2.Round = 2
if LuxRoundSessionID(ctxR1) == LuxRoundSessionID(ctxR2) {
if RoundSessionID(ctxR1) == RoundSessionID(ctxR2) {
t.Fatalf("session IDs collide across Lux rounds")
}
}
// TestLuxRoundCommitteeRoot_OrderIndependent verifies the committee
// TestRoundCommitteeRoot_OrderIndependent verifies the committee
// root is canonical under permutation -- Wave's K-sample order is
// not stable, so the root must be too.
func TestLuxRoundCommitteeRoot_OrderIndependent(t *testing.T) {
func TestRoundCommitteeRoot_OrderIndependent(t *testing.T) {
a := []NodeID{{0x01}, {0x02}, {0x03}}
b := []NodeID{{0x03}, {0x01}, {0x02}}
if LuxRoundCommitteeRoot(a) != LuxRoundCommitteeRoot(b) {
if RoundCommitteeRoot(a) != RoundCommitteeRoot(b) {
t.Fatalf("committee root depends on order")
}
}
// TestLuxRound_E2E_BetaRounds_Pulsar_M is the headline metastable
// TestRound_E2E_BetaRounds_Pulsar_M is the headline metastable
// signing test. It simulates:
// 1. Validator pool of 100 (beyond GF(257) cap not needed; tests math).
// 2. β = 4 consecutive Lux rounds.
@@ -77,7 +77,7 @@ func TestLuxRoundCommitteeRoot_OrderIndependent(t *testing.T) {
// the Wave / Focus / Cut wiring (those live in consensus). The
// test asserts that each per-Lux-round signature verifies under
// unmodified FIPS 204 ML-DSA.Verify.
func TestLuxRound_E2E_BetaRounds_Pulsar_M(t *testing.T) {
func TestRound_E2E_BetaRounds_Pulsar_M(t *testing.T) {
const validatorPool = 100
const K = 3
const T = 2
@@ -102,14 +102,14 @@ func TestLuxRound_E2E_BetaRounds_Pulsar_M(t *testing.T) {
for i, idx := range idxs {
committee[i] = pool[idx]
}
committeeRoot := LuxRoundCommitteeRoot(committee)
ctx := LuxRoundContext{
committeeRoot := RoundCommitteeRoot(committee)
ctx := RoundContext{
Epoch: 1,
Round: round,
Item: item,
CommitteeRoot: committeeRoot,
}
sessionID := LuxRoundSessionID(ctx)
sessionID := RoundSessionID(ctx)
// Per-round DKG.
dkg := make([]*DKGSession, K)
@@ -202,7 +202,7 @@ func TestLuxRound_E2E_BetaRounds_Pulsar_M(t *testing.T) {
// 2. rho=1 gives 1 (adversary controls everything).
// 3. monotonically increasing in rho.
func TestApproxRoundSecurity_BoundsMatchExpectedShape(t *testing.T) {
policy := DefaultLuxRoundQuorumPolicy // K=21, alpha=15, beta=12.
policy := DefaultRoundQuorumPolicy // K=21, alpha=15, beta=12.
if got := ApproxRoundSecurity(0.0, policy); got != 0 {
t.Fatalf("rho=0: got %v, want 0", got)
}
@@ -228,7 +228,7 @@ func TestApproxRoundSecurity_BoundsMatchExpectedShape(t *testing.T) {
// compounds to (4e-4)^12 ≈ 2^-135 -- ample security for finality.
// See proofs/pulsar-m/lux-round-metastable.tex for the full claim.
func TestApproxRoundSecurity_DefaultPolicyAt1OfThird(t *testing.T) {
v := ApproxRoundSecurity(1.0/3.0, DefaultLuxRoundQuorumPolicy)
v := ApproxRoundSecurity(1.0/3.0, DefaultRoundQuorumPolicy)
if v >= math.Pow(2, -10) {
t.Fatalf("at rho=1/3, K=21, alpha=15: per-round adv %v too high (>= 2^-10)", v)
}
@@ -236,7 +236,7 @@ func TestApproxRoundSecurity_DefaultPolicyAt1OfThird(t *testing.T) {
t.Fatalf("at rho=1/3, K=21, alpha=15: per-round adv %v unexpectedly low (<= 2^-20)", v)
}
// Amplified bound after beta=12 rounds (assuming independent rounds).
amplified := math.Pow(v, float64(DefaultLuxRoundQuorumPolicy.Beta))
amplified := math.Pow(v, float64(DefaultRoundQuorumPolicy.Beta))
if amplified >= math.Pow(2, -100) {
t.Fatalf("beta=12 amplified bound %v too high (>= 2^-100)", amplified)
}