OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / scripts / test-vrf-sortition.js

231 lines · javascript ref:
#!/usr/bin/env node

/**
 * test-vrf-sortition.js
 *
 * Exercises the pure sortition primitive (packages/services/builtin/vrf/sortition.js)
 * that turns an unbiasable VRF output into a verifiable shuffle or committee.
 *
 *   seededShuffle:
 *     - deterministic for a fixed (seed, domain); is a true permutation
 *     - different seeds (and different domains) yield different orders
 *     - verifyShuffle accepts a faithful replay and rejects a tampered one
 *
 *   weightedSample:
 *     - unweighted draws a distinct k-subset, deterministic, order-independent
 *     - weighted draws without replacement; zero-weight is never picked;
 *       heavy weights dominate over many independent seeds (statistical)
 *     - canonical ordering: enumerating candidates in any order is identical
 *     - count >= pool returns the whole pool; count 0 / empty pool are clean
 *     - verifyCommittee accepts a faithful replay and rejects a wrong one
 *     - malformed inputs throw (non-integer weight, duplicate id, bad seed)
 *
 *   quantizeWeights:
 *     - rounds to fixed point; positive floors to >= 1; zero/negative -> 0
 */

import { sha512 } from '@noble/hashes/sha2.js'
import {
  seededShuffle, weightedSample, quantizeWeights,
  verifyCommittee, verifyShuffle,
  SHUFFLE_DOMAIN, SELECT_DOMAIN, DEFAULT_WEIGHT_SCALE
} from '../packages/services/builtin/vrf/sortition.js'

let passed = 0
let failed = 0
function assert (cond, label) {
  if (cond) { console.log(`  PASS  ${label}`); passed++ } else { console.log(`  FAIL  ${label}`); failed++ }
}
function rejects (fn, label) {
  try { fn(); assert(false, label + ' (expected throw)') } catch { assert(true, label) }
}

function toHex (bytes) {
  let s = ''
  for (let i = 0; i < bytes.length; i++) s += bytes[i].toString(16).padStart(2, '0')
  return s
}
// Distinct 64-byte seeds for statistical sweeps.
function seedFor (i) {
  const buf = new Uint8Array(8)
  let v = BigInt(i)
  for (let k = 0; k < 8; k++) { buf[k] = Number(v & 0xffn); v >>= 8n }
  return toHex(sha512(buf))
}
function sameMultiset (a, b) {
  if (a.length !== b.length) return false
  const sa = [...a].sort(); const sb = [...b].sort()
  return sa.every((x, i) => x === sb[i])
}

function main () {
  // ── 1. seededShuffle: determinism + permutation ─────────────────────────────
  console.log('── seededShuffle ──')
  const deck = Array.from({ length: 52 }, (_, i) => i)
  const seedA = 'a1'.repeat(32)
  const s1 = seededShuffle(seedA, deck)
  const s2 = seededShuffle(seedA, deck)
  assert(JSON.stringify(s1) === JSON.stringify(s2), 'shuffle is deterministic for a fixed seed')
  assert(sameMultiset(s1, deck), 'shuffle is a true permutation (same multiset)')
  assert(JSON.stringify(s1) !== JSON.stringify(deck), 'shuffle actually reorders')
  assert(JSON.stringify(seededShuffle(seedA, deck)) === JSON.stringify(s1) &&
         deck[0] === 0 && deck[51] === 51, 'shuffle does not mutate the input array')

  const s3 = seededShuffle('b2'.repeat(32), deck)
  assert(JSON.stringify(s3) !== JSON.stringify(s1), 'different seeds give different orders')
  // domain separation: same seed, different domain ⇒ different permutation
  const sDom = seededShuffle(seedA, deck, { domain: 'other/domain/v1' })
  assert(JSON.stringify(sDom) !== JSON.stringify(s1), 'domain tag separates streams off the same seed')

  // single / empty
  assert(JSON.stringify(seededShuffle(seedA, [7])) === JSON.stringify([7]), 'shuffle of one element is identity')
  assert(JSON.stringify(seededShuffle(seedA, [])) === JSON.stringify([]), 'shuffle of empty is empty')

  // verifyShuffle
  assert(verifyShuffle(seedA, deck, s1) === true, 'verifyShuffle accepts a faithful replay')
  const tampered = s1.slice(); [tampered[0], tampered[1]] = [tampered[1], tampered[0]]
  assert(verifyShuffle(seedA, deck, tampered) === false, 'verifyShuffle rejects a tampered order')
  assert(verifyShuffle('c3'.repeat(32), deck, s1) === false, 'verifyShuffle rejects a wrong seed')

  // ── 2. shuffle uniformity (loose, non-flaky) ────────────────────────────────
  console.log('\n── shuffle uniformity ──')
  const N = 4
  const small = [0, 1, 2, 3]
  const trials = 4000
  const posCount = 0 // element 0 landing in position 0
  let elem0pos0 = 0
  for (let i = 0; i < trials; i++) {
    const r = seededShuffle(seedFor(i), small)
    if (r[0] === 0) elem0pos0++
  }
  // expected trials/N = 1000; ~7σ bounds [800,1200] — never flaky for a sane RNG
  assert(elem0pos0 > 800 && elem0pos0 < 1200, `shuffle roughly uniform (elem0@pos0=${elem0pos0}, expect ~${trials / N})`)
  void posCount

  // ── 3. weightedSample: unweighted k-subset ──────────────────────────────────
  console.log('\n── weightedSample (unweighted) ──')
  const pool = ['aa', 'bb', 'cc', 'dd', 'ee', 'ff', '11', '22']
  const c3 = weightedSample({ seed: seedA, candidates: pool, count: 3 })
  assert(c3.length === 3, 'draws the requested count')
  assert(new Set(c3).size === 3, 'committee members are distinct (no replacement)')
  assert(c3.every(id => pool.includes(id)), 'all members come from the candidate pool')
  assert(JSON.stringify(weightedSample({ seed: seedA, candidates: pool, count: 3 })) === JSON.stringify(c3),
    'unweighted selection is deterministic')

  // order-independence: shuffling the *input* list must not change the committee
  const poolShuffled = seededShuffle('ff'.repeat(32), pool)
  const c3b = weightedSample({ seed: seedA, candidates: poolShuffled, count: 3 })
  assert(JSON.stringify(c3b) === JSON.stringify(c3), 'committee depends only on the candidate set, not enumeration order')

  // object form with ignored weights behaves like string form
  const objPool = pool.map(id => ({ id, weight: 5 }))
  assert(JSON.stringify(weightedSample({ seed: seedA, candidates: objPool, count: 3 })) === JSON.stringify(c3),
    'unweighted ignores weights on object candidates')

  // count >= pool returns the whole set; count 0 / empty are clean
  const all = weightedSample({ seed: seedA, candidates: pool, count: 999 })
  assert(all.length === pool.length && sameMultiset(all, pool), 'count >= size returns the full pool')
  assert(weightedSample({ seed: seedA, candidates: pool, count: 0 }).length === 0, 'count 0 returns empty')
  assert(weightedSample({ seed: seedA, candidates: [], count: 3 }).length === 0, 'empty pool returns empty')

  // ── 4. weightedSample: weighted, without replacement ────────────────────────
  console.log('\n── weightedSample (weighted) ──')
  const wPool = [
    { id: 'heavy', weight: 100 },
    { id: 'light', weight: 1 },
    { id: 'zero', weight: 0 }
  ]
  // zero-weight can never be drawn for a single seat
  let zeroSeen = 0
  for (let i = 0; i < 500; i++) {
    const one = weightedSample({ seed: seedFor(i), candidates: wPool, count: 1, weighted: true })
    if (one[0] === 'zero') zeroSeen++
  }
  assert(zeroSeen === 0, 'zero-weight candidate is never selected')

  // heavy dominates light over many independent seeds (P(heavy)=100/101)
  let heavy = 0; let light = 0
  const wTrials = 2000
  for (let i = 0; i < wTrials; i++) {
    const one = weightedSample({ seed: seedFor(i), candidates: [{ id: 'heavy', weight: 100 }, { id: 'light', weight: 1 }], count: 1, weighted: true })
    if (one[0] === 'heavy') heavy++; else light++
  }
  assert(heavy > 1700 && light > 0, `weighting biases selection (heavy=${heavy}, light=${light} of ${wTrials})`)

  // determinism + without-replacement for a multi-seat draw
  const wp = [
    { id: 'a', weight: 10 }, { id: 'b', weight: 10 }, { id: 'c', weight: 10 },
    { id: 'd', weight: 10 }, { id: 'e', weight: 10 }
  ]
  const w2 = weightedSample({ seed: seedA, candidates: wp, count: 3, weighted: true })
  assert(w2.length === 3 && new Set(w2).size === 3, 'weighted multi-seat draw has no repeats')
  assert(JSON.stringify(weightedSample({ seed: seedA, candidates: wp, count: 3, weighted: true })) === JSON.stringify(w2),
    'weighted selection is deterministic')
  // order independence under weighting too
  const wpRev = wp.slice().reverse()
  assert(JSON.stringify(weightedSample({ seed: seedA, candidates: wpRev, count: 3, weighted: true })) === JSON.stringify(w2),
    'weighted committee is independent of enumeration order')
  // drawing the whole pool returns every positive-weight member
  const wAll = weightedSample({ seed: seedA, candidates: wp, count: 5, weighted: true })
  assert(sameMultiset(wAll, wp.map(c => c.id)), 'weighted full draw returns all positive-weight members')

  // domain separation between shuffle and select off the same beta
  const beta = '7e'.repeat(64)
  const asShuffleFirst = seededShuffle(beta, pool, { domain: SHUFFLE_DOMAIN })[0]
  const asSelectFirst = weightedSample({ seed: beta, candidates: pool, count: pool.length, domain: SELECT_DOMAIN })[0]
  assert(typeof asShuffleFirst === 'number' || typeof asShuffleFirst === 'string', 'shuffle/select domains are distinct constants')
  assert(SHUFFLE_DOMAIN !== SELECT_DOMAIN, 'shuffle and select default domains differ')

  // ── 5. verifyCommittee ──────────────────────────────────────────────────────
  console.log('\n── verifyCommittee ──')
  assert(verifyCommittee({ seed: seedA, candidates: wp, count: 3, weighted: true, expected: w2 }) === true,
    'verifyCommittee accepts a faithful weighted replay')
  const wrong = w2.slice().reverse()
  assert(verifyCommittee({ seed: seedA, candidates: wp, count: 3, weighted: true, expected: wrong }) === false,
    'verifyCommittee rejects a reordered committee')
  assert(verifyCommittee({ seed: 'de'.repeat(32), candidates: wp, count: 3, weighted: true, expected: w2 }) === false,
    'verifyCommittee rejects a wrong seed')
  assert(verifyCommittee({ seed: seedA, candidates: pool, count: 3, expected: c3 }) === true,
    'verifyCommittee accepts a faithful unweighted replay')

  // ── 6. quantizeWeights ──────────────────────────────────────────────────────
  console.log('\n── quantizeWeights ──')
  const q = quantizeWeights([
    { id: 'p', weight: 12.5 },
    { id: 'tiny', weight: 0.0000001 }, // < 1 fixed-point unit but positive
    { id: 'z', weight: 0 },
    { id: 'neg', weight: -3 }
  ])
  const byId = Object.fromEntries(q.map(e => [e.id, e.weight]))
  assert(byId.p === Math.round(12.5 * DEFAULT_WEIGHT_SCALE), 'quantize rounds to fixed point')
  assert(byId.tiny === 1, 'positive-but-tiny weight floors to 1 (stays drawable)')
  assert(byId.z === 0, 'zero weight stays 0')
  assert(byId.neg === 0, 'negative weight clamps to 0')
  // array-tuple form
  const q2 = quantizeWeights([['x', 2], ['y', 3]])
  assert(q2[0].weight === 2 * DEFAULT_WEIGHT_SCALE && q2[1].weight === 3 * DEFAULT_WEIGHT_SCALE, 'quantize accepts [id, weight] tuples')
  // round-trips into a weighted draw
  const qc = weightedSample({ seed: seedA, candidates: q.filter(e => e.weight > 0), count: 2, weighted: true })
  assert(qc.length === 2 && !qc.includes('z') && !qc.includes('neg'), 'quantized weights feed a weighted draw cleanly')

  // ── 7. error handling ───────────────────────────────────────────────────────
  console.log('\n── error handling ──')
  rejects(() => weightedSample({ seed: seedA, candidates: [{ id: 'a', weight: 1.5 }], count: 1, weighted: true }),
    'weighted mode rejects a non-integer weight')
  rejects(() => weightedSample({ seed: seedA, candidates: [{ id: 'a', weight: -1 }], count: 1, weighted: true }),
    'weighted mode rejects a negative weight')
  rejects(() => weightedSample({ seed: seedA, candidates: ['dup', 'dup'], count: 1 }),
    'duplicate candidate id throws')
  rejects(() => weightedSample({ seed: '', candidates: pool, count: 1 }), 'empty seed throws')
  rejects(() => weightedSample({ seed: 'zz', candidates: pool, count: 1 }), 'non-hex seed throws')
  rejects(() => weightedSample({ seed: seedA, candidates: pool, count: -1 }), 'negative count throws')
  rejects(() => weightedSample({ seed: seedA, candidates: 'notarray', count: 1 }), 'non-array candidates throws')
  rejects(() => seededShuffle(seedA, 'notarray'), 'seededShuffle rejects non-array items')
  rejects(() => quantizeWeights([{ id: 'a', weight: Infinity }]), 'quantize rejects non-finite weight')

  console.log(`\n${passed} passed, ${failed} failed\n`)
  process.exit(failed === 0 ? 0 : 1)
}

main()