longevity: unico arrotondamento al pari nel motore, esatto sul valore binario

Sposta arrotonda1 (round-half-to-even come Python) da test-fisici.ts a
curve.ts, esportata, cosi' entrambi i moduli condividono la stessa funzione
invece di due copie che sui pareggi potrebbero divergere.

Nel farlo, la versione precedente (moltiplica per 10, tolleranza 1e-9 sul
pareggio) si e' rivelata sbagliata su un caso reale: 86.35 non e' un
pareggio nel double che lo rappresenta (vale 86.34999999999999431...,
Python arrotonda a 86.3 senza ambiguita'), ma 86.35*10 arrotonda ESATTAMENTE
a 863.5 in virgola mobile, un pareggio fasullo creato dalla moltiplicazione.
La versione naive dava 86.4. Sostituita con un confronto esatto: scompone il
double in mantissa/esponente (bit IEEE 754) e confronta con aritmetica
razionale su BigInt, senza mai passare per una moltiplicazione che possa
introdurre o cancellare un pareggio.

Aggiunti in motore-curve.test.ts i casi che dimostrano il pareggio vero
(86.25 -> 86.2, 86.75 -> 86.8) e quello fasullo (86.35 -> 86.3, non 86.4:
blocca la regressione appena descritta).
This commit is contained in:
2026-08-22 09:16:31 +02:00
parent 6f926e3197
commit 4d76030839
3 changed files with 88 additions and 22 deletions
+63 -3
View File
@@ -33,9 +33,69 @@ export function lerp(x: number, x0: number, x1: number, y0: number, y1: number):
return y0 + t * (y1 - y0);
}
/** Arrotonda a una cifra decimale, come `round(x, 1)` in Python. */
function arrotonda1(x: number): number {
return Math.round(x * 10) / 10;
/**
* Arrotonda a una cifra decimale, come `round(x, 1)` in Python: sui pareggi
* ESATTI (il valore binario del double è esattamente a metà, es. 86.25)
* arrotonda al pari (banker's rounding), non sempre in su come farebbe
* `Math.round`. Alcune curve a gradini producono pareggi veri — `lerp` che
* restituisce 86.25 esatto va a 86.2, non 86.3.
*
* Non basta moltiplicare per 10 e confrontare con una tolleranza: un valore
* come 86.35 NON è un pareggio vero (il double che gli sta dietro è
* 86.34999999999999431..., quindi Python arrotonda a 86.3, non a metà), ma
* `86.35 * 10` in virgola mobile arrotonda esattamente a 863.5 — un pareggio
* fasullo creato dalla moltiplicazione, non presente nel valore originale.
* Una prima versione con tolleranza ci cadeva (dava 86.4). Per evitarlo si
* scompone il double nella sua mantissa ed esponente esatti (bit IEEE 754,
* via `DataView`) e si confrontano `ax*10` e il pareggio con aritmetica
* razionale su `BigInt`, senza mai passare per una moltiplicazione in
* virgola mobile che potrebbe introdurre o cancellare un pareggio.
*
* Unica funzione di arrotondamento del motore: la usano sia `curve.ts` sia
* `test-fisici.ts`, per non avere due implementazioni che sui pareggi
* potrebbero divergere fra loro.
*/
export function arrotonda1(x: number): number {
if (!Number.isFinite(x) || x === 0) return x === 0 ? 0 : x;
const negativo = x < 0;
const ax = Math.abs(x);
// Scompone il double nella sua rappresentazione esatta ax = mantissa * 2^exponente.
const view = new DataView(new ArrayBuffer(8));
view.setFloat64(0, ax);
const hi = view.getUint32(0);
const lo = view.getUint32(4);
const expBits = (hi >>> 20) & 0x7ff;
let mantissa = (BigInt(hi & 0xfffff) << 32n) | BigInt(lo);
let exponent: number;
if (expBits === 0) {
exponent = -1074; // subnormale, non atteso su questi dati ma corretto comunque
} else {
mantissa |= 1n << 52n; // bit implicito
exponent = expBits - 1075;
}
// ax*10 = num/den, con num e den interi esatti: nessuna moltiplicazione
// in virgola mobile, quindi nessun pareggio fasullo introdotto qui.
let num: bigint;
let den: bigint;
if (exponent >= 0) {
num = mantissa * (10n << BigInt(exponent));
den = 1n;
} else {
num = mantissa * 10n;
den = 1n << BigInt(-exponent);
}
const n = num / den; // floor(ax*10), esatto
const resto2 = (num % den) * 2n; // confronta il resto con 1/2 di den, esatto
let risultato: bigint;
if (resto2 < den) risultato = n;
else if (resto2 > den) risultato = n + 1n;
else risultato = n % 2n === 0n ? n : n + 1n; // pareggio vero: arrotonda al pari
const valore = Number(risultato) / 10;
return negativo ? -valore : valore;
}
/**
+1 -18
View File
@@ -16,7 +16,7 @@
* nessun peso.
*/
import { clamp, lerp } from './curve';
import { clamp, lerp, arrotonda1 } from './curve';
export type Sesso = 'M' | 'F';
@@ -25,23 +25,6 @@ function eMaschio(sesso: Sesso): boolean {
return sesso.trim().toLowerCase().startsWith('m');
}
/**
* Arrotonda a una cifra decimale, come `round(x, 1)` in Python: sui pareggi
* esatti (x.x5) Python arrotonda al pari (banker's rounding), non sempre in
* su come `Math.round`. Le curve a gradini dell'oracolo (plank, flamingo,
* sollevamenti) producono pareggi reali — es. `lerp` di 190 su score_plank
* dà 86.25 esatto, e l'oracolo lo porta a 86.2, non 86.3.
*/
function arrotonda1(x: number): number {
const scaled = x * 10;
const pavimento = Math.floor(scaled);
const diff = scaled - pavimento;
if (Math.abs(diff - 0.5) < 1e-9) {
return (pavimento % 2 === 0 ? pavimento : pavimento + 1) / 10;
}
return Math.round(scaled) / 10;
}
// --- Composizione Corporea -------------------------------------------------
/** Curva a campana ACE. Il picco è su Atleti/Fitness, non sul grasso più basso. Come `score_fat_percent`. */