bench(pulsar): HYPERBALL no-reconstruct sign + reconstruct-tax baseline

Decomplected, one concern: SignHyperball (no-reconstruct 3-round threshold
signer) cost per (mode in {65,87}, family). Keygen is setup; the timed op
is the full sign. Reports derived K parallel slots, per-party subset load,
sig bytes, and the mean rejection-round count (rounds/op).

Real finding (Quasar finality deployment): the no-reconstruct path runs
K=126-256 parallel commitment slots -> 250-580 ms/op and 1.8-4.6M allocs/op,
a 120-290x tax over the reconstruct path (BenchmarkHyperballReconstructTax,
1.85-2.4 ms). n=16,t=14 EXHAUSTS the 64-round budget on the hyperball path
(reported as a skip) though the reconstruct path signs it in ~8 ms -- the
real no-reconstruct scaling boundary.

Per-round cost is not independently invocable (one-shot signer loops the 3
rounds x K slots internally); reported as derived rounds/op, flagged in the
file header as a public-surface observation (not refactored).
This commit is contained in:
2026-06-28 07:12:17 -07:00
parent 503c90af07
commit c9710d003b
@@ -0,0 +1,169 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// bench_hyperball_sign_test.go — DECOMPLECTED benchmark, ONE concern: the cost
// of the Mithril HYPERBALL no-reconstruct threshold signer (SignHyperball,
// mithril_rss_hyperball.go) — the path where NO party and NO coordinator ever
// reconstructs the full (s1,s2); each active party emits only z_j = y_j + c·s1_(j)
// and the hint is recovered from the PUBLIC w' = A·z − c·t1·2^d.
//
// What this isolates and why it matters: SignHyperball runs K parallel
// commitment slots (kReps, derived per committee by deriveHyperballParams) over
// T parties, re-running up to maxRounds until one slot clears the boundary, the
// ‖z‖∞ bound, the FIPS-204 hint, and all-party acceptance. The per-sign COMPUTE
// is ∝ kReps·T (network latency = 3 protocol rounds × RTT is additive on top in
// a real deployment; this in-process number is the pure compute). The committee
// therefore drives block-time feasibility. The benchmark keeps keygen in setup
// (not timed) and measures only the sign, reporting the derived K (parallel
// slots), the per-party subset load, the sig size, and — the rejection-sampling
// cost the task asks for — the MEAN winning-round count across the timed
// iterations (rounds/op), i.e. how many maxRounds re-runs the signer consumed.
//
// Per-round cost is NOT independently invocable: SignHyperball loops the 3
// protocol rounds × K slots internally behind a single call (by design — it is
// a one-shot signer, not a steppable state machine), so the per-round cost is
// the derived total/rounds, reported via the rounds/op metric rather than as a
// separate sub-benchmark. This is an observation about the public surface, not
// a defect — the round messages are exercised structurally by the no-leak gate.
//
// Scope: ML-DSA-65/87 only (deriveHyperballParams fail-closes ML-DSA-44 with
// ErrHyperballScope — ML-DSA-44 violates the BCC ‖c·t0‖∞ < γ2 bound). Families:
// the admitted RSS committees n=8,t=8 / n=8,t=7 / n=16,t=14; n=16,t=12 skips in
// keygen (ValidateCommittee rejects it).
//
// Run:
//
// cd pulsar && export SDKROOT="$(xcrun --show-sdk-path)"; export GOWORK=off
// go test -run='^$' -bench=BenchmarkHyperball -benchmem -benchtime=10x ./ref/go/pkg/pulsar/
package pulsar
import (
"fmt"
"testing"
)
// hyperballSignModes — the BCC-proven scope (deriveHyperballParams admits 65/87).
var hyperballSignModes = []Mode{ModeP65, ModeP87}
// hyperballSignFamilies — the admitted RSS committees plus the rejected
// n=16,t=12 (skips in keygen, documenting the wall on the signing side too).
var hyperballSignFamilies = []committeeFamily{
{n: 8, t: 8},
{n: 8, t: 7},
{n: 16, t: 14},
{n: 16, t: 12},
}
// hyperballSignBudget is the maxRounds re-run budget — the same 64 the
// no-reconstruct gate tests use. (n=16,t=14 exhausts this within budget on the
// hyperball path — a real scaling boundary, reported as a skip — even though the
// reconstruct path signs it fine.)
const hyperballSignBudget = 64
// reconstructBudget is the BCC rejection-sampling attempt ceiling for the
// reconstruct-path baseline. It is the per-nonce retry budget (a different
// mechanism than the hyperball protocol rounds), set generously so the ~9%
// boundary yield reliably clears — at budget 64 a single committee/seed
// exhausts spuriously ~0.2% of the time, polluting the mean-cost number.
const reconstructBudget = 256
type hyperballSignCase struct {
mode Mode
t, n int
}
func hyperballSignCases() []hyperballSignCase {
out := make([]hyperballSignCase, 0, len(hyperballSignModes)*len(hyperballSignFamilies))
for _, mode := range hyperballSignModes {
for _, fam := range hyperballSignFamilies {
out = append(out, hyperballSignCase{mode: mode, t: fam.t, n: fam.n})
}
}
return out
}
// BenchmarkHyperballNoReconstructSign measures SignHyperball per (mode, family).
// Keygen + a warm viability sign are setup (not timed). The timed op is the full
// no-reconstruct sign; rounds/op reports the mean rejection-round count.
func BenchmarkHyperballNoReconstructSign(b *testing.B) {
msg := []byte("Quasar finality subject M — hyperball no-reconstruct sign bench")
ctx := []byte("quasar-pulsar-leg")
for _, tc := range hyperballSignCases() {
mode, t, n := tc.mode, tc.t, tc.n
b.Run(fmt.Sprintf("%s_N%d_T%d", mode, n, t), func(b *testing.B) {
mk, err := MithrilRSSKeygen(mode, t, n, mithrilSeeds(n))
if err != nil {
b.Skipf("keygen(%s,T=%d,N=%d): %v", mode, t, n, err)
}
hp, err := deriveHyperballParams(mode, t, n)
if err != nil {
b.Skipf("deriveHyperballParams(%s,T=%d,N=%d): %v", mode, t, n, err)
}
active := canonicalActive(t)
// Warm one sign: confirm viability and capture the derived shape.
warmRNG := newBCCDeterministicRNG(fmt.Sprintf("HB/WARM/%s/%d-%d", mode, t, n))
sig, tr, err := mk.SignHyperball(active, msg, ctx, warmRNG, hyperballSignBudget)
if err != nil {
b.Skipf("warm SignHyperball(%s,T=%d,N=%d): %v", mode, t, n, err)
}
var totalRounds int
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
r := newBCCDeterministicRNG(fmt.Sprintf("HB/%s/%d-%d/%d", mode, t, n, i))
_, tri, err := mk.SignHyperball(active, msg, ctx, r, hyperballSignBudget)
if err != nil {
// Large committees can intermittently exhaust the K-clamped
// slot budget — a documented scaling limit, not a bench fault.
b.Skipf("SignHyperball(%s,T=%d,N=%d) exhausted at iter %d: %v", mode, t, n, i, err)
}
totalRounds += tri.Rounds
}
b.StopTimer()
b.ReportMetric(float64(hp.kReps), "K_slots")
b.ReportMetric(float64(maxSubsetsPerParty(t, n)), "max_subsets")
if b.N > 0 {
b.ReportMetric(float64(totalRounds)/float64(b.N), "rounds/op")
}
b.ReportMetric(float64(tr.Rounds), "warm_rounds")
b.ReportMetric(float64(len(sig.Bytes)), "sig_bytes")
})
}
}
// BenchmarkHyperballReconstructTax measures MithrilKey.Sign — the RECONSTRUCT
// path (rebuilds the full key at the coordinator via ReconstructKeyMaterial,
// then bccSign). The delta to BenchmarkHyperballNoReconstructSign on the same
// (mode, family) is the no-reconstruct tax: what the trustless signer pays in
// compute to never form the whole key. Keygen is setup; the timed op is Sign.
func BenchmarkHyperballReconstructTax(b *testing.B) {
msg := []byte("Quasar finality subject M — reconstruct-path sign baseline")
ctx := []byte("quasar-pulsar-leg")
for _, tc := range hyperballSignCases() {
mode, t, n := tc.mode, tc.t, tc.n
b.Run(fmt.Sprintf("%s_N%d_T%d", mode, n, t), func(b *testing.B) {
mk, err := MithrilRSSKeygen(mode, t, n, mithrilSeeds(n))
if err != nil {
b.Skipf("keygen(%s,T=%d,N=%d): %v", mode, t, n, err)
}
active := canonicalActive(t)
warmRNG := newBCCDeterministicRNG(fmt.Sprintf("RECON/WARM/%s/%d-%d", mode, t, n))
if _, err := mk.Sign(active, msg, ctx, warmRNG, reconstructBudget); err != nil {
b.Skipf("warm Sign(%s,T=%d,N=%d): %v", mode, t, n, err)
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
r := newBCCDeterministicRNG(fmt.Sprintf("RECON/%s/%d-%d/%d", mode, t, n, i))
if _, err := mk.Sign(active, msg, ctx, r, reconstructBudget); err != nil {
b.Skipf("Sign(%s,T=%d,N=%d) exhausted at iter %d: %v", mode, t, n, i, err)
}
}
})
}
}