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); return y0 + t * (y1 - y0);
} }
/** Arrotonda a una cifra decimale, come `round(x, 1)` in Python. */ /**
function arrotonda1(x: number): number { * Arrotonda a una cifra decimale, come `round(x, 1)` in Python: sui pareggi
return Math.round(x * 10) / 10; * 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. * nessun peso.
*/ */
import { clamp, lerp } from './curve'; import { clamp, lerp, arrotonda1 } from './curve';
export type Sesso = 'M' | 'F'; export type Sesso = 'M' | 'F';
@@ -25,23 +25,6 @@ function eMaschio(sesso: Sesso): boolean {
return sesso.trim().toLowerCase().startsWith('m'); 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 ------------------------------------------------- // --- Composizione Corporea -------------------------------------------------
/** Curva a campana ACE. Il picco è su Atleti/Fitness, non sul grasso più basso. Come `score_fat_percent`. */ /** Curva a campana ACE. Il picco è su Atleti/Fitness, non sul grasso più basso. Come `score_fat_percent`. */
+24 -1
View File
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { import {
clamp, lerp, curvaCampana, curvaDecrescente, curvaCrescenteConPlateau, clamp, lerp, arrotonda1, curvaCampana, curvaDecrescente, curvaCrescenteConPlateau,
curvaDirettaX10, curvaDirettaX10Invertita, curvaGradini, curvaDirettaX10, curvaDirettaX10Invertita, curvaGradini,
} from '../../src/lib/longevity/motore/curve'; } from '../../src/lib/longevity/motore/curve';
@@ -123,4 +123,27 @@ describe('curve del questionario, confrontate con l oracolo', () => {
expect(lerp(-1, 0, 10, 0, 100)).toBe(0); // t viene limitato a [0,1] expect(lerp(-1, 0, 10, 0, 100)).toBe(0); // t viene limitato a [0,1]
expect(lerp(11, 0, 10, 0, 100)).toBe(100); expect(lerp(11, 0, 10, 0, 100)).toBe(100);
}); });
// round(x, 1) di Python arrotonda al pari sui pareggi ESATTI (banker's
// rounding), non sempre in su. Senza questi casi un arrotondamento naive
// (Math.round(x*10)/10, che su 86.25 darebbe 86.3) sarebbe indistinguibile
// dal corretto in tutti gli altri test: qui il pareggio e' deliberato.
it('arrotonda1 arrotonda al pari sui pareggi esatti, come round() di Python', () => {
expect(arrotonda1(86.25)).toBe(86.2); // 862 e' pari: resta 86.2, non sale a 86.3
expect(arrotonda1(86.75)).toBe(86.8); // 867 e' dispari: sale a 86.8, non resta 86.7
expect(arrotonda1(86.24)).toBe(86.2); // non un pareggio: arrotondamento normale, per basso
expect(arrotonda1(86.26)).toBe(86.3); // non un pareggio: arrotondamento normale, per alto
});
// 86.35 NON e' un pareggio vero: il double che gli sta dietro vale
// 86.34999999999999431..., quindi Python arrotonda (senza ambiguita') a
// 86.3. Una prima versione di arrotonda1, che moltiplicava per 10 e
// testava una tolleranza, ci cadeva: 86.35 * 10 arrotonda ESATTAMENTE a
// 863.5 in virgola mobile (un pareggio creato dalla moltiplicazione, non
// presente nel valore originale) e restituiva 86.4. Questo test blocca
// proprio quella regressione.
it('arrotonda1 non inventa un pareggio dove il double non ce l ha (86.35 -> 86.3, non un pareggio)', () => {
expect(arrotonda1(86.35)).toBe(86.3);
expect(arrotonda1(-86.35)).toBe(-86.3);
});
}); });