From 93ecc3d36785057441b59e98bb030541b41c28f8 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sat, 22 Aug 2026 09:39:58 +0200 Subject: [PATCH] longevity: la copertura si confronta grezza, non arrotondata Bug in aggrega(): la soglia veniva confrontata con la copertura gia' arrotondata a due decimali, quindi 0.396 (< 0.40) passava per 'ok' perche' arrotondava a 0.40. Confronto ora sulla copertura grezza; l'arrotondamento resta solo sul valore esposto. Generalizzata arrotonda1 in curve.ts a un numero qualsiasi di decimali (bit-exact, banker's rounding) e riusata qui a due decimali, invece di tenere una seconda logica di arrotondamento nel motore. --- src/lib/longevity/motore/cascata.ts | 18 +++++---- src/lib/longevity/motore/curve.ts | 52 ++++++++++++++------------ tests/longevity/motore-cascata.test.ts | 22 +++++++++++ tests/longevity/motore-curve.test.ts | 24 ++++++++++++ 4 files changed, 85 insertions(+), 31 deletions(-) diff --git a/src/lib/longevity/motore/cascata.ts b/src/lib/longevity/motore/cascata.ts index 1eb978b..f930bf5 100644 --- a/src/lib/longevity/motore/cascata.ts +++ b/src/lib/longevity/motore/cascata.ts @@ -31,27 +31,31 @@ export type Punteggio = /** Una voce da aggregare: punteggio 0-100 (o null se il dato manca) e il suo peso nominale. */ export type VocePesata = { punteggio: number | null; peso: number }; -/** Arrotonda a due cifre decimali, come `round(x, 2)` per la copertura nell'oracolo. */ -function arrotonda2(x: number): number { - return Math.round(x * 100) / 100; -} - /** * Media pesata delle voci disponibili, con i pesi delle voci mancanti * ridistribuiti su quelle presenti. Come `aggregate` nell'oracolo, con la * differenza di tipo descritta sopra. + * + * ATTENZIONE (bug corretto, trovato in revisione): il confronto con la soglia + * usa la copertura GREZZA (`pesoDisponibile / pesoTotale`), non quella + * arrotondata. L'arrotondamento a due decimali (`arrotonda1(.., 2)`) serve + * solo al valore esposto nel campo `copertura` del risultato. Confrontare la + * copertura gia' arrotondata avrebbe fatto passare per 'ok' una copertura + * grezza appena sotto 0.40 che arrotonda a 0.40 (es. 0.396) - esattamente il + * numero pieno fasullo che il tipo `Punteggio` esiste per impedire. */ export function aggrega(voci: VocePesata[]): Punteggio { const pesoTotale = voci.reduce((s, v) => s + v.peso, 0); const disponibili = voci.filter((v) => v.punteggio !== null); const pesoDisponibile = disponibili.reduce((s, v) => s + v.peso, 0); - const copertura = pesoTotale ? arrotonda2(pesoDisponibile / pesoTotale) : 0; + const coperturaGrezza = pesoTotale ? pesoDisponibile / pesoTotale : 0; + const copertura = arrotonda1(coperturaGrezza, 2); if (disponibili.length === 0) { return { stato: 'insufficiente', copertura: 0 }; } - if (copertura < COPERTURA_MINIMA) { + if (coperturaGrezza < COPERTURA_MINIMA) { return { stato: 'insufficiente', copertura }; } diff --git a/src/lib/longevity/motore/curve.ts b/src/lib/longevity/motore/curve.ts index 0920615..3f77ed7 100644 --- a/src/lib/longevity/motore/curve.ts +++ b/src/lib/longevity/motore/curve.ts @@ -34,31 +34,34 @@ export function lerp(x: number, x0: number, x1: number, y0: number, y1: number): } /** - * 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. + * Arrotonda a `decimali` cifre decimali (default 1), come `round(x, decimali)` + * in Python: sui pareggi ESATTI (il valore binario del double è esattamente a + * metà, es. 86.25 per un decimale) 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. + * Non basta moltiplicare per 10^decimali e confrontare con una tolleranza: un + * valore come 86.35 NON è un pareggio vero a un decimale (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^decimali` 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 + * Unica funzione di arrotondamento del motore: la usano `curve.ts`, + * `test-fisici.ts` (a un decimale, i punteggi) e `cascata.ts` (anche a due + * decimali, la copertura), per non avere due implementazioni che sui pareggi * potrebbero divergere fra loro. */ -export function arrotonda1(x: number): number { +export function arrotonda1(x: number, decimali = 1): number { if (!Number.isFinite(x) || x === 0) return x === 0 ? 0 : x; const negativo = x < 0; const ax = Math.abs(x); + const potenza = 10n ** BigInt(decimali); // Scompone il double nella sua rappresentazione esatta ax = mantissa * 2^exponente. const view = new DataView(new ArrayBuffer(8)); @@ -75,26 +78,27 @@ export function arrotonda1(x: number): number { exponent = expBits - 1075; } - // ax*10 = num/den, con num e den interi esatti: nessuna moltiplicazione - // in virgola mobile, quindi nessun pareggio fasullo introdotto qui. + // ax*10^decimali = 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)); + num = mantissa * (potenza << BigInt(exponent)); den = 1n; } else { - num = mantissa * 10n; + num = mantissa * potenza; den = 1n << BigInt(-exponent); } - const n = num / den; // floor(ax*10), esatto + const n = num / den; // floor(ax*10^decimali), 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; + const valore = Number(risultato) / Number(potenza); return negativo ? -valore : valore; } diff --git a/tests/longevity/motore-cascata.test.ts b/tests/longevity/motore-cascata.test.ts index 3723949..2317509 100644 --- a/tests/longevity/motore-cascata.test.ts +++ b/tests/longevity/motore-cascata.test.ts @@ -37,6 +37,28 @@ describe('aggregazione e rinormalizzazione', () => { expect(r.stato).toBe('insufficiente'); expect(r.copertura).toBe(0); }); + + // Bug trovato in revisione: il confronto con la soglia usava la copertura + // GIA' arrotondata a due decimali, non quella grezza. Una copertura grezza + // di 0.396 arrotonda a 0.40 - uguale alla soglia - e con l'arrotondamento + // fatto PRIMA del confronto passava per 'ok'. Verificato anche contro + // l'oracolo Python: aggregate([WeightedScore(90, 0.396), WeightedScore(None, + // 0.604)]) -> (90.0, 0.4, 'insufficiente'). + it('una copertura grezza appena sotto la soglia resta insufficiente anche se arrotonda a 0.40', () => { + const r = aggrega([{ punteggio: 90, peso: 0.396 }, { punteggio: null, peso: 0.604 }]); + expect(r.stato).toBe('insufficiente'); + expect(r.copertura).toBeCloseTo(0.40, 2); // il valore esposto arrotonda a 0.40, ma lo stato resta insufficiente + }); + + // Simmetrico: una copertura grezza appena SOPRA la soglia (0.404, che + // arrotonda anch'essa a 0.40) deve restare 'ok' - il fix non deve aver + // spostato la soglia dall'altra parte. Oracolo: aggregate([WeightedScore(90, + // 0.404), WeightedScore(None, 0.596)]) -> (90.0, 0.4, 'ok'). + it('una copertura grezza appena sopra la soglia resta ok: il fix non ha spostato la soglia', () => { + const r = aggrega([{ punteggio: 90, peso: 0.404 }, { punteggio: null, peso: 0.596 }]); + expect(r.stato).toBe('ok'); + if (r.stato === 'ok') expect(r.copertura).toBeCloseTo(0.40, 2); + }); }); describe('assi, macro e Fitness Age', () => { diff --git a/tests/longevity/motore-curve.test.ts b/tests/longevity/motore-curve.test.ts index a9ca934..46fceaf 100644 --- a/tests/longevity/motore-curve.test.ts +++ b/tests/longevity/motore-curve.test.ts @@ -146,4 +146,28 @@ describe('curve del questionario, confrontate con l oracolo', () => { expect(arrotonda1(86.35)).toBe(86.3); expect(arrotonda1(-86.35)).toBe(-86.3); }); + + // Generalizzazione a `decimali` cifre (usata da cascata.ts per la copertura, + // a 2 decimali): stesso criterio dei test sopra, un gradino piu' in la'. + // 0.125 e 0.375 sono pareggi ESATTI a due decimali (double rappresentabili + // in binario senza resto: 1/8 e 3/8), verificati anche a mano con + // round(0.125, 2) e round(0.375, 2) in Python. + it('arrotonda1(x, 2) arrotonda al pari sui pareggi esatti a due decimali', () => { + expect(arrotonda1(0.125, 2)).toBe(0.12); // 12 e' pari: resta 0.12, non sale a 0.13 + expect(arrotonda1(0.375, 2)).toBe(0.38); // 37 e' dispari: sale a 0.38, non resta 0.37 + expect(arrotonda1(0.124, 2)).toBe(0.12); // non un pareggio: arrotondamento normale, per basso + expect(arrotonda1(0.126, 2)).toBe(0.13); // non un pareggio: arrotondamento normale, per alto + }); + + // 0.045 e' l'analogo a due decimali di 86.35: il double che gli sta dietro + // vale 0.044999999999999998..., quindi Python (e l'oracolo) arrotondano + // SENZA ambiguita' a 0.04. Ma 0.045 * 100 arrotonda ESATTAMENTE a 4.5 in + // virgola mobile - un pareggio creato dalla moltiplicazione, non presente + // nel valore originale - e un arrotondamento naive (Math.round(x*100)/100, + // la stessa `arrotonda2` locale rimossa da cascata.ts) ci sarebbe caduto, + // restituendo 0.05 invece di 0.04. + it('arrotonda1(x, 2) non inventa un pareggio dove il double non ce l ha (0.045 -> 0.04, non un pareggio)', () => { + expect(arrotonda1(0.045, 2)).toBe(0.04); + expect(arrotonda1(-0.045, 2)).toBe(-0.04); + }); });