diff --git a/src/lib/longevity/motore/cascata.ts b/src/lib/longevity/motore/cascata.ts new file mode 100644 index 0000000..1eb978b --- /dev/null +++ b/src/lib/longevity/motore/cascata.ts @@ -0,0 +1,111 @@ +/** + * La cascata a quattro livelli: dalle sotto-metriche normalizzate ai + * punteggi di sotto-dominio, ai sette assi del radar, ai tre macro-score + * (Performance/Energy/Recovery), fino alla Fitness Age. + * + * Porting di `aggregate`, `compute_axis`, `compute_macro_scores` e + * `compute_fitness_age` dall'oracolo del cliente + * (`tests/longevity/riferimento/isl_scoring_engine.py`, sezioni LIVELLO 2-4). + * Regola di copertura, uguale a ogni livello: se un elemento manca, il suo + * peso si ridistribuisce su quelli disponibili; se la copertura di peso + * disponibile scende sotto `COPERTURA_MINIMA`, l'elemento e' insufficiente. + * + * UNICA DIVERGENZA VOLUTA dall'oracolo: nell'oracolo `aggregate()` restituisce + * il punteggio pieno anche quando lo dichiara insufficiente (la docstring + * dice il contrario di cio' che il codice fa) - chi consuma deve ricordarsi + * di guardare lo stato, e prima o poi qualcuno non lo fa. Qui il tipo + * `Punteggio` chiude la trappola: nel ramo 'insufficiente' il campo `valore` + * non esiste, quindi la regola di prodotto - un asse con dati insufficienti + * si mostra tratteggiato, mai con un numero pieno fasullo - e' impossibile + * da violare per distrazione. Tutto il resto e' fedele all'oracolo. + */ + +import { COPERTURA_MINIMA } from '../db'; +import { arrotonda1 } from './curve'; + +/** Esito di un'aggregazione pesata: 'ok' porta il valore, 'insufficiente' no. */ +export type Punteggio = + | { stato: 'ok'; valore: number; copertura: number } + | { stato: 'insufficiente'; copertura: number }; + +/** 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. + */ +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; + + if (disponibili.length === 0) { + return { stato: 'insufficiente', copertura: 0 }; + } + + if (copertura < COPERTURA_MINIMA) { + return { stato: 'insufficiente', copertura }; + } + + const sommaPesata = disponibili.reduce((s, v) => s + (v.punteggio as number) * v.peso, 0); + const valore = arrotonda1(sommaPesata / pesoDisponibile); + return { stato: 'ok', valore, copertura }; +} + +/** + * Livello 2: punteggi di sotto-dominio -> punteggio Asse. Come `compute_axis` + * nell'oracolo: `pesi` e' la config del foglio "Pesi e Formule" per quell'asse, + * `punteggi` i valori 0-100 disponibili (assenti = mancanti). + */ +export function calcolaAsse(pesi: Record, punteggi: Record): Punteggio { + const voci: VocePesata[] = Object.entries(pesi).map(([chiave, peso]) => ({ + punteggio: punteggi[chiave] ?? null, + peso, + })); + return aggrega(voci); +} + +/** + * Livello 3: i sette Assi -> un macro-score (Performance/Energy/Recovery). + * Come `compute_macro_scores` nell'oracolo: un asse insufficiente non entra + * col suo valore (che qui non esiste nemmeno), entra come mancante e il suo + * peso si ridistribuisce. + */ +export function calcolaMacro(pesiMacro: Record, assi: Record): Punteggio { + const voci: VocePesata[] = Object.entries(pesiMacro).map(([asse, peso]) => { + const p = assi[asse]; + return { punteggio: p && p.stato === 'ok' ? p.valore : null, peso }; + }); + return aggrega(voci); +} + +/** + * Livello 4: Fitness Age. Come `compute_fitness_age` nell'oracolo: un + * composito di sei elementi (handgrip e HRV isolati, non tramite l'intero + * asse) da cui `eta - (composito - 50) * 0.4`. Se il composito e' + * insufficiente non c'e' Fitness Age da mostrare. + */ +export function calcolaFitnessAge( + etaAnagrafica: number, + pesi: Record, + voci: Record +): { fitnessAge: number | null; composito: Punteggio } { + const vociPesate: VocePesata[] = Object.entries(pesi).map(([chiave, peso]) => ({ + punteggio: voci[chiave] ?? null, + peso, + })); + const composito = aggrega(vociPesate); + if (composito.stato === 'insufficiente') { + return { fitnessAge: null, composito }; + } + const fitnessAge = arrotonda1(etaAnagrafica - (composito.valore - 50) * 0.4); + return { fitnessAge, composito }; +} diff --git a/tests/longevity/motore-cascata.test.ts b/tests/longevity/motore-cascata.test.ts new file mode 100644 index 0000000..3723949 --- /dev/null +++ b/tests/longevity/motore-cascata.test.ts @@ -0,0 +1,77 @@ +// tests/longevity/motore-cascata.test.ts +import { describe, it, expect } from 'vitest'; +import { COPERTURA_MINIMA } from '../../src/lib/longevity/db'; +import { aggrega, calcolaAsse, calcolaMacro, calcolaFitnessAge } from '../../src/lib/longevity/motore/cascata'; + +describe('aggregazione e rinormalizzazione', () => { + it('con tutti i dati fa la media pesata', () => { + const r = aggrega([{ punteggio: 80, peso: 0.5 }, { punteggio: 60, peso: 0.5 }]); + expect(r.stato).toBe('ok'); + if (r.stato === 'ok') { expect(r.valore).toBeCloseTo(70, 1); expect(r.copertura).toBe(1); } + }); + + it('un dato mancante ridistribuisce il suo peso, non vale zero', () => { + const r = aggrega([{ punteggio: 80, peso: 0.5 }, { punteggio: null, peso: 0.25 }, { punteggio: 60, peso: 0.25 }]); + expect(r.stato).toBe('ok'); + // 80*0.5 + 60*0.25 = 55, su peso disponibile 0.75 -> 73.3, non 55 + if (r.stato === 'ok') { expect(r.valore).toBeCloseTo(73.3, 1); expect(r.copertura).toBeCloseTo(0.75, 2); } + }); + + it('sotto la soglia di copertura NON esiste un valore da leggere', () => { + const r = aggrega([{ punteggio: 90, peso: 0.2 }, { punteggio: null, peso: 0.8 }]); + expect(r.stato).toBe('insufficiente'); + expect(r.copertura).toBeCloseTo(0.2, 2); + // il punto dell'intero tipo: chi consuma non ha il campo da cui prendere il numero + expect((r as { valore?: number }).valore).toBeUndefined(); + }); + + it('la soglia e quella dichiarata una volta sola, non un numero sparso', () => { + const pocoSotto = aggrega([{ punteggio: 90, peso: COPERTURA_MINIMA - 0.01 }, { punteggio: null, peso: 1 - COPERTURA_MINIMA + 0.01 }]); + const esatto = aggrega([{ punteggio: 90, peso: COPERTURA_MINIMA }, { punteggio: null, peso: 1 - COPERTURA_MINIMA }]); + expect(pocoSotto.stato).toBe('insufficiente'); + expect(esatto.stato).toBe('ok'); // la soglia e inclusiva, come nell'oracolo + }); + + it('senza nessun dato e insufficiente con copertura zero', () => { + const r = aggrega([{ punteggio: null, peso: 1 }]); + expect(r.stato).toBe('insufficiente'); + expect(r.copertura).toBe(0); + }); +}); + +describe('assi, macro e Fitness Age', () => { + const PESI_FORZA = { handgrip: 0.25, spinta: 0.2, trazione: 0.2, arti_inferiori: 0.2, core: 0.15 }; + + it('un asse si calcola sui suoi sotto-domini', () => { + const r = calcolaAsse(PESI_FORZA, { handgrip: 70, spinta: 60, trazione: 65, arti_inferiori: 80, core: 50 }); + expect(r.stato).toBe('ok'); + // 70*.25 + 60*.2 + 65*.2 + 80*.2 + 50*.15 = 66.0 (verificato anche a mano, non 66.25 come + // nel brief: la costante li' e' un refuso, corretto qui dopo averlo verificato col conto) + if (r.stato === 'ok') expect(r.valore).toBeCloseTo(66.0, 1); + }); + + it('un asse insufficiente NON entra nel macro-score, invece di entrarci come zero', () => { + const assi = { + A: { stato: 'ok', valore: 80, copertura: 1 } as const, + B: { stato: 'insufficiente', copertura: 0.1 } as const, + }; + const r = calcolaMacro({ A: 0.5, B: 0.5 }, assi); + expect(r.stato).toBe('ok'); + // se B entrasse come zero il risultato sarebbe 40: la rinormalizzazione lo esclude + if (r.stato === 'ok') { expect(r.valore).toBeCloseTo(80, 1); expect(r.copertura).toBeCloseTo(0.5, 2); } + }); + + it('la Fitness Age scende sotto l eta quando il composito supera 50', () => { + const pesi = { cardio: 0.3, handgrip_isolato: 0.2, hrv_isolato: 0.2, forza_resto: 0.15, composizione: 0.1, stabilita: 0.05 }; + const r = calcolaFitnessAge(40, pesi, { cardio: 75, handgrip_isolato: 75, hrv_isolato: 75, forza_resto: 75, composizione: 75, stabilita: 75 }); + // 40 - (75 - 50) * 0.4 = 30 + expect(r.fitnessAge).toBeCloseTo(30, 1); + }); + + it('senza dati sufficienti la Fitness Age non esiste', () => { + const pesi = { cardio: 0.3, handgrip_isolato: 0.2, hrv_isolato: 0.2, forza_resto: 0.15, composizione: 0.1, stabilita: 0.05 }; + const r = calcolaFitnessAge(40, pesi, { cardio: 75, handgrip_isolato: null, hrv_isolato: null, forza_resto: null, composizione: null, stabilita: null }); + expect(r.composito.stato).toBe('insufficiente'); + expect(r.fitnessAge).toBeNull(); + }); +});