From 4d76030839f88b0ea19d8dc5a0f97bc801fb79c7 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sat, 22 Aug 2026 09:16:31 +0200 Subject: [PATCH] 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). --- src/lib/longevity/motore/curve.ts | 66 +++++++++++++++++++++++-- src/lib/longevity/motore/test-fisici.ts | 19 +------ tests/longevity/motore-curve.test.ts | 25 +++++++++- 3 files changed, 88 insertions(+), 22 deletions(-) diff --git a/src/lib/longevity/motore/curve.ts b/src/lib/longevity/motore/curve.ts index 987bc8c..0920615 100644 --- a/src/lib/longevity/motore/curve.ts +++ b/src/lib/longevity/motore/curve.ts @@ -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; } /** diff --git a/src/lib/longevity/motore/test-fisici.ts b/src/lib/longevity/motore/test-fisici.ts index b85ef42..175ccf1 100644 --- a/src/lib/longevity/motore/test-fisici.ts +++ b/src/lib/longevity/motore/test-fisici.ts @@ -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`. */ diff --git a/tests/longevity/motore-curve.test.ts b/tests/longevity/motore-curve.test.ts index 32ef3d1..a9ca934 100644 --- a/tests/longevity/motore-curve.test.ts +++ b/tests/longevity/motore-curve.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { - clamp, lerp, curvaCampana, curvaDecrescente, curvaCrescenteConPlateau, + clamp, lerp, arrotonda1, curvaCampana, curvaDecrescente, curvaCrescenteConPlateau, curvaDirettaX10, curvaDirettaX10Invertita, curvaGradini, } 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(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); + }); });