From 73be2a3b8468c8df0c18d529b1b71172b0709aff Mon Sep 17 00:00:00 2001 From: itsrubberduck Date: Sun, 19 Jul 2026 11:09:38 +0200 Subject: [PATCH] fix(voice-pool): avalanche hash bits before pool modulo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fnv1a's low bits barely mix: for the live-atc persona keys (shared session prefix, ':TYPE:freq' suffix) hash % 4 was identical for Delivery, Ground and Tower in 100% of sessions — every position spoke with one voice. A murmur3 fmix32 finalizer before the modulo restores uniform assignment; verified in the browser (three stations, three voices, three paces). Co-Authored-By: Claude Fable 5 --- shared/utils/voicePool.ts | 21 +++++++++++++++++++-- tests/shared/voicePool.test.ts | 20 ++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/shared/utils/voicePool.ts b/shared/utils/voicePool.ts index 510ba99..717f2cc 100644 --- a/shared/utils/voicePool.ts +++ b/shared/utils/voicePool.ts @@ -33,6 +33,23 @@ export function fnv1a(input: string): number { return hash >>> 0 } +/** + * Murmur3 finalizer (fmix32). FNV-1a's low bits barely avalanche: for the + * live-atc persona keys (shared session prefix, `:TYPE:freq` suffix) the raw + * hash mod 4 was identical for every position of a session — one voice for + * Delivery, Ground and Tower, 100% of the time. Mixing before the modulo + * makes every input bit reach the low bits. + */ +export function mix32(hash: number): number { + let h = hash >>> 0 + h ^= h >>> 16 + h = Math.imul(h, 0x85ebca6b) >>> 0 + h ^= h >>> 13 + h = Math.imul(h, 0xc2b6ae35) >>> 0 + h ^= h >>> 16 + return h >>> 0 +} + /** * Pick a voice from `pool` for `key`, skipping anything in `reserved`. * Walks forward from the hashed index so a collision with a reserved voice @@ -46,7 +63,7 @@ export function voiceFromPool( const usable = pool.filter(v => !reserved.includes(v)) const candidates = usable.length ? usable : pool if (!candidates.length) throw new Error('voiceFromPool: empty voice pool') - return candidates[fnv1a(key) % candidates.length]! + return candidates[mix32(fnv1a(key)) % candidates.length]! } /** @@ -83,7 +100,7 @@ export function controllerPersonaFor(positionKey: string): ControllerPersona { const key = positionKey.toUpperCase() const voice = voiceFromPool(key, CONTROLLER_VOICES, []) // Independent hash stream for speed so voice and pace don't correlate. - const baseSpeed = 1.1 + (fnv1a(`speed:${key}`) % 21) / 100 + const baseSpeed = 1.1 + (mix32(fnv1a(`speed:${key}`)) % 21) / 100 return { voice, baseSpeed: Math.round(baseSpeed * 100) / 100 } } diff --git a/tests/shared/voicePool.test.ts b/tests/shared/voicePool.test.ts index 00d634f..9c84adc 100644 --- a/tests/shared/voicePool.test.ts +++ b/tests/shared/voicePool.test.ts @@ -91,6 +91,26 @@ describe('voicePool — controller personas', () => { } }) + it('does not hand every position of a session the same voice (FNV low-bit bias)', () => { + // Real /live-atc key shapes: shared session prefix, position+frequency + // suffix. Raw fnv1a % poolSize collapses here — every position got the + // same voice in 100% of sessions. + let allSame = 0 + const sessions = 200 + for (let i = 0; i < sessions; i++) { + const s = `sess${i}-${(i * 2654435761 % 1e9).toString(36)}` + const voices = [ + `${s}:FRANKFURT MAIN:DEL:122.035`, + `${s}:FRANKFURT MAIN:GND:121.805`, + `${s}:FRANKFURT MAIN:TWR:118.780`, + ].map(k => controllerPersonaFor(k).voice) + if (voices[0] === voices[1] && voices[1] === voices[2]) allSame++ + } + // Uniform hashing would give ~6.3% — allow slack, but the 100% failure + // mode must never come back. + assert.ok(allSame / sessions < 0.2, `${allSame}/${sessions} sessions had one voice for all positions`) + }) + it('gives different sessions a different shift of controllers', () => { const keys = ['EDDF:DEL', 'EDDF:GND', 'EDDF:TWR', 'EDDF:APP'] const shiftA = keys.map(k => controllerPersonaFor(`sessionA:${k}`))