LiveOps: cambiare un gioco in produzione senza rilasciare un client nuovo
Un gioco che vive di eventi stagionali ha un problema che i giochi «chiusi» non hanno: il contenuto cambia più spesso del client. Il client sta negli store, e ogni versione nuova passa da una revisione e da un aggiornamento che metà dei giocatori fa in ritardo. Il backend, invece, lo controlli tu. La risposta è spostare sul server la decisione di che cosa è attivo, per chi e da quando, e lasciare al client il compito di eseguirla. Questo articolo mette insieme i quattro meccanismi che servono, e un quinto che chi li costruisce scopre per ultimo: come si torna indietro in fretta quando l'evento va storto.
I meccanismi sono i feature flag (interruttori valutati sul server, anche solo per una parte dei giocatori), gli esperimenti A/B (confronto misurato fra due versioni), la interfaccia guidata dal server (il server descrive le schermate, il client le disegna) e il rollback. Ogni sezione ha un listato che ho eseguito con Node 24: sono modelli in un solo processo, con seme fisso, che provano la logica senza rete né database. Dove un numero esce da una simulazione lo dico, e non lo presento come una misura su un gioco vero.
Prerequisito: sapere dove stanno profili e rating dei giocatori, descritto in Anatomia di un game backend multiplayer. La parte precedente della serie, sul framework open source di Nakama, è Open Match e Nakama.
Che cosa contiene un evento live
Un evento non è un banner con un conto alla rovescia. Tocca il negozio, le missioni, la modalità di gioco e l'interfaccia, e per questo conviene che sia un record unico che gli altri servizi leggono, invece di una configurazione sparsa in cinque posti. Un esempio concreto: «Halloween Horror Night» dal 24 al 31 ottobre per i giocatori europei con più di sette giorni di anzianità.
- Finestra: inizio e fine in UTC, mai nell'ora locale del server
- Destinatari: regione, piattaforma, segmento di giocatori, anzianità dell'account
- Flag collegati: gli interruttori che accendono la mappa, la valuta a tema, il negozio
- Esposizione: la quota di giocatori che lo vede, per un lancio graduale
- Via di ritorno: quali flag spegnere e che cosa fare dei dati già scritti
L'ultima voce è quella che manca nei progetti improvvisati. Un evento che regala valuta o oggetti scrive nei dati dei giocatori, e spegnere il flag non cancella ciò che è stato già concesso: va deciso prima del lancio se quei dati restano, si compensano o si ripristinano da una copia. Le prime quattro voci sono dati; la quinta è una decisione, e la scrive chi conosce l'economia del gioco.
Feature flag: valutare per giocatore, in modo stabile
Un feature flag è una funzione che, dati un interruttore e un giocatore, risponde sì o no. La parte delicata è la percentuale: «attiva per il 10%» deve voler dire sempre gli stessi giocatori, altrimenti uno vede la mappa di Halloween al primo accesso e non la trova al secondo. La soluzione è un sorteggio deterministico: si calcola un hash dell'identificativo del giocatore e lo si riduce a un numero da 0 a 99, il «bucket». Chi ha un bucket sotto la percentuale vede la funzione.
Nel listato la scelta cade su FNV-1a a 32 bit, con Math.imul per la moltiplicazione a 32 bit senza
sconfinare in virgola mobile. La riga che conta è bucket(playerId, salt): il sale è la
chiave del flag, e serve perché due flag diversi non siano accesi per le stesse persone. Se due flag al 10%
condividessero il sale, il 10% fortunato avrebbe sempre tutto, e il 90% non vedrebbe mai nulla. Le regole sono in
AND, con l'uscita anticipata al primo false, e l'elenco di override per QA e beta tester ha la
precedenza su tutto il resto. Pete Hodgson, nell'articolo ospitato da Martin Fowler, classifica questi interruttori per scopo (di rilascio, di esperimento,
operativi, di permessi), ed è utile perché hanno vita e proprietari diversi.
// FNV-1a a 32 bit: veloce, senza dipendenze, distribuisce bene gli identificativi
function fnv1a(s: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); }
return h >>> 0;
}
// Il sale (qui la chiave del flag) rende indipendenti i sorteggi di flag diversi
function bucket(playerId: string, salt: string): number { return fnv1a(salt + ':' + playerId) % 100; }
function evaluateFlag(flag: FeatureFlag, p: PlayerContext, nowMs: number): boolean {
if (!flag.enabled) return false;
if (flag.overrideUsers?.includes(p.id)) return true;
if (flag.countries && !flag.countries.includes(p.country)) return false;
if (flag.platforms && !flag.platforms.includes(p.platform)) return false;
if (flag.segments && !flag.segments.some(s => p.segments.includes(s))) return false;
if (flag.minAccountAgeDays !== undefined && (nowMs - p.createdAtMs) / 86_400_000 < flag.minAccountAgeDays) return false;
if (flag.percentage !== undefined && bucket(p.id, flag.key) >= flag.percentage) return false;
return true;
}
Lo stesso file, con una prova su 100.000 identificativi sintetici, mostra ciò che si può promettere.
percentage: 10 accende il flag a 10.024 giocatori, cioè al 10,02%: il numero non è esatto perché
l'hash è una funzione pseudo-casuale, non un contatore. Il risultato è identico un'ora dopo. Con sale diverso i
due flag al 10% condividono 1.037 giocatori, vicino ai circa 1.000 attesi se fossero indipendenti (con lo stesso sale i due
insiemi coinciderebbero per costruzione). La distribuzione dei bucket oscilla fra 947 e 1.052 giocatori per bucket contro i 1.000 ideali.
// --- prova ---
const NOW = Date.UTC(2026, 9, 31, 18, 0, 0);
const players: PlayerContext[] = Array.from({ length: 100_000 }, (_, i) => ({
id: 'p' + String(i).padStart(6, '0'), country: i % 4 === 0 ? 'IT' : 'DE',
platform: 'pc', segments: [], createdAtMs: NOW - 40 * 86_400_000,
}));
const halloween: FeatureFlag = { key: 'halloween_map', enabled: true, percentage: 10 };
const pumpkin: FeatureFlag = { key: 'pumpkin_currency', enabled: true, percentage: 10 };
const on = players.filter(p => evaluateFlag(halloween, p, NOW));
console.log('con percentage=10:', on.length, 'giocatori su', players.length);
const stable = players.every(p => evaluateFlag(halloween, p, NOW) === evaluateFlag(halloween, p, NOW + 3_600_000));
console.log('stesso esito un\'ora dopo:', stable);
const both = players.filter(p => evaluateFlag(halloween, p, NOW) && evaluateFlag(pumpkin, p, NOW)).length;
console.log('in entrambi i flag (sale diverso):', both, '(atteso se indipendenti: circa 1000)');
const onlyIt = { key: 'it_only', enabled: true, countries: ['IT'] };
console.log('solo Italia:', players.filter(p => evaluateFlag(onlyIt, p, NOW)).length);
const counts = new Array(100).fill(0);
for (const p of players) counts[bucket(p.id, 'halloween_map')]++;
console.log('bucket 0..99 su 100000 id: min', Math.min(...counts), 'max', Math.max(...counts), '(uniforme: 1000)');
// Output:
// con percentage=10: 10024 giocatori su 100000
// stesso esito un'ora dopo: true
// in entrambi i flag (sale diverso): 1037 (atteso se indipendenti: circa 1000)
// solo Italia: 25000
// bucket 0..99 su 100000 id: min 947 max 1052 (uniforme: 1000)
Quando un flag è la scelta sbagliata
Un flag lasciato acceso per sempre diventa un ramo morto che nessuno osa togliere. Lo stesso articolo distingue i flag transitori, da eliminare appena l'evento finisce, da pochi interruttori operativi di lunga durata (i «kill switch», che degradano le funzioni non vitali quando il sistema è sotto carico). Ogni flag dovrebbe nascere con un proprietario e una data in cui toglierlo.
Spegnere in fretta: quanto dura davvero un rollback
Il pannello di controllo scrive «enabled: false», ma i nodi del backend leggono i flag da una cache per non interrogare il database a ogni richiesta. Il tempo di rollback è quindi il tempo di vita della cache nel peggiore dei casi. Un TTL di 30 secondi significa che per quasi mezzo minuto una parte dei nodi continua a servire l'evento. Ci sono due rimedi, e il secondo è quello che si dimentica.
Il primo è l'invalidazione attiva: chi spegne il flag pubblica un messaggio e i nodi svuotano la cache. Ma la documentazione di Redis dice che il Pub/Sub ha una semantica di consegna at-most-once: un messaggio è recapitato una volta, se va bene, e non c'è nessun nuovo tentativo. Un nodo che si riconnette in quel momento non lo riceverà mai. Il secondo rimedio è un numero di versione nello store, che ogni nodo controlla a intervalli brevi: poco costoso, e non dipende da un messaggio che può perdersi. Il listato simula tre strategie e misura, nel tempo simulato, quanto passa prima che tutti i nodi siano spenti.
// Modello didattico: quanto dura un rollback? Tempo simulato in secondi, nessuna rete.
interface Store { flags: Record<string, boolean>; version: number; }
class FlagNode {
private cache: Record<string, boolean> = {};
private cachedAt = -Infinity;
private lastPoll = -Infinity;
private seenVersion = -1;
private store: Store;
private ttlSec: number;
private pollSec: number | null;
constructor(store: Store, ttlSec: number, pollSec: number | null) {
this.store = store; this.ttlSec = ttlSec; this.pollSec = pollSec;
}
// Legge dalla cache. Rilegge lo store se il TTL è scaduto, oppure, quando il
// controllo di versione è attivo, se ogni pollSec secondi scopre una versione nuova.
isOn(flag: string, now: number): boolean {
let refresh = now - this.cachedAt >= this.ttlSec;
if (!refresh && this.pollSec !== null && now - this.lastPoll >= this.pollSec) {
this.lastPoll = now;
refresh = this.store.version !== this.seenVersion;
}
if (refresh) { this.cache = { ...this.store.flags }; this.cachedAt = now; this.seenVersion = this.store.version; }
return this.cache[flag];
}
invalidate(): void { this.cachedAt = -Infinity; }
}
type Strategy = 'solo TTL' | 'TTL + invalidazione recapitata' | 'TTL + invalidazione persa + versione';
function secondsUntilAllOff(strategy: Strategy, rollbackAt: number): number {
const store: Store = { flags: { halloween_map: true }, version: 1 };
const poll = strategy.endsWith('versione') ? 5 : null;
const nodes = [new FlagNode(store, 30, poll), new FlagNode(store, 30, poll)];
for (let t = 0; t < rollbackAt; t++) nodes.forEach(n => n.isOn('halloween_map', t)); // cache calda
store.flags.halloween_map = false; store.version++; // l'operatore spegne il flag
if (strategy === 'TTL + invalidazione recapitata') nodes.forEach(n => n.invalidate());
if (strategy === 'TTL + invalidazione persa + versione') nodes[0].invalidate(); // il nodo 1 non riceve il messaggio
for (let t = rollbackAt; t < rollbackAt + 120; t++) {
if (nodes.every(n => !n.isOn('halloween_map', t))) return t - rollbackAt;
}
return -1;
}
const strategies: Strategy[] = ['solo TTL', 'TTL + invalidazione recapitata', 'TTL + invalidazione persa + versione'];
for (const s of strategies) {
// il momento del rollback cade in ogni punto del ciclo di 30 s della cache: si prende il caso peggiore
const waits = Array.from({ length: 30 }, (_, k) => secondsUntilAllOff(s, 100 + k));
console.log(s.padEnd(38), '-> peggiore:', Math.max(...waits), 's, migliore:', Math.min(...waits), 's');
}
// Output:
// solo TTL -> peggiore: 29 s, migliore: 0 s
// TTL + invalidazione recapitata -> peggiore: 0 s, migliore: 0 s
// TTL + invalidazione persa + versione -> peggiore: 4 s, migliore: 0 s
Con il solo TTL il caso peggiore è 29 secondi, uno meno del TTL, e il migliore è zero: dipende dal punto del ciclo in cui cade lo spegnimento. L'invalidazione recapitata porta a zero. Il caso interessante è il terzo: il messaggio arriva solo al nodo 0 e il nodo 1 lo perde, ma il controllo di versione ogni 5 secondi chiude comunque il divario entro 4 secondi. Sono secondi simulati su un modello con due nodi: i numeri reali dipendono dalla rete e dal carico, ma il ragionamento resta valido. La combinazione è quindi TTL come rete di sicurezza, invalidazione per la velocità e versione per la correttezza.
Soft, hard e kill switch
Distinguere i livelli di rollback evita di usare l'arma più grossa per un problema piccolo. Il rollback morbido spegne i flag e non tocca i dati: è reversibile e si fa per primo. Quello duro ripristina i dati dell'economia da una copia, è lento, richiede un operatore e va fatto solo se i dati sono stati corrotti. Il kill switch ferma il traffico verso un servizio intero e si lascia ai guasti gravi. Il tempo del ripristino duro dipende dalla dimensione dell'economia e dalla copia: si misura su una prova generale, non si assume.
Esperimenti A/B: misurare senza ingannarsi
Un esperimento assegna ogni giocatore a una variante con lo stesso meccanismo dei flag (hash stabile del giocatore e dell'esperimento), registra una metrica e poi confronta. Il confronto più comune nel gioco è fra due percentuali, per esempio la conversione al pass stagionale. Il test usato è lo z per due proporzioni, con la proporzione combinata a denominatore: è la formula del NIST Engineering Statistics Handbook, e il listato la implementa. Prima di lanciare, però, serve un numero che si calcola a tavolino: quanti giocatori per braccio.
// Funzione di ripartizione della normale standard (Abramowitz-Stegun 7.1.26, errore < 1,5e-7)
function normalCdf(x: number): number {
const z = Math.abs(x) / Math.SQRT2, t = 1 / (1 + 0.3275911 * z);
const erf = 1 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-z * z);
return x >= 0 ? 0.5 * (1 + erf) : 0.5 * (1 - erf);
}
// Test z a due code per due proporzioni (formula con proporzione combinata)
function zTest(x1: number, n1: number, x2: number, n2: number): { z: number; p: number } {
const p1 = x1 / n1, p2 = x2 / n2, pooled = (x1 + x2) / (n1 + n2);
const se = Math.sqrt(pooled * (1 - pooled) * (1 / n1 + 1 / n2));
const z = (p1 - p2) / se;
return { z, p: 2 * (1 - normalCdf(Math.abs(z))) };
}
// Utenti per braccio per rilevare p0 -> p1 con alfa 5% a due code e potenza 80% (z = 1,96 e 0,84)
function sampleSizePerArm(p0: number, p1: number): number {
const pbar = (p0 + p1) / 2;
const num = 1.96 * Math.sqrt(2 * pbar * (1 - pbar)) + 0.8416 * Math.sqrt(p0 * (1 - p0) + p1 * (1 - p1));
return Math.ceil((num * num) / ((p1 - p0) ** 2));
}
La funzione sampleSizePerArm risponde alla domanda «quanti utenti mi servono per distinguere il 10% dal 12%
con significatività del 5% e potenza dell'80%?». La risposta è 3.841 per braccio, e l'esperimento successivo ne usa
esattamente quel numero. Ridurre il campione per «chiudere prima» non è gratis: abbassa la
potenza, cioè la probabilità di accorgersi di un effetto che c'è. Con campioni troppo piccoli un esperimento che
«non mostra differenze» spesso non può mostrarle.
const n = sampleSizePerArm(0.10, 0.12);
console.log('utenti per braccio per vedere 10% -> 12%:', n);
// 1) Un esperimento vero: controllo 10%, variante 12%, esattamente n utenti per braccio
const r = rng(42);
const run = (p: number, k: number) => { let x = 0; for (let i = 0; i < k; i++) if (r() < p) x++; return x; };
const xc = run(0.10, n), xv = run(0.12, n);
const res = zTest(xv, n, xc, n);
console.log(`controllo ${xc}/${n}, variante ${xv}/${n}, z=${res.z.toFixed(2)}, p=${res.p.toFixed(4)}`);
// 2) Test A/A (nessuna differenza reale): quanti falsi positivi con 14 giorni di dati?
const EXPS = 1000, DAYS = 14, PER_DAY = 500, ALPHA = 0.05;
let endOnly = 0, peeking = 0;
for (let e = 0; e < EXPS; e++) {
let xa = 0, xb = 0, na = 0, nb = 0, stopped = false;
for (let d = 1; d <= DAYS; d++) {
for (let i = 0; i < PER_DAY; i++) { if (r() < 0.10) xa++; if (r() < 0.10) xb++; }
na += PER_DAY; nb += PER_DAY;
if (!stopped && zTest(xa, na, xb, nb).p < ALPHA) { stopped = true; } // si guarda ogni giorno e ci si ferma al primo "significativo"
if (d === DAYS && zTest(xa, na, xb, nb).p < ALPHA) endOnly++;
}
if (stopped) peeking++;
}
console.log(`A/A, ${EXPS} esperimenti: falsi positivi guardando solo al giorno ${DAYS}: ${(100 * endOnly / EXPS).toFixed(1)}%`);
console.log(`A/A, ${EXPS} esperimenti: falsi positivi guardando ogni giorno e fermandosi al primo p<0,05: ${(100 * peeking / EXPS).toFixed(1)}%`);
// Output:
// utenti per braccio per vedere 10% -> 12%: 3841
// controllo 383/3841, variante 495/3841, z=4.02, p=0.0001
// A/A, 1000 esperimenti: falsi positivi guardando solo al giorno 14: 6.0%
// A/A, 1000 esperimenti: falsi positivi guardando ogni giorno e fermandosi al primo p<0,05: 22.9%
Il primo esperimento, con seme fisso, vede 383 conversioni su 3.841 nel controllo e 495 nella variante: z=4,02, p=0,0001. La seconda parte è la lezione più utile. In un test A/A le due varianti sono identiche, quindi ogni «vittoria» è un falso positivo. Guardando i dati una sola volta, al giorno 14, i falsi positivi sono il 6,0% su 1.000 esperimenti simulati, vicino al 5% atteso. Guardando ogni giorno e fermandosi appena il p-value scende sotto 0,05 salgono al 22,9%. Il tema è noto nella letteratura: Johari, Pekelis e Walsh mostrano che i p-value classici non reggono il monitoraggio continuo e propongono metodi «sempre validi». In pratica: fissa prima durata e campione e non fermare l'esperimento al primo picco, oppure usa un metodo sequenziale pensato per farlo.
Interfaccia guidata dal server
Nell'interfaccia guidata dal server la risposta dell'API non contiene dati ma schermate: un elenco di sezioni, ognuna con un tipo, una priorità e una configurazione. Il client ha un disegnatore per ciascun tipo che conosce. Il vantaggio è cambiare negozio e home senza passare dagli store; il costo è che il client e il server devono accordarsi su un vocabolario di tipi, e che il vocabolario cresce nel tempo, mentre i client vecchi restano in circolazione.
interface Section { id: string; type: string; priority: number; config: Record<string, unknown>; }
interface HomeScreen { version: number; sections: Section[]; }
const renderers: Record<string, (c: Record<string, unknown>) => string> = {
banner: c => `[banner] ${c.title} - ${c.subtitle}`,
shop_carousel: c => `[shop] ${c.title} (${(c.items as unknown[]).length} articoli)`,
};
function render(screen: HomeScreen): string[] {
return [...screen.sections]
.sort((a, b) => a.priority - b.priority)
.map(s => (renderers[s.type] ? renderers[s.type](s.config) : null))
.filter((line): line is string => line !== null); // tipo sconosciuto: sezione saltata, non crash
}
const screen: HomeScreen = { version: 7, sections: [
{ id: 's2', type: 'shop_carousel', priority: 2, config: { title: 'Shop Halloween', items: [{ id: 'skin_1' }, { id: 'emote_1' }] } },
{ id: 's1', type: 'banner', priority: 1, config: { title: 'Halloween Horror Night', subtitle: 'finisce tra 3 giorni' } },
{ id: 's3', type: 'hologram_wall', priority: 3, config: {} }, // tipo introdotto da un client più nuovo
] };
console.log(render(screen));
// Output:
// [
// '[banner] Halloween Horror Night - finisce tra 3 giorni',
// '[shop] Shop Halloween (2 articoli)'
// ]
La riga che protegge i giocatori è il filter finale: la terza sezione ha tipo hologram_wall, introdotto da
un client più nuovo, e nel disegnatore non esiste. Viene saltata, e la home mostra il banner e il negozio in ordine di
priorità. Senza quella regola un tipo sconosciuto fa crollare l'intera schermata. Il campo version serve a
mettere in cache la risposta e a sapere quale vocabolario ha visto il client. Quando non usarlo: schermate che richiedono animazioni e
interazioni complesse restano più semplici e più fluide nel client; l'interfaccia dichiarativa vale per negozio, banner ed elenchi.
Ricompense: un ritentativo non deve pagare due volte
Un evento è, in buona parte, un sistema che assegna ricompense. Il client chiama, la rete va in timeout, il client riprova: se il server ha già accreditato la prima chiamata, la seconda moltiplica il premio. La difesa è una chiave di idempotenza che identifica l'operazione (giocatore, evento, sfida) e non il tentativo.
const wallet = new Map<string, number>();
const done = new Set<string>();
function grantReward(playerId: string, rewardKey: string, coins: number): 'accreditato' | 'già accreditato' {
const idem = playerId + ':' + rewardKey; // la chiave identifica l'operazione, non il tentativo
if (done.has(idem)) return 'già accreditato';
wallet.set(playerId, (wallet.get(playerId) ?? 0) + coins);
done.add(idem);
return 'accreditato';
}
console.log(grantReward('p1', 'halloween-2026:challenge-1', 100));
console.log(grantReward('p1', 'halloween-2026:challenge-1', 100)); // retry dopo un timeout
console.log('saldo p1:', wallet.get('p1'));
// Output:
// accreditato
// già accreditato
// saldo p1: 100
La seconda chiamata risponde «già accreditato» e il saldo resta a 100. Il listato usa un insieme in memoria, che va bene per mostrare l'idea e non per un sistema vero: tra due processi, il controllo seguito dalla scrittura ha una finestra in cui entrambi passano. In produzione la garanzia deve stare nel database, con un vincolo di unicità sulla chiave, oppure in una transazione che scrive insieme chiave e saldo. Se salti questo punto, un doppio clic o un ritentativo del client si trasforma in valuta inflazionata, che poi nessuno sa togliere.
Prima del lancio
- Ogni parte dell'evento dietro un flag che un operatore può spegnere senza intervento degli sviluppatori
- Rollback provato su staging, con il tempo misurato e scritto nel runbook
- Carico di prova sul picco atteso, perché l'apertura dell'evento concentra le richieste
- Date solo sul server: una data scritta nel client non si può prorogare senza un aggiornamento
- Registro dei cambi di flag: chi, quando e perché, per capire una regressione dopo
Sono cinque abitudini con la stessa radice: ridurre il numero di cose che solo uno sviluppatore può fare durante un incidente. Se devi sceglierne una sola per iniziare, scegli la seconda: un rollback mai provato è una speranza. I carichi di prova e la lettura delle metriche in produzione sono nell'articolo Observability di un game backend, mentre l'analisi dei dati degli eventi è nell'articolo sulla telemetria.
Domande frequenti
Come si fa a tenere stabile una percentuale di rollout?
Con un hash deterministico dell'identificativo del giocatore e di un sale legato al flag, ridotto a un bucket da 0 a 99. Lo stesso giocatore cade sempre nello stesso bucket, e flag diversi usano sali diversi per non coinvolgere le stesse persone.
Perché non basta il Pub/Sub di Redis per invalidare la cache?
Perché ha consegna at-most-once: un messaggio può non arrivare a un nodo che in quel momento è scollegato. Serve una rete di sicurezza, come un TTL breve o un numero di versione controllato a intervalli.
Posso guardare i risultati di un A/B test ogni giorno?
Puoi guardarli, ma non fermarti al primo risultato significativo con un test a campione fisso: nella simulazione dell'articolo i falsi positivi passano dal 6,0% al 22,9%. Fissa prima il campione, oppure usa un metodo sequenziale.
Che cosa sai fare adesso
Sai descrivere un evento come un record con una via di ritorno, valutare un flag in modo stabile e indipendente dagli altri, stimare quanto dura un rollback e quale combinazione di cache, messaggio e versione lo riduce. Sai dimensionare un esperimento, e perché non va fermato al primo risultato favorevole. Sai far disegnare al client schermate che non conosce senza farlo cadere, e proteggere le ricompense dai ritentativi.
Fonti
- Pete Hodgson, Feature Toggles (aka Feature Flags), martinfowler.com - le categorie di interruttore, i flag transitori contro i kill switch di lunga durata, consultata il 2026-11-12
- Redis: Pub/Sub - la semantica di consegna at-most-once, consultata il 2026-11-12
- NIST/SEMATECH e-Handbook of Statistical Methods, sezione 7.3.3 - la statistica z per due proporzioni con proporzione combinata, consultata il 2026-11-12
- Johari, Pekelis, Walsh, Always Valid Inference: Bringing Sequential Analysis to A/B Testing (arXiv:1512.04922) - perché i p-value classici non reggono il monitoraggio continuo, consultata il 2026-11-12
Tutti i listati sono TypeScript eseguito con Node 24 (rimozione dei tipi, senza dipendenze) e producono le uscite riportate; la dimensione del campione, i falsi positivi e i tempi di propagazione vengono da simulazioni con seme fisso, non da dati di un gioco reale. L'approssimazione della funzione di ripartizione normale è la formula 7.1.26 di Abramowitz e Stegun, scritta nel listato.

Commenti
Caricamento commenti...
Accedi per commentare