anonymize.test.ts
4,191 bytes
| 1 | import { describe, expect, it } from 'vitest'; |
|---|---|
| 2 | import { |
| 3 | anonymizePeers, |
| 4 | hashSeed, |
| 5 | labelForIndex, |
| 6 | makeRng, |
| 7 | shuffle, |
| 8 | } from './anonymize'; |
| 9 | |
| 10 | describe('makeRng', () => { |
| 11 | it('is deterministic for a given seed', () => { |
| 12 | const a = makeRng(42); |
| 13 | const b = makeRng(42); |
| 14 | const seqA = [a(), a(), a(), a()]; |
| 15 | const seqB = [b(), b(), b(), b()]; |
| 16 | expect(seqA).toEqual(seqB); |
| 17 | }); |
| 18 | |
| 19 | it('produces values in [0, 1)', () => { |
| 20 | const rng = makeRng(7); |
| 21 | for (let i = 0; i < 1000; i++) { |
| 22 | const v = rng(); |
| 23 | expect(v).toBeGreaterThanOrEqual(0); |
| 24 | expect(v).toBeLessThan(1); |
| 25 | } |
| 26 | }); |
| 27 | |
| 28 | it('produces different sequences for different seeds', () => { |
| 29 | expect(makeRng(1)()).not.toEqual(makeRng(2)()); |
| 30 | }); |
| 31 | }); |
| 32 | |
| 33 | describe('hashSeed', () => { |
| 34 | it('is stable and order-sensitive', () => { |
| 35 | expect(hashSeed('debate', 1, 'critique', 'p0')).toBe(hashSeed('debate', 1, 'critique', 'p0')); |
| 36 | expect(hashSeed('debate', 1, 'critique', 'p0')).not.toBe(hashSeed('debate', 1, 'critique', 'p1')); |
| 37 | expect(hashSeed('a', 'b')).not.toBe(hashSeed('b', 'a')); |
| 38 | }); |
| 39 | |
| 40 | it('returns an unsigned 32-bit integer', () => { |
| 41 | const h = hashSeed('anything', 99); |
| 42 | expect(Number.isInteger(h)).toBe(true); |
| 43 | expect(h).toBeGreaterThanOrEqual(0); |
| 44 | expect(h).toBeLessThanOrEqual(0xffffffff); |
| 45 | }); |
| 46 | }); |
| 47 | |
| 48 | describe('shuffle', () => { |
| 49 | const rng = () => 0.5; // fixed generator |
| 50 | |
| 51 | it('does not mutate the input', () => { |
| 52 | const input = [1, 2, 3, 4]; |
| 53 | const copy = [...input]; |
| 54 | shuffle(input, makeRng(1)); |
| 55 | expect(input).toEqual(copy); |
| 56 | }); |
| 57 | |
| 58 | it('returns a permutation (same multiset)', () => { |
| 59 | const input = ['a', 'b', 'c', 'd', 'e']; |
| 60 | const out = shuffle(input, makeRng(123)); |
| 61 | expect([...out].sort()).toEqual([...input].sort()); |
| 62 | expect(out).toHaveLength(input.length); |
| 63 | }); |
| 64 | |
| 65 | it('is deterministic for a given rng seed', () => { |
| 66 | const input = [1, 2, 3, 4, 5, 6]; |
| 67 | expect(shuffle(input, makeRng(9))).toEqual(shuffle(input, makeRng(9))); |
| 68 | }); |
| 69 | |
| 70 | it('handles empty and singleton arrays', () => { |
| 71 | expect(shuffle([], rng)).toEqual([]); |
| 72 | expect(shuffle([42], rng)).toEqual([42]); |
| 73 | }); |
| 74 | }); |
| 75 | |
| 76 | describe('labelForIndex', () => { |
| 77 | it('maps 0..25 to A..Z', () => { |
| 78 | expect(labelForIndex(0)).toBe('A'); |
| 79 | expect(labelForIndex(1)).toBe('B'); |
| 80 | expect(labelForIndex(25)).toBe('Z'); |
| 81 | }); |
| 82 | |
| 83 | it('continues to AA, AB for larger councils', () => { |
| 84 | expect(labelForIndex(26)).toBe('AA'); |
| 85 | expect(labelForIndex(27)).toBe('AB'); |
| 86 | }); |
| 87 | }); |
| 88 | |
| 89 | describe('anonymizePeers', () => { |
| 90 | const peers = [ |
| 91 | { participantId: 'p0', content: 'answer zero' }, |
| 92 | { participantId: 'p1', content: 'answer one' }, |
| 93 | { participantId: 'p2', content: 'answer two' }, |
| 94 | ]; |
| 95 | |
| 96 | it('assigns unique sequential labels and a consistent labelMap', () => { |
| 97 | const { peers: out, labelMap } = anonymizePeers(peers, hashSeed('d', 1, 'p0')); |
| 98 | const labels = out.map((p) => p.label); |
| 99 | expect(new Set(labels).size).toBe(labels.length); |
| 100 | expect(labels).toEqual(['A', 'B', 'C']); |
| 101 | for (const p of out) { |
| 102 | expect(labelMap[p.label]).toBe(p.participantId); |
| 103 | } |
| 104 | }); |
| 105 | |
| 106 | it('preserves every participant exactly once (no dropping or duplication)', () => { |
| 107 | const { peers: out } = anonymizePeers(peers, 555); |
| 108 | expect(out.map((p) => p.participantId).sort()).toEqual(['p0', 'p1', 'p2']); |
| 109 | }); |
| 110 | |
| 111 | it('is deterministic for a given seed', () => { |
| 112 | const seed = hashSeed('debate', 1, 'critique', 'reviewerA'); |
| 113 | const a1 = anonymizePeers(peers, seed).peers.map((p) => p.participantId); |
| 114 | const a2 = anonymizePeers(peers, seed).peers.map((p) => p.participantId); |
| 115 | expect(a1).toEqual(a2); |
| 116 | }); |
| 117 | |
| 118 | it('produces more than one ordering across reviewers (order is not shared)', () => { |
| 119 | // The point of per-reviewer shuffling is that different seeds generally |
| 120 | // yield different orderings. Sample many seeds and require real variation. |
| 121 | const orderings = new Set<string>(); |
| 122 | for (let i = 0; i < 50; i++) { |
| 123 | const seed = hashSeed('debate', 1, 'critique', `reviewer-${i}`); |
| 124 | orderings.add(anonymizePeers(peers, seed).peers.map((p) => p.participantId).join(',')); |
| 125 | } |
| 126 | expect(orderings.size).toBeGreaterThan(1); |
| 127 | }); |
| 128 | }); |
| 129 | |