fix(voice-pool): avalanche hash bits before pool modulo

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 <noreply@anthropic.com>
This commit is contained in:
itsrubberduck
2026-07-19 11:09:38 +02:00
parent 04eb2f18ff
commit a60942bfd8
2 changed files with 39 additions and 2 deletions

View File

@@ -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 }
}